idare2 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. idare2/__init__.py +10 -0
  2. idare2/__main__.py +6 -0
  3. idare2/argv.py +342 -0
  4. idare2/assembler.py +199 -0
  5. idare2/cli.py +498 -0
  6. idare2/cmd/__init__.py +306 -0
  7. idare2/cmd/analysis.py +418 -0
  8. idare2/cmd/extras.py +347 -0
  9. idare2/cmd/flag.py +249 -0
  10. idare2/cmd/ic.py +133 -0
  11. idare2/cmd/ida.py +153 -0
  12. idare2/cmd/info.py +674 -0
  13. idare2/cmd/meta.py +249 -0
  14. idare2/cmd/meta_info.py +48 -0
  15. idare2/cmd/misc.py +603 -0
  16. idare2/cmd/print.py +1604 -0
  17. idare2/cmd/search.py +237 -0
  18. idare2/cmd/seek.py +216 -0
  19. idare2/cmd/types.py +335 -0
  20. idare2/cmd/write.py +315 -0
  21. idare2/complete.py +152 -0
  22. idare2/config.py +222 -0
  23. idare2/errors.py +159 -0
  24. idare2/expr.py +349 -0
  25. idare2/filter.py +956 -0
  26. idare2/flags.py +87 -0
  27. idare2/fmt/__init__.py +88 -0
  28. idare2/fmt/colors.py +75 -0
  29. idare2/fmt/disasm.py +202 -0
  30. idare2/fmt/graph.py +141 -0
  31. idare2/fmt/listing.py +340 -0
  32. idare2/fmt/memory.py +243 -0
  33. idare2/fmt/xrefs.py +54 -0
  34. idare2/nexus.py +322 -0
  35. idare2/parser.py +278 -0
  36. idare2/pf.py +444 -0
  37. idare2/pipe.py +67 -0
  38. idare2/protocol.py +107 -0
  39. idare2/remote/__init__.py +11 -0
  40. idare2/remote/dispatch.py +117 -0
  41. idare2/remote/elf.py +1243 -0
  42. idare2/remote/entry.py +36 -0
  43. idare2/remote/helpers.py +271 -0
  44. idare2/remote/ops/__init__.py +30 -0
  45. idare2/remote/ops/analysis.py +657 -0
  46. idare2/remote/ops/comments.py +138 -0
  47. idare2/remote/ops/decompile.py +152 -0
  48. idare2/remote/ops/disasm.py +302 -0
  49. idare2/remote/ops/info.py +843 -0
  50. idare2/remote/ops/memory.py +134 -0
  51. idare2/remote/ops/search.py +270 -0
  52. idare2/remote/ops/session.py +207 -0
  53. idare2/remote/ops/strings.py +290 -0
  54. idare2/remote/ops/types.py +666 -0
  55. idare2/remote/ops/write.py +161 -0
  56. idare2/rpc.py +123 -0
  57. idare2/session.py +742 -0
  58. idare2-0.1.0.dist-info/METADATA +309 -0
  59. idare2-0.1.0.dist-info/RECORD +62 -0
  60. idare2-0.1.0.dist-info/WHEEL +4 -0
  61. idare2-0.1.0.dist-info/entry_points.txt +3 -0
  62. idare2-0.1.0.dist-info/licenses/LICENSE +21 -0
