Actions
Bug #8841
open
SB
VJ
mpm: AC-KS large-table search truncates 32-bit state to uint16_t before output lookup → detection bypass / OOB read
Bug #8841:
mpm: AC-KS large-table search truncates 32-bit state to uint16_t before output lookup → detection bypass / OOB read
Affected Versions:
Effort:
Difficulty:
Label:
Description
Reported by Communications Security Establishment (CSE):
## Summary
The Aho-Corasick "Ken Steele" multi-pattern matcher (`ac-ks`) selects
`SCACTileSearchLarge()` whenever the compiled automaton has 32767 or more states and
stores next-state indices in 4-byte cells. The running state variable in that loop can
therefore legitimately exceed 65535, yet on every potential match it is passed through a
`(uint16_t)` cast to `CheckMatch()`, whose `state` parameter is itself declared
`uint16_t`. The high bits of the state index are silently discarded and
`ctx->output_table` is dereferenced for the *wrong* state. The truncated index is always
in-bounds and the offset guard in `CheckMatch()` prevents any out-of-buffer read, so the
net effect is a pure logic flaw: every signature whose MPM fast-pattern terminates in an
AC state with index ≥ 65536 is permanently and silently skipped by the prefilter — a
deterministic detection bypass for that slice of the ruleset.
## Affected Piece of Code
- **File:** `src/util-mpm-ac-ks.c`
- **Function / Location:** `SCACTileSearchLarge()` ~L1134-1156 and `CheckMatch()`
~L1065-1107
- **Subsystem:** util-mpm-spm — Multi-pattern and single-pattern matchers (Aho-Corasick,
Hyperscan, Boyer-Moore)
```c
/* src/util-mpm-ac-ks.c */
1065 static int CheckMatch(const SCACTileSearchCtx *ctx, PrefilterRuleStore *pmq,
1066 const uint8_t *buf, uint32_t buflen,
1067 uint16_t state, int i, int matches,
1068 uint8_t *mpm_bitarray)
1069 {
1070 const SCACTilePatternList *pattern_list = ctx->pattern_list;
1071 const uint8_t *buf_offset = buf + i + 1; // Lift out of loop
1072 uint32_t no_of_entries = ctx->output_table[state].no_of_entries;
1073 MpmPatternIndex *patterns = ctx->output_table[state].patterns;
...
1082 const SCACTilePatternList *pat = &pattern_list[pindex];
1083 const int offset = i - pat->patlen + 1;
1084 if (offset < (int)pat->offset || (pat->depth && i > pat->depth))
1085 continue;
...
1133 /* This function handles (ctx->state_count >= 32767) */
1134 uint32_t SCACTileSearchLarge(const SCACTileSearchCtx *ctx, MpmThreadCtx *mpm_thread_ctx,
1135 PrefilterRuleStore *pmq,
1136 const uint8_t *buf, uint32_t buflen)
1137 {
...
1144 const uint8_t* restrict xlate = ctx->translate_table;
1145 register int state = 0;
1146 int32_t (*state_table_u32)[256] = ctx->state_table;
1147 for (i = 0; i < buflen; i++) {
1148 state = state_table_u32[state & 0x00FFFFFF][xlate[buf[i]]];
1149 if (SCHECK(state)) {
1150 DEBUG_VALIDATE_BUG_ON(state < 0 || state > UINT16_MAX);
1151 matches = CheckMatch(ctx, pmq, buf, buflen, (uint16_t)state, i, matches, mpm_bitarray);
1152 }
1153 } /* for (i = 0; i < buflen; i++) */
```
## The Bug
### Root cause
`SCACTileCreateDeltaTable()` chooses the search routine based on the final automaton
size. When `ctx->state_count < 32767` it picks one of the `Small*`/`Tiny*` variants (1-
or 2-byte state cells); otherwise it falls through to:
```c
/* src/util-mpm-ac-ks.c:618-624 */
} else {
/* 32-bit next state */
ctx->Search = SCACTileSearchLarge;
ctx->bytes_per_state = 4;
ctx->SetNextState = SCACTileSetState4Bytes;
ctx->alphabet_storage = 256;
}
```
`SCACTileSetState4Bytes()` (`:539-552`) writes each delta-table cell as a 32-bit word
holding the 24-bit next-state index in the low bits and a single "no-output" flag in bit
31. In `SCACTileSearchLarge()` the loop loads that cell into `register int state` and
uses `state & 0x00FFFFFF` to index the next row. Up to this point the full 24-bit index
is preserved.
When `SCHECK(state)` (`#define SCHECK(x) ((x) > 0)`, line 1056) is true — i.e. bit 31 is
clear, meaning this state has at least one output pattern — line 1151 calls:
```c
matches = CheckMatch(ctx, pmq, buf, buflen, (uint16_t)state, i, matches, mpm_bitarray);
```
`CheckMatch()`'s fourth parameter is declared `uint16_t state` (line 1067), so the
explicit cast plus the implicit prototype conversion strip everything above bit 15.
Inside `CheckMatch()` that truncated value is used directly to index the output table:
```c
uint32_t no_of_entries = ctx->output_table[state].no_of_entries;
MpmPatternIndex *patterns = ctx->output_table[state].patterns;
```
For any output state `S` with `65536 ≤ S < state_count`, the function inspects
`output_table[S & 0xFFFF]` instead of `output_table[S]`.
The `DEBUG_VALIDATE_BUG_ON(state < 0 || state > UINT16_MAX)` on line 1150 explicitly
acknowledges the hazard, but `DEBUG_VALIDATE_BUG_ON` compiles to nothing unless
`DEBUG_VALIDATION` is enabled, so production builds have no runtime guard.
### Why this is a bypass and not a memory-safety bug
Two properties bound the consequences to a logic error:
1. **`output_table` index is in-bounds.** The truncated index is `< 65536`.
`SCACTileSearchLarge` is only selected when `state_count ≥ 32767`, and the truncation
only matters once `state_count > 65536`; in that regime `output_table` has at least
`state_count > 65536` entries, so `output_table[S & 0xFFFF]` is always a valid slot.
2. **The haystack read is guarded.** The aliased slot may belong to a different pattern
with a longer `patlen` than the bytes consumed so far, which would make
`buf_offset - patlen` point before `buf`. However, line 1083 computes
`offset = i - pat->patlen + 1` and line 1084 enforces `offset >= (int)pat->offset`
(with `pat->offset ≥ 0`), so any case where `patlen > i + 1` is rejected before the
`SCMemcmp` at line 1090. No out-of-bounds read occurs.
The aliased slot is therefore either:
- a non-terminal interior node with `no_of_entries == 0` → the loop body never runs →
the genuine match is dropped; or
- a terminal node for an unrelated pattern → the offset/`SCMemcmp` recheck almost always
fails (different bytes), and even if it spuriously passes, the resulting extra
prefilter candidate is discarded later by the full content-match stage of the
detection engine.
Either way, the *correct* pattern's SIDs are never added to the `PrefilterRuleStore`.
Because the MPM prefilter is the gate that decides which signatures `DetectRun()` will
evaluate at all, every rule whose fast-pattern terminates in a state ≥ 65536 is silently
and permanently disabled.
### Network-reachable call chain
```
Packet capture source (ReceivePcap / ReceiveAFP, src/source-*.c)
→ decoder chain: DecodeEthernet → DecodeIPV4 → DecodeUDP/DecodeTCP (src/decode-*.c, fills p->payload)
→ FlowWorker() src/flow-worker.c:562
→ Detect() src/detect.c:2892
→ DetectFlow() / DetectNoFlow() src/detect.c:2804 / :2850
→ DetectRun() src/detect.c:106
→ DetectRunPrefilterPkt() src/detect.c:622
→ Prefilter() src/detect-engine-prefilter.c:219
→ engine->cb.Prefilter = PrefilterPktPayload() src/detect-engine-payload.c:116
→ mpm_table[MPM_AC_KS].Search = SCACTileSearch() src/util-mpm-ac-ks.c:1121
→ search_ctx->Search = SCACTileSearchLarge() src/util-mpm-ac-ks.c:1134
(selected at build time in SCACTileCreateDeltaTable() :618-622
because ctx->state_count >= 32767)
→ CheckMatch() src/util-mpm-ac-ks.c:1065
```
### Required field values to trigger
- `suricata.yaml`: `mpm-algo: ac-ks`.
- An MPM group whose Aho-Corasick automaton has > 65536 states — readily achieved with
`detect.sgh-mpm-context: single` and a large commercial/ET ruleset, or any ruleset
with many distinct fast-patterns.
- Packet payload bytes equal to any pattern whose terminal AC state index is ≥ 65536.
State indices are assigned sequentially by `SCACTileInitNewState()` during goto-table
construction, so the affected patterns are simply those whose trie nodes are created
after state 65535 — i.e. the tail of the load order once shared prefixes are accounted
for.
**Vulnerability class:** network-reachable integer truncation → logic bypass (initially
suspected OOB read; ruled out on re-analysis — see Severity).
## Reproduction Results
This is an **analytically-derived trigger** validated against the source. A real packet
trigger against a production deployment depends on the operator's exact rule mix,
because AC state indices are a function of rule load order and shared prefixes; with a
controlled synthetic ruleset the trigger is fully deterministic.
1. **Configuration** — `suricata.yaml`:
```yaml
mpm-algo: ac-ks
detect:
sgh-mpm-context: single
```
2. **Synthetic ruleset** — generate 70 000 rules whose `fast_pattern` contents are
3-byte big-endian counters `0x000000 .. 0x01116F`. Every byte value 0-255 appears, so
the ac-ks `translate_table` keeps the full alphabet. Each 3-byte pattern contributes
one new trie leaf; total distinct prefixes ≈ 256 (len-1) + 65536 (len-2) + 70000
(len-3) + 1 root ≈ 135 793 states, comfortably > 65 536, forcing
`SCACTileSearchLarge`.
```sh
for i in $(seq 0 69999); do
printf 'alert udp any any -> any 9999 (msg:"p%05d"; content:"|%02x %02x %02x|"; fast_pattern; sid:%d; rev:1;)\n' \
$i $((i>>16&255)) $((i>>8&255)) $((i&255)) $((1000000+i));
done > big.rules
```
States are numbered in pattern-insertion order (`SCACTileCreateGotoTable` iterates
`parray[0..N-1]` and `SCACTileEnter` walks/creates one node per new prefix byte), so
patterns inserted after the cumulative prefix count passes 65 536 — roughly
`sid ≥ 1065280` (`i ≥ 0x00FF00`) — have terminal state index ≥ 65 536.
3. **Run** Suricata in IDS mode reading from a pcap or live interface with `big.rules`
loaded.
4. **Send** one UDP datagram to port 9999 whose 3-byte payload is the content of a
high-index rule, e.g. `i = 69000 = 0x010D88`:
```
Ethernet / IPv4 / UDP dst-port 9999, UDP payload = 01 0d 88
```
```python
# scapy
send(IP(dst=TARGET)/UDP(dport=9999)/b"\x01\x0d\x88")
```
5. **Result:**
- *Expected (correct) behaviour:* alert for `sid:1069000`.
- *Observed:* no alert. In `SCACTileSearchLarge` the automaton reaches the terminal
state `S` (≥ 65 536) for `"|01 0d 88|"`, `SCHECK(S)` is true, but `CheckMatch`
receives `S & 0xFFFF`. `ctx->output_table[S & 0xFFFF]` is the output list of an
unrelated low-numbered state — typically a non-terminal interior node with
`no_of_entries == 0`, or a terminal for a different 3-byte pattern that fails the
offset/`SCMemcmp` recheck — so `PrefilterAddSids` never adds sid 1069000 and
`DetectRun` never evaluates the signature.
**Reproduction caveat / blocker for a one-shot packet against a production sensor:** the
attacker must know (or brute-force) which of the operator's signatures landed in states
≥ 65 536, since that depends on rule load order and shared prefixes. With the controlled
synthetic ruleset above the trigger is fully deterministic; against an unknown ruleset
it is probabilistic per-rule but guaranteed to affect *some* subset whenever the
automaton exceeds 65 536 states.
## Severity
**MEDIUM** — Detection bypass. Any signature whose MPM fast-pattern terminates in an
Aho-Corasick state with index ≥ 65 536 is never pre-filtered into the candidate set, so
the rule never fires regardless of payload — a silent, permanent false-negative for that
subset of the ruleset.
Both independent verification passes and re-analysis confirm there is **no out-of-bounds
read or crash**: the truncated index is always `< 65536 ≤ state_count` (in-bounds for
`output_table`), and `CheckMatch()`'s `if (offset < (int)pat->offset) continue;` guard
at `:1083-1085` guarantees `offset ≥ 0` before the `SCMemcmp`, so memory safety is
preserved. Spurious prefilter hits from the aliased slot are harmless because the full
detection engine re-verifies content.
Impact class: **logic / detection-bypass only** — no DoS, no info-leak, no RCE. Rated
MEDIUM rather than HIGH because it requires the non-default `mpm-algo: ac-ks` plus a
ruleset large enough to exceed 65 536 AC states, and the attacker cannot directly choose
*which* rules are blinded.
## Suggested Fix
Widen the `state` parameter so the full 24-bit state index reaches `output_table`, and
drop the now-pointless `DEBUG_VALIDATE` / cast:
```diff
--- a/src/util-mpm-ac-ks.c
+++ b/src/util-mpm-ac-ks.c
@@ -1065,7 +1065,7 @@
static int CheckMatch(const SCACTileSearchCtx *ctx, PrefilterRuleStore *pmq,
const uint8_t *buf, uint32_t buflen,
- uint16_t state, int i, int matches,
+ uint32_t state, int i, int matches,
uint8_t *mpm_bitarray)
{
@@ -1147,10 +1147,9 @@
for (i = 0; i < buflen; i++) {
state = state_table_u32[state & 0x00FFFFFF][xlate[buf[i]]];
if (SCHECK(state)) {
- DEBUG_VALIDATE_BUG_ON(state < 0 || state > UINT16_MAX);
- matches = CheckMatch(ctx, pmq, buf, buflen, (uint16_t)state, i, matches, mpm_bitarray);
+ matches = CheckMatch(ctx, pmq, buf, buflen,
+ (uint32_t)(state & 0x00FFFFFF), i, matches, mpm_bitarray);
}
}
```
The `Small*` / `Tiny*` search variants pass values that already fit in 15 / 7 bits, so
widening the parameter is backward-compatible with all callers. Optionally also add a
startup `FatalError` in `SCACTilePreparePatterns()` when `state_count > 0x00FFFFFF` to
make the 24-bit ceiling explicit.
VJ Updated by Victor Julien 23 days ago
- Subject changed from AC-KS large-table search truncates 32-bit state to uint16_t before output lookup → detection bypass / OOB read to mpm: AC-KS large-table search truncates 32-bit state to uint16_t before output lookup → detection bypass / OOB read
- Status changed from New to Assigned
- Assignee changed from OISF Dev to Victor Julien
VJ Updated by Victor Julien 22 days ago
- Status changed from Assigned to In Review
PA Updated by Philippe Antoine 2 days ago
I would say this is just a bug as I do not see how an attacker can exploit this...
VJ Updated by Victor Julien 1 day ago
- Tracker changed from Security to Bug
- Private changed from Yes to No
- GHSA deleted (
GHSA-886c-wqx9-36w4)
VJ Updated by Victor Julien about 14 hours ago
Actions