@lodestar/reqresp
A Typescript implementation of the Ethereum Consensus Req/Resp protocol
Supply chain provenance
Status for the latest visible version.
Maintainers
Keywords
Accepted risks
Findings the reviewer chose to accept rather than block on.
| Source | Rule | Reason | Accepted by | When |
|---|---|---|---|---|
| provenance | publisher-changed | AI (provenance): ChainSafe/lodestar transitioned to GitHub Actions CI publishing with SLSA provenance attestation — a security improvement, not a takeover signal. Stable for this package. | ai | |
| publish-pattern | dormant-publish | AI (publish-pattern): Dormancy is explained by the transition to GitHub Actions publishing with SLSA attestation from the official ChainSafe/lodestar repo. Not indicative of account takeover. | ai | |
| phantom-deps | phantom-dep:it-all | AI (phantom-deps): it-all is a legitimate libp2p ecosystem package; phantom-dep finding is a false positive common in monorepo/config-driven setups. | ai | |
| typosquat | typosquat.levenshtein:request | AI (typosquat): @lodestar/reqresp is named after 'request/response' in the Ethereum P2P protocol context, not a typosquat of the 'request' npm package. This is a stable false positive for this package. | ai |
Versions (showing 50 of 50)
| Version | Deps | Published |
|---|---|---|
| 1.45.0 | 10 / 6 | |
| 1.44.0 | 10 / 6 | |
| 1.43.0 | 10 / 7 | |
| 1.42.0 | 10 / 7 | |
| 1.41.1 | 10 / 7 | |
| 1.41.0 | 10 / 7 | |
| 1.40.0 | 11 / 8 | |
| 1.39.1 | 11 / 8 | |
| 1.39.0 | 11 / 8 | |
| 1.38.0 | 11 / 3 | |
| 1.37.0 | 11 / 3 | |
| 1.36.0 | 11 / 3 | |
| 1.35.0 | 11 / 3 | |
| 1.34.1 | 11 / 3 | |
| 1.34.0 | 11 / 3 | |
| 1.33.0 | 11 / 3 | |
| 1.32.0 | 11 / 3 | |
| 1.31.0 | 11 / 3 | |
| 1.30.0 | 11 / 3 | |
| 1.29.0 | 11 / 3 | |
| 1.28.1 | 11 / 3 | |
| 1.28.0 | 11 / 3 | |
| 1.27.1 | 11 / 3 | |
| 1.27.0 | 11 / 3 | |
| 1.26.0 | 11 / 3 | |
| 1.25.0 | 11 / 3 | |
| 1.24.0 | 11 / 3 | |
| 1.23.1 | 11 / 3 | |
| 1.23.0 | 11 / 3 | |
| 1.22.0 | 11 / 3 | |
| 1.21.0 | 11 / 3 | |
| 1.20.2 | 11 / 3 | |
| 1.20.1 | 11 / 3 | |
| 1.20.0 | 11 / 3 | |
| 1.19.0 | 11 / 3 | |
| 1.18.1 | 11 / 3 | |
| 1.18.0 | 11 / 3 | |
| 1.17.0 | 11 / 3 | |
| 1.16.0 | 11 / 3 | |
| 1.15.1 | 11 / 3 | |
| 1.15.0 | 11 / 3 | |
| 1.14.0 | 11 / 3 | |
| 1.13.0 | 11 / 3 | |
| 1.12.1 | 11 / 3 | |
| 1.12.0 | 11 / 3 | |
| 1.11.3 | 11 / 3 | |
| 1.11.2 | 11 / 3 | |
| 1.11.1 | 11 / 3 | |
| 1.11.0 | 11 / 3 | |
| 1.10.0 | 12 / 3 |
v1.45.0
1 findingPublished via CI/CD with Sigstore attestation (predicate: https://slsa.dev/provenance/v1). This is the strongest supply chain integrity signal.
v1.44.0
1 findingPublished via CI/CD with Sigstore attestation (predicate: https://slsa.dev/provenance/v1). This is the strongest supply chain integrity signal.
v1.29.0
1 findingPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
v1.28.1
1 findingPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
v1.28.0
1 findingPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
v1.27.1
1 findingPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
v1.27.0
1 findingPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
v1.26.0
1 findingPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
v1.25.0
1 findingPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
v1.24.0
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.23.1
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.23.0
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.22.0
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.21.0
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.20.2
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.20.1
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.20.0
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.19.0
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.18.1
3 findingsPackage was published without Sigstore provenance. Only ~12% of npm packages have provenance, so this is common but not ideal.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.18.0
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.17.0
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.16.0
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.15.1
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.15.0
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.14.0
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.13.0
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.12.1
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.12.0
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.11.3
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.11.2
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.11.1
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.11.0
3 findingsPackage was published without Sigstore provenance. Consider requesting the maintainer enable provenance via CI/CD.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```
v1.10.0
3 findingsPackage was published without Sigstore provenance. Only ~12% of npm packages have provenance, so this is common but not ideal.
### Impact Unintended permanent chain split affecting greater than or equal to 25% of the network, requiring hard fork (network partition requiring hard fork) ### Description Lodestar client may fail to decode snappy framing compressed messages. ### Vulnerability Details In Req/Resp protocol the message are encoded by using ssz_snappy encoding, which is basically snappy framing compression over ssz encoded message. It's mentioned here - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md ``` The token of the negotiated protocol ID specifies the type of encoding to be used for the req/resp interaction. Only one value is possible at this time: ssz_snappy: The contents are first SSZ-encoded and then compressed with Snappy frames compression. For objects containing a single field, only the field is SSZ-encoded not a container with a single field. For example, the BeaconBlocksByRoot request is an SSZ-encoded list of Root's. This encoding type MUST be supported by all clients. ``` In snappy framing format there a few types of chunks. We are interested in so called reserved skippable chunks. These are chunks with chunk type in range [0x80, 0xfd] Let's see how rust snappy handles them https://github.com/BurntSushi/rust-snappy/blob/master/src/read.rs#L137 ``` impl<R: io::Read> io::Read for FrameDecoder<R> { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { ... ... let len = len64 as usize; match ty { Err(b) if 0x02 <= b && b <= 0x7F => { // Spec says that chunk types 0x02-0x7F are reserved and // conformant decoders must return an error. fail!(Error::UnsupportedChunkType { byte: b }); } Err(b) if 0x80 <= b && b <= 0xFD => { // Spec says that chunk types 0x80-0xFD are reserved but // skippable. self.r.read_exact(&mut self.src[0..len])?; } ``` Similar code can be found in golang implementation - https://github.com/golang/snappy/blob/master/decode.go#L221 ``` func (r *Reader) fill() error { ... if chunkType <= 0x7f { // Section 4.5. Reserved unskippable chunks (chunk types 0x02-0x7f). r.err = ErrUnsupported return r.err } // Section 4.4 Padding (chunk type 0xfe). // Section 4.6. Reserved skippable chunks (chunk types 0x80-0xfd). if !r.readFull(r.buf[:chunkLen], false) { return r.err } ``` Now let's see how lodestar handles such chunks 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) { result.append(data.subarray(4)); } } if (result.length === 0) { return null; } return result; } function getChunkType(value: number): ChunkType { switch (value) { case ChunkType.IDENTIFIER: return ChunkType.IDENTIFIER; case ChunkType.COMPRESSED: return ChunkType.COMPRESSED; case ChunkType.UNCOMPRESSED: return ChunkType.UNCOMPRESSED; case ChunkType.PADDING: return ChunkType.PADDING; default: throw new Error("Unsupported snappy chunk type"); } ``` As you can see, lodestar does not recognize such chunks. If it sees such chunk, function getChunkType() throws an exception and decoding fails. ### Impact Details Faulty nodes may trigger chain stall by sending messages which lodestar fails to parse, while other clients will be able to handle. ### Proof of Concept How to reproduce: 1. get archive (via provided [gist link](https://gist.github.com/gln7/bdde7f4e0bdf9d47bf810a015796867a)), decode and unpack it: ``` $ base64 -d poc.txt > poc.tgz $ tar zxf poc.tgz ``` 2. run dec1.go to verify that our snappy file decompressed successfully ``` $ go run dec1.go reading 1.snappy... read 124 bytes, err <nil> ``` 3. run dec1.mjs to verify that lodestar fails to decode such file ``` checking chunk type=255 checking chunk type=1 got uncompressed chunk.. checking chunk type=129 file:///../poc/dec1.mjs:74 throw new Error("Unsupported snappy chunk type"); ```
### Impact 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](https://gist.github.com/gln7/aab55674431b1c8d42a59ccf9d7cbf60) and run it: ``` $ node dec1.mjs checking chunk type=255 checking chunk type=1 got uncompressed chunk.. Decompressed ok 124 bytes ```