[Bug libfortran/126610] New: A correct list-directed READ of a NaN(...) value overflows libgfortran's fixed 300-byte scratch buffer, because the runtime alone chooses and mis-manages the buffer size
"arthur.chan at adalogics dot com via Gcc-bugs" <[email protected]> Mon, 03 Aug 2026 11:03:03 +0000
| Newsgroups | gmane.comp.gcc.bugs |
|---|---|
| Message-ID | <[email protected]/bugzilla/> |
https://gcc.gnu.org/bugzilla/show_bug.cgi?id=3D126610
Bug ID: 126610
Summary: A correct list-directed READ of a NaN(...) value
overflows libgfortran's fixed 300-byte scratch buffer,
because the runtime alone chooses and mis-manages the
buffer size
Product: gcc
Version: 17.0
Status: UNCONFIRMED
Severity: normal
Priority: P3
Component: libfortran
Assignee: unassigned at gcc dot gnu.org
Reporter: arthur.chan at adalogics dot com
Target Milestone: ---
Created attachment 65219
--> https://gcc.gnu.org/bugzilla/attachment.cgi?id=3D65219&action=3Dedit
A zip file contains a Dockerfile, a fortran code and a c code that demonstr=
ate
the issue.
A standard Fortran list-directed read, `READ(unit,*) x` into a `REAL`,
overflows a heap buffer inside libgfortran when the input value is a `NaN(.=
..)`
token longer than 300 characters. The overflow is not caused by the caller:=
the
program uses the I/O statement exactly as documented, reads into an ordinary
4-byte `REAL`, and supplies no buffer and no size, because the list-directed
READ statement has no parameter for either. The buffer that overflows is
libgfortran's private 300-byte scratch buffer `line_buffer`, allocated once=
by
`l_push_char()` and never grown or bounds-checked; the number of bytes copi=
ed
into it is decided entirely by libgfortran's own NaN-parsing loop, which pu=
shes
every non-separator character of the token with no cap. The size is therefo=
re
miscalculated inside the runtime, not provided by the application. Because
`NaN(...)` is an input form libgfortran deliberately accepts, any program t=
hat
performs a list-directed read of a `REAL`/`COMPLEX` from data an attacker
controls (a file, a pipe, a socket) can be driven into a heap buffer overfl=
ow
whose length and content the attacker chooses. This is a memory-corruption
defect in a GCC-maintained runtime library, reachable from untrusted input,=
and
in scope of GCC's security policy.
## Root cause
The application side supplies no buffer and no size. A list-directed READ n=
ames
only a unit and a destination variable:
```fortran
real :: x
read (*, *) x ! no buffer, no size: the statement has no such argum=
ent
```
Everything about the parsing buffer is internal to libgfortran. When the va=
lue
being read begins with `n`/`N`, `read_real()` enters its NaN branch, and on=
a
`NaN(alphanum)` token it pushes every character up to the closing `)` into =
the
scratch buffer, terminating only on `)`, EOF, or a separator, never on a
length:
https://github.com/gcc-mirror/gcc/blob/da9cb50722ba0341bab9d0bf29d1cfa12be1=
8f42/libgfortran/io/list_read.c#L2274-L2285
```c
/* Match NAN(alphanum). */
if (c =3D=3D '(')
{
for (c =3D next_char (dtp); c !=3D ')'; c =3D next_char (dtp))
if (is_separator (c))
goto unwind;
else
l_push_char (dtp, c);
l_push_char (dtp, ')');
c =3D next_char (dtp);
l_push_char (dtp, c);
}
```
`l_push_char()` is the sink. It allocates `line_buffer` exactly once as a f=
ixed
`SCRATCH_SIZE` bytes and then stores one byte per call at `line_buffer_pos+=
+`
with no bound and no reallocation:
https://github.com/gcc-mirror/gcc/blob/da9cb50722ba0341bab9d0bf29d1cfa12be1=
8f42/libgfortran/io/list_read.c#L925-L931
```c
static void
l_push_char (st_parameter_dt *dtp, char c)
{
if (dtp->u.p.line_buffer =3D=3D NULL)
dtp->u.p.line_buffer =3D xcalloc (SCRATCH_SIZE, 1);
dtp->u.p.line_buffer[dtp->u.p.line_buffer_pos++] =3D c;
}
```
`SCRATCH_SIZE` is a compile-time constant of 300, and `line_buffer` is a pl=
ain
`char *` with no associated capacity the caller could set:
https://github.com/gcc-mirror/gcc/blob/da9cb50722ba0341bab9d0bf29d1cfa12be1=
8f42/libgfortran/io/io.h#L829
https://github.com/gcc-mirror/gcc/blob/da9cb50722ba0341bab9d0bf29d1cfa12be1=
8f42/libgfortran/io/io.h#L566
The defect is the mismatch between two internal decisions that only libgfor=
tran
makes: it fixes the buffer at 300 bytes in `l_push_char()`, and it copies an
unbounded, input-determined number of bytes into it in the NaN loop. The
application contributes nothing but the input stream; it cannot enlarge the
buffer, cannot cap the copy, and has no API through which to pass a size. So
once the token exceeds 300 characters the store runs past the allocation, w=
ith
the token's own characters as the out-of-bounds content. This is precisely =
the
"the library miscalculated the size", not "the caller passed a bad size", c=
ase:
the miscalculation is internal and unreachable from the interface. The same
fixed buffer is also fed by other list-read paths, but the NaN branch is the
simplest unbounded producer. The vulnerable code is unchanged from the rele=
ased
runtime back through many years, and no CVE covers it.
## Proof of Concept
The PoC has two stages. Stage 1 is the important one for attribution: an
ordinary, correct Fortran program (`read(*,*) x`) compiled with the stock,
unmodified system gfortran and run against the released `libgfortran`, given
only attacker data on stdin. No PoC code and no size are in the trigger pat=
h,
so the resulting heap corruption cannot be blamed on caller misuse. Stage 2
localises the fault: it compiles libgfortran's own `l_push_char()` and NaN
loop, transcribed verbatim from the pinned source (the only change is
`xcalloc(SCRATCH_SIZE,1)` to `calloc(SCRATCH_SIZE,1)`, same size), under
AddressSanitizer, so ASan names the exact 300-byte boundary the Stage 1 cra=
sh
reached. Inside the Docker build it clones and pins gcc to
`da9cb50722ba0341bab9d0bf29d1cfa12be18f42`, asserts `HEAD` equals the pin, =
and
prints the cited source; the Stage 1 code path in the released 13.3.0 runti=
me
is byte-identical to that pinned source. A stock `-fsanitize=3Daddress` bui=
ld of
the same Fortran program is shown to stay silent, because the shipped
libgfortran is not instrumented; only instrumenting GCC's own code (Stage 2)
makes ASan pinpoint the store.
```
docker build -t poc . && docker run --rm poc
```
### Result
```
checked-out HEAD: da9cb50722ba0341bab9d0bf29d1cfa12be18f42
expected pin : da9cb50722ba0341bab9d0bf29d1cfa12be18f42
PIN OK
=3D=3D=3D the runtime alone owns the buffer and its size: a fixed 300-byte =
scratch
buffer =3D=3D=3D
829:#define SCRATCH_SIZE 300
############################################################################
# STAGE 1: correct API use, data only -> heap corruption in the released
# libgfortran. No PoC code and no size is supplied by the caller.
############################################################################
GNU Fortran (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0
compiled the ordinary program: read(*,*) x
--- control: a normal NaN(abc) value ---
NaN
control exit=3D0
--- attack: the same program, same statement, only the DATA differs (NaN( +=
400
chars + )) ---
malloc(): corrupted top size
Program received signal SIGABRT: Process abort signal.
attack exit=3D134
(for reference, an ordinary -fsanitize=3Daddress build of the SAME program
stays silent,
because the shipped libgfortran is not instrumented; Stage 2 instruments=
GCC
own code):
-fsanitize=3Daddress user build exit=3D0
############################################################################
# STAGE 2: localisation. Instrument GCC own l_push_char + NaN loop (verbati=
m)
# so ASan names the exact 300-byte boundary the Stage 1 crash hit.
############################################################################
built the localisation harness
--- control: NaN(abc) -> a few bytes, inside 300 ---
parsed NaN(...) token: 5 bytes pushed into a 300-byte buffer
harness control exit=3D0
--- attack: NaN( + 350 non-separator bytes + ) ---
=3D=3D39=3D=3DERROR: AddressSanitizer: heap-buffer-overflow on address 0x51=
200000016c
at pc 0x559d29b6c4cd bp 0x7fff12710230 sp 0x7fff12710220
WRITE of size 1 at 0x51200000016c thread T0
#0 0x559d29b6c4cc in l_push_char /poc/poc.c:58
#1 0x559d29b6c70b in main /poc/poc.c:78
0x51200000016c is located 0 bytes after 300-byte region
[0x512000000040,0x51200000016c)
allocated by thread T0 here:
#1 0x559d29b6c41c in l_push_char /poc/poc.c:56
#2 0x559d29b6c70b in main /poc/poc.c:78
RESULT: PASS - Stage 1: a correct read(*,*) x on the released libgfortran,
given only
attacker DATA (no buffer, no size from the caller), corrupts the he=
ap
and aborts.
Stage 2: instrumenting GCC own l_push_char + NaN loop shows the wri=
te
lands 0 bytes
past the fixed 300-byte line_buffer. The size is miscalculated insi=
de
libgfortran,
not supplied by the caller. Control values stay in bounds.
```
Stage 1 is decisive on attribution. The control `NaN(abc)` reads cleanly and
prints `NaN`. The attack changes only the input data, the same program and =
the
same `read(*,*) x` statement, and the released libgfortran corrupts its own
heap and aborts (`malloc(): corrupted top size`, `SIGABRT`). The program ne=
ver
allocated a buffer, never named `line_buffer`, and had no way to pass a siz=
e,
so this is a fault in the library, not in its use. Stage 2 names the exact
location: instrumenting GCC's own `l_push_char()` shows the store landing `0
bytes after` the 300-byte allocation that the same function created, driven=
by
the NaN loop copying past it. This is the "not caller misuse" argument made
concrete: the number of bytes copied is computed inside libgfortran, exceeds
the buffer libgfortran itself fixed at 300, and no interface exists through
which the caller could have prevented or influenced it. In a deployed progr=
am
that reads floating-point data from an untrusted source, the store continues
for every subsequent byte of the token, a heap buffer overflow of
attacker-controlled length and content.
## Mitigation
Bound `l_push_char()` against `SCRATCH_SIZE`: grow `line_buffer` with
`xrealloc` when `line_buffer_pos` reaches the current capacity, or stop
consuming and reject the token once the cap is reached, so no list-read path
can write past the allocation regardless of token length. Since the buffer =
size
is entirely a runtime-internal concern, the fix belongs there and requires =
no
change to any calling program.
## 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.=