idare2/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """idare2 — radare2-style CLI over IDA Pro databases.
2
+
3
+ The package root is intentionally trivial: this module is imported inside the
4
+ IDA process by :mod:`idare2.remote.entry`, so it must not pull in anything that
5
+ is unavailable in IDA's own Python (no ``ida_nexus``, no ``ida_*``).
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = ["__version__"]
idare2/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from idare2.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
idare2/argv.py ADDED
@@ -0,0 +1,342 @@
1
+ """radare2-flavoured argument parsing.
2
+
3
+ ``argparse`` cannot express what r2's CLI actually does: clustered short flags
4
+ (``-qc 'pd 10'``), doubled letters that mean something different from a repeat
5
+ (``-qq`` is "quit before the prompt", ``-AA`` is "deep analysis"), and repeated
6
+ ``-c``/``-e``. So this is hand-rolled.
7
+
8
+ The flag set is deliberately a subset. Three tiers:
9
+
10
+ **Implemented** -- kept with r2's meaning.
11
+ **Accepted but ignored** -- harmless in an r2 script, meaningless here; each one
12
+ warns once. Hard-failing on ``-T`` would only break scripts for no gain.
13
+ **Rejected** -- anything that would silently produce a *wrong* result, such as
14
+ ``-d`` (debugger), where the user would think they were debugging.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Iterator
20
+ from dataclasses import dataclass, field
21
+
22
+ from idare2.errors import UsageError
23
+
24
+ #: Short flags that take a value.
25
+ VALUE_FLAGS = frozenset("abcBeIikmpst")
26
+ #: Short flags that take no value.
27
+ BOOLEAN_FLAGS = frozenset("0A1E2fhjLnNqQSTuvVw")
28
+ #: Doubled letters with their own meaning, checked before single letters.
29
+ DOUBLED = ("AA", "NN", "qq", "LL", "nn")
30
+ #: Accepted and ignored, with the reason shown in the warning.
31
+ IGNORED = {
32
+ "0": "output is never NUL-separated",
33
+ "1": "use the shell's 2>&1 instead",
34
+ "2": "use the shell's 2>&1 instead",
35
+ "E": "edit ~/.idare2rc directly",
36
+ "f": "use `b $s` to set the block size to the file size",
37
+ "k": "idare2 does not do syscall resolution",
38
+ "M": "use -e asm.demangle=false",
39
+ "nn": "-n already covers this",
40
+ "Q": "the teardown saving is negligible",
41
+ "S": "use -e cfg.sandbox=true",
42
+ "t": "IDA analyses the database itself",
43
+ "T": "IDA's hashes are precomputed, so there is no cost to skip",
44
+ "u": "idare2 does not filter symbol names",
45
+ }
46
+ #: Rejected outright, with the reason.
47
+ REJECTED = {
48
+ "d": "the debugger is not available through idalib",
49
+ "D": "the debugger is not available through idalib",
50
+ "r": "there is no debugger to profile",
51
+ "R": "there is no debugger to profile",
52
+ "x": "load permissions come from IDA's loader",
53
+ "X": "extraction plugins are IDA's business",
54
+ "P": "rapatch would write to the IDB with different semantics",
55
+ }
56
+
57
+ LONG_WITH_VALUE = frozenset(
58
+ {
59
+ "attach",
60
+ "idb",
61
+ "backend",
62
+ "linger",
63
+ "idle",
64
+ "timeout",
65
+ "startup-timeout",
66
+ "color",
67
+ "endian",
68
+ }
69
+ )
70
+ LONG_BOOLEAN = frozenset(
71
+ {
72
+ "help",
73
+ "version",
74
+ "no-spawn",
75
+ "save-on-close",
76
+ "no-save-on-close",
77
+ "sessions",
78
+ "json",
79
+ }
80
+ )
81
+
82
+
83
+ @dataclass
84
+ class Options:
85
+ """Everything the CLI parsed off the command line."""
86
+
87
+ file: str | None = None
88
+ commands: list[str] = field(default_factory=list)
89
+ evals: list[tuple[str, str]] = field(default_factory=list)
90
+ pre_scripts: list[str] = field(default_factory=list)
91
+ post_scripts: list[str] = field(default_factory=list)
92
+ ignored: list[tuple[str, str]] = field(default_factory=list)
93
+
94
+ analyze: int = 0
95
+ quiet: int = 0
96
+ json: bool = False
97
+ readonly: bool = True
98
+ skip_rc: int = 0
99
+ color: int | None = None
100
+ seek: str | None = None
101
+ project: str | None = None
102
+
103
+ arch: str | None = None
104
+ bits: str | None = None
105
+ base: str | None = None
106
+ #: Raw-binary import hint: ``big`` or ``little``. Spelled ``--endian``
107
+ #: because ``-e`` is r2's config-eval flag (and already is here).
108
+ endian: str | None = None
109
+ no_analysis: bool = False
110
+
111
+ attach: str | None = None
112
+ idb: str | None = None
113
+ spawn: bool = True
114
+ backend: str = "any"
115
+ linger: float | None = None
116
+ idle: float | None = None
117
+ timeout: float | None = None
118
+ startup_timeout: float | None = None
119
+ save_on_close: bool | None = None
120
+
121
+ show_help: bool = False
122
+ show_version: int = 0
123
+ list_sessions: bool = False
124
+
125
+ @property
126
+ def wants_database(self) -> bool:
127
+ return not (self.show_help or self.show_version or self.list_sessions)
128
+
129
+
130
+ def parse(argv: list[str]) -> Options:
131
+ options = Options()
132
+ index = 0
133
+ only_positional = False
134
+
135
+ while index < len(argv):
136
+ token = argv[index]
137
+ index += 1
138
+
139
+ if only_positional or not token.startswith("-") or token == "-":
140
+ _set_file(options, token)
141
+ continue
142
+ if token == "--":
143
+ only_positional = True
144
+ continue
145
+ if token.startswith("--"):
146
+ index = _long(options, token[2:], argv, index)
147
+ continue
148
+ index = _short(options, token[1:], argv, index)
149
+
150
+ return options
151
+
152
+
153
+ def _set_file(options: Options, token: str) -> None:
154
+ if options.file is not None:
155
+ raise UsageError(f"only one file can be opened, got {options.file!r} and {token!r}")
156
+ options.file = token
157
+
158
+
159
+ def _long(options: Options, body: str, argv: list[str], index: int) -> int:
160
+ name, _, inline = body.partition("=")
161
+ if name in LONG_WITH_VALUE:
162
+ if inline:
163
+ value = inline
164
+ else:
165
+ if index >= len(argv):
166
+ raise UsageError(f"--{name} needs a value")
167
+ value = argv[index]
168
+ index += 1
169
+ _apply_long_value(options, name, value)
170
+ return index
171
+ if name in LONG_BOOLEAN:
172
+ if inline:
173
+ raise UsageError(f"--{name} does not take a value")
174
+ _apply_long_flag(options, name)
175
+ return index
176
+ raise UsageError(f"unknown option --{name}")
177
+
178
+
179
+ def _apply_long_value(options: Options, name: str, value: str) -> None:
180
+ if name == "attach":
181
+ options.attach = value
182
+ elif name == "idb":
183
+ options.idb = value
184
+ elif name == "backend":
185
+ if value not in ("gui", "idalib", "any"):
186
+ raise UsageError("--backend must be gui, idalib or any")
187
+ options.backend = value
188
+ elif name == "linger":
189
+ options.linger = _float(value, "--linger")
190
+ elif name == "idle":
191
+ options.idle = _float(value, "--idle")
192
+ elif name == "timeout":
193
+ options.timeout = _float(value, "--timeout")
194
+ elif name == "startup-timeout":
195
+ options.startup_timeout = _float(value, "--startup-timeout")
196
+ elif name == "color":
197
+ options.color = _int(value, "--color")
198
+ elif name == "endian":
199
+ if value not in ("big", "little"):
200
+ raise UsageError("--endian must be big or little")
201
+ options.endian = value
202
+
203
+
204
+ def _apply_long_flag(options: Options, name: str) -> None:
205
+ if name == "help":
206
+ options.show_help = True
207
+ elif name == "version":
208
+ options.show_version = 1
209
+ elif name == "no-spawn":
210
+ options.spawn = False
211
+ elif name == "save-on-close":
212
+ options.save_on_close = True
213
+ elif name == "no-save-on-close":
214
+ options.save_on_close = False
215
+ elif name == "sessions":
216
+ options.list_sessions = True
217
+ elif name == "json":
218
+ options.json = True
219
+
220
+
221
+ def _short(options: Options, cluster: str, argv: list[str], index: int) -> int:
222
+ position = 0
223
+ while position < len(cluster):
224
+ doubled = cluster[position : position + 2]
225
+ if doubled in DOUBLED:
226
+ _apply_doubled(options, doubled)
227
+ position += 2
228
+ continue
229
+
230
+ char = cluster[position]
231
+ if char in REJECTED:
232
+ raise UsageError(
233
+ f"-{char} is not supported by idare2: {REJECTED[char]}"
234
+ )
235
+ if char in IGNORED:
236
+ options.ignored.append((f"-{char}", IGNORED[char]))
237
+ position += 1
238
+ continue
239
+ if char == "-":
240
+ # A bare `-` means "no file" in r2 (malloc://512); idare2 has no
241
+ # such concept, so treat it as the end of the flag cluster.
242
+ break
243
+ if char in VALUE_FLAGS:
244
+ rest = cluster[position + 1 :]
245
+ if rest:
246
+ value = rest
247
+ else:
248
+ if index >= len(argv):
249
+ raise UsageError(f"-{char} needs a value")
250
+ value = argv[index]
251
+ index += 1
252
+ _apply_short_value(options, char, value)
253
+ return index
254
+ if char in BOOLEAN_FLAGS:
255
+ _apply_short_flag(options, char)
256
+ position += 1
257
+ continue
258
+ raise UsageError(f"unknown option -{char}")
259
+
260
+ return index
261
+
262
+
263
+ def _apply_doubled(options: Options, doubled: str) -> None:
264
+ if doubled == "AA":
265
+ options.analyze = 2
266
+ elif doubled == "NN":
267
+ options.skip_rc = 2
268
+ elif doubled == "qq":
269
+ options.quiet = 2
270
+ elif doubled == "LL":
271
+ options.list_sessions = True
272
+ elif doubled == "nn":
273
+ options.ignored.append(("-nn", IGNORED["nn"]))
274
+
275
+
276
+ def _apply_short_value(options: Options, char: str, value: str) -> None:
277
+ if char == "a":
278
+ options.arch = value
279
+ elif char == "b":
280
+ options.bits = value
281
+ elif char == "B" or char == "m":
282
+ options.base = value
283
+ elif char == "c":
284
+ options.commands.append(value)
285
+ elif char == "e":
286
+ key, separator, setting = value.partition("=")
287
+ if not separator:
288
+ raise UsageError(f"-e expects key=value, got {value!r}")
289
+ options.evals.append((key.strip(), setting.strip()))
290
+ elif char == "i":
291
+ options.post_scripts.append(value)
292
+ elif char == "I":
293
+ options.pre_scripts.append(value)
294
+ elif char == "k":
295
+ options.ignored.append(("-k", IGNORED["k"]))
296
+ elif char == "p":
297
+ options.project = value
298
+ elif char == "s":
299
+ options.seek = value
300
+ elif char == "t":
301
+ options.ignored.append(("-t", IGNORED["t"]))
302
+
303
+
304
+ def _apply_short_flag(options: Options, char: str) -> None:
305
+ if char == "A":
306
+ options.analyze = max(options.analyze, 1)
307
+ elif char == "h":
308
+ options.show_help = True
309
+ elif char == "j":
310
+ options.json = True
311
+ elif char == "L":
312
+ options.list_sessions = True
313
+ elif char == "n":
314
+ options.no_analysis = True
315
+ elif char == "N":
316
+ options.skip_rc = max(options.skip_rc, 1)
317
+ elif char == "q":
318
+ options.quiet = max(options.quiet, 1)
319
+ elif char == "v":
320
+ options.show_version = 1
321
+ elif char == "V":
322
+ options.show_version = 2
323
+ elif char == "w":
324
+ options.readonly = False
325
+
326
+
327
+ def _float(value: str, what: str) -> float:
328
+ try:
329
+ return float(value)
330
+ except ValueError:
331
+ raise UsageError(f"{what} needs a number, got {value!r}") from None
332
+
333
+
334
+ def _int(value: str, what: str) -> int:
335
+ try:
336
+ return int(value, 0)
337
+ except ValueError:
338
+ raise UsageError(f"{what} needs a number, got {value!r}") from None
339
+
340
+
341
+ def iter_ignored(options: Options) -> Iterator[str]:
342
+ return (f"WARN: {flag} ignored: {reason}" for flag, reason in options.ignored)
idare2/assembler.py ADDED
@@ -0,0 +1,199 @@
1
+ """Client-side assembly backend shared by ``pa`` and ``wa``.
2
+
3
+ IDA owns the authoritative disassembler for the current database, and for x86 it
4
+ also provides an assembler through ``idautils.Assemble``. Most other processor
5
+ modules registered in IDA do not provide the reverse operation. radare2 solves
6
+ that with per-architecture assembler plugins; when ``rasm2`` is installed we use
7
+ the same backend as an optional fallback instead of turning ``pa``/``wa`` into
8
+ x86-only commands.
9
+
10
+ The fallback is deliberately optional: IDA remains the primary implementation,
11
+ and a missing ``rasm2`` leaves the original IDA error untouched. This mirrors
12
+ radare2's own split between the generic assembler frontend (``r_asm``) and the
13
+ architecture plugins in ``libr/arch/p``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ from typing import TYPE_CHECKING
22
+
23
+ from idare2.errors import CommandError, UsageError
24
+
25
+ if TYPE_CHECKING:
26
+ from idare2.session import Session
27
+
28
+
29
+ #: Enough for assembling a shellcode-sized block without hanging a session.
30
+ RASM2_TIMEOUT = 15.0
31
+ MAX_ASSEMBLY = 1 << 20
32
+
33
+
34
+ #: IDA processor names -> radare2 assembler plugin names. IDA reports the
35
+ #: architecture, not the plugin name, so this is the small translation layer
36
+ #: between the two engines.
37
+ _R2_ARCH = {
38
+ "metapc": "x86",
39
+ "x86": "x86",
40
+ "x86_64": "x86",
41
+ "amd64": "x86",
42
+ "arm": "arm",
43
+ "arm64": "arm",
44
+ "arm64e": "arm",
45
+ "aarch64": "arm",
46
+ "mips": "mips",
47
+ "mipsel": "mips",
48
+ "mips64": "mips",
49
+ "ppc": "ppc",
50
+ "powerpc": "ppc",
51
+ "riscv": "riscv",
52
+ "risc-v": "riscv",
53
+ "m68k": "m68k",
54
+ "68000": "m68k",
55
+ "s390": "s390",
56
+ "s390x": "s390",
57
+ "sparc": "sparc",
58
+ "sparc64": "sparc",
59
+ "sh": "sh",
60
+ "sh4": "sh",
61
+ "superh": "sh",
62
+ "msp430": "msp430",
63
+ "avr": "avr",
64
+ "8051": "8051",
65
+ "h8300": "h8300",
66
+ "v850": "v850",
67
+ "tricore": "tricore",
68
+ "bpf": "bpf",
69
+ "ebpf": "bpf",
70
+ "evm": "evm",
71
+ "dalvik": "dalvik",
72
+ "java": "java",
73
+ "cil": "cil",
74
+ "wasm": "wasm",
75
+ "6502": "6502",
76
+ "z80": "z80",
77
+ }
78
+
79
+
80
+ def assemble(session: Session, raw_text: str, *, ea: int | None = None) -> bytes:
81
+ """Assemble ``raw_text`` at ``ea`` (the seek by default).
82
+
83
+ The IDA operation is read-only: it returns bytes so callers can either print
84
+ them (``pa``) or pass them through the write cache (``wa``).
85
+ """
86
+ text = instruction_text(raw_text)
87
+ if not text:
88
+ raise UsageError("assembly needs an instruction")
89
+ target = session.seek if ea is None else int(ea)
90
+ # r2 substitutes `$$` with the assembler's program counter before handing
91
+ # the expression to the architecture plugin.
92
+ text = text.replace("$$", f"0x{target:x}")
93
+
94
+ try:
95
+ payload = session.remote.call("assemble", ea=target, text=text)
96
+ except CommandError as ida_error:
97
+ if "assembl" not in str(ida_error).lower():
98
+ raise
99
+ fallback = _rasm2_assemble(session, text, target, ida_error)
100
+ if fallback is None:
101
+ raise
102
+ return fallback
103
+
104
+ raw = payload.get("data") if isinstance(payload, dict) else None
105
+ if isinstance(raw, (bytes, bytearray)):
106
+ data = bytes(raw)
107
+ elif isinstance(raw, str):
108
+ try:
109
+ data = bytes.fromhex(raw)
110
+ except ValueError:
111
+ raise CommandError(f"assembler returned invalid hexpairs: {raw!r}") from None
112
+ else:
113
+ raise CommandError(
114
+ f"assembler returned {type(raw).__name__} instead of bytes"
115
+ )
116
+ if not data:
117
+ raise CommandError(f"assembler produced nothing for {text!r}")
118
+ if len(data) > MAX_ASSEMBLY:
119
+ raise CommandError(f"refusing to assemble {len(data)} bytes at once")
120
+ return data
121
+
122
+
123
+ def instruction_text(raw_text: str) -> str:
124
+ """Strip r2's optional quoting around an assembly expression."""
125
+ text = raw_text.strip()
126
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in "'\"`":
127
+ return text[1:-1].strip()
128
+ return text
129
+
130
+
131
+ def r2_arch(session: Session) -> str | None:
132
+ """The radare2 assembler plugin for the attached database, if known."""
133
+ name = str(
134
+ session.info.get("architecture") or session.info.get("arch") or ""
135
+ ).strip().lower()
136
+ return _R2_ARCH.get(name)
137
+
138
+
139
+ def _rasm2_assemble(
140
+ session: Session, text: str, ea: int, ida_error: CommandError
141
+ ) -> bytes | None:
142
+ """Try the optional ``rasm2`` fallback; ``None`` means it is unavailable."""
143
+ plugin = r2_arch(session)
144
+ if plugin is None:
145
+ return None
146
+ executable = os.environ.get("IDARE2_RASM2") or shutil.which("rasm2")
147
+ if not executable:
148
+ return None
149
+
150
+ # `open_summary` exposes ``bitness``; ``file_info``/the session also expose
151
+ # ``bits``. Use both, otherwise rasm2 silently defaults to 64 bits.
152
+ bits = int(session.info.get("bitness") or session.info.get("bits") or 0)
153
+ argv = [executable, "-q", "-a", plugin, "-s", f"0x{ea:x}"]
154
+ if bits:
155
+ argv.extend(["-b", str(bits)])
156
+ if session.info.get("endian") == "big":
157
+ argv.append("-e")
158
+ argv.extend(["--", text])
159
+
160
+ try:
161
+ completed = subprocess.run(
162
+ argv,
163
+ capture_output=True,
164
+ text=True,
165
+ timeout=RASM2_TIMEOUT,
166
+ check=False,
167
+ )
168
+ except FileNotFoundError:
169
+ return None
170
+ except subprocess.TimeoutExpired:
171
+ raise CommandError(
172
+ f"rasm2 timed out after {RASM2_TIMEOUT:g}s assembling {text!r}"
173
+ ) from ida_error
174
+ except OSError as exc:
175
+ raise CommandError(f"cannot run rasm2: {exc}") from ida_error
176
+
177
+ if completed.returncode != 0:
178
+ detail = (completed.stderr or completed.stdout or "").strip()
179
+ raise CommandError(
180
+ f"IDA could not assemble {text!r}; rasm2 ({plugin}) also failed"
181
+ + (f": {detail}" if detail else "")
182
+ ) from ida_error
183
+
184
+ encoded = "".join(completed.stdout.split())
185
+ if not encoded or len(encoded) % 2:
186
+ raise CommandError(
187
+ f"rasm2 returned invalid hexpairs for {text!r}: {completed.stdout!r}"
188
+ ) from ida_error
189
+ try:
190
+ raw = bytes.fromhex(encoded)
191
+ except ValueError:
192
+ raise CommandError(
193
+ f"rasm2 returned invalid hexpairs for {text!r}: {completed.stdout!r}"
194
+ ) from ida_error
195
+ if not raw:
196
+ raise CommandError(f"assembler produced nothing for {text!r}") from ida_error
197
+ if len(raw) > MAX_ASSEMBLY:
198
+ raise CommandError(f"refusing to assemble {len(raw)} bytes at once") from ida_error
199
+ return raw