Skip to content

Commit 17f3445

Browse files
committed
test: regression coverage for collection_unpack value_len underflow
Standalone harness that replicates the unpack arithmetic and asserts that the patched bound check rejects a value_len=0 blob while the buggy version would have produced a 4 GiB copy length. Runs without APR / Apache build dependencies so it integrates into `make check` as a self-contained TAP test.
1 parent e22b706 commit 17f3445

2 files changed

Lines changed: 306 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
TESTS = test_persist_dbm_value_len
2+
check_PROGRAMS = test_persist_dbm_value_len
3+
test_persist_dbm_value_len_SOURCES = test_persist_dbm_value_len.c
4+
test_persist_dbm_value_len_CFLAGS = -fsanitize=address,undefined -O0 -g -Wall
5+
test_persist_dbm_value_len_LDFLAGS = -fsanitize=address,undefined
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
/*
2+
* Regression test for collection_unpack value_len underflow.
3+
*
4+
* The original bug was reported in modsecurity#3082 and fixed by commit
5+
* 4f33f5b ("Fix possible segfault in collection_unpack", 2024-03-01).
6+
* The fix was silently dropped during merge 649aea7 (2024-04-04) and
7+
* has been missing from every release v2.9.10 .. v2.9.13.
8+
*
9+
* This test asserts that:
10+
*
11+
* (1) the BUGGY arithmetic, when invoked with a value_len of 0,
12+
* produces a copy length of UINT_MAX (this is the bug);
13+
*
14+
* (2) the PATCHED predicate (`value_len < 1`) rejects the same
15+
* blob and returns NULL, so apr_pstrmemdup is never called.
16+
*
17+
* The test does NOT link against libmodsecurity or APR. It replicates
18+
* the exact integer arithmetic of collection_unpack so that it can be
19+
* cherry-picked into any v2 source tree and run as part of CI without
20+
* touching APR / Apache build dependencies. Combined with -fsanitize=
21+
* address,undefined it also exercises the runtime sanitizers' view of
22+
* the underflow.
23+
*
24+
* Build (host):
25+
*
26+
* cc -O0 -g -Wall -Wextra -fsanitize=address,undefined \
27+
* test_persist_dbm_value_len.c -o test_persist_dbm_value_len
28+
* ./test_persist_dbm_value_len
29+
*
30+
* Exit codes:
31+
*
32+
* 0 = both before-fix and after-fix paths behaved as expected
33+
* (test PASSED — confirms regression and confirms patch fixes it)
34+
* 1 = test FAILED (either path returned an unexpected result)
35+
*
36+
* Drop into upstream:
37+
*
38+
* tests/regression/persist_dbm/test_persist_dbm_value_len.c
39+
* add to tests/regression/persist_dbm/Makefile.am
40+
*/
41+
42+
#define _GNU_SOURCE
43+
#include <assert.h>
44+
#include <inttypes.h>
45+
#include <limits.h>
46+
#include <stdbool.h>
47+
#include <stddef.h>
48+
#include <stdint.h>
49+
#include <stdio.h>
50+
#include <stdlib.h>
51+
#include <string.h>
52+
53+
/* ------------------------------------------------------------------------
54+
* Faithful replica of collection_unpack's deserialisation arithmetic.
55+
*
56+
* The two functions below differ ONLY in the `value_len < 1 ||` clause
57+
* that 4f33f5b added and 649aea7 removed. The blob layout, the read of
58+
* the 16-bit big-endian length, and the apr_pstrmemdup() argument
59+
* computation are otherwise byte-for-byte identical.
60+
*
61+
* Returns:
62+
* 0 -> rejected the blob (return NULL path was taken)
63+
* 1 -> would have called apr_pstrmemdup with copy_len = *out_copy_len
64+
* -1 -> blob malformed in a way unrelated to this regression
65+
* ------------------------------------------------------------------------ */
66+
67+
static int unpack_buggy(const unsigned char *blob, unsigned int blob_size,
68+
size_t *out_copy_len)
69+
{
70+
unsigned int blob_offset = 3; /* skip 3-byte header */
71+
unsigned int name_len, value_len;
72+
73+
if (blob_offset + 1 >= blob_size) return -1;
74+
name_len = (blob[blob_offset] << 8) + blob[blob_offset + 1];
75+
76+
/* (preserved zero-check on name_len; only value_len is the regression) */
77+
if (name_len == 0) return -1;
78+
if (name_len > 65536) return -1;
79+
blob_offset += 2;
80+
81+
/* === buggy bound check on name (no `< 1` clause) ===================== */
82+
if (blob_offset + name_len > blob_size) return 0;
83+
/* name pstrmemdup(name_len - 1) would happen here */
84+
blob_offset += name_len;
85+
86+
if (blob_offset + 1 >= blob_size) return -1;
87+
value_len = (blob[blob_offset] << 8) + blob[blob_offset + 1];
88+
blob_offset += 2;
89+
90+
/* === buggy bound check on value (no `< 1` clause) ==================== *
91+
* THIS is the line that 4f33f5b patched and 649aea7 reverted. */
92+
if (blob_offset + value_len > blob_size) return 0;
93+
94+
/* The actual call inside collection_unpack is:
95+
*
96+
* var->value = apr_pstrmemdup(msr->mp,
97+
* (const char *)blob + blob_offset,
98+
* var->value_len - 1);
99+
*
100+
* with var->value_len declared `unsigned int`. If value_len == 0,
101+
* `value_len - 1` wraps to UINT_MAX. Reproduce that arithmetic
102+
* verbatim so the sanitizers see exactly the same operation. */
103+
size_t copy_len = (size_t)(unsigned int)(value_len - 1);
104+
if (out_copy_len) *out_copy_len = copy_len;
105+
return 1;
106+
}
107+
108+
static int unpack_patched(const unsigned char *blob, unsigned int blob_size,
109+
size_t *out_copy_len)
110+
{
111+
unsigned int blob_offset = 3;
112+
unsigned int name_len, value_len;
113+
114+
if (blob_offset + 1 >= blob_size) return -1;
115+
name_len = (blob[blob_offset] << 8) + blob[blob_offset + 1];
116+
117+
if (name_len == 0) return -1;
118+
if (name_len > 65536) return -1;
119+
blob_offset += 2;
120+
121+
/* === patched bound check on name ==================================== */
122+
if (name_len < 1 || blob_offset + name_len > blob_size) return 0;
123+
blob_offset += name_len;
124+
125+
if (blob_offset + 1 >= blob_size) return -1;
126+
value_len = (blob[blob_offset] << 8) + blob[blob_offset + 1];
127+
blob_offset += 2;
128+
129+
/* === patched bound check on value (the fix from 4f33f5b) ============= */
130+
if (value_len < 1 || blob_offset + value_len > blob_size) return 0;
131+
132+
size_t copy_len = (size_t)(unsigned int)(value_len - 1);
133+
if (out_copy_len) *out_copy_len = copy_len;
134+
return 1;
135+
}
136+
137+
/* ------------------------------------------------------------------------ *
138+
* Test cases.
139+
* ------------------------------------------------------------------------ */
140+
141+
struct case_t {
142+
const char *name;
143+
const unsigned char *blob;
144+
unsigned int blob_size;
145+
int expect_buggy_rc;
146+
size_t expect_buggy_copy_len; /* only checked when rc==1 */
147+
int expect_patched_rc;
148+
size_t expect_patched_copy_len;
149+
};
150+
151+
/* Case 1 — the malicious blob from poc_dbm_oob.c. Triggers the regression. */
152+
static const unsigned char blob_value_len_zero[] = {
153+
0x49, 0x52, 0x01, /* SDBM-side header */
154+
0x00, 0x01, /* name_len = 1 */
155+
0x00, /* name = "" */
156+
0x00, 0x00, /* value_len = 0 ← BUG TRIGGER */
157+
};
158+
159+
/* Case 2 — well-formed blob, name="", value="X". Both versions accept. */
160+
static const unsigned char blob_well_formed[] = {
161+
0x49, 0x52, 0x01,
162+
0x00, 0x01, /* name_len = 1 */
163+
0x00, /* name = "" */
164+
0x00, 0x02, /* value_len = 2 (X + NUL) */
165+
'X', 0x00,
166+
};
167+
168+
/* Case 3 — value_len exactly 1 (just the trailing NUL of empty value). */
169+
static const unsigned char blob_empty_value[] = {
170+
0x49, 0x52, 0x01,
171+
0x00, 0x01, /* name_len = 1 */
172+
0x00, /* name = "" */
173+
0x00, 0x01, /* value_len = 1 (just NUL) */
174+
0x00,
175+
};
176+
177+
/* Case 4 — value_len overflows blob_size (different bug class, both reject). */
178+
static const unsigned char blob_value_len_too_big[] = {
179+
0x49, 0x52, 0x01,
180+
0x00, 0x01,
181+
0x00,
182+
0xFF, 0xFF, /* value_len = 65535, way past blob_size */
183+
};
184+
185+
static const struct case_t cases[] = {
186+
{
187+
.name = "value_len = 0 (CVE candidate)",
188+
.blob = blob_value_len_zero,
189+
.blob_size = sizeof(blob_value_len_zero),
190+
.expect_buggy_rc = 1,
191+
.expect_buggy_copy_len = (size_t)UINT_MAX,
192+
.expect_patched_rc = 0,
193+
.expect_patched_copy_len = 0,
194+
},
195+
{
196+
.name = "well-formed value_len = 2",
197+
.blob = blob_well_formed,
198+
.blob_size = sizeof(blob_well_formed),
199+
.expect_buggy_rc = 1,
200+
.expect_buggy_copy_len = 1,
201+
.expect_patched_rc = 1,
202+
.expect_patched_copy_len = 1,
203+
},
204+
{
205+
.name = "empty value (value_len = 1)",
206+
.blob = blob_empty_value,
207+
.blob_size = sizeof(blob_empty_value),
208+
.expect_buggy_rc = 1,
209+
.expect_buggy_copy_len = 0,
210+
.expect_patched_rc = 1,
211+
.expect_patched_copy_len = 0,
212+
},
213+
{
214+
.name = "value_len > blob_size",
215+
.blob = blob_value_len_too_big,
216+
.blob_size = sizeof(blob_value_len_too_big),
217+
.expect_buggy_rc = 0,
218+
.expect_buggy_copy_len = 0,
219+
.expect_patched_rc = 0,
220+
.expect_patched_copy_len = 0,
221+
},
222+
};
223+
224+
static const size_t n_cases = sizeof(cases) / sizeof(cases[0]);
225+
226+
/* ------------------------------------------------------------------------ *
227+
* Runner.
228+
* ------------------------------------------------------------------------ */
229+
230+
static int run_one(const struct case_t *c)
231+
{
232+
size_t buggy_copy = 0, patched_copy = 0;
233+
int buggy_rc = unpack_buggy (c->blob, c->blob_size, &buggy_copy);
234+
int patched_rc = unpack_patched(c->blob, c->blob_size, &patched_copy);
235+
236+
bool ok = true;
237+
238+
if (buggy_rc != c->expect_buggy_rc) {
239+
printf(" [FAIL] buggy_rc: got %d, want %d\n",
240+
buggy_rc, c->expect_buggy_rc);
241+
ok = false;
242+
} else if (buggy_rc == 1 && buggy_copy != c->expect_buggy_copy_len) {
243+
printf(" [FAIL] buggy_copy: got %zu, want %zu\n",
244+
buggy_copy, c->expect_buggy_copy_len);
245+
ok = false;
246+
}
247+
248+
if (patched_rc != c->expect_patched_rc) {
249+
printf(" [FAIL] patched_rc: got %d, want %d\n",
250+
patched_rc, c->expect_patched_rc);
251+
ok = false;
252+
} else if (patched_rc == 1 && patched_copy != c->expect_patched_copy_len) {
253+
printf(" [FAIL] patched_copy: got %zu, want %zu\n",
254+
patched_copy, c->expect_patched_copy_len);
255+
ok = false;
256+
}
257+
258+
if (ok) {
259+
printf(" buggy_rc=%d buggy_copy=%-12zu"
260+
" patched_rc=%d patched_copy=%zu\n",
261+
buggy_rc, buggy_copy, patched_rc, patched_copy);
262+
}
263+
return ok ? 0 : 1;
264+
}
265+
266+
int main(void)
267+
{
268+
printf("collection_unpack value_len underflow regression test\n");
269+
printf("======================================================\n\n");
270+
271+
/* Sanity: the buggy path's copy_len for value_len=0 must really
272+
* underflow. If a future compiler does something clever and the
273+
* underflow goes away, this test might silently start passing
274+
* even on the buggy code. Force a runtime assert. */
275+
{
276+
unsigned int v = 0;
277+
size_t got = (size_t)(unsigned int)(v - 1);
278+
if (got != (size_t)UINT_MAX) {
279+
fprintf(stderr,
280+
"FATAL: this platform's unsigned int wrap is %zu,"
281+
" not UINT_MAX. Test cannot meaningfully run.\n", got);
282+
return 2;
283+
}
284+
}
285+
286+
int fails = 0;
287+
for (size_t i = 0; i < n_cases; i++) {
288+
printf("[%zu/%zu] %s\n", i + 1, n_cases, cases[i].name);
289+
fails += run_one(&cases[i]);
290+
printf("\n");
291+
}
292+
293+
printf("======================================================\n");
294+
if (fails == 0) {
295+
printf("PASS — regression confirmed in buggy path, fixed in patched path\n");
296+
return 0;
297+
} else {
298+
printf("FAIL — %d case(s) did not match expectations\n", fails);
299+
return 1;
300+
}
301+
}

0 commit comments

Comments
 (0)