Skip to content

Mach-O synthesis emits a 32-bit image for a 64-bit report when the source was not a Mach-O #235

Description

@r0ny123

Synthesizing a 64-bit report to Mach-O emits a 32-bit Mach-O whenever the report came from something that is not a Mach-O, so every address above 4 GiB is truncated mod 2^32 and none of the planted code can be read back.

rust_pe_gnu is based at 0x140000000, so all 154207 of its recovered instructions are lost this way. PE and ELF round-trip it intact.

Reproducing

import struct, lief
from pathlib import Path
from smda.Disassembler import Disassembler
from smda.SmdaConfig import SmdaConfig

raw = Path("tests/rust_pe_gnu_xored").read_bytes()
data = bytes(b ^ (i % 256) for i, b in enumerate(raw))
report = Disassembler(SmdaConfig()).disassembleUnmappedBuffer(data)
print("report bitness", report.bitness, "base 0x%x" % report.base_addr)

blob = bytes(report.synthesizeBinary(output_format="macho"))
print("emitted magic 0x%08x" % struct.unpack("<I", blob[:4])[0])   # 0xfeedface, the 32-bit magic
print([hex(s.virtual_address) for s in lief.parse(list(blob)).sections][:3])
report bitness 64 base 0x140000000
emitted magic 0xfeedface
['0x40001000', '0x4009b000', '0x4009c000']

0x140001000 & 0xFFFFFFFF == 0x40001000, so the section addresses are the real ones with the top 32 bits cut off.

Cause

MachoSynthesizer._is64 (src/smda/synthesis/MachoSynthesizer.py:101) reads the stored xheader as a Mach-O header without first checking that it is one:

def _is64(self):
    if self._hasHeader(4):
        magic = struct.unpack("<I", self.report.xheader[0:4])[0]
        return magic in (MH_MAGIC_64, MH_CIGAM_64)
    return self.report.bitness != 32

_hasHeader (src/smda/synthesis/BinarySynthesizer.py:109) only tests that an xheader exists and is long enough — it says nothing about the format. For a PE-origin report the first four bytes are 4d 5a 90 00, which is not MH_MAGIC_64, so the method returns False and the correct report.bitness fallback on the last line is never reached.

_getCpuType (src/smda/synthesis/MachoSynthesizer.py:107) has the same shape and reads xheader[4:8] as a Mach-O cputype. For rust_pe_gnu those bytes are 03 00 00 00, so the synthesized Mach-O also claims CPU_TYPE_I386 for a 64-bit x86-64 report.

Both are only reached when an xheader is present but belongs to another format, which is why the Mach-O fixtures are unaffected: komplex supplies a real MH_MAGIC_64 and takes the intended path.

Measured

fixture origin base PE ELF Mach-O
rust_pe_gnu PE 0x140000000 154207 / 154207 154207 / 154207 0 / 154207
komplex Mach-O 0x100000000 4914 / 4914 4914 / 4914 4914 / 4914
cutwail PE 0x400000 1611 / 1611 1611 / 1611 1611 / 1611

cutwail hides it: it is also a PE-origin report that synthesizes to a 32-bit Mach-O, but its base is below 4 GiB so nothing truncates. Only a report based above 4 GiB whose xheader is not a Mach-O exposes the bug, which is why rust_pe_gnu is the only fixture that fails.

Measured with the round-trip loop from #232 against master at 5ad5504.

Suggested direction

Gate both methods on the xheader actually being a Mach-O — the file already has MH_MAGIC/MH_CIGAM/MH_MAGIC_64/MH_CIGAM_64 and a _parseSegments path that validates the magic before trusting the rest — and otherwise fall through to report.bitness and the architecture. Happy to put a PR together if the direction looks right.

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