Skip to content

FastMM_ScanDebugBlocksForCorruption no longer detects header/footer corruption of small debug blocks (regression in cad1f04) #102

Description

@TetzkatLipHoka

Since cad1f04 ("Fix a potential race condition in FastMM_WalkBlocks: The header for a small block cannot be assumed to be valid if it was recently split off from a sequential feed span") FastMM_ScanDebugBlocksForCorruption silently ignores small debug blocks whose header or footer checksum is wrong - which is exactly the state it exists to find. Medium and large blocks are unaffected, and so is the use-after-free (fill pattern) check for small blocks.

Why

In the small block branch of FastMM_WalkBlocks, the decision whether the block may be reported as a debug block ends with the two checksum comparisons:

LPDebugHeader := PFastMM_DebugBlockHeader(LBlockInfo.BlockAddress);
LMayCheckForDebugInfo := BlockHasDebugInfo(LPDebugHeader)
  and (GetSpanForSmallBlock(LPDebugHeader) = LPMediumBlock)
  and (LBlockInfo.UsableSize > CDebugBlockHeaderSize)
  and (LBlockInfo.UsableSize >= CDebugBlockHeaderSize + LPDebugHeader.UserSize
    + CalculateDebugBlockFooterSize(LPDebugHeader.StackTraceEntryCount))
  and (LPDebugHeader.CalculateHeaderCheckSum = LPDebugHeader.HeaderCheckSum)
  and (LPDebugHeader.CalculateFooterCheckSum = LPDebugHeader.DebugFooterPtr^);

A block whose checksums do not match is therefore handed to the callback with DebugInformation = nil, and FastMM_ScanDebugBlocksForCorruption_CallBack returns at its first line. So the one condition the scan is looking for is the one condition that hides the block from it. (The same applies to FastMM_DebugMode_ScanForCorruptionBeforeEveryOperation.)

The corruption is still caught when the block is eventually freed, via CheckDebugBlockHeaderAndFooterCheckSumsValid - what is lost is the early detection the scan provides, and the detection of blocks that are never freed.

Repro

program ScanBlindSpot;
{$APPTYPE CONSOLE}
uses
  FastMM5, SysUtils;

function ScanDetects: Boolean;
begin
  try
    FastMM_ScanDebugBlocksForCorruption(1000);
    Result := False;
  except
    Result := True;
  end;
end;

procedure Test(const AWhat: string; ASize: Integer);
var
  LP: Pointer;
  LHeader: PFastMM_DebugBlockHeader;
  LOriginal: Cardinal;
  LDetected: Boolean;
begin
  GetMem(LP, ASize);
  LHeader := PFastMM_DebugBlockHeader(PByte(LP) - SizeOf(TFastMM_DebugBlockHeader));
  LOriginal := LHeader.HeaderCheckSum;
  LHeader.HeaderCheckSum := LOriginal xor $DEADBEEF;   //simulate a corruption
  LDetected := ScanDetects;
  LHeader.HeaderCheckSum := LOriginal;                 //repair before freeing
  FreeMem(LP);
  WriteLn(AWhat, ' -> detected: ', LDetected);
end;

begin
  FastMM_MessageBoxEvents := [];
  FastMM_LogToFileEvents := [];
  FastMM_EnterDebugMode;
  Test('small  (100 bytes)', 100);
  Test('medium (50000 bytes)', 50000);
  Test('large  (300000 bytes)', 300000);
  FastMM_ExitDebugMode;
end.

Output on current master (fb46810), Delphi 13.1 Win32:

small  (100 bytes) -> detected: FALSE
medium (50000 bytes) -> detected: TRUE
large  (300000 bytes) -> detected: TRUE

I ran a slightly larger matrix (header checksum corrupted, buffer overrun into the debug footer, and a write into a freed block) across the three size classes:

Case cad1f04^ fb46810
header checksum, small (100 and 2000 bytes) detected not detected
header checksum, medium / large detected detected
overrun into the footer, small detected not detected
overrun into the footer, medium / large detected detected
write after free (fill pattern), all sizes detected detected

So all ten cases pass on the commit before cad1f04, and three fail on master. The use-after-free case still works for small blocks because a freed debug block's checksums are recalculated when it is freed, so they are valid and the block stays visible to the scan.

Suggested fix

The first four conditions are what make the block safe to touch: the debug flag, the span identity check, and the two size checks that keep DebugFooterPtr inside the block. It is only the last two - the checksums themselves - that swallow real corruption. Dropping those two lines restores all ten cases:

LMayCheckForDebugInfo := BlockHasDebugInfo(LPDebugHeader)
  and (GetSpanForSmallBlock(LPDebugHeader) = LPMediumBlock)
  and (LBlockInfo.UsableSize > CDebugBlockHeaderSize)
  and (LBlockInfo.UsableSize >= CDebugBlockHeaderSize + LPDebugHeader.UserSize
    + CalculateDebugBlockFooterSize(LPDebugHeader.StackTraceEntryCount));

A half-written header that gets through the remaining checks is then handled by the retry loop you added in the scan callback: it re-checks BlockHasDebugInfo and the checksums up to 100 times before reporting anything, so a block that is still being initialised converges instead of being flagged.

To see whether that reintroduces the problem cad1f04 was about, I ran a stress test: 6 threads continuously allocating, filling and freeing small debug blocks (16..2016 bytes, 64 live blocks each, so spans are constantly fed, split off and recycled) while a seventh thread runs FastMM_ScanDebugBlocksForCorruption in a loop. Nothing corrupts anything, so any exception from the scan is a false positive. 25 seconds per variant, Delphi 13.1 Win32:

Variant allocations scans false positives A/Vs
cad1f04^ (no guard at all) 244 M 72,308 0 0
fb46810 (current) 254 M 56,427 0 0
fb46810 minus the two checksum conditions 259 M 69,791 0 0

I should be explicit about what this does and does not show: my workload does not reproduce the original race (the unguarded build is clean too), so it is evidence that the proposed change does not make things worse here, not evidence that the guard is unnecessary. If you have the repro from the original report, that is the one worth running against the change.

If you would rather keep the conservative behaviour for the other FastMM_WalkBlocks consumers (FastMM_LogStateToFile, the leak report), the alternative is to make the strictness a parameter of the walk so only the corruption scan opts into seeing blocks with mismatched checksums. That is a bigger change and your call - I am happy to send whichever you prefer as a PR, with the test programs above.

Found while merging your recent changes into my fork; the test programs are plain console Delphi and need nothing from the fork.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions