Actions
Bug #8817
open
SB
VJ
decode/ipv6: OOB read with Hop-by-Hop and Destination Options extension headers
Bug #8817:
decode/ipv6: OOB read with Hop-by-Hop and Destination Options extension headers
Affected Versions:
Effort:
Difficulty:
Label:
Description
Reported by Communications Security Establishment (CSE):
## Summary
When parsing TLV sub-options inside an IPv6 Hop-by-Hop or Destination Options extension header, `DecodeIPV6ExtHdrs()` validates each option's length with a bound check that under-counts the option header by one byte. As a result, a crafted Router Alert, Jumbo Payload, or Home Address sub-option positioned at the very end of the extension header causes the subsequent fixed-size `memcpy()` to read exactly one byte past the end of the extension header — and, when the extension header is the last data in the packet, one byte past the captured packet buffer. The over-read is reachable from any unauthenticated IPv6 packet on the monitored network, but the copied data lands in dead stack-local structs and is never used, so the practical impact is limited to an ASAN/fuzzer-detectable bounds violation with a remote (page-boundary) crash possibility.
## Affected Piece of Code
- **File:** `src/decode-ipv6.c`
- **Function / Location:** `DecodeIPV6ExtHdrs()` ~L289-368 (specifically the bound check at L308 and the `memcpy` calls at L330, L343, L355)
- **Subsystem:** decode-ip — IPv4/IPv6 packet decoders incl. options/extension-header parsing
```c
src/decode-ipv6.c (DecodeIPV6ExtHdrs, IPPROTO_HOPOPTS/DSTOPTS sub-option loop):
299 if (offset + 1 >= optslen) {
300 ENGINE_SET_INVALID_EVENT(p, IPV6_EXTHDR_INVALID_OPTLEN);
301 break;
302 }
303
304 /* length field for each opt */
305 uint8_t ip6_optlen = *(ptr + 1);
306
307 /* see if the optlen from the packet fits the total optslen */
308 if ((offset + 1 + ip6_optlen) > optslen) {
309 ENGINE_SET_INVALID_EVENT(p, IPV6_EXTHDR_INVALID_OPTLEN);
310 break;
311 }
...
325 if (ip6_optlen < sizeof(ra->ip6ra_value)) {
...
330 memcpy(&ra->ip6ra_value, (ptr + 2), sizeof(ra->ip6ra_value));
...
343 memcpy(&jumbo->ip6j_payload_len, (ptr+2), sizeof(jumbo->ip6j_payload_len));
...
355 memcpy(&hao->ip6hao_hoa, (ptr + 2), sizeof(hao->ip6hao_hoa));
```
## The Bug
**Vulnerability class:** network-reachable out-of-bounds read.
### Call chain from the wire to the sink
Any capture source (e.g. `source-af-packet.c` / `source-pcap-file-helper.c`) hands raw frames to `DecodeEthernet()` (`src/decode-ethernet.c:42`), which dispatches via the inline `DecodeNetworkLayer()` (`src/decode.h:1495`). For EtherType `0x86DD` this calls `DecodeIPV6()` (`src/decode-ipv6.c:549`).
`DecodeIPV6()` first invokes `DecodeIPV6Packet()` (L522), which only verifies `len >= IPV6_HEADER_LEN + IPV6_GET_RAW_PLEN(...)` (L538) — equality is allowed, so the IPv6 payload may end exactly at the capture-buffer boundary. When the IPv6 Next Header is `IPPROTO_HOPOPTS` (0) or `IPPROTO_DSTOPTS` (60), control falls through to `DecodeIPV6ExtHdrs()` (L618 → L132).
Inside the `IPPROTO_HOPOPTS` / `IPPROTO_DSTOPTS` case (L221), the decoder computes:
- `hdrextlen = (pkt[1] + 1) << 3` — total length of the extension header in bytes,
- `optslen = hdrextlen - 2` — length of the TLV options area (everything after the 2-byte ext-header header),
then verifies only `hdrextlen <= plen` (L231, equality allowed), sets `ptr = pkt + 2`, and walks the TLV sub-options starting at L289.
### The off-by-one
For each non-PAD1 sub-option, the loop reads `ip6_optlen = ptr[1]` (L305) and applies the guard at L308:
```c
if ((offset + 1 + ip6_optlen) > optslen) { ... break; }
```
A TLV sub-option occupies bytes `[offset, offset + 2 + ip6_optlen)` of the options area: 1 type byte, 1 length byte, then `ip6_optlen` data bytes. The correct rejection condition is therefore:
```c
offset + 2 + ip6_optlen > optslen
```
The existing check is off by one and **accepts** the boundary case `offset + 1 + ip6_optlen == optslen`. In that case the option-data bytes span options-area indices `[offset + 2 .. optslen]` — the last index, `optslen`, is one past the end of the options area, i.e. it lands at `pkt[hdrextlen]`, one byte beyond the extension header.
The decoder then performs a fixed-size copy of the option payload from `ptr + 2`:
| Option | Type | `sizeof(field)` | Sink |
| --- | --- | --- | --- |
| Router Alert | `IPV6OPT_RA` (0x05) | 2 bytes | `memcpy(&ra->ip6ra_value, ptr+2, 2)` at L330 |
| Jumbo Payload | `IPV6OPT_JUMBO` (0xC2) | 4 bytes | `memcpy(&jumbo->ip6j_payload_len, ptr+2, 4)` at L343 |
| Home Address | `IPV6OPT_HAO` (0xC9) | 16 bytes | `memcpy(&hao->ip6hao_hoa, ptr+2, 16)` at L355 |
Each path first checks `ip6_optlen < sizeof(field)` and breaks if too small (e.g. L325), so the attacker simply sets `ip6_optlen` exactly equal to `sizeof(field)` and positions the option at `offset = optslen - ip6_optlen - 1` (using PAD1 padding). The `memcpy` then reads exactly one byte past `pkt[hdrextlen - 1]`.
When the extension header is the last data in the IPv6 payload (`hdrextlen == plen == IPV6_GET_RAW_PLEN`) and the captured frame length equals `14 + 40 + plen`, that extra byte lies one past the end of the packet buffer — a genuine 1-byte heap/buffer over-read.
### Concrete trigger arithmetic (Jumbo path)
- IPv6 Next Header = `0x00` (HOPOPTS), Payload Length = `0x0008`.
- HOPOPTS bytes: `3B 00 00 C2 04 AA BB CC`.
- `pkt[1] = 0x00` → `hdrextlen = 8`, `optslen = 6`.
- `pkt[2] = 0x00` (PAD1) → `offset` becomes 1.
- `pkt[3] = 0xC2` (`IPV6OPT_JUMBO`), `pkt[4] = 0x04` → `ip6_optlen = 4`.
- Bound check L308: `1 + 1 + 4 == 6 == optslen` → **not** `> optslen`, so it passes.
- L343 copies 4 bytes from `ptr + 2 = pkt + 5`, reading `pkt[5..8]`. Only `pkt[0..7]` belong to the 8-byte extension header; `pkt[8]` is one byte past it.
The destination of every such `memcpy` is one of the stack-local structs `hao_s` / `ra_s` / `jumbo_s` declared at L224-226, which are never read after the loop. There is therefore no information leak; the only observable failure mode is a crash if the over-read byte lies on an unmapped page, or an ASAN/fuzzer report.
## Reproduction Results
The trigger below is **analytically derived** from the source; it has not been executed against an ASAN build in this environment, but every value has been computed to satisfy each guard on the path and the resulting memory access is deterministic.
No special configuration or rule is required — the bug is in the unconditional packet-decode path and fires on the very first matching IPv6 frame.
1. Build Suricata with AddressSanitizer (`CFLAGS="-fsanitize=address -g"`). A non-ASAN build will silently swallow the 1-byte over-read in virtually all cases.
2. Craft a 62-byte Ethernet/IPv6/Hop-by-Hop frame whose captured length exactly equals the IPv6 total length (`14 + 40 + 8`) and write it to a pcap (snaplen = caplen = 62, linktype = `DLT_EN10MB`).
Frame bytes (hex):
```
--- Ethernet (14) ---
ff ff ff ff ff ff dst MAC
00 11 22 33 44 55 src MAC
86 dd EtherType = IPv6
--- IPv6 header (40) ---
60 00 00 00 ver=6, tc=0, flow=0
00 08 Payload Length = 8
00 Next Header = 0 (IPPROTO_HOPOPTS)
40 Hop Limit = 64
20 01 0d b8 00 00 00 00 00 00 00 00 00 00 00 01 src
20 01 0d b8 00 00 00 00 00 00 00 00 00 00 00 02 dst
--- Hop-by-Hop Options ext header (8) ---
3b Next Header = 59 (IPPROTO_NONE)
00 Hdr Ext Len = 0 -> hdrextlen=8, optslen=6
00 PAD1 (IPV6OPT_PAD1) -> offset becomes 1
c2 IPV6OPT_JUMBO (option type)
04 ip6_optlen = 4
aa bb cc only 3 of the 4 Jumbo data bytes fit in the header
```
3. Run: `suricata -r trigger.pcap -l /tmp/out`.
4. Expected ASAN report: `heap-buffer-overflow READ of size 4` at the byte immediately following the 62-byte packet buffer, in `memcpy` called from `DecodeIPV6ExtHdrs` at `src/decode-ipv6.c:343`.
**Variant triggers** (same off-by-one, different `memcpy`):
- *Router Alert* (2-byte read, L330): HOPOPTS bytes `3b 00 00 00 00 05 02 aa` (3×PAD1, type `0x05`, len `0x02`, 1 data byte). PLEN = 8, frame = 62 B.
- *Home Address* (16-byte read, L355): IPv6 NH = `0x3C` (DSTOPTS), PLEN = 24; DSTOPTS bytes `3b 02` + `00 00 00 00 00` (5×PAD1) + `c9 10` + 15 arbitrary bytes. Frame = 14 + 40 + 24 = 78 B. The `memcpy` reads `pkt[9..24]`; `pkt[24]` is one past the 24-byte ext header.
**Scapy one-liner** for the Jumbo variant:
```python
wrpcap('trigger.pcap',
Ether(dst='ff:ff:ff:ff:ff:ff', src='00:11:22:33:44:55') /
IPv6(nh=0, plen=8, src='2001:db8::1', dst='2001:db8::2') /
Raw(bytes.fromhex('3b0000c204aabbcc')))
```
Note: in a non-ASAN production build the read lands in slack/adjacent heap memory and is copied into stack-local structs (`hao_s` / `ra_s` / `jumbo_s`, L224-226) that are never read again, so no observable effect occurs unless the packet buffer ends exactly at an unmapped page boundary.
## Severity
**LOW** — 1-byte out-of-bounds heap/buffer read of attacker-adjacent memory during packet decode.
- **Confidentiality:** None. The destination of the `memcpy` is a stack-local struct (`IPV6OptRA` / `IPV6OptJumbo` / `IPV6OptHAO` at L224-226) that is never consumed after the loop, so the over-read byte is never exposed.
- **Integrity:** None. Read-only over-read; no write past bounds.
- **Availability:** Theoretical only. A crash requires the packet buffer to end exactly on an unmapped page boundary, which Suricata's packet-storage layouts (inline `Packet` buffer with `default_packet_size` slack, large mmap'd AF_PACKET/Netmap/DPDK rings, libpcap buffers) make practically unreachable.
- **Reachability:** High. Deterministically reachable from arbitrary unauthenticated IPv6 packets on the monitored network with no configuration required.
In summary: a real, deterministically ASAN/fuzzer-detectable bounds-check error in attacker-controlled parsing code, but effectively non-exploitable in practice.
## Suggested Fix
Account for both the 1-byte type field and the 1-byte length field when validating that the sub-option fits inside the options area. Change the bound check at `src/decode-ipv6.c:308` from `+ 1` to `+ 2`:
```diff
--- a/src/decode-ipv6.c
+++ b/src/decode-ipv6.c
@@ -305,8 +305,8 @@
uint8_t ip6_optlen = *(ptr + 1);
/* see if the optlen from the packet fits the total optslen */
- if ((offset + 1 + ip6_optlen) > optslen) {
+ if ((offset + 2 + ip6_optlen) > optslen) {
ENGINE_SET_INVALID_EVENT(p, IPV6_EXTHDR_INVALID_OPTLEN);
break;
}
```
Optionally, also delete the now-dead `hao_s` / `ra_s` / `jumbo_s` parsing at L224-226 / L320-356, since the parsed values are never consumed; that removes the `memcpy` sinks entirely and hardens the loop against any future regression.
VJ Updated by Victor Julien 7 days ago
- Subject changed from Off-by-one in IPv6 Hop-by-Hop/Destination Options sub-option length check allows 1-byte OOB read to decode/ipv6: Off-by-one in IPv6 Hop-by-Hop/Destination Options sub-option length check allows 1-byte OOB read
VJ Updated by Victor Julien 7 days ago
- Tracker changed from Security to Bug
- Private changed from Yes to No
Looks like this is addressed by https://github.com/OISF/suricata/pull/15796
VJ Updated by Victor Julien 7 days ago
- Status changed from New to In Review
- Assignee changed from OISF Dev to Victor Julien
SB Updated by Shivani Bhardwaj 3 days ago
- Subject changed from decode/ipv6: Off-by-one in IPv6 Hop-by-Hop/Destination Options sub-option length check allows 1-byte OOB read to decode/ipv6: OOB read with Hop-by-Hop and Destination Options extension headers
Actions