Skip to content

Integer overflow of NetCDF-derived history dimensions in SetFatesGlobalElements2 leads to undersized allocation and heap out-of-bounds write #1592

Description

@Champollion9012

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions