GHSA-M9C9-MC2H-9WJW
Vulnerability from github – Published: 2025-01-14 22:04 – Updated: 2025-01-14 22:04Impact
Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork)
Lodestar does not verify checksum in snappy framing uncompressed chunks.
Vulnerability Details
In Req/Resp protocol the messages are encoded by using ssz_snappy encoding, which is a snappy framing compression over ssz encoded message.
In snappy framing format there are uncompressed chunks, each such chunk is prefixed with a checksum.
Let's see how golang implementation parses such chunks - https://github.com/golang/snappy/blob/master/decode.go#L176
case chunkTypeUncompressedData:
// Section 4.3. Uncompressed data (chunk type 0x01).
if chunkLen < checksumSize {
r.err = ErrCorrupt
return r.err
}
buf := r.buf[:checksumSize]
if !r.readFull(buf, false) {
return r.err
}
checksum := uint32(buf[0]) | uint32(buf[1])<<8 | uint32(buf[2])<<16 | uint32(buf[3])<<24
// Read directly into r.decoded instead of via r.buf.
n := chunkLen - checksumSize
if n > len(r.decoded) {
r.err = ErrCorrupt
return r.err
}
if !r.readFull(r.decoded[:n], false) {
return r.err
}
if crc(r.decoded[:n]) != checksum {
r.err = ErrCorrupt
return r.err
}
r.i, r.j = 0, n
continue
As you can see, if checksum is incorrect, decoder fails and returns error.
Now let's look at lodestar decoder https://github.com/ChainSafe/lodestar/blob/unstable/packages/reqresp/src/encodingStrategies/sszSnappy/snappyFrames/uncompress.ts#L17
uncompress(chunk: Uint8ArrayList): Uint8ArrayList | null {
this.buffer.append(chunk);
const result = new Uint8ArrayList();
while (this.buffer.length > 0) {
if (this.buffer.length < 4) break;
const type = getChunkType(this.buffer.get(0));
const frameSize = getFrameSize(this.buffer, 1);
if (this.buffer.length - 4 < frameSize) {
break;
}
const data = this.buffer.subarray(4, 4 + frameSize);
this.buffer.consume(4 + frameSize);
if (!this.state.foundIdentifier && type !== ChunkType.IDENTIFIER) {
throw "malformed input: must begin with an identifier";
}
if (type === ChunkType.IDENTIFIER) {
if (!Buffer.prototype.equals.call(data, IDENTIFIER)) {
throw "malformed input: bad identifier";
}
this.state.foundIdentifier = true;
continue;
}
if (type === ChunkType.COMPRESSED) {
result.append(uncompress(data.subarray(4)));
}
if (type === ChunkType.UNCOMPRESSED) {
1) result.append(data.subarray(4));
}
}
if (result.length === 0) {
return null;
}
return result;
}
As you can see, checksum is not verified, bytes are appended to 'result'
Proof of Concept
How to reproduce:
get poc via gist link and run it:
$ node dec1.mjs
checking chunk type=255
checking chunk type=1
got uncompressed chunk..
Decompressed ok 124 bytes
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "@lodestar/reqresp"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.25.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-354"
],
"github_reviewed": true,
"github_reviewed_at": "2025-01-14T22:04:02Z",
"nvd_published_at": null,
"severity": "LOW"
},
"details": "### Impact\nUnintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork)\n\nLodestar does not verify checksum in snappy framing uncompressed chunks.\n\n### Vulnerability Details\nIn Req/Resp protocol the messages are encoded by using ssz_snappy encoding, which is a snappy framing compression over ssz encoded message.\n\nIn snappy framing format there are uncompressed chunks, each such chunk is prefixed with a checksum.\n\nLet\u0027s see how golang implementation parses such chunks - https://github.com/golang/snappy/blob/master/decode.go#L176\n\n```\n\tcase chunkTypeUncompressedData:\n\t\t\t// Section 4.3. Uncompressed data (chunk type 0x01).\n\t\t\tif chunkLen \u003c checksumSize {\n\t\t\t\tr.err = ErrCorrupt\n\t\t\t\treturn r.err\n\t\t\t}\n\t\t\tbuf := r.buf[:checksumSize]\n\t\t\tif !r.readFull(buf, false) {\n\t\t\t\treturn r.err\n\t\t\t}\n\t\t\tchecksum := uint32(buf[0]) | uint32(buf[1])\u003c\u003c8 | uint32(buf[2])\u003c\u003c16 | uint32(buf[3])\u003c\u003c24\n\t\t\t// Read directly into r.decoded instead of via r.buf.\n\t\t\tn := chunkLen - checksumSize\n\t\t\tif n \u003e len(r.decoded) {\n\t\t\t\tr.err = ErrCorrupt\n\t\t\t\treturn r.err\n\t\t\t}\n\t\t\tif !r.readFull(r.decoded[:n], false) {\n\t\t\t\treturn r.err\n\t\t\t}\n\t\t\tif crc(r.decoded[:n]) != checksum {\n\t\t\t\tr.err = ErrCorrupt\n\t\t\t\treturn r.err\n\t\t\t}\n\t\t\tr.i, r.j = 0, n\n\t\t\tcontinue\n```\n\nAs you can see, if checksum is incorrect, decoder fails and returns error.\n\nNow let\u0027s look at lodestar decoder https://github.com/ChainSafe/lodestar/blob/unstable/packages/reqresp/src/encodingStrategies/sszSnappy/snappyFrames/uncompress.ts#L17\n\n```\nuncompress(chunk: Uint8ArrayList): Uint8ArrayList | null {\n this.buffer.append(chunk);\n const result = new Uint8ArrayList();\n while (this.buffer.length \u003e 0) {\n if (this.buffer.length \u003c 4) break;\n\n const type = getChunkType(this.buffer.get(0));\n const frameSize = getFrameSize(this.buffer, 1);\n\n if (this.buffer.length - 4 \u003c frameSize) {\n break;\n }\n\n const data = this.buffer.subarray(4, 4 + frameSize);\n this.buffer.consume(4 + frameSize);\n\n if (!this.state.foundIdentifier \u0026\u0026 type !== ChunkType.IDENTIFIER) {\n throw \"malformed input: must begin with an identifier\";\n }\n\n if (type === ChunkType.IDENTIFIER) {\n if (!Buffer.prototype.equals.call(data, IDENTIFIER)) {\n throw \"malformed input: bad identifier\";\n }\n this.state.foundIdentifier = true;\n continue;\n }\n\n if (type === ChunkType.COMPRESSED) {\n result.append(uncompress(data.subarray(4)));\n }\n if (type === ChunkType.UNCOMPRESSED) {\n1) result.append(data.subarray(4));\n }\n }\n if (result.length === 0) {\n return null;\n }\n return result;\n }\n```\n\nAs you can see, checksum is not verified, bytes are appended to \u0027result\u0027\n\n### Proof of Concept\n\nHow to reproduce:\n\nget poc via [gist link](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it:\n\n```\n$ node dec1.mjs \nchecking chunk type=255\nchecking chunk type=1\ngot uncompressed chunk..\nDecompressed ok 124 bytes\n```\n",
"id": "GHSA-m9c9-mc2h-9wjw",
"modified": "2025-01-14T22:04:02Z",
"published": "2025-01-14T22:04:02Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/ChainSafe/lodestar/security/advisories/GHSA-m9c9-mc2h-9wjw"
},
{
"type": "WEB",
"url": "https://github.com/ChainSafe/lodestar/commit/18a0d681dbcc51fb2ac9456f31e91f4e31a18300"
},
{
"type": "PACKAGE",
"url": "https://github.com/ChainSafe/lodestar"
}
],
"schema_version": "1.4.0",
"severity": [],
"summary": "Lodestar snappy checksum issue"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.