Windows.Memory.Mem2Disk

This artifact compares executables in memory (RAM) with those on hard disk. This way, RAM injections are detected. This rarely happens legitimately and is mostly used by malware. This check is executed without dumping the memory and works live on the target system(s). See also https://github.com/lautarolecumberry/DetectingFilelessMalware

(Ignored) False Positives

  • ASLR: If jumps or comparisons are not relative and Address Space Layout Randomization (ASLR) is enabled, then addresses within these jumps are adjusted in RAM with a constant offset. This offset can be computed and ignored.
  • BaseOfData: Relative Virtual Adresses (RVA) cause an offset in the code in memory of a 32-bit process. This is the case when the field BaseOfData is set. Like ASLR it is a constant offset that is added to addresses.

Test this artifact using AMSIBypassPatch.ps1


name: Windows.Memory.Mem2Disk
author: Lautaro Lecumberry, Dr. Michael Denzel
description: |
    This artifact compares executables in memory (RAM) with those
    on hard disk. This way, RAM injections are detected. This rarely
    happens legitimately and is mostly used by malware.
    This check is executed without dumping the memory and works live
    on the target system(s).
    See also https://github.com/lautarolecumberry/DetectingFilelessMalware

    (Ignored) False Positives
    - `ASLR`:       If jumps or comparisons are not relative and Address Space Layout Randomization
                    (ASLR) is enabled, then addresses within these jumps are adjusted in RAM
                    with a constant offset. This offset can be computed and ignored.
    - `BaseOfData`: Relative Virtual Adresses (RVA) cause an offset in the code in memory of a
                    32-bit process. This is the case when the field BaseOfData is set.
                    Like ASLR it is a constant offset that is added to addresses.

    Test this artifact using `AMSIBypassPatch.ps1`

references:
  - https://github.com/okankurtuluss/AMSIBypassPatch/blob/090b54a518fecf1ccf8f54f8691805ef0f9a30f1/AMSIBypassPatch.ps1

parameters:
- name: UploadFindings
  description: Upload all executables where code in memory does not match code on disk. This
               can potentially generate a lot of traffic. Dry-run before enabling this option.
  default: False
  type: bool
- name: ProcessNameFilter
  type: regex
  default: .
- name: PidFilter
  default: .
  type: regex
- name: ModuleRegEx
  type: regex
  description: Filter for modules to check. If you want to scan all modules (i.e. libraries) within
               a binary, replace with `.*`. The default parameter checks the original binary itself (.exe)
               as well as kernelbase.dll, ntdll.dll, user32.dll, kernel32.dll, shell32.dll, msvcrt.dll,
               advapi32.dll, and comdlg32.dll (i.e. commonly injected libraries).
  default: "\\.exe$|(KERNELBASE|ntdll|amsi|user32|kenrel32|shell32|msvcrt|advapi32|comdlg32)\\.dll$"
- name: Workers
  type: int
  default: "5"
  description: "Number of parallel workers to use"

precondition: SELECT OS From info() where OS = 'windows'

export: |
  -- These functions help to resolve the Kernel Device Filenames
  -- into a regular filename with drive letter.
  LET DriveReplaceLookup <= SELECT
      split(sep_string="\\", string=Name)[-1] AS Drive,
      upcase(string=SymlinkTarget) AS Target,
      len(list=SymlinkTarget) AS Len
    FROM winobj()
    WHERE Name =~ "^\\\\GLOBAL\\?\\?\\\\.:"

  LET _DriveReplace(Path) = SELECT Drive + Path[Len:] AS ResolvedPath
    FROM DriveReplaceLookup
    WHERE upcase(string=Path[:Len]) = Target

  LET DriveReplace(Path) = _DriveReplace(Path=Path)[0].ResolvedPath ||
      Path

