diff options
| author | blasty <blasty@local> | 2026-07-26 00:00:19 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-26 00:00:19 +0200 |
| commit | ba0b26ebb0ce16632e8a2e67675dd79a11e26551 (patch) | |
| tree | 9eaed56efc36544c5c347e6d120d00c8ddb05eff /idatui/formats.py | |
| parent | loading: say how to read a headerless blob (processor, base) (diff) | |
| download | ida-tui-ba0b26ebb0ce16632e8a2e67675dd79a11e26551.tar.gz ida-tui-ba0b26ebb0ce16632e8a2e67675dd79a11e26551.tar.xz ida-tui-ba0b26ebb0ce16632e8a2e67675dd79a11e26551.zip | |
loading: ask how to load an unrecognised file, like IDA does
Last commit let you SAY how to load a blob. This one notices when you should
have. Interactive IDA pops a dialog when no loader matches; we silently loaded
as x86 at 0 and analysed to nothing, so the flag only helped people who already
knew they needed it — which is exactly the people who don't need help.
formats.sniff() recognises the formats IDA definitely handles (ELF, PE, Mach-O,
dex, wasm, ar, COFF, Intel HEX, S-records). Anything else gets
LoadOptionsScreen: a filterable processor list with human labels, a load-address
field, Enter to accept, Esc to load it the way IDA would have anyway.
Deliberate asymmetry: the sniff only claims formats it is sure about. A false
"unknown" costs one dismissible dialog; a false "known" is the silent wrong
answer this exists to kill. Esc is always an escape hatch.
The list offers 20 processors, not IDA's 73 — most of the rest are museum
pieces, and a name typed into the filter that matches nothing is taken literally
so nothing is actually unreachable. Endianness is spelled out (arm vs armb)
because getting it backwards is the most common route to zero functions.
Asked only when nobody has answered yet: not with --processor, not in project
mode (entries carry their own), and not when a database exists — the .i64
already records how the image was loaded.
Textual trap worth recording: the screen stored the file size in self._size,
which is Widget's own backing field for outer_size. Assigning an int to it
crashes layout with "'int' object has no attribute 'region'" from deep inside
_set_dirty, nowhere near the cause. Same family as the _render collision.
The paragraph conversion now lives in exactly one place (formats.load_args);
BinaryRef and launch both call it.
Verified on a real AArch64 blob: dialog appears, filtering to "arm" leaves two
entries, base 0x8000000 accepted -> "-parm -b800000" -> 35 functions at
0x8002440. An ELF never asks, and neither does a blob that already has a .i64.
tests: new tests/test_formats.py (21) and a load_options scenario asserting the
dialog stays out of the way for a recognised binary. 199/0 scenarios, 39/0
project, 30/0 project UI, 36/0 index, 27/0 pool, 21/0 formats.
Diffstat (limited to 'idatui/formats.py')
| -rw-r--r-- | idatui/formats.py | 122 |
1 files changed, 122 insertions, 0 deletions
diff --git a/idatui/formats.py b/idatui/formats.py new file mode 100644 index 0000000..744276d --- /dev/null +++ b/idatui/formats.py @@ -0,0 +1,122 @@ +"""Recognising what a file is, and what to ask when we can't. + +IDA picks a loader by looking at the file. When one matches, it knows the +processor, the load address and often the entry point, and there is nothing to +ask. When none matches — a raw firmware dump, a flash image, a decompressed blob +— IDA falls back to a plain binary load: **x86 at address 0**. That is a silent +wrong answer, not an error; the database opens, analysis runs, and finds nothing. + +Interactive IDA handles this by asking. This module is the "should we ask, and +what are the sensible answers" half of doing the same. + +The sniff deliberately only recognises formats IDA definitely has a loader for. +Being wrong in the cautious direction costs one dismissible dialog; being wrong +the other way is the silent-nothing case we're trying to kill. +""" + +from __future__ import annotations + +import os + +#: (magic, offset, name) for formats IDA loads without help. +_MAGIC: tuple[tuple[bytes, int, str], ...] = ( + (b"\x7fELF", 0, "ELF"), + (b"MZ", 0, "PE/MZ"), + (b"\xfe\xed\xfa\xce", 0, "Mach-O"), + (b"\xfe\xed\xfa\xcf", 0, "Mach-O 64"), + (b"\xce\xfa\xed\xfe", 0, "Mach-O (LE)"), + (b"\xcf\xfa\xed\xfe", 0, "Mach-O 64 (LE)"), + (b"\xca\xfe\xba\xbe", 0, "Mach-O fat/Java class"), + (b"dex\n", 0, "Dalvik"), + (b"\x00asm", 0, "WebAssembly"), + (b"!<arch>", 0, "ar archive"), + (b"\x4c\x01", 0, "COFF i386"), + (b"\x64\x86", 0, "COFF x64"), + (b"\x00\x00\x03\xf3", 0, "Amiga hunk"), + (b"\x7fCGC", 0, "CGC"), +) + +#: Text-ish container formats: recognised by their first line, not a magic word. +_TEXT_PREFIX: tuple[tuple[bytes, str], ...] = ( + (b":", "Intel HEX"), + (b"S0", "Motorola S-record"), + (b"S1", "Motorola S-record"), + (b"S2", "Motorola S-record"), + (b"S3", "Motorola S-record"), +) + + +def sniff(path: str) -> str | None: + """The container format of ``path``, or None when nothing recognises it. + + None is the interesting answer: it means IDA will guess, and its guess is + x86-at-zero regardless of what the bytes actually are. + """ + try: + with open(path, "rb") as f: + head = f.read(64) + except OSError: + return None + if not head: + return None + for magic, off, name in _MAGIC: + if head[off:off + len(magic)] == magic: + return name + # Only treat a text prefix as a format if the whole head is printable — + # a raw blob starting with 0x3a (':') is far more likely than Intel HEX. + if all(32 <= b < 127 or b in (9, 10, 13) for b in head): + for prefix, name in _TEXT_PREFIX: + if head.startswith(prefix): + return name + return None + + +def needs_load_options(path: str) -> bool: + """True when we should ask how to load ``path`` rather than let IDA guess.""" + return os.path.isfile(path) and sniff(path) is None + + +#: Processors worth offering, as (IDA -p name, human label). +#: +#: IDA ships 73 processor modules, most of which are museum pieces; a list that +#: long is a worse answer than a short one plus free text. These are the targets +#: that actually turn up in firmware work, endianness spelled out because +#: getting it wrong is the most common way to end up with zero functions. +PROCESSORS: tuple[tuple[str, str], ...] = ( + ("arm", "ARM / AArch64 — little-endian"), + ("armb", "ARM — big-endian"), + ("metapc", "x86 / x86-64"), + ("mipsl", "MIPS — little-endian"), + ("mipsb", "MIPS — big-endian"), + ("ppc", "PowerPC — big-endian"), + ("ppcl", "PowerPC — little-endian"), + ("sh4", "SuperH SH-4"), + ("68k", "Motorola 68000"), + ("riscv", "RISC-V"), + ("tricore", "Infineon TriCore"), + ("xtensa", "Tensilica Xtensa (ESP32 etc.)"), + ("avr", "Atmel AVR"), + ("z80", "Zilog Z80"), + ("tms320c6", "TI TMS320C6x DSP"), + ("m32r", "Renesas M32R"), + ("arc", "Synopsys ARC"), + ("h8", "Renesas H8"), + ("sparc", "SPARC"), + ("s390", "IBM S/390"), +) + + +def load_args(processor: str = "", base: int = 0, extra: str = "") -> str: + """``processor``/``base`` as IDA command-line switches. + + ``-b`` is in PARAGRAPHS, not bytes: ``-b1000`` loads at 0x10000. Every caller + goes through here so that conversion is done in exactly one place. + """ + parts = [] + if processor: + parts.append(f"-p{processor}") + if base: + parts.append(f"-b{int(base) >> 4:x}") + if extra: + parts.append(extra) + return " ".join(parts) |
