Routing note. This report is filed here at NGEET/fates, the canonical upstream of
main/FatesInterfaceMod.F90, where any fix must merge. The defect is also reachable via NCAR's
CESM/CTSM (ESCOMP/CTSM), which vendors FATES as an external (src/fates), in FATES-enabled
CLM/CTSM runs (use_fates = .true.). Line numbers below are from NGEET/fates main.
Summary
In main/FatesInterfaceMod.F90, the FATES history-dimension mapping arrays are allocated with sizes formed by multiplying together several dimension counts (nlevsclass, nlevage, nlevdamage, numpft, ...) that are read from the FATES parameter file (fates_paramfile, a NetCDF file). Two of these factors, nlevsclass and nlevage, are taken directly from NetCDF dimension lengths via size(...) and are never bounded against a maximum. The products are computed in default (32-bit) Fortran integer, so a parameter file whose size-class and age-class edge arrays are large enough for their product to exceed 2**31 causes the allocation length to wrap to a small or non-positive value. The subsequent index loops then write past the end of the undersized allocation, producing a heap out-of-bounds write. numpft is already validated against the compiled-in constant maxpft a few lines earlier; nlevsclass and nlevage have no equivalent guard.
Affected code
main/FatesInterfaceMod.F90 @ main, in subroutine SetFatesGlobalElements2 (begins L897).
The two dimensions read straight from NetCDF-backed parameter arrays with no upper bound (L938, L1017; nlevdamage at L1020 is in the same class):
938 nlevsclass = size(ED_val_history_sizeclass_bin_edges,dim=1)
...
1017 nlevage = size(ED_val_history_ageclass_bin_edges,dim=1)
1018 nlevheight = size(ED_val_history_height_bin_edges,dim=1)
1019 nlevcoage = size(ED_val_history_coageclass_bin_edges,dim=1)
1020 nlevdamage = size(ED_val_history_damage_bin_edges, dim=1)
The only validation performed on these arrays (L1024-1047) checks that bin edges start at zero and increase monotonically — there is no maximum-size / product-overflow clamp:
1024 if ( ED_val_history_sizeclass_bin_edges(1) .ne. 0._r8 ) then
1025 write(fates_log(), *) 'size class bins specified in parameter file must start at zero'
1026 call endrun(msg=errMsg(sourcefile, __LINE__))
1027 endif
1028 if ( ED_val_history_ageclass_bin_edges(1) .ne. 0._r8 ) then
1029 write(fates_log(), *) 'age class bins specified in parameter file must start at zero'
1030 call endrun(msg=errMsg(sourcefile, __LINE__))
1031 endif
The undersized allocations formed from the 32-bit products (L1261-1269, L1279-1283):
1261 allocate( fates_hdim_scmap_levscag(nlevsclass * nlevage ))
1262 allocate( fates_hdim_agmap_levscag(nlevsclass * nlevage ))
1263 allocate( fates_hdim_scmap_levscagpft(nlevsclass * nlevage * numpft))
1264 allocate( fates_hdim_agmap_levscagpft(nlevsclass * nlevage * numpft))
1265 allocate( fates_hdim_pftmap_levscagpft(nlevsclass * nlevage * numpft))
1266 allocate( fates_hdim_agmap_levagepft(nlevage * numpft))
1267 allocate( fates_hdim_pftmap_levagepft(nlevage * numpft))
...
1279 allocate( fates_hdim_scmap_levcdsc(nlevsclass*nlevdamage))
1280 allocate( fates_hdim_cdmap_levcdsc(nlevsclass*nlevdamage))
1281 allocate( fates_hdim_scmap_levcdpf(nlevsclass*nlevdamage * numpft))
1282 allocate( fates_hdim_cdmap_levcdpf(nlevsclass*nlevdamage * numpft))
1283 allocate( fates_hdim_pftmap_levcdpf(nlevsclass*nlevdamage * numpft))
These arrays are then filled by loops over the true extents (e.g. the do ipft=1,numpft / nested size-class/age loops beginning around L1289), so the fill writes the full un-wrapped element count into the wrap-shrunk allocation.
For reference, the existing bound that this report proposes to mirror (L920-925):
920 if(numpft>maxpft) then
921 write(fates_log(), *) 'The number of PFTs dictated by the FATES parameter file'
922 write(fates_log(), *) 'is larger than the maximum allowed. Increase the FATES parameter constant'
923 write(fates_log(), *) 'FatesInterfaceMod.F90:maxpft accordingly'
924 call endrun(msg=errMsg(sourcefile, __LINE__))
925 end if
Relevant imports: endrun => fates_endrun (L48, use FatesGlobals), errMsg => shr_log_errMsg (L126), sourcefile = __FILE__ (L174-175), maxpft (L24, use EDParamsMod). The dimension variables themselves come from use FatesInterfaceTypesMod (L132).
Root cause
nlevsclass, nlevage, nlevdamage and numpft are default 4-byte Fortran integers. NetCDF dimension lengths are 64-bit on disk but are consumed here into default integer via size(...). The allocation length expressions (e.g. nlevsclass * nlevage * numpft) are evaluated in 32-bit arithmetic. When the mathematical product exceeds 2**31 - 1, the result wraps (to a small positive value, or negative → a zero-length allocation), so allocate(...) reserves far fewer elements than the fill loops subsequently write. This is a classic CWE-190 (integer overflow) feeding CWE-787 (out-of-bounds write). Because numpft is already clamped to maxpft but the bin-count dimensions are not, an attacker-influenced parameter file can drive nlevsclass * nlevage past the 32-bit boundary on its own.
Trigger / attack vector
- Input file: the FATES parameter file (
fates_paramfile, NetCDF), read during SetFatesGlobalElements2.
- Dimensions: the lengths of
fates_history_sizeclass_bin_edges (→ nlevsclass) and fates_history_ageclass_bin_edges (→ nlevage); the damage-class edges (→ nlevdamage) give an equivalent path via nlevsclass*nlevdamage*....
- Reach / config: a run configured with
use_fates = .true. and a fates_paramfile pointing at the crafted NetCDF reaches this code path at initialization; no further namelist gymnastics are required. Any workflow that accepts a user-supplied or third-party FATES parameter file (shared parameter sets, reproducibility archives, automated calibration/ensemble tooling) is exposed.
Impact
Integer overflow (CWE-190) leading to a heap out-of-bounds write (CWE-787): a denial of service / heap-integrity violation triggered by a crafted parameter file. This is not remote code execution — the deficit between the wrapped allocation and the true fill count is structurally enormous (≥ 2**31 elements), so the contiguous fill loop runs off the end of mapped memory and the process crashes during the write, long before any overwritten heap metadata or pointer could be dereferenced in a controlled way.
Reproduction
Craft a FATES parameter NetCDF in which the size-class and age-class edge arrays are dimensioned so their product lands just above 2**32 (e.g. nlevsclass ≈ 66000, nlevage ≈ 66000, giving nlevsclass*nlevage ≈ 4.356e9, which wraps modulo 2**32 to roughly 6.1e7 — an allocation ~70× too small; choosing the product to sit just over 2**31 for a signed-only interpretation works equally well). Point fates_paramfile at that file and start a use_fates=.true. run; initialization reaches SetFatesGlobalElements2, the allocate at L1261/1262 under-provisions, and the subsequent fill overruns the buffer.
An independent ifx + AddressSanitizer harness isolates the exact pattern — the 32-bit product of two NetCDF-derived dimension lengths used as the allocate extent (L1261-1262), filled by the real nested loop whose running counter i reaches the true (un-wrapped) product (L1379-1386):
integer :: nlevsclass, nlevage, isc, iage, i ! default integer(4)
integer, allocatable :: fates_hdim_scmap_levscag(:)
integer, allocatable :: fates_hdim_agmap_levscag(:)
! L1261-1262: extent evaluated in 32-bit arithmetic -> WRAPS
allocate( fates_hdim_scmap_levscag(nlevsclass * nlevage) )
allocate( fates_hdim_agmap_levscag(nlevsclass * nlevage) )
! L1379-1386: counter i climbs to the TRUE product nlevage*nlevsclass
i=0
do iage=1,nlevage
do isc=1,nlevsclass
i=i+1
fates_hdim_scmap_levscag(i) = isc ! first OOB WRITE lands here
fates_hdim_agmap_levscag(i) = iage
end do
end do
Built with ifx -O0 -g -fsanitize=address and run as ... repro_asan 65536 65537
(65536*65537 = 4295032832, which wraps modulo 2**32 to 65536 → the allocation;
the fill counter targets the true 4295032832, so the first out-of-bounds write is at i = 65537).
AddressSanitizer reports a heap-buffer-overflow WRITE at the fill line, confirming the mechanism:
==105625==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7ff2e88c5828 ...
WRITE of size 4 at 0x7ff2e88c5828 thread T0
#0 ... in MAIN .../repro_fates/repro_fates.F90:73:10 ! fates_hdim_scmap_levscag(i) = isc
0x7ff2e88c5828 is located 0 bytes after 262184-byte region [0x7ff2e8885800,0x7ff2e88c5828)
allocated by thread T0 here:
#0 ... in malloc
#2 ... in MAIN .../repro_fates/repro_fates.F90:63:4 ! allocate(... nlevsclass * nlevage)
SUMMARY: AddressSanitizer: heap-buffer-overflow .../repro_fates/repro_fates.F90:73:10 in MAIN
The wrapped allocation holds only 65536 elements; the nested fill loop's counter reaches the true product, so the write at i = 65537 lands exactly 0 bytes after the end of the region. Harness and full output: disclosure/poc/repro_fates/repro_fates.F90, disclosure/poc/repro_fates/repro_fates.asan.txt.
Suggested fix
Bound each NetCDF-derived dimension factor against a compiled-in maximum immediately after it is read, aborting through FATES's standard error routine (call endrun(msg=errMsg(sourcefile, __LINE__))), exactly mirroring the existing numpft > maxpft guard. Because numpft is already clamped, bounding nlevsclass, nlevage (and nlevdamage) is sufficient to make every product above safe.
Add compiled-in maxima (alongside maxpft, e.g. in EDParamsMod, chosen so that max_nlevsclass * max_nlevage * maxpft stays well within a 32-bit integer):
integer, parameter :: max_nlevsclass = 1000
integer, parameter :: max_nlevage = 1000
integer, parameter :: max_nlevdamage = 1000
Then guard right after each assignment. After L938:
nlevsclass = size(ED_val_history_sizeclass_bin_edges,dim=1)
if (nlevsclass > max_nlevsclass) then
write(fates_log(), *) 'The number of size-class bins in the FATES parameter file'
write(fates_log(), *) 'exceeds the compiled-in maximum (EDParamsMod:max_nlevsclass).'
write(fates_log(), *) 'nlevsclass = ', nlevsclass, ' max_nlevsclass = ', max_nlevsclass
call endrun(msg=errMsg(sourcefile, __LINE__))
end if
And after L1017 / L1020:
nlevage = size(ED_val_history_ageclass_bin_edges,dim=1)
if (nlevage > max_nlevage) then
write(fates_log(), *) 'The number of age-class bins in the FATES parameter file'
write(fates_log(), *) 'exceeds the compiled-in maximum (EDParamsMod:max_nlevage).'
write(fates_log(), *) 'nlevage = ', nlevage, ' max_nlevage = ', max_nlevage
call endrun(msg=errMsg(sourcefile, __LINE__))
end if
...
nlevdamage = size(ED_val_history_damage_bin_edges, dim=1)
if (nlevdamage > max_nlevdamage) then
write(fates_log(), *) 'The number of damage-class bins in the FATES parameter file'
write(fates_log(), *) 'exceeds the compiled-in maximum (EDParamsMod:max_nlevdamage).'
write(fates_log(), *) 'nlevdamage = ', nlevdamage, ' max_nlevdamage = ', max_nlevdamage
call endrun(msg=errMsg(sourcefile, __LINE__))
end if
The same treatment is advisable for the other paramfile-derived history dimensions that enter these products (nlevheight, nlevcoage, nlevleaf). Equivalent alternative: compute each allocation extent in integer(8) and validate ≤ huge(1_4) before allocate. Bounding at read time is preferred because it is cheap, fails fast at initialization, and matches the existing numpft/maxpft idiom.
Severity
Medium — remotely/externally triggerable crash (denial of service) and heap-integrity violation from a crafted FATES parameter file; requires supplying a malicious NetCDF parameter file to a use_fates run, and is not exploitable for code execution.
Summary
In
main/FatesInterfaceMod.F90, the FATES history-dimension mapping arrays are allocated with sizes formed by multiplying together several dimension counts (nlevsclass,nlevage,nlevdamage,numpft, ...) that are read from the FATES parameter file (fates_paramfile, a NetCDF file). Two of these factors,nlevsclassandnlevage, are taken directly from NetCDF dimension lengths viasize(...)and are never bounded against a maximum. The products are computed in default (32-bit) Fortraninteger, so a parameter file whose size-class and age-class edge arrays are large enough for their product to exceed2**31causes the allocation length to wrap to a small or non-positive value. The subsequent index loops then write past the end of the undersized allocation, producing a heap out-of-bounds write.numpftis already validated against the compiled-in constantmaxpfta few lines earlier;nlevsclassandnlevagehave no equivalent guard.Affected code
main/FatesInterfaceMod.F90@main, insubroutine SetFatesGlobalElements2(begins L897).The two dimensions read straight from NetCDF-backed parameter arrays with no upper bound (L938, L1017;
nlevdamageat L1020 is in the same class):The only validation performed on these arrays (L1024-1047) checks that bin edges start at zero and increase monotonically — there is no maximum-size / product-overflow clamp:
The undersized allocations formed from the 32-bit products (L1261-1269, L1279-1283):
These arrays are then filled by loops over the true extents (e.g. the
do ipft=1,numpft/ nested size-class/age loops beginning around L1289), so the fill writes the full un-wrapped element count into the wrap-shrunk allocation.For reference, the existing bound that this report proposes to mirror (L920-925):
Relevant imports:
endrun => fates_endrun(L48,use FatesGlobals),errMsg => shr_log_errMsg(L126),sourcefile = __FILE__(L174-175),maxpft(L24,use EDParamsMod). The dimension variables themselves come fromuse FatesInterfaceTypesMod(L132).Root cause
nlevsclass,nlevage,nlevdamageandnumpftare default 4-byte Fortran integers. NetCDF dimension lengths are 64-bit on disk but are consumed here into defaultintegerviasize(...). The allocation length expressions (e.g.nlevsclass * nlevage * numpft) are evaluated in 32-bit arithmetic. When the mathematical product exceeds2**31 - 1, the result wraps (to a small positive value, or negative → a zero-length allocation), soallocate(...)reserves far fewer elements than the fill loops subsequently write. This is a classic CWE-190 (integer overflow) feeding CWE-787 (out-of-bounds write). Becausenumpftis already clamped tomaxpftbut the bin-count dimensions are not, an attacker-influenced parameter file can drivenlevsclass * nlevagepast the 32-bit boundary on its own.Trigger / attack vector
fates_paramfile, NetCDF), read duringSetFatesGlobalElements2.fates_history_sizeclass_bin_edges(→nlevsclass) andfates_history_ageclass_bin_edges(→nlevage); the damage-class edges (→nlevdamage) give an equivalent path vianlevsclass*nlevdamage*....use_fates = .true.and afates_paramfilepointing at the crafted NetCDF reaches this code path at initialization; no further namelist gymnastics are required. Any workflow that accepts a user-supplied or third-party FATES parameter file (shared parameter sets, reproducibility archives, automated calibration/ensemble tooling) is exposed.Impact
Integer overflow (CWE-190) leading to a heap out-of-bounds write (CWE-787): a denial of service / heap-integrity violation triggered by a crafted parameter file. This is not remote code execution — the deficit between the wrapped allocation and the true fill count is structurally enormous (≥
2**31elements), so the contiguous fill loop runs off the end of mapped memory and the process crashes during the write, long before any overwritten heap metadata or pointer could be dereferenced in a controlled way.Reproduction
Craft a FATES parameter NetCDF in which the size-class and age-class edge arrays are dimensioned so their product lands just above
2**32(e.g.nlevsclass ≈ 66000,nlevage ≈ 66000, givingnlevsclass*nlevage ≈ 4.356e9, which wraps modulo2**32to roughly6.1e7— an allocation ~70× too small; choosing the product to sit just over2**31for a signed-only interpretation works equally well). Pointfates_paramfileat that file and start ause_fates=.true.run; initialization reachesSetFatesGlobalElements2, theallocateat L1261/1262 under-provisions, and the subsequent fill overruns the buffer.An independent
ifx+ AddressSanitizer harness isolates the exact pattern — the 32-bit product of two NetCDF-derived dimension lengths used as theallocateextent (L1261-1262), filled by the real nested loop whose running counterireaches the true (un-wrapped) product (L1379-1386):Built with
ifx -O0 -g -fsanitize=addressand run as... repro_asan 65536 65537(
65536*65537 = 4295032832, which wraps modulo2**32to65536→ the allocation;the fill counter targets the true
4295032832, so the first out-of-bounds write is ati = 65537).AddressSanitizer reports a
heap-buffer-overflowWRITE at the fill line, confirming the mechanism:The wrapped allocation holds only
65536elements; the nested fill loop's counter reaches the true product, so the write ati = 65537lands exactly0 bytes afterthe end of the region. Harness and full output:disclosure/poc/repro_fates/repro_fates.F90,disclosure/poc/repro_fates/repro_fates.asan.txt.Suggested fix
Bound each NetCDF-derived dimension factor against a compiled-in maximum immediately after it is read, aborting through FATES's standard error routine (
call endrun(msg=errMsg(sourcefile, __LINE__))), exactly mirroring the existingnumpft > maxpftguard. Becausenumpftis already clamped, boundingnlevsclass,nlevage(andnlevdamage) is sufficient to make every product above safe.Add compiled-in maxima (alongside
maxpft, e.g. inEDParamsMod, chosen so thatmax_nlevsclass * max_nlevage * maxpftstays well within a 32-bitinteger):Then guard right after each assignment. After L938:
And after L1017 / L1020:
The same treatment is advisable for the other paramfile-derived history dimensions that enter these products (
nlevheight,nlevcoage,nlevleaf). Equivalent alternative: compute each allocation extent ininteger(8)and validate≤ huge(1_4)beforeallocate. Bounding at read time is preferred because it is cheap, fails fast at initialization, and matches the existingnumpft/maxpftidiom.Severity
Medium — remotely/externally triggerable crash (denial of service) and heap-integrity violation from a crafted FATES parameter file; requires supplying a malicious NetCDF parameter file to a
use_fatesrun, and is not exploitable for code execution.