Project

General

Profile

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

Added by Shivani Bhardwaj 23 days ago. Updated about 15 hours ago.

Status:
In Review
Priority:
Normal
Assignee:
Target version:
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.
Actions

Also available in: PDF Atom