|
| 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