Skip to content

Failed memory-manager mode transitions poison nesting counters and later report success #85

Description

@janrysavy

Context

This comes from a GPT-5.6-SOL-ULTRA follow-up review of current FastMM5 master. I
reviewed the finding manually and reproduced it locally against the unmodified
revision:

13976edfc84f83c3c55e5731d30fd8686ad51f15
#81 Export FastMM_ProcessAllPendingFrees in BorlndMM.dll

The issue is related to, but distinct from, #72. It does not require concurrent
mode switching, FullDebugMode, or an application callback.

Problem

FastMM_SetMemoryManagerEntryPoints refuses to modify the installed memory
manager if it has been changed externally:

function FastMM_SetMemoryManagerEntryPoints: Boolean;
begin
  {Check that the memory manager has not been changed since the last time it was set.}
  if FastMM_InstalledMemoryManagerChangedExternally then
    Exit(False);

  {Select and install the configured entry points...}
end;

The public debug/erasure mode functions modify their nesting counter before this
operation and do not roll the modification back when it returns False.

For example:

function FastMM_BeginEraseFreedBlockContent: Boolean;
begin
  SetMemoryManagerLock.Lock;
  try
    if CurrentInstallationState = mmisInstalled then
    begin
      Inc(EraseFreedBlockContentCounter);
      if EraseFreedBlockContentCounter = 1 then
        Result := FastMM_SetMemoryManagerEntryPoints
      else
        Result := True;
    end
    else
      Result := False;
  finally
    SetMemoryManagerLock.Unlock;
  end;
end;

A single-threaded failure sequence is enough:

  1. An external memory-manager record is installed after FastMM.
  2. The first FastMM_BeginEraseFreedBlockContent increments the counter from 0
    to 1.
  3. FastMM_SetMemoryManagerEntryPoints detects the external change and returns
    False.
  4. The counter incorrectly remains 1.
  5. A second begin call increments it to 2 and returns True without retrying the
    entry-point installation.
  6. The external FreeMem entry point remains installed, so freed blocks are not
    erased even though the public call reported success.

FastMM_EraseFreedBlockContentActive also reports True, because it only tests
whether the poisoned counter is positive:

function FastMM_EraseFreedBlockContentActive: Boolean;
begin
  Result := EraseFreedBlockContentCounter > 0;
end;

The same counter-before-installation pattern appears in all three mode families:

  • FastMM_EnterDebugMode / FastMM_ExitDebugMode;
  • FastMM_BeginEraseAllocatedBlockContent /
    FastMM_EndEraseAllocatedBlockContent;
  • FastMM_BeginEraseFreedBlockContent /
    FastMM_EndEraseFreedBlockContent.

Both begin/enter and end/exit transitions can leave the counter inconsistent if
the required entry-point update fails.

Reproducer

The reproducer copies FastMM's installed TMemoryManagerEx, changes only its
GetMem entry point to a pass-through wrapper, and installs that record through
the RTL. This safely makes FastMM_InstalledMemoryManagerChangedExternally
return True while preserving ordinary allocation behavior.

It then calls FastMM_BeginEraseFreedBlockContent twice and verifies that the
second call returns True even though the original external FreeMem entry point
is still installed.

Cleanup first restores FastMM's original memory-manager record and calls the end
routine only while the public active-state query remains true. Thus the buggy
implementation drains its two poisoned nesting levels, while a fixed
implementation whose failed begin calls roll back to zero makes no unmatched end
calls.

program ModeFailureCounterRollbackRegression;

{$APPTYPE CONSOLE}

uses
  FastMM5;

var
  GOriginalMemoryManager: TMemoryManagerEx;

function ForwardGetMem(ASize: NativeInt): Pointer;
begin
  Result := GOriginalMemoryManager.GetMem(ASize);
end;

function SameFreeMemEntryPoint(const ALeft, ARight: TMemoryManagerEx): Boolean;
begin
  Result := NativeUInt(@ALeft.FreeMem) = NativeUInt(@ARight.FreeMem);
end;

procedure Fail(const AMessage: string);
begin
  Writeln(AMessage);
  Halt(1);
end;

var
  LExternalMemoryManager: TMemoryManagerEx;
  LFirstResult: Boolean;
  LInstalledAfterSecondCall: TMemoryManagerEx;
  LSecondResult: Boolean;
begin
  GetMemoryManager(GOriginalMemoryManager);
  LExternalMemoryManager := GOriginalMemoryManager;
  LExternalMemoryManager.GetMem := ForwardGetMem;

  SetMemoryManager(LExternalMemoryManager);
  try
    LFirstResult := FastMM_BeginEraseFreedBlockContent;
    LSecondResult := FastMM_BeginEraseFreedBlockContent;
    GetMemoryManager(LInstalledAfterSecondCall);
  finally
    SetMemoryManager(GOriginalMemoryManager);
    while FastMM_EraseFreedBlockContentActive do
    begin
      if not FastMM_EndEraseFreedBlockContent then
        Fail('Cleanup could not restore the erase-before-free nesting state.');
    end;
  end;

  if LFirstResult then
    Fail('Unexpected: first begin call succeeded after an external memory-manager change.');

  if LSecondResult
    and SameFreeMemEntryPoint(LInstalledAfterSecondCall, LExternalMemoryManager) then
    Fail('BUG: the second begin call returned True although the erase-before-free entry point was never installed.');

  Writeln('OK: failed mode transition did not poison the nesting counter.');
end.

Local validation

Built and run with RAD Studio 37.0 against unmodified 13976ed:

Build Result
Default Win32 Exit 1: false success reproduced
Default Win64 Exit 1: false success reproduced

Both runs print:

BUG: the second begin call returned True although the erase-before-free entry point was never installed.

Both results were reproduced again while preparing this report.

Expected fixed output:

OK: failed mode transition did not poison the nesting counter.

Impact and scope

This violates the Boolean success contract of the public mode functions and makes
the corresponding ...Active state queries unreliable after a failed transition.
The freed-content mode is documented as useful for security purposes, so false
success can also cause an application to believe freed memory is being erased
when its installed FreeMem entry point performs no erasure.

The reproducer deliberately uses an external memory-manager change—the exact
failure condition FastMM detects and reports through False. It does not rely on
invalid allocation/free behavior or a rapid cross-thread transition.

I would rate this as Medium severity for configuration integrity and false
security assurance.

Suggested fix direction

Make each counter transition transactional while SetMemoryManagerLock is held:

  • if a 0-to-1 enter/begin transition cannot install its entry points, decrement
    the counter back to 0;
  • if a 1-to-0 exit/end transition cannot install its entry points, increment the
    counter back to 1;
  • apply the same rule consistently to debug mode, allocated-content erasure, and
    freed-content erasure.

For example, the begin side can retain the existing shape and add rollback only
on the transition that calls FastMM_SetMemoryManagerEntryPoints:

Inc(EraseFreedBlockContentCounter);
if EraseFreedBlockContentCounter = 1 then
begin
  Result := FastMM_SetMemoryManagerEntryPoints;
  if not Result then
    Dec(EraseFreedBlockContentCounter);
end
else
  Result := True;

This does not add work to the allocation/free fast paths. The affected public
configuration calls already hold SetMemoryManagerLock.

Prior issue check

Issue #72 reported a concurrent
nested FastMM_EnterDebugMode returning before the first thread completed the
entry-point transition. It noted that the erase routines had a similar transition
shape, and the resulting fix serialized transitions.

This finding is different: it is a failure-path rollback defect after
FastMM_SetMemoryManagerEntryPoints explicitly returns False, it reproduces with
one thread, and it causes a later call to return a false success without retrying.

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