sources:
- query: |
    -- get all processes
    LET GetPids = SELECT Pid,
                         Name,
                         Username,
                         if(condition=IsWow64, then=4, else=8) AS IntSize
      FROM pslist()
      WHERE Name =~ ProcessNameFilter
          AND format(format="%d", args=Pid) =~ PidFilter

    -- get all memory pages for a certain pid
    LET InfoFromVad(Pid) = SELECT Address,
                                  Size,
                                  DriveReplace(Path=MappingName) AS Path
      FROM vad(pid=Pid)
      WHERE MappingName
      AND Protection =~ "xr-"
      AND MappingName =~ ModuleRegEx

    LET GetTextSegment(Path) = filter(condition="x=>x.Name = '.text'",
                                      list=parse_pe(file=Path).Sections)[0]

    -- parse the executable (PE) from memory (specifically, the text segment)
    LET GetMetadata(Pid) = SELECT
         Path,
         str(str=Pid) AS PidFilename,
         Address,
         GetTextSegment(Path=Path) AS TextSegmentData
      FROM InfoFromVad(Pid=Pid)
      WHERE Address != 0
      AND TextSegmentData.FileOffset

    -- helper function for formating
    LET Hex(X) = format(format="%#x", args=X)

    LET Int64(X) = parse_binary(profile="",
                            struct="int64",
                            accessor="data",
                            filename=X)

    -- ASLR may be shifted due to alignment.
    -- This is OK so the following values are allowed.
    LET CalculateAllowedASLR(ASLR) = SELECT *
      FROM foreach(row={
        SELECT format(format="00" * 7 + "%08x" + "00" * 8, args=ASLR) AS Data
        FROM scope()
      }, query={
        SELECT int(int="0x" + Data[_value:(_value + 16)]) AS Allowed
        FROM range(start=0, end=22, step=2)
    })

    -- read the executable from memory and hard disk
    LET GetContent(Pid, Name) = SELECT
        *,
        Name,
        Path,
        Address AS MemAddress,
        TextSegmentData.RVA AS BaseRVA,
        --calculate ASLR offset to later filter it out
        Address - TextSegmentData.VMA AS ASLR,

        CalculateAllowedASLR(ASLR=Address - TextSegmentData.VMA).Allowed AS AllowedASLR,
        read_file(accessor="process",
                  offset=Address,
                  filename=PidFilename,
                  length=TextSegmentData.Size) AS MemoryData,
        TextSegmentData.FileOffset AS DiskAddress,
        TextSegmentData.Size AS SegmentSize,
        read_file(accessor="file",
                  offset=TextSegmentData.FileOffset,
                  filename=Path,
                  length=TextSegmentData.Size) AS DiskData
      FROM GetMetadata(Pid=Pid)
      WHERE MemoryData
      AND log(dedup=-1,
              message="Inspecting Pid %v (%v): %#x-%#x vs %#x-%#x in %v",
              args=[Pid, Name, Address, Address + SegmentSize,
                DiskAddress, DiskAddress + SegmentSize, Path])

    LET FilterContent(Pid, Name) = SELECT *
      MemoryData = DiskData AS Comparison
      FROM GetContent(Pid=Pid, Name=Name)

      -- Filter out not needed comparisons early
      WHERE NOT Comparison

    -- parameter for start PAGESIZE; compare 1 MB pages first
    LET PAGESIZE <= 1024 * 1024

    -- helper function for comparisons of PAGESIZE
    LET _CompareRegions(Base, X, Y, PAGESIZE) = SELECT
        _value + Base AS Offset,
        X[_value:(_value + PAGESIZE)] AS XInt,
        Y[_value:(_value + PAGESIZE)] AS YInt
      FROM range(end=len(list=X), step=PAGESIZE)
      WHERE XInt != YInt

    -- compare full pages (to speed up comparison)
    -- for each 1 MB page which does not match
    -- compare 4096 B page
    -- for each 4096 B page which does not match
    -- compare single integers
    LET CompareRegions(X, Y, IntSize) = SELECT
        Offset,
        Hex(X=XInt) AS X,
        Hex(X=YInt) AS Y,
        Int64(X=XInt) AS ValueX,
        Int64(X=YInt) AS ValueY
      FROM foreach(row={
        -- 1 MB pages
        SELECT *
        FROM _CompareRegions(Base=0, X=X, Y=Y, PAGESIZE=PAGESIZE)
      },
      query={
        SELECT *
        FROM foreach(row={
          -- 4096 B pages
          SELECT *
          FROM _CompareRegions(Base=Offset, X=XInt, Y=YInt, PAGESIZE=4096)
        },
        query={
          -- single integers
          SELECT *
          FROM _CompareRegions(Base=Offset, X=XInt, Y=YInt, PAGESIZE=IntSize)
        })
      })
      LIMIT 500

    -- check if offsets between X and Y (i.e. RAM and disk) are
    -- always the same offset. Then it is ASLR or BaseOfData.
    LET CompareUniqueRegions(X, Y, IntSize, ASLR) = SELECT *,
        ValueX - ValueY AS Difference
      FROM CompareRegions(X=X, Y=Y, IntSize=IntSize)
      WHERE ValueX AND NOT Difference IN AllowedASLR
      GROUP BY Difference

    LET DescribeAddress(rva, module) = version(function="describe_address") != NULL &&
       describe_address(rva=rva, module=module).func

    -- compare the executable from memory and hard disk
    -- only print the ones where they do not match
    LET Compare(Pid, Name, IntSize) =
      SELECT Pid,
             Name,
             Path,
             ASLR,
             {
               SELECT Offset + BaseRVA AS RVA,
                      Offset AS TextOffset,
                      DescribeAddress(rva= Offset + BaseRVA, module=Path) AS Func,
                      X AS MemoryValue,
                      Y AS DiskValue,
                      Hex(X=Difference) AS Difference,
                      Hex(X=ASLR) AS ASLR
               FROM CompareUniqueRegions(
                     X=MemoryData,
                     Y=DiskData,
                     IntSize=IntSize,
                     ASLR=ASLR)
             } AS Differences,
             MemAddress,
             DiskAddress,
             SegmentSize
      FROM FilterContent(Pid=Pid, Name=Name)
      WHERE Differences AND log(dedup=-1,
          message="Comparing process %v - %v", args=[Pid, Name])

    -- compare with uploading the suspicious executables
    LET CompareAndUpload(Pid, Name, IntSize) = SELECT
        Pid,
        Name,
        Path,
        ASLR,
        MemAddress,
        DiskAddress,
        SegmentSize,
        upload(
          file=pathspec(DelegateAccessor="process",
                        DelegatePath=PidFilename,
                        Path=[dict(Offset=MemAddress, Length=SegmentSize), ]),
          name=pathspec(parse=format(format="%s.%d.mem", args=[Path, Pid]),
                        path_type="windows"),
          accessor="sparse") AS UploadMem,
        upload(
          file=pathspec(DelegateAccessor="file",
                        DelegatePath=Path,
                        Path=[dict(Offset=DiskAddress, Length=SegmentSize), ]),
          name=pathspec(parse=format(format="%s.%d.disk", args=[Path, Pid]),
                        path_type="windows"),
          accessor="sparse") AS UploadDisk,
        Differences
      FROM Compare(Pid=Pid, Name=Name, IntSize=IntSize)

    -- for every process, evaluate the memory-harddisk-comparison
    SELECT *,
      Hex(X=MemAddress) AS MemAddress,
      Hex(X=DiskAddress) AS DiskAddress,
      Hex(X=SegmentSize) AS SegmentSize
    FROM foreach(row=GetPids,
                 workers=Workers,
      query={
        SELECT *
        FROM if(condition=UploadFindings,
      then={
        SELECT *
        FROM CompareAndUpload(Pid=Pid, Name=Name, IntSize=IntSize)
      },
      else={
        SELECT *
        FROM Compare(Pid=Pid, Name=Name, IntSize=IntSize)
      })
    })