
Behavior-preserving fix for CVE-2025-60876 HTTP header injection in BusyBox wget, with proof-of-concept, percent-encoding patch, and upstream submission artifacts.
A behavior-preserving fix for CVE-2025-60876, an HTTP header-injection vulnerability in BusyBox wget, plus the proof-of-concept that demonstrates it and the artifacts for submitting the fix upstream to BusyBox.
BusyBox wget copies the URL path and query into the HTTP request line verbatim. A URL that carries a raw CR (0x0D), LF (0x0A), or other control byte can split the request line and inject attacker-controlled headers. A space (0x20) has the same effect: it breaks the METHOD SP request-target SP HTTP/1.1 framing.
Affected: BusyBox through 1.37.0 (the pkgver shipped on every in-support Alpine branch). Reported on the BusyBox mailing list in August 2025; CVE assigned as CVE-2025-60876 (NVD CVSS 6.5, Medium).
Two patches were already proposed upstream (Takeuchi Yuma, 2025-08; Radoslav Kolev, 2025-11). Both reject an offending URL and call bb_error_msg_and_die. That stops the injection, but it also rejects a plain space, so a URL like that used to work now errors. That regression is why the fix has not been picked up downstream. Alpine is holding its backport on it ().
http://example.org/foo barThis fix handles the path and the host differently, matching how GNU wget and curl behave.
Path: percent_encode_target() in networking/wget.c percent-encodes control bytes (0x00 through 0x1f), space (0x20), and DEL (0x7f) in the request-target before the request line is built:
http://example.org/foo bar is sent as /foo%20bar, matching GNU wget and curl. No regression.% is left unchanged, so an already-encoded path is not double-encoded.Host: the same bytes are rejected in the URL host. A hostname cannot legitimately contain control characters or a space, and percent-encoding is not defined for the authority component. This matters in proxy mode: the host is placed in the absolute-form request-target (GET http://host/path) and the Host: header but is not resolved locally, so a raw CR or LF in the host would otherwise inject. GNU wget (since CVE-2017-6508) and curl reject control characters in the host too.
The change is confined to networking/wget.c and adds one helper.
Out of scope: the FTP control channel (ftpcmd sends target->path and the user/password raw, an upstream-acknowledged TODO) is a separate injection class, not part of CVE-2025-60876, and is not addressed here. It should be tracked separately.
Built against BusyBox 1.37.0 with the same config Alpine uses (CONFIG_WERROR and CONFIG_TC off). A local listener captures the exact bytes wget puts on the wire.
| Input URL | Vanilla 1.37.0 | Patched |
|---|---|---|
/x + CRLF + Evil: injected | Evil: injected arrives as a real header (vulnerable) | GET /x%0D%0AEvil:%20injected HTTP/1.1, no injected header |
/foo bar | sends a literal space | GET /foo%20bar HTTP/1.1 (no regression) |
/foo%20bar (already encoded) | unchanged | GET /foo%20bar HTTP/1.1 (not %2520) |
/normal | unchanged | GET /normal HTTP/1.1 (unchanged) |
proxy, host = h + CRLF + PInjected: 1 | PInjected: 1 arrives as a real header (vulnerable) | dies with bad character in URL host, nothing sent |
proxy, host = example.test | normal | GET http://example.test/p HTTP/1.1 (unchanged) |
Full capture: test/poc-output.txt. The four BusyBox testsuite/wget tests all pass against the patched build (test/testsuite-output.txt); the other full-suite failures noted there are unrelated (mount/taskset need root, and a few known restricted-container quirks). Size impact on aarch64 defconfig is +185 bytes (wget_main +142, .rodata +43, no symbols added or removed), measured with scripts/bloat-o-meter and recorded in cover-letter.txt.
| File | What it is |
|---|---|
wget-cve-2025-60876.patch | The fix as a git format-patch against BusyBox networking/wget.c, ready to send |
commit-message.txt | The commit message (also the patch header) |
cover-letter.txt | The [PATCH v3] mailing-list cover letter that revives the existing thread |
apply_fix.py | Generator that applies the change to a networking/wget.c (used to produce the patch) |
test/serve_once.py | Single-connection listener that echoes the raw HTTP request |
test/driver.sh | Builds vanilla and patched BusyBox and runs the direct + proxy before/after PoC |
test/finalize.sh | Full run: before/after PoC, bloat-o-meter, testsuite/wget, and regenerates the patch |
test/poc-output.txt | Captured before/after request bytes (direct and proxy) |
test/testsuite-output.txt | BusyBox testsuite/wget results against the patched build |
test/finalize.sh runs inside an alpine:edge container with Docker and reproduces everything (PoC, size, testsuite, patch):
docker run --rm -v "$PWD:/work" alpine:edge sh /work/test/finalize.sh
It downloads BusyBox 1.37.0, builds it vanilla (showing the injection directly and via a proxy), applies the fix, rebuilds, replays the request cases above, runs bloat-o-meter and the wget testsuite, and writes the patch. test/driver.sh is a lighter PoC-only variant.
BusyBox is a mailing-list project. The fix goes to [email protected] via git send-email, as a [PATCH v3] reply on Radoslav Kolev's November 2025 thread, crediting the prior reporters and reviewers. See cover-letter.txt. The fix is GPLv2, the same license as BusyBox.