{"repro_id":"REPRO-2026-00183","version":7,"title":"MapServer: heap-buffer-overflow in SLD Categorize parser (msSLDParseRasterSymbolizer)","repro_type":"security","status":"published","severity":"high","description":"MapServer is a CGI binary that parses OGC SLD (Styled Layer Descriptor) XML to style raster/vector layers it serves over WMS/WFS. `msSLDParseRasterSymbolizer` in `src/mapogcsld.cpp` (around line 2894) allocates a fixed-size buffer for **100** threshold pointers for a `<se:Categorize>` element. The reallocation guard checks the **wrong variable** — `nValues == nMaxThreshold` instead of `nThresholds == nMaxThreshold`. Because `nValues` increments at a different rate than `nThresholds`, reallocation never fires when the SLD contains more than 100 `<se:Threshold>` children, and the subsequent pointer writes spill past the 100-slot array.\n\nThe bug is reachable **unauthenticated** on any MapServer instance that accepts an SLD body in its WMS request (`SLD_BODY` parameter is the standard OGC angle; `SLD` URL-by-reference also reaches the same parser). Practical impact is at minimum a worker-process crash (CVSS `A:H` per NIST); depending on heap state, the spilled pointers may be controllable enough for a stronger consequence — that's outside the scope this ticket asks for, but it does NOT cap at \"ASAN goes brr\". The reproduction MUST hit the real `mapserv` CGI entry, not a unit-test harness.","root_cause":"# Root Cause Analysis — CVE-2026-33721\n\n## Summary\n\nMapServer versions 4.2.0 through 8.6.0 contain a heap-buffer-overflow vulnerability in the OGC SLD (Styled Layer Descriptor) XML parser. The function `msSLDParseRasterSymbolizer` in `src/mapogcsld.cpp` allocates a fixed-size buffer for 100 threshold pointers when parsing a `<se:Categorize>` element. The reallocation guard incorrectly checks `nValues == nMaxThreshold` instead of `nThresholds == nMaxThreshold`. Because `nValues` and `nThresholds` increment at different rates, the buffer is never expanded when more than 100 `<se:Threshold>` children are present, causing subsequent pointer writes to spill past the 800-byte (100 × 8) array boundary. The bug is reachable unauthenticated via the `SLD_BODY` parameter in a WMS `GetMap` request.\n\n## Impact\n\n- **Package**: OSGeo MapServer (`mapserv` CGI binary)\n- **Affected versions**: 4.2.0 — 8.6.0 (last vulnerable tag: `rel-8-6-0`)\n- **Fixed version**: 8.6.1 (`rel-8-6-1`)\n- **Risk level**: High (CVSS 3.1: 7.5)\n- **Consequences**: At minimum, an attacker can crash the MapServer worker process (denial of service). Depending on heap layout and input control, the overflowed pointers may be further exploitable.\n\n## Root Cause\n\nIn `src/mapogcsld.cpp`, around line 2880, `msSLDParseRasterSymbolizer` initializes:\n\n```cpp\nint nMaxThreshold = 100;\nchar **papszThresholds = (char **)msSmallMalloc(sizeof(char *) * nMaxThreshold);\n```\n\nAs the parser iterates over `<se:Categorize>` children, every `<se:Threshold>` increments `nThresholds` and stores a pointer into `papszThresholds`. The growth guard is meant to reallocate the array when it fills:\n\n```cpp\n// VULNERABLE (rel-8-6-0):\nif (nValues == nMaxThreshold) {\n    nMaxThreshold += 100;\n    papszThresholds = (char **)msSmallRealloc(\n        papszThresholds, sizeof(char *) * nMaxThreshold);\n}\n```\n\nHowever, `nValues` tracks a *different* counter (the number of `<se:Value>` elements), while `nThresholds` tracks the actual number of threshold entries. When an SLD contains more than 100 thresholds, `nThresholds` exceeds 100 but `nValues` may still be much lower (e.g., 1), so reallocation never occurs. The next `papszThresholds[nThresholds] = …` write lands beyond the 100-slot buffer, producing a heap-buffer-overflow.\n\n**Fix commit**: `ddd246b90acc6c7f920dfd056f33613cebe9154d`  \n**Merge commit**: `7dbe91b`  \nThe patch changes exactly one line:\n\n```diff\n-          if (nValues == nMaxThreshold) {\n+          if (nThresholds == nMaxThreshold) {\n```\n\nThis ensures the array is resized based on the same counter that tracks live entries.\n\n## Reproduction Steps\n\nThe complete reproduction is automated in `repro/reproduction_steps.sh`. It performs the following:\n\n1. Clones MapServer at `rel-8-6-0` (vulnerable) and `rel-8-6-1` (fixed).\n2. Builds both `mapserv` binaries with AddressSanitizer (`-fsanitize=address`).\n3. Generates a minimal `test.map`, a tiny GeoTIFF (`tiny.tif`), and a malicious SLD payload (`payload.sld`) containing 200 `<se:Threshold>` elements.\n4. Invokes the vulnerable binary through the real CGI path (`QUERY_STRING` + `REQUEST_METHOD=GET`) with the SLD payload via `SLD_BODY`.\n5. Captures the ASAN crash log (`logs/vulnerable_asan.txt`).\n6. Runs the same request against the fixed binary and verifies it returns a valid PNG image with no ASAN error (`logs/fixed_response.txt`).\n\n## Evidence\n\n- **Vulnerable ASAN log**: `logs/vulnerable_asan.txt`\n  - Key excerpt:\n    ```\n    ==10268==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x518000008fa0\n    WRITE of size 8 at 0x518000008fa0 thread T0\n        #0 0x7fd97611c7d4 in msSLDParseRasterSymbolizer /.../src/mapogcsld.cpp:2895\n    0x518000008fa0 is located 0 bytes after 800-byte region [0x518000008c80,0x518000008fa0)\n    ```\n- **Fixed response**: `logs/fixed_response.txt`\n  - Starts with `Content-Type: image/png` and contains a valid PNG stream, confirming the fixed binary processes the same payload without crashing.\n- **Runtime manifest**: `repro/runtime_manifest.json` — records binary paths, tags, crash signature, and payload details.\n- **Validation verdict**: `repro/validation_verdict.json` — marks status `confirmed`.\n\n## Recommendations / Next Steps\n\n1. **Immediate fix**: Upgrade to MapServer 8.6.1 or later. The patch is a single-line bound-check correction and carries no functional regression.\n2. **Defensive measure**: If upgrading is not immediately possible, restrict the `SLD_BODY` / `SLD` query parameters at the reverse-proxy or WAF level, or disable WMS SLD support in the MapServer configuration.\n3. **Testing**: Add a regression test that submits an SLD with >100 thresholds and asserts the process does not crash.\n4. **Code-review note**: When arrays are grown dynamically, always use the same counter for both indexing and resize checks.\n\n## Additional Notes\n\n- **Idempotency**: `repro/reproduction_steps.sh` was run twice consecutively with identical results (confirmed heap-buffer-overflow on vulnerable, valid PNG on fixed).\n- **Edge cases**: The crash is triggered with any SLD containing >100 `<se:Threshold>` children inside a `<se:Categorize>` block. The exact threshold count is 100; 101 is sufficient to overflow. The reproduction uses 200 to provide a clear safety margin.\n- **Limitations**: The reproduction requires a functional GDAL/PROJ/libxml2 environment. The script installs missing Debian/Ubuntu packages automatically if they are absent.\n","ghsa_id":"GHSA-cv4m-mr84-fgjp","cve_id":"CVE-2026-33721","cwe_id":"CWE-787 (Out-of-bounds Write)","package":{"name":"mapserver","ecosystem":"c","affected_versions":"4.2.0 - 8.6.0","fixed_version":"8.6.1"},"reproduced_at":"2026-05-28T14:02:42.831612+00:00","duration_secs":873.5309588909149,"tool_calls":177,"turns":142,"handoffs":2,"total_cost_usd":1.3348599300000008,"agent_costs":{"repro":0.43612442,"support":0.0164045,"vuln_variant":0.8823310100000002},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":0.43612442},"support":{"accounts/fireworks/models/kimi-k2p6":0.0164045},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":0.8823310100000002}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-28T14:02:45.451657+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":5443,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":7558,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":8924,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":8108,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":3480,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":809,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":6591,"category":"ticket"},{"path":"bundle/repro/tiny.tif","filename":"tiny.tif","size":466,"category":"other"},{"path":"bundle/repro/mapserver.conf","filename":"mapserver.conf","size":46,"category":"other"},{"path":"bundle/repro/gen_sld.py","filename":"gen_sld.py","size":803,"category":"script"},{"path":"bundle/repro/payload.sld","filename":"payload.sld","size":7058,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":848,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":407,"category":"other"},{"path":"bundle/repro/gen_tiny.py","filename":"gen_tiny.py","size":549,"category":"script"},{"path":"bundle/repro/test.map","filename":"test.map","size":358,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":2580,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":4455,"category":"documentation"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":3536,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1798,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":3103,"category":"other"},{"path":"bundle/logs/vulnerable_asan.txt","filename":"vulnerable_asan.txt","size":5868,"category":"other"},{"path":"bundle/logs/variant_onlythresholds_fixed.log","filename":"variant_onlythresholds_fixed.log","size":0,"category":"log"},{"path":"bundle/logs/variant_onlythresholds_vuln.log","filename":"variant_onlythresholds_vuln.log","size":0,"category":"log"},{"path":"bundle/logs/fixed_response.txt","filename":"fixed_response.txt","size":297,"category":"other"},{"path":"bundle/logs/variant_run1.log","filename":"variant_run1.log","size":1288,"category":"log"},{"path":"bundle/logs/variant_overflowprobe_vuln.log","filename":"variant_overflowprobe_vuln.log","size":5868,"category":"log"},{"path":"bundle/logs/variant_overflowprobe_fixed.log","filename":"variant_overflowprobe_fixed.log","size":0,"category":"log"},{"path":"bundle/logs/variant_getstyles_vuln.log","filename":"variant_getstyles_vuln.log","size":5854,"category":"log"},{"path":"bundle/logs/variant_legendgraphic_vuln.log","filename":"variant_legendgraphic_vuln.log","size":5862,"category":"log"},{"path":"bundle/logs/variant_baseline_vuln.log","filename":"variant_baseline_vuln.log","size":5868,"category":"log"},{"path":"bundle/logs/variant_reordered_vuln.log","filename":"variant_reordered_vuln.log","size":0,"category":"log"},{"path":"bundle/logs/variant_baseline_fixed.log","filename":"variant_baseline_fixed.log","size":0,"category":"log"},{"path":"bundle/logs/variant_run2.log","filename":"variant_run2.log","size":1385,"category":"log"},{"path":"bundle/logs/variant_reordered_fixed.log","filename":"variant_reordered_fixed.log","size":0,"category":"log"},{"path":"bundle/logs/variant_legendgraphic_fixed.log","filename":"variant_legendgraphic_fixed.log","size":0,"category":"log"},{"path":"bundle/logs/variant_getstyles_fixed.log","filename":"variant_getstyles_fixed.log","size":0,"category":"log"}]}