sigmaker 1.4.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.
sigmaker/__init__.py ADDED
@@ -0,0 +1,1612 @@
1
+ """
2
+ sigmaker.py - IDA Python Signature Maker
3
+ https://github.com/mahmoudimus/ida-sigmaker
4
+
5
+ by @mahmoudimus (Mahmoud Abdelkader)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import contextvars
12
+ import dataclasses
13
+ import enum
14
+ import os
15
+ import re
16
+ import string
17
+ import traceback
18
+ import typing
19
+
20
+ import idaapi
21
+ import idc
22
+
23
+ __author__ = "mahmoudimus"
24
+ __version__ = "1.4.0"
25
+
26
+ PLUGIN_NAME: str = "Signature Maker (py)"
27
+ PLUGIN_VERSION: str = __version__
28
+ PLUGIN_AUTHOR: str = __author__
29
+
30
+ WILDCARD_POLICY_CTX: contextvars.ContextVar["WildcardPolicy"] = contextvars.ContextVar(
31
+ "wildcard_policy"
32
+ )
33
+
34
+
35
+ class Unexpected(Exception):
36
+ """Exception type used throughout the module to indicate unexpected errors."""
37
+
38
+
39
+ def is_address_marked_as_code(ea: int) -> bool:
40
+ """Returns True if the specified address (ea) is marked as code in the disassembled binary."""
41
+ return idaapi.is_code(idaapi.get_flags(ea))
42
+
43
+
44
+ @dataclasses.dataclass
45
+ class SigMakerConfig:
46
+ """Configuration for SigMaker operations.
47
+
48
+ This class holds all the configuration parameters needed for
49
+ SigMaker operations.
50
+ """
51
+
52
+ output_format: SignatureType
53
+ wildcard_operands: bool
54
+ continue_outside_of_function: bool
55
+ wildcard_optimized: bool
56
+ ask_longer_signature: bool = True
57
+ print_top_x: int = 5
58
+ max_single_signature_length: int = 100
59
+ max_xref_signature_length: int = 250
60
+
61
+
62
+ @dataclasses.dataclass(slots=True, frozen=True, repr=False)
63
+ class Match:
64
+ """Container for a single match.
65
+
66
+ Acts like an int, but provides a more readable representation.
67
+ """
68
+
69
+ address: int
70
+
71
+ def __repr__(self) -> str:
72
+ return f"Match(address={hex(self.address)})"
73
+
74
+ def __str__(self) -> str:
75
+ return hex(self.address)
76
+
77
+ def __int__(self) -> int:
78
+ return self.address
79
+
80
+ __index__ = __int__
81
+
82
+
83
+ class SignatureType(enum.Enum):
84
+ """Enumeration representing the various supported signature output formats."""
85
+
86
+ IDA = "ida"
87
+ x64Dbg = "x64dbg"
88
+ Mask = "mask"
89
+ BitMask = "bitmask"
90
+
91
+ @classmethod
92
+ def at(cls, index: int) -> "SignatureType":
93
+ """Return the enum member at a given index (definition order)."""
94
+ return list(cls.__members__.values())[index]
95
+
96
+
97
+ class SignatureByte(typing.NamedTuple):
98
+ """Container representing a single byte in a signature.
99
+
100
+ The ``value`` attribute holds the byte value and ``is_wildcard`` indicates
101
+ whether this byte should be treated as a wildcard in comparisons and output.
102
+ """
103
+
104
+ value: int
105
+ is_wildcard: bool
106
+
107
+
108
+ class Signature(list[SignatureByte]):
109
+ """
110
+ A data container for a sequence of signature bytes.
111
+
112
+ This class is responsible for storing and manipulating the raw data of a
113
+ signature. It does not handle formatting into string representations.
114
+ """
115
+
116
+ def add_byte_to_signature(self, address: int, is_wildcard: bool) -> None:
117
+ """Appends a single byte from the IDA database to the signature."""
118
+ byte_value = idaapi.get_byte(address)
119
+ self.append(SignatureByte(byte_value, is_wildcard))
120
+
121
+ def add_bytes_to_signature(
122
+ self, address: int, count: int, is_wildcard: bool
123
+ ) -> None:
124
+ """Appends multiple bytes from the IDA database to the signature."""
125
+ # Using get_bytes is more efficient than a loop of get_byte
126
+ bytes_data = idaapi.get_bytes(address, count)
127
+ if bytes_data:
128
+ self.extend(SignatureByte(b, is_wildcard) for b in bytes_data)
129
+
130
+ def trim_signature(self) -> None:
131
+ """Removes trailing wildcard bytes from the signature in-place."""
132
+ n = len(self)
133
+ while n > 0 and self[n - 1].is_wildcard:
134
+ n -= 1
135
+ # Efficiently truncate the list
136
+ del self[n:]
137
+
138
+ def __str__(self) -> str:
139
+ """
140
+ Provides the default string representation.
141
+ This is equivalent to format(self, '').
142
+ """
143
+ return self.__format__("")
144
+
145
+ def __format__(self, format_spec: str) -> str:
146
+ """
147
+ Formats the signature according to the provided format specifier.
148
+
149
+ This method allows the Signature object to be used with f-strings
150
+ and the format() built-in function.
151
+
152
+ Supported format_spec values:
153
+ - '' (default) or 'ida': "55 8B ? EC"
154
+ - 'x64dbg': "55 8B ?? EC"
155
+ - 'mask': "\\x55\\x8B\\x00\\xEC xx?x"
156
+ - 'bitmask': "0x55, 0x8B, 0x00, 0xEC 0b1101"
157
+ """
158
+ # Use .lower() to make specifiers case-insensitive
159
+ spec = format_spec.lower()
160
+ try:
161
+ formatter = FORMATTER_MAP[SignatureType(spec)]
162
+ except KeyError:
163
+ raise ValueError(
164
+ f"Unknown format code '{format_spec}' for object of type 'Signature'"
165
+ )
166
+ return formatter.format(self)
167
+
168
+
169
+ class SignatureFormatter(typing.Protocol):
170
+ """
171
+ A protocol for objects that can format a Signature into a string.
172
+ """
173
+
174
+ def format(self, signature: "Signature") -> str:
175
+ """Formats the given Signature object into a string."""
176
+ ...
177
+
178
+
179
+ @dataclasses.dataclass(frozen=True, slots=True)
180
+ class IdaFormatter:
181
+ """
182
+ Formats a signature into the IDA style ('DE AD ? EF').
183
+ The wildcard character can be configured.
184
+ """
185
+
186
+ wildcard_byte: str = "?"
187
+
188
+ def format(self, signature: "Signature") -> str:
189
+ parts = []
190
+ for byte in signature:
191
+ if byte.is_wildcard:
192
+ parts.append(self.wildcard_byte)
193
+ else:
194
+ parts.append(f"{byte.value:02X}")
195
+ return " ".join(parts)
196
+
197
+
198
+ @dataclasses.dataclass(frozen=True, slots=True)
199
+ class X64DbgFormatter(IdaFormatter):
200
+ """
201
+ Formats a signature for x64Dbg by specializing IdaFormatter
202
+ to use '??' as the wildcard.
203
+ """
204
+
205
+ wildcard_byte: str = "??"
206
+
207
+
208
+ @dataclasses.dataclass(frozen=True, slots=True)
209
+ class MaskedBytesFormatter:
210
+ """Formats into a C-style byte array and a mask string ('\\xDE\\xAD', 'xx?')."""
211
+
212
+ wildcard_byte: str = "\\x00"
213
+ mask: str = "x"
214
+ wildcard_mask: str = "?"
215
+
216
+ @staticmethod
217
+ def build_signature_parts(
218
+ signature: "Signature",
219
+ byte_format: str,
220
+ wildcard_byte: str,
221
+ mask_char: str,
222
+ wildcard_mask_char: str,
223
+ ) -> tuple[list[str], list[str]]:
224
+ """
225
+ Iterates over a signature and builds lists of its pattern and mask parts.
226
+ This is the common logic shared by multiple masked byte formatters.
227
+ """
228
+ pattern_parts = []
229
+ mask_parts = []
230
+ for byte in signature:
231
+ if byte.is_wildcard:
232
+ pattern_parts.append(wildcard_byte)
233
+ mask_parts.append(wildcard_mask_char)
234
+ else:
235
+ pattern_parts.append(byte_format.format(byte.value))
236
+ mask_parts.append(mask_char)
237
+ return pattern_parts, mask_parts
238
+
239
+ def format(self, signature: "Signature") -> str:
240
+ pattern_parts, mask_parts = self.build_signature_parts(
241
+ signature,
242
+ "\\x{:02X}",
243
+ self.wildcard_byte,
244
+ self.mask,
245
+ self.wildcard_mask,
246
+ )
247
+ return "".join(pattern_parts) + " " + "".join(mask_parts)
248
+
249
+
250
+ @dataclasses.dataclass(frozen=True, slots=True)
251
+ class ByteArrayBitmaskFormatter:
252
+ """Formats into a C-style byte array and a bitmask ('0xDE,', '0b1101')."""
253
+
254
+ wildcard_byte: str = "0x00"
255
+ mask: str = "1"
256
+ wildcard_mask: str = "0"
257
+
258
+ def format(self, signature: "Signature") -> str:
259
+ pattern_parts, mask_parts = MaskedBytesFormatter.build_signature_parts(
260
+ signature,
261
+ "0x{:02X}",
262
+ self.wildcard_byte,
263
+ self.mask,
264
+ self.wildcard_mask,
265
+ )
266
+ pattern_str = ", ".join(pattern_parts)
267
+ mask_str = "".join(mask_parts)[::-1]
268
+ return f"{pattern_str} 0b{mask_str}"
269
+
270
+
271
+ FORMATTER_MAP: typing.Dict[SignatureType, SignatureFormatter] = {
272
+ SignatureType.IDA: IdaFormatter(),
273
+ SignatureType.x64Dbg: X64DbgFormatter(),
274
+ SignatureType.Mask: MaskedBytesFormatter(),
275
+ SignatureType.BitMask: ByteArrayBitmaskFormatter(),
276
+ }
277
+
278
+
279
+ @dataclasses.dataclass(slots=True, frozen=True)
280
+ class WildcardPolicy:
281
+ """
282
+ Policy for which operand types are wildcardable.
283
+ Stores allowed IDA operand type codes (ints).
284
+ """
285
+
286
+ allowed_types: frozenset[int]
287
+ _ctx = WILDCARD_POLICY_CTX
288
+
289
+ class RarelyWildcardable(enum.IntEnum):
290
+ VOID = idaapi.o_void
291
+ REG = idaapi.o_reg
292
+
293
+ # Base operand types common to all architectures
294
+ class BaseKind(enum.IntEnum):
295
+ MEM = idaapi.o_mem
296
+ PHRASE = idaapi.o_phrase
297
+ DISPL = idaapi.o_displ
298
+ IMM = idaapi.o_imm
299
+ FAR = idaapi.o_far
300
+ NEAR = idaapi.o_near
301
+
302
+ # Architecture-specific operand types
303
+ class X86Kind(enum.IntEnum):
304
+ TRREG = idaapi.o_idpspec0 # Trace register
305
+ DBREG = idaapi.o_idpspec1 # Debug register
306
+ CRREG = idaapi.o_idpspec2 # Control register
307
+ FPREG = idaapi.o_idpspec3 # Floating point register
308
+ MMX = idaapi.o_idpspec4 # MMX register
309
+ XMM = idaapi.o_idpspec5 # XMM register
310
+ YMM = idaapi.o_idpspec5 + 1 # YMM register
311
+ ZMM = idaapi.o_idpspec5 + 2 # ZMM register
312
+ KREG = idaapi.o_idpspec5 + 3 # K register (mask)
313
+
314
+ class ARMKind(enum.IntEnum):
315
+ REGLIST = idaapi.o_idpspec1 # Register list (LDM/STM)
316
+ CREGLIST = idaapi.o_idpspec2 # Coprocessor register list (CDP)
317
+ CREG = idaapi.o_idpspec3 # Coprocessor register (LDC/STC)
318
+ FPREGLIST = idaapi.o_idpspec4 # Floating point register list
319
+ TEXT = idaapi.o_idpspec5 # Arbitrary text
320
+ COND = idaapi.o_idpspec5 + 1 # ARM condition
321
+
322
+ class MIPSKind(enum.IntEnum):
323
+ # MIPS doesn't have specific operand types in the example
324
+ pass
325
+
326
+ class PPCKind(enum.IntEnum):
327
+ SPR = idaapi.o_idpspec0 # Special purpose register
328
+ TWOFPR = idaapi.o_idpspec1 # Two FPRs
329
+ SHMBME = idaapi.o_idpspec2 # SH & MB & ME
330
+ CRF = idaapi.o_idpspec3 # CR field
331
+ CRB = idaapi.o_idpspec4 # CR bit
332
+ DCR = idaapi.o_idpspec5 # Device control register
333
+
334
+ # Hoisted context manager class
335
+ @dataclasses.dataclass(slots=True)
336
+ class _Use:
337
+ """Context manager to temporarily override current policy."""
338
+
339
+ policy: "WildcardPolicy"
340
+ policy_class: type["WildcardPolicy"]
341
+ token: contextvars.Token | None = None
342
+
343
+ def __enter__(self):
344
+ self.token = self.policy_class.set_current(self.policy)
345
+ return self.policy
346
+
347
+ def __exit__(self, exc_type, exc, tb):
348
+ if self.token is not None:
349
+ self.policy_class.reset_current(self.token)
350
+
351
+ # ---- construction helpers ----
352
+ @classmethod
353
+ def for_x86(cls) -> "WildcardPolicy":
354
+ return cls(frozenset(cls.BaseKind) | frozenset(cls.X86Kind))
355
+
356
+ @classmethod
357
+ def for_arm(cls) -> "WildcardPolicy":
358
+ return cls(frozenset(cls.BaseKind) | frozenset(cls.ARMKind))
359
+
360
+ @classmethod
361
+ def for_mips(cls) -> "WildcardPolicy":
362
+ return cls(frozenset({cls.BaseKind.MEM, cls.BaseKind.FAR, cls.BaseKind.NEAR}))
363
+
364
+ @classmethod
365
+ def for_ppc(cls) -> "WildcardPolicy":
366
+ return cls(frozenset(cls.BaseKind) | frozenset(cls.PPCKind))
367
+
368
+ @classmethod
369
+ def default_generic(cls) -> "WildcardPolicy":
370
+ return cls(frozenset(cls.BaseKind))
371
+
372
+ @classmethod
373
+ def detect_from_processor(cls) -> "WildcardPolicy":
374
+ arch = idaapi.ph_get_id()
375
+ if arch == idaapi.PLFM_386:
376
+ return cls.for_x86()
377
+ if arch == idaapi.PLFM_ARM:
378
+ return cls.for_arm()
379
+ if arch == idaapi.PLFM_MIPS:
380
+ return cls.for_mips()
381
+ if arch == idaapi.PLFM_PPC:
382
+ return cls.for_ppc()
383
+ return cls.default_generic()
384
+
385
+ # ---- queries / adapters ----
386
+ def allows_type(self, op_type: int) -> bool:
387
+ return op_type in self.allowed_types
388
+
389
+ def to_mask(self) -> int:
390
+ """Compatibility bitmask: 1 << op.type for each allowed type."""
391
+ return sum(1 << int(t) for t in self.allowed_types)
392
+
393
+ @classmethod
394
+ def from_mask(cls, mask: int) -> "WildcardPolicy":
395
+ types = {t for t in range(0, 64) if (mask >> t) & 1}
396
+ return cls(frozenset(types))
397
+
398
+ @classmethod
399
+ def current(cls) -> "WildcardPolicy":
400
+ """Get current policy (falling back to arch-detected default)."""
401
+ policy = cls._ctx.get(cls.detect_from_processor())
402
+ cls._ctx.set(policy)
403
+ return policy
404
+
405
+ @classmethod
406
+ def set_current(cls, policy: "WildcardPolicy") -> contextvars.Token:
407
+ """Override current policy (returns token for reset)."""
408
+ return cls._ctx.set(policy)
409
+
410
+ @classmethod
411
+ def reset_current(cls, token: contextvars.Token) -> None:
412
+ cls._ctx.reset(token)
413
+
414
+ @classmethod
415
+ def use(cls, policy: "WildcardPolicy") -> "WildcardPolicy._Use":
416
+ """Context manager to temporarily override current policy.
417
+
418
+ Example:
419
+ ```
420
+ with WildcardPolicy.use(WildcardPolicy.for_x86()):
421
+ sig = SignatureMaker().make_signature(anchor_ea, ctx)
422
+ assert any(b.is_wildcard for b in sig.signature)
423
+ ```
424
+ """
425
+ return cls._Use(policy, cls)
426
+
427
+
428
+ @dataclasses.dataclass(slots=True, frozen=True)
429
+ class GeneratedSignature:
430
+ """Result container for signature generation operations."""
431
+
432
+ signature: Signature
433
+ address: Match | None = None
434
+
435
+ def display(self, cfg: SigMakerConfig) -> None:
436
+ """Display the signature result to the user."""
437
+ if not self.signature:
438
+ idaapi.msg("Error: Empty signature\n")
439
+ return
440
+ t = cfg.output_format.value
441
+ fmted = format(self.signature, t)
442
+ if self.address is not None:
443
+ idaapi.msg(f"Signature for {self.address}: {fmted}\n")
444
+ else:
445
+ idaapi.msg(f"Signature: {fmted}\n")
446
+
447
+ if not Clipboard.set_text(fmted):
448
+ idaapi.msg("Failed to copy to clipboard!")
449
+
450
+ def __lt__(self, other) -> bool:
451
+ if not isinstance(other, GeneratedSignature):
452
+ return NotImplemented
453
+ return len(self.signature) < len(other.signature)
454
+
455
+
456
+ @dataclasses.dataclass(slots=True)
457
+ class XrefGeneratedSignature:
458
+ """Result container for XREF signature finding operations."""
459
+
460
+ signatures: list[GeneratedSignature]
461
+
462
+ def display(self, cfg: SigMakerConfig) -> None:
463
+ """Display the XREF signatures to the user."""
464
+ if not self.signatures:
465
+ idaapi.msg("No XREFs have been found for your address\n")
466
+ return
467
+ t = cfg.output_format.value
468
+ top_length = min(cfg.print_top_x, len(self.signatures))
469
+ idaapi.msg(
470
+ f"Top {top_length} Signatures out of {len(self.signatures)} xrefs:\n"
471
+ )
472
+ for i, generated_signature in enumerate(self.signatures[:top_length], start=1):
473
+ address = generated_signature.address
474
+ signature = generated_signature.signature
475
+ fmted = format(signature, t)
476
+ idaapi.msg(f"XREF Signature #{i} @ {address}: {fmted}\n")
477
+ if i == 0:
478
+ Clipboard.set_text(fmted)
479
+
480
+
481
+ class SigText:
482
+ """Signature normalizer with wildcard support ('?' per nibble)."""
483
+
484
+ _HEX_SET = frozenset(string.hexdigits)
485
+ _TRANS = str.maketrans(
486
+ {
487
+ ",": " ",
488
+ ";": " ",
489
+ ":": " ",
490
+ "|": " ",
491
+ "_": " ",
492
+ "-": " ",
493
+ "\t": " ",
494
+ "\n": " ",
495
+ "\r": " ",
496
+ ".": "?", # '.' → '?' (optional)
497
+ }
498
+ )
499
+
500
+ @staticmethod
501
+ def _tok_is_hex(s: str) -> bool:
502
+ return len(s) > 0 and all(c in SigText._HEX_SET for c in s)
503
+
504
+ @staticmethod
505
+ def _split_hex_pairs(s: str) -> list[str]:
506
+ # Split an even-length pure-hex string into HH pairs
507
+ return [s[i : i + 2].upper() for i in range(0, len(s), 2)]
508
+
509
+ @staticmethod
510
+ def normalize(sig_str: str) -> tuple[str, list[tuple[int, bool]]]:
511
+ if not sig_str:
512
+ return "", []
513
+ # 1) normalize separators -> spaces; remove 0x prefixes token-wise
514
+ s = sig_str.translate(SigText._TRANS)
515
+ raw = [t for t in s.split() if t]
516
+ toks: list[str] = []
517
+ for t in raw:
518
+ t = t.strip()
519
+ if t.startswith(("0x", "0X")):
520
+ t = t[2:]
521
+ if not t:
522
+ continue
523
+ toks.append(t)
524
+
525
+ out: list[str] = []
526
+ i = 0
527
+ while i < len(toks):
528
+ t = toks[i]
529
+
530
+ # Fast path: canonical tokens we already accept
531
+ if t == "??":
532
+ out.append("??")
533
+ i += 1
534
+ continue
535
+
536
+ if len(t) == 2 and SigText._tok_is_hex(t):
537
+ out.append(t.upper())
538
+ i += 1
539
+ continue
540
+
541
+ # Single hex nibble -> 'H?'
542
+ if len(t) == 1 and t in SigText._HEX_SET:
543
+ out.append((t + "?").upper())
544
+ i += 1
545
+ continue
546
+
547
+ # Single '?'
548
+ if t == "?":
549
+ out.append("??")
550
+ i += 1
551
+ continue
552
+
553
+ # Long pure-hex strings (must be even length)
554
+ if SigText._tok_is_hex(t):
555
+ if (len(t) & 1) != 0:
556
+ # odd-length => split into pairs and pad last nibble with '?' as high nibble
557
+ pairs = SigText._split_hex_pairs(t)
558
+ pairs_len = len(pairs)
559
+ # Last pair will be single character, make it '?X'
560
+ if pairs and len(pairs[pairs_len - 1]) == 1:
561
+ pairs[pairs_len - 1] = "?" + pairs[pairs_len - 1]
562
+ out.extend(pairs)
563
+ i += 1
564
+ continue
565
+ else:
566
+ out.extend(SigText._split_hex_pairs(t))
567
+ i += 1
568
+ continue
569
+
570
+ # Mixed 2-char forms with nibble wildcards: '?F', 'F?', '??'
571
+ if len(t) == 2:
572
+ hi, lo = t[0], t[1]
573
+ if (hi in SigText._HEX_SET or hi == "?") and (
574
+ lo in SigText._HEX_SET or lo == "?"
575
+ ):
576
+ out.append((hi + lo).upper())
577
+ i += 1
578
+ continue
579
+
580
+ # Unrecognized token format
581
+ raise ValueError(f"invalid signature token: {t!r}")
582
+
583
+ # Build (value, wildcard) list
584
+ pattern: list[tuple[int, bool]] = []
585
+ for tok in out:
586
+ hi, lo = tok[0], tok[1]
587
+ wild = (hi == "?") or (lo == "?")
588
+ hv = 0 if hi == "?" else int(hi, 16)
589
+ lv = 0 if lo == "?" else int(lo, 16)
590
+ pattern.append(((hv << 4) | lv, wild))
591
+
592
+ return " ".join(out), pattern
593
+
594
+
595
+ class OperandProcessor:
596
+ """Handles operand processing for signature generation (policy-driven).
597
+ # TODO: refactor this to support more architectures, not just ARM/X64.
598
+ """
599
+
600
+ def __init__(self):
601
+ self._is_arm = self._check_is_arm()
602
+
603
+ @staticmethod
604
+ def _check_is_arm() -> bool:
605
+ return idaapi.ph_get_id() == idaapi.PLFM_ARM
606
+
607
+ def _get_operand_offset_arm(
608
+ self, ins: idaapi.insn_t, off: typing.List[int], length: typing.List[int]
609
+ ) -> bool:
610
+ policy = WildcardPolicy.current()
611
+ for op in ins:
612
+ if op.type in policy.allowed_types:
613
+ off[0] = op.offb
614
+ length[0] = 3 if ins.size == 4 else (7 if ins.size == 8 else 0)
615
+ return True
616
+ return False
617
+
618
+ def get_operand(
619
+ self,
620
+ ins: idaapi.insn_t,
621
+ off: typing.List[int],
622
+ length: typing.List[int],
623
+ wildcard_optimized: bool,
624
+ ) -> bool:
625
+ policy = WildcardPolicy.current()
626
+ if self._is_arm:
627
+ return self._get_operand_offset_arm(ins, off, length)
628
+ for op in ins:
629
+ if op.type == idaapi.o_void:
630
+ continue
631
+ if not policy.allows_type(op.type):
632
+ continue
633
+ if op.offb == 0 and not wildcard_optimized:
634
+ continue
635
+ off[0] = op.offb
636
+ length[0] = ins.size - op.offb
637
+ return True
638
+ return False
639
+
640
+
641
+ class InstructionProcessor:
642
+ """Processes a single instruction to append its bytes to a signature."""
643
+
644
+ def __init__(self, operand_processor: OperandProcessor):
645
+ self.operand_processor = operand_processor
646
+
647
+ def append_instruction_to_sig(
648
+ self,
649
+ sig: Signature,
650
+ ea: int,
651
+ ins: idaapi.insn_t,
652
+ wildcard_operands: bool,
653
+ wildcard_optimized: bool,
654
+ ) -> None:
655
+ """
656
+ Appends instruction bytes to the signature, optionally wildcarding operands.
657
+ """
658
+ if not wildcard_operands:
659
+ # Default case: add the whole instruction as-is
660
+ sig.add_bytes_to_signature(ea, ins.size, is_wildcard=False)
661
+ return
662
+
663
+ off, length = [0], [0]
664
+ has_operand = self.operand_processor.get_operand(
665
+ ins, off, length, wildcard_optimized
666
+ )
667
+ if not has_operand or length[0] <= 0:
668
+ sig.add_bytes_to_signature(ea, ins.size, is_wildcard=False)
669
+ return
670
+
671
+ # Add bytes before the operand
672
+ if off[0] > 0:
673
+ sig.add_bytes_to_signature(ea, off[0], is_wildcard=False)
674
+
675
+ # Add the operand as a wildcard
676
+ sig.add_bytes_to_signature(ea + off[0], length[0], is_wildcard=True)
677
+
678
+ # Add bytes after the operand
679
+ remaining_len = ins.size - (off[0] + length[0])
680
+ if remaining_len > 0:
681
+ sig.add_bytes_to_signature(
682
+ ea + off[0] + length[0], remaining_len, is_wildcard=False
683
+ )
684
+
685
+
686
+ @dataclasses.dataclass(slots=True)
687
+ class InstructionWalker:
688
+ """
689
+ A stateful iterator for walking instructions within a given address range.
690
+
691
+ This class encapsulates the logic of decoding instructions and tracks the
692
+ current address (cursor), which remains available for inspection after
693
+ the iteration is complete.
694
+ """
695
+
696
+ start_ea: int
697
+ end_ea: int = idaapi.BADADDR
698
+
699
+ # Internal state fields
700
+ cursor: int = dataclasses.field(init=False)
701
+ _instruction: idaapi.insn_t = dataclasses.field(
702
+ init=False, repr=False, default_factory=idaapi.insn_t
703
+ )
704
+
705
+ def __post_init__(self):
706
+ if self.start_ea == idaapi.BADADDR:
707
+ raise ValueError("Invalid start address for InstructionWalker")
708
+ # Initialize the cursor to the starting address
709
+ self.cursor = self.start_ea
710
+
711
+ def __iter__(self):
712
+ # Reset cursor to allow for re-iteration if needed
713
+ self.cursor = self.start_ea
714
+ return self
715
+
716
+ def __next__(self) -> tuple[int, idaapi.insn_t, int]:
717
+ """Decodes and returns the next instruction, advancing the cursor."""
718
+ if self.end_ea != idaapi.BADADDR and self.cursor >= self.end_ea:
719
+ raise StopIteration
720
+
721
+ if idaapi.user_cancelled():
722
+ raise StopIteration("Aborted by user")
723
+
724
+ current_instruction_ea = self.cursor
725
+ ins_len = idaapi.decode_insn(self._instruction, current_instruction_ea)
726
+
727
+ if ins_len <= 0:
728
+ raise StopIteration
729
+
730
+ self.cursor += ins_len
731
+
732
+ return current_instruction_ea, self._instruction, ins_len
733
+
734
+
735
+ class UniqueSignatureGenerator:
736
+ """Strategy for generating a signature that is guaranteed to be unique."""
737
+
738
+ def __init__(self, processor: InstructionProcessor):
739
+ self.processor = processor
740
+
741
+ def generate(self, ea: int, cfg: SigMakerConfig) -> Signature:
742
+ if not is_address_marked_as_code(ea):
743
+ raise Unexpected("Cannot create code signature for data")
744
+
745
+ sig = Signature()
746
+ start_fn = idaapi.get_func(ea)
747
+ bytes_since_last_check = 0
748
+
749
+ for cur_ea, ins, ins_len in InstructionWalker(ea):
750
+ # Check length constraint
751
+ if bytes_since_last_check > cfg.max_single_signature_length:
752
+ if (
753
+ not cfg.ask_longer_signature
754
+ or idaapi.ask_yn(
755
+ idaapi.ASKBTN_NO,
756
+ f"Signature is already {len(sig)} bytes. Continue?",
757
+ )
758
+ != idaapi.ASKBTN_YES
759
+ ):
760
+ raise Unexpected("Signature not unique within length constraints")
761
+ bytes_since_last_check = 0 # Reset counter after user confirmation
762
+
763
+ # Check function boundary constraint
764
+ if (
765
+ not cfg.continue_outside_of_function
766
+ and start_fn
767
+ and cur_ea >= start_fn.end_ea
768
+ ):
769
+ raise Unexpected("Signature left function scope without being unique")
770
+
771
+ self.processor.append_instruction_to_sig(
772
+ sig, cur_ea, ins, cfg.wildcard_operands, cfg.wildcard_optimized
773
+ )
774
+ bytes_since_last_check += ins_len
775
+
776
+ if SignatureSearcher.is_unique(f"{sig:ida}"):
777
+ sig.trim_signature()
778
+ return sig
779
+
780
+ raise Unexpected("Signature not unique (reached end of analysis)")
781
+
782
+
783
+ class RangeSignatureGenerator:
784
+ """Strategy for generating a signature for a fixed address range."""
785
+
786
+ def __init__(self, processor: InstructionProcessor):
787
+ self.processor = processor
788
+
789
+ def generate(self, start_ea: int, end_ea: int, cfg: SigMakerConfig) -> Signature:
790
+ sig = Signature()
791
+
792
+ # Handle pure data ranges
793
+ if not is_address_marked_as_code(start_ea):
794
+ sig.add_bytes_to_signature(start_ea, end_ea - start_ea, is_wildcard=False)
795
+ return sig
796
+
797
+ # Iterate through instructions within the range
798
+ walker = InstructionWalker(start_ea, end_ea)
799
+ for cur_ea, ins, _ in walker:
800
+ self.processor.append_instruction_to_sig(
801
+ sig, cur_ea, ins, cfg.wildcard_operands, cfg.wildcard_optimized
802
+ )
803
+
804
+ # Add any remaining bytes if the last instruction was partially in range
805
+ # or if the range ended in a data block.
806
+ if walker.cursor < end_ea:
807
+ remaining_bytes = end_ea - walker.cursor
808
+ sig.add_bytes_to_signature(
809
+ walker.cursor, remaining_bytes, is_wildcard=False
810
+ )
811
+
812
+ sig.trim_signature()
813
+ return sig
814
+
815
+
816
+ @dataclasses.dataclass(slots=True)
817
+ class SignatureMaker:
818
+ """
819
+ Generates unique or range-based signatures.
820
+ """
821
+
822
+ _operand_processor: OperandProcessor = dataclasses.field(
823
+ default_factory=OperandProcessor
824
+ )
825
+
826
+ # Internal components built from dependencies
827
+ _instruction_processor: InstructionProcessor = dataclasses.field(init=False)
828
+ _unique_generator: UniqueSignatureGenerator = dataclasses.field(init=False)
829
+ _range_generator: RangeSignatureGenerator = dataclasses.field(init=False)
830
+
831
+ def __post_init__(self):
832
+ """Initialize internal components after the main object is created."""
833
+ self._instruction_processor = InstructionProcessor(self._operand_processor)
834
+ self._unique_generator = UniqueSignatureGenerator(self._instruction_processor)
835
+ self._range_generator = RangeSignatureGenerator(self._instruction_processor)
836
+
837
+ def make_signature(
838
+ self, ea: int | Match, cfg: SigMakerConfig, end: int | None = None
839
+ ) -> GeneratedSignature:
840
+ """
841
+ Creates a signature for a single address (unique) or an address range.
842
+ """
843
+ start_ea = int(ea)
844
+ if start_ea == idaapi.BADADDR:
845
+ raise Unexpected("Invalid start address")
846
+
847
+ if end is None:
848
+ # Delegate to the unique signature generation strategy
849
+ sig = self._unique_generator.generate(start_ea, cfg)
850
+ return GeneratedSignature(sig, Match(start_ea))
851
+
852
+ if end <= start_ea:
853
+ raise Unexpected("End address must be after start address")
854
+
855
+ # Delegate to the range signature generation strategy
856
+ sig = self._range_generator.generate(start_ea, end, cfg)
857
+ return GeneratedSignature(sig)
858
+
859
+
860
+ class XrefFinder:
861
+ """Handles finding and generating signatures for XREF addresses."""
862
+
863
+ def __init__(self):
864
+ self.progress_dialog = ProgressDialog()
865
+ self.signature_maker = SignatureMaker()
866
+
867
+ @classmethod
868
+ def iter_code_xrefs_to(cls, ea: int) -> typing.Iterable[int]:
869
+ """Yield code xref sources (xb.frm) that point *to* 'ea'."""
870
+ xb = idaapi.xrefblk_t()
871
+ if not xb.first_to(ea, idaapi.XREF_ALL):
872
+ return
873
+
874
+ while True:
875
+ if is_address_marked_as_code(xb.frm):
876
+ yield xb.frm
877
+ if not xb.next_to():
878
+ break
879
+
880
+ @classmethod
881
+ def count_code_xrefs_to(cls, ea: int) -> int:
882
+ """Count code xrefs to 'ea' without duplicating traversal logic."""
883
+ return sum(1 for _ in cls.iter_code_xrefs_to(ea))
884
+
885
+ def find_xrefs(self, ea: int, cfg: SigMakerConfig) -> XrefGeneratedSignature:
886
+ """Find XREF signatures to a given address."""
887
+ xref_signatures: list[GeneratedSignature] = []
888
+
889
+ total = self.count_code_xrefs_to(ea)
890
+ if total == 0:
891
+ return XrefGeneratedSignature([])
892
+
893
+ # Non-interactive during xref search
894
+ cfg_no_prompt = dataclasses.replace(cfg, ask_longer_signature=False)
895
+
896
+ shortest_len = cfg.max_xref_signature_length + 1
897
+
898
+ for i, frm_ea in enumerate(self.iter_code_xrefs_to(ea), start=1):
899
+ if self.progress_dialog.user_canceled():
900
+ break
901
+
902
+ self.progress_dialog.replace_message(
903
+ f"Processing xref {i} of {total} ({(i / total) * 100.0:.1f}%)...\n\n"
904
+ f"Suitable Signatures: {len(xref_signatures)}\n"
905
+ f"Shortest Signature: {shortest_len if shortest_len <= cfg.max_xref_signature_length else 0} Bytes"
906
+ )
907
+
908
+ try:
909
+ # Public API: returns SignatureResult
910
+ result = self.signature_maker.make_signature(frm_ea, cfg_no_prompt)
911
+ sig: typing.Optional[Signature] = result.signature
912
+ except Exception:
913
+ sig = None
914
+
915
+ if sig is None:
916
+ continue
917
+
918
+ if len(sig) < shortest_len:
919
+ shortest_len = len(sig)
920
+ xref_signatures.append(GeneratedSignature(sig, Match(frm_ea)))
921
+
922
+ xref_signatures.sort()
923
+ return XrefGeneratedSignature(xref_signatures)
924
+
925
+
926
+ @dataclasses.dataclass(slots=True)
927
+ class SearchResults:
928
+ """Result container for signature search operations."""
929
+
930
+ matches: list[Match]
931
+ signature_str: str
932
+
933
+ def display(self) -> None:
934
+ """Display the search results to the user."""
935
+ idaapi.msg(f"Signature: {self.signature_str}\n")
936
+
937
+ if not self.matches:
938
+ idaapi.msg("Signature does not match!\n")
939
+ return
940
+
941
+ for ea in self.matches:
942
+ fn_name = None
943
+ with contextlib.suppress(BaseException):
944
+ fn_name = idaapi.get_func_name(int(ea))
945
+ if fn_name:
946
+ idaapi.msg(f"Match @ {ea} in {fn_name}\n")
947
+ else:
948
+ idaapi.msg(f"Match @ {ea}\n")
949
+
950
+
951
+ class SignatureParser:
952
+ """Centralized, readable parsing for various signature input styles.
953
+
954
+ Supported inputs (examples):
955
+ - Mask notation: bytes + mask string like "xxxx?x" or binary mask "0b10101"
956
+ - Hex escapes: "\x48\x8b\x05 ..."
957
+ - 0x-prefixed run: "0x48 0x8B 0x05 ..." or "0x488B05..."
958
+ - Loose hex: "48 8B 05 ? ? 00"
959
+
960
+ Output is an IDA-style signature string (space-separated; '?' for wildcards),
961
+ or an empty string on failure.
962
+ """
963
+
964
+ _HEX_PAIR = re.compile(r"^[0-9A-Fa-f]{2}$")
965
+ _ESCAPED_HEX = re.compile(r"\\x[0-9A-Fa-f]{2}")
966
+ _RUN_0X = re.compile(r"(?:0x[0-9A-Fa-f]{2})+")
967
+
968
+ # Regex to match a mask string consisting of 'x' and '?' characters, starting with 'x'
969
+ _MASK_REGEX = re.compile(r"x(?:x|\?)+")
970
+ # Regex to match a binary mask string, e.g., '0b10101'
971
+ _BINARY_MASK_REGEX = re.compile(r"0b[01]+")
972
+
973
+ @classmethod
974
+ def parse(cls, input_str: str) -> str:
975
+ mask = cls._extract_mask(input_str)
976
+ parsed = ""
977
+ if mask:
978
+ # Try to pair mask with bytes from either escaped form or 0x run
979
+ bytestr: list[str] = []
980
+ if (bytestr := cls._ESCAPED_HEX.findall(input_str)) and len(bytestr) == len(
981
+ mask
982
+ ):
983
+ parsed = cls._masked_bytes_to_ida(bytestr, mask, slice_from=2)
984
+
985
+ elif (bytestr := cls._RUN_0X.findall(input_str)) and len(bytestr) == len(
986
+ mask
987
+ ):
988
+ parsed = cls._masked_bytes_to_ida(bytestr, mask, slice_from=2)
989
+ else:
990
+ idaapi.msg(
991
+ f'Detected mask "{mask}" but failed to match corresponding bytes\n'
992
+ )
993
+ else:
994
+ # Fallback: normalize a loose byte string into IDA format
995
+ parsed = cls._normalize_loose_hex(input_str)
996
+ return parsed.strip()
997
+
998
+ # ---- internals ----
999
+
1000
+ @classmethod
1001
+ def _extract_mask(cls, s: str) -> str:
1002
+ """Extract mask from patterns like 'xxx?x' or binary '0b10101'."""
1003
+
1004
+ m = cls._MASK_REGEX.search(s)
1005
+ if m:
1006
+ return m.group(0)
1007
+
1008
+ m = cls._BINARY_MASK_REGEX.search(s)
1009
+ if not m:
1010
+ return ""
1011
+ bits = m.group(0)[2:]
1012
+ # Binary mask is LSB-first in original code; reverse to align with bytes
1013
+ return "".join("x" if b == "1" else "?" for b in bits[::-1])
1014
+
1015
+ @staticmethod
1016
+ def _masked_bytes_to_ida(
1017
+ byte_tokens: list[str], mask: str, *, slice_from: int
1018
+ ) -> str:
1019
+ sig = Signature(
1020
+ [
1021
+ SignatureByte(int(tok[slice_from:], 16), mask[i] == "?")
1022
+ for i, tok in enumerate(byte_tokens)
1023
+ ]
1024
+ )
1025
+ return f"{sig:ida}"
1026
+
1027
+ @classmethod
1028
+ def _normalize_loose_hex(cls, input_str: str) -> str:
1029
+ """Best-effort cleanup into 'AA BB CC ? DD ' format expected by downstream."""
1030
+ s = input_str
1031
+ s = re.sub(r"[\)\(\[\]]+", "", s) # strip brackets
1032
+ s = re.sub(r"^\s+", "", s) # lstrip
1033
+ s = re.sub(r"[? ]+$", "", s) + " " # ensure trailing space
1034
+ s = re.sub(r"\\?\\x", "", s) # drop any stray \x or escaped \x
1035
+ s = re.sub(r"\s+", " ", s) # collapse whitespace
1036
+
1037
+ # Also coerce any '??' or '?' tokens into a single '?' and ensure hex pairs are normalized
1038
+ tokens = [t.strip() for t in s.split() if t.strip()]
1039
+ out: list[str] = []
1040
+ for t in tokens:
1041
+ if t == "?" or t == "??":
1042
+ out.append("?")
1043
+ continue
1044
+ # accept '0xAA' or 'AA'; normalize to two hex chars upper
1045
+ if t.lower().startswith("0x"):
1046
+ t = t[2:]
1047
+ if not cls._HEX_PAIR.match(t):
1048
+ # If it's not a hex pair, treat as wildcard to be safe
1049
+ out.append("?")
1050
+ continue
1051
+ out.append(t.upper())
1052
+
1053
+ return (" ".join(out) + " ") if out else ""
1054
+
1055
+
1056
+ @dataclasses.dataclass(slots=True)
1057
+ class SignatureSearcher:
1058
+ """Parses a signature string and searches the DB for matches."""
1059
+
1060
+ input_signature: str = ""
1061
+
1062
+ @classmethod
1063
+ def from_signature(cls, input_signature: str) -> "SignatureSearcher":
1064
+ return cls(input_signature=input_signature)
1065
+
1066
+ def search(self) -> SearchResults:
1067
+ sig_str = SignatureParser.parse(self.input_signature)
1068
+ if not sig_str:
1069
+ idaapi.msg("Unrecognized signature type\n")
1070
+ return SearchResults([], "")
1071
+ matches = self.find_all(sig_str)
1072
+ return SearchResults(matches, sig_str)
1073
+
1074
+ @staticmethod
1075
+ def find_all(ida_signature: str) -> list[Match]:
1076
+ binary = idaapi.compiled_binpat_vec_t()
1077
+ idaapi.parse_binpat_str(binary, idaapi.inf_get_min_ea(), ida_signature, 16)
1078
+ out: list[Match] = []
1079
+ ea = idaapi.inf_get_min_ea()
1080
+ _bin_search = getattr(idaapi, "bin_search", None) or getattr(
1081
+ idaapi, "bin_search3"
1082
+ )
1083
+ while True:
1084
+ hit, _ = _bin_search(
1085
+ ea,
1086
+ idaapi.inf_get_max_ea(),
1087
+ binary,
1088
+ idaapi.BIN_SEARCH_NOCASE | idaapi.BIN_SEARCH_FORWARD,
1089
+ )
1090
+ if hit == idaapi.BADADDR:
1091
+ break
1092
+ out.append(Match(hit))
1093
+ ea = hit + 1
1094
+ return out
1095
+
1096
+ @classmethod
1097
+ def is_unique(cls, ida_signature: str) -> bool:
1098
+ return len(cls.find_all(ida_signature)) == 1
1099
+
1100
+
1101
+ # no cover: start
1102
+ # we do not cover the below because this is mainly executing IDA GUI functionality.
1103
+ # any logic here should be pulled out into a separate class and tested separately.
1104
+ class ProgressDialog:
1105
+ """Context manager wrapping IDA wait boxes.
1106
+
1107
+ When used as a context manager the progress dialog will display a wait box
1108
+ on entry and hide it on exit.
1109
+
1110
+ The message may be updated via `replace_message()` and cancelation can be tested with `user_canceled()` from this class or `idaapi.user_cancelled()` from IDA API.
1111
+ """
1112
+
1113
+ def __init__(self, message: str = "Please wait...", hide_cancel: bool = False):
1114
+ self._default_msg: str = message
1115
+ self.hide_cancel: bool = hide_cancel
1116
+
1117
+ def _message(
1118
+ self,
1119
+ message: typing.Optional[str] = None,
1120
+ hide_cancel: typing.Optional[bool] = None,
1121
+ ) -> str:
1122
+ """Internal helper to assemble the full wait box message string."""
1123
+ display_msg = self._default_msg if message is None else message
1124
+ hide = self.hide_cancel if hide_cancel is None else hide_cancel
1125
+ prefix = "HIDECANCEL\n" if hide else ""
1126
+ return prefix + display_msg
1127
+
1128
+ def configure(
1129
+ self, message: str = "Please wait...", hide_cancel: bool = False
1130
+ ) -> "ProgressDialog":
1131
+ """Configure the default message and cancel button visibility."""
1132
+ self._default_msg = message
1133
+ self.hide_cancel = hide_cancel
1134
+ return self
1135
+
1136
+ __call__ = configure # Allow calling instance to reconfigure.
1137
+
1138
+ def __enter__(self) -> "ProgressDialog":
1139
+ idaapi.show_wait_box(self._message())
1140
+ return self
1141
+
1142
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
1143
+ idaapi.hide_wait_box()
1144
+
1145
+ def replace_message(self, new_message: str, hide_cancel: bool = False) -> None:
1146
+ """Replace the currently displayed message."""
1147
+ msg = self._message(message=new_message, hide_cancel=hide_cancel)
1148
+ idaapi.replace_wait_box(msg)
1149
+
1150
+ def user_canceled(self) -> bool:
1151
+ """Return True if the user has canceled the wait box."""
1152
+ return idaapi.user_cancelled()
1153
+
1154
+ # Provide alias with alternative spelling for backwards compatibility.
1155
+ user_cancelled = user_canceled
1156
+
1157
+
1158
+ class Clipboard:
1159
+ """Cross platform utilities for setting text on the system clipboard."""
1160
+
1161
+ @staticmethod
1162
+ def _set_text_pyqt5(text: str) -> bool:
1163
+ """Set clipboard text via PyQt5 if available."""
1164
+ try:
1165
+ from PyQt5.QtWidgets import QApplication # type: ignore
1166
+
1167
+ QApplication.clipboard().setText(text)
1168
+ return True
1169
+ except (ImportError, Exception) as e:
1170
+ idaapi.msg(f"Error setting clipboard text: {e}")
1171
+ return False
1172
+
1173
+ @classmethod
1174
+ def set_text(cls, text: str) -> bool:
1175
+ """Set the clipboard text on the current operating system.
1176
+
1177
+ This method first attempts to use PyQt5 for cross-platform clipboard
1178
+ support and falls back to platform specific implementations.
1179
+
1180
+ Parameters
1181
+ ----------
1182
+ text : str
1183
+ The text to place on the clipboard.
1184
+
1185
+ Returns
1186
+ -------
1187
+ bool
1188
+ True on success, False on failure.
1189
+ """
1190
+ return cls._set_text_pyqt5(text)
1191
+
1192
+ def __call__(self, text: str) -> bool:
1193
+ """Allow instances to be invoked directly as a function."""
1194
+ return self.set_text(text)
1195
+
1196
+
1197
+ class ConfigureOperandWildcardBitmaskForm(idaapi.Form):
1198
+ """Interactive form to configure wildcardable operands using checkboxes."""
1199
+
1200
+ def __init__(self) -> None:
1201
+ F = idaapi.Form
1202
+ # Define the form layout
1203
+ form_text = """BUTTON YES* OK
1204
+ BUTTON CANCEL Cancel
1205
+ Wildcardable Operands
1206
+ {FormChangeCb}
1207
+ Select operand types that should be wildcarded:
1208
+
1209
+ <General Register (al, ax, es, ds...):{opt1}>
1210
+ <Direct Memory Reference (DATA) :{opt2}>
1211
+ <Memory Ref [Base Reg + Index Reg] :{opt3}>
1212
+ <Memory Ref [Base Reg + Index Reg + Displacement] :{opt4}>
1213
+ <Immediate Value :{opt5}>
1214
+ <Immediate Far Address (CODE) :{opt6}>
1215
+ <Immediate Near Address (CODE) :{opt7}>"""
1216
+ registers: typing.List[str] = [
1217
+ "opt1",
1218
+ "opt2",
1219
+ "opt3",
1220
+ "opt4",
1221
+ "opt5",
1222
+ "opt6",
1223
+ "opt7",
1224
+ ]
1225
+
1226
+ # Processor-specific operand types
1227
+ proc_arch = idaapi.ph_get_id()
1228
+ if proc_arch == idaapi.PLFM_386:
1229
+ form_text += """
1230
+ <Trace Register :{opt8}>
1231
+ <Debug Register :{opt9}>
1232
+ <Control Register :{opt10}>
1233
+ <Floating Point Register :{opt11}>
1234
+ <MMX Register :{opt12}>
1235
+ <XMM Register :{opt13}>
1236
+ <YMM Register :{opt14}>
1237
+ <ZMM Register :{opt15}>
1238
+ <Opmask Register :{opt16}>{cWildcardableOperands}>"""
1239
+ registers.extend(
1240
+ [
1241
+ "opt8",
1242
+ "opt9",
1243
+ "opt10",
1244
+ "opt11",
1245
+ "opt12",
1246
+ "opt13",
1247
+ "opt14",
1248
+ "opt15",
1249
+ "opt16",
1250
+ ]
1251
+ )
1252
+ elif proc_arch == idaapi.PLFM_ARM:
1253
+ form_text += """
1254
+ <(Unused) :{opt8}>
1255
+ <Register list (for LDM/STM) :{opt9}>
1256
+ <Coprocessor register list (for CDP) :{opt10}>
1257
+ <Coprocessor register (for LDC/STC) :{opt11}>
1258
+ <Floating point register list :{opt12}>
1259
+ <Arbitrary text stored in the operand :{opt13}>
1260
+ <ARM condition as an operand :{opt14}>{cWildcardableOperands}>"""
1261
+ registers.extend(
1262
+ ["opt8", "opt9", "opt10", "opt11", "opt12", "opt13", "opt14"]
1263
+ )
1264
+ elif proc_arch == idaapi.PLFM_PPC:
1265
+ form_text += """
1266
+ <Special purpose register :{opt8}>
1267
+ <Two FPRs :{opt9}>
1268
+ <SH & MB & ME :{opt10}>
1269
+ <crfield :{opt11}>
1270
+ <crbit :{opt12}>
1271
+ <Device control register :{opt13}>{cWildcardableOperands}>"""
1272
+ registers.extend(["opt8", "opt9", "opt10", "opt11", "opt12", "opt13"])
1273
+ else:
1274
+ form_text += """{cWildcardableOperands}>
1275
+ """
1276
+ # Skip o_void visually (>>1) by shifting the bitmask
1277
+ options = WildcardPolicy.current().to_mask() >> 1
1278
+
1279
+ controls = {
1280
+ "FormChangeCb": F.FormChangeCb(self.OnFormChange),
1281
+ "cWildcardableOperands": F.ChkGroupControl(
1282
+ tuple(registers),
1283
+ value=options,
1284
+ ),
1285
+ }
1286
+ super().__init__(form_text, controls)
1287
+
1288
+ def OnFormChange(self, fid: int) -> int:
1289
+ """Callback invoked when the form state changes."""
1290
+ if fid == self.cWildcardableOperands.id: # type: ignore
1291
+ # re-shift b/c we skipped o_void
1292
+ mask = self.GetControlValue(self.cWildcardableOperands) << 1 # type: ignore
1293
+ WildcardPolicy.set_current(WildcardPolicy.from_mask(mask))
1294
+ return 1
1295
+
1296
+
1297
+ class ConfigureOptionsForm(idaapi.Form):
1298
+ """Interactive form to configure XREF and signature generation options."""
1299
+
1300
+ def __init__(self) -> None:
1301
+ F = idaapi.Form
1302
+
1303
+ # Define the form layout
1304
+ form_text = """BUTTON YES* OK
1305
+ BUTTON CANCEL Cancel
1306
+ Options
1307
+
1308
+ <#Print top X shortest signatures when generating xref signatures#Print top X XREF signatures :{opt1}>
1309
+ <#Stop after reaching X bytes when generating a single signature#Maximum single signature length :{opt2}>
1310
+ <#Stop after reaching X bytes when generating xref signatures#Maximum xref signature length :{opt3}>
1311
+ """
1312
+
1313
+ self.controls = {
1314
+ "opt1": F.NumericInput(tp=F.FT_DEC),
1315
+ "opt2": F.NumericInput(tp=F.FT_DEC),
1316
+ "opt3": F.NumericInput(tp=F.FT_DEC),
1317
+ }
1318
+ super().__init__(form_text, self.controls)
1319
+
1320
+ def ExecuteForm(self) -> int:
1321
+ """Execute the form and apply changes to global variables."""
1322
+
1323
+ # Pre-fill form values
1324
+ self.controls["opt1"].value = SigMakerConfig.print_top_x
1325
+ self.controls["opt2"].value = SigMakerConfig.max_single_signature_length
1326
+ self.controls["opt3"].value = SigMakerConfig.max_xref_signature_length
1327
+
1328
+ result = self.Execute()
1329
+ if result != 1:
1330
+ self.Free()
1331
+ return result
1332
+
1333
+ SigMakerConfig.print_top_x = self.controls["opt1"].value
1334
+ SigMakerConfig.max_single_signature_length = self.controls["opt2"].value
1335
+ SigMakerConfig.max_xref_signature_length = self.controls["opt3"].value
1336
+ self.Free()
1337
+ return result
1338
+
1339
+
1340
+ class SignatureMakerForm(idaapi.Form):
1341
+ """Main form presented when the user invokes the SigMaker plugin."""
1342
+
1343
+ def __init__(self) -> None:
1344
+ F = idaapi.Form
1345
+ form_text = (
1346
+ f"""STARTITEM 0
1347
+ BUTTON YES* OK
1348
+ BUTTON CANCEL Cancel
1349
+ Signature Maker v{PLUGIN_VERSION}"""
1350
+ + r"""
1351
+ {FormChangeCb}
1352
+ Select action:
1353
+ <#Select an address, and create a code signature for it#Create unique signature for current code address:{rCreateUniqueSig}>
1354
+ <#Select an address or variable, and create code signatures for its references. Will output the shortest 5 signatures#Find shortest XREF signature for current data or code address:{rFindXRefSig}>
1355
+ <#Select 1+ instructions, and copy the bytes using the specified output format#Copy selected code:{rCopyCode}>
1356
+ <#Paste any string containing your signature/mask and find matches#Search for a signature:{rSearchSignature}>{rAction}>
1357
+
1358
+ Output format:
1359
+ <#Example - E8 ? ? ? ? 45 33 F6 66 44 89 34 33#IDA Signature:{rIDASig}>
1360
+ <#Example - E8 ?? ?? ?? ?? 45 33 F6 66 44 89 34 33#x64Dbg Signature:{rx64DbgSig}>
1361
+ <#Example - \\\xE8\\\x00\\\x00\\\x00\\\x00\\\x45\\\x33\\\xF6\\\x66\\\x44\\\x89\\\x34\\\x33 x????xxxxxxxx#C Byte Array String Signature + String mask:{rByteArrayMaskSig}>
1362
+ <#Example - 0xE8, 0x00, 0x00, 0x00, 0x00, 0x45, 0x33, 0xF6, 0x66, 0x44, 0x89, 0x34, 0x33 0b1111111100001#C Bytes Signature + Bitmask:{rRawBytesBitmaskSig}>{rOutputFormat}>
1363
+
1364
+ Quick Options:
1365
+ <#Enable wildcarding for operands, to improve stability of created signatures#Wildcards for operands:{cWildcardOperands}>
1366
+ <#Don't stop signature generation when reaching end of function#Continue when leaving function scope:{cContinueOutside}>
1367
+ <#Wildcard the whole instruction when the operand (usually a register) is encoded into the operator#Wildcard optimized / combined instructions:{cWildcardOptimized}>{cGroupOptions}>
1368
+
1369
+ <Operand types...:{bOperandTypes}><Other options...:{bOtherOptions}>
1370
+ """
1371
+ )
1372
+ controls = {
1373
+ "cVersion": F.StringLabel(PLUGIN_VERSION),
1374
+ "FormChangeCb": F.FormChangeCb(self.OnFormChange),
1375
+ "rAction": F.RadGroupControl(
1376
+ ("rCreateUniqueSig", "rFindXRefSig", "rCopyCode", "rSearchSignature")
1377
+ ),
1378
+ "rOutputFormat": F.RadGroupControl(
1379
+ ("rIDASig", "rx64DbgSig", "rByteArrayMaskSig", "rRawBytesBitmaskSig")
1380
+ ),
1381
+ "cGroupOptions": idaapi.Form.ChkGroupControl(
1382
+ ("cWildcardOperands", "cContinueOutside", "cWildcardOptimized"),
1383
+ value=5,
1384
+ ),
1385
+ "bOperandTypes": F.ButtonInput(self.ConfigureOperandWildcardBitmask),
1386
+ "bOtherOptions": F.ButtonInput(self.ConfigureOptions),
1387
+ }
1388
+ super().__init__(form_text, controls)
1389
+
1390
+ def OnFormChange(self, fid: int) -> int:
1391
+ """Optional form change handler; currently unused."""
1392
+ return 1
1393
+
1394
+ def ConfigureOperandWildcardBitmask(self, code: int = 0) -> int:
1395
+ form = ConfigureOperandWildcardBitmaskForm()
1396
+ form.Compile()
1397
+ ok = form.Execute()
1398
+ if not ok:
1399
+ return 0
1400
+ return 1
1401
+
1402
+ def ConfigureOptions(self, code: int = 0) -> int:
1403
+ """Launch the options configuration form."""
1404
+ form = ConfigureOptionsForm()
1405
+ form.Compile()
1406
+ return form.ExecuteForm()
1407
+
1408
+ def __enter__(self) -> "SignatureMakerForm":
1409
+ return self
1410
+
1411
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
1412
+ self.Free()
1413
+
1414
+
1415
+ class _ActionHandler(idaapi.action_handler_t):
1416
+ """Internal helper bridging IDA UI actions to plugin methods."""
1417
+
1418
+ def __init__(self, action_function):
1419
+ super().__init__()
1420
+ self.action_function = action_function
1421
+
1422
+ def activate(self, ctx: idaapi.action_ctx_base_t) -> int:
1423
+ self.action_function(ctx=ctx)
1424
+ return 1
1425
+
1426
+ def update(self, ctx: idaapi.action_ctx_base_t) -> int:
1427
+ if ctx.widget_type == idaapi.BWN_DISASM:
1428
+ return idaapi.AST_ENABLE_FOR_WIDGET
1429
+ return idaapi.AST_DISABLE_FOR_WIDGET
1430
+
1431
+
1432
+ class _PopupHook(idaapi.UI_Hooks):
1433
+ """Hook used to attach actions to IDA pop-ups."""
1434
+
1435
+ def __init__(
1436
+ self,
1437
+ action_name: str,
1438
+ predicate=None,
1439
+ widget_populator=None,
1440
+ category: typing.Optional[str] = None,
1441
+ ) -> None:
1442
+ super().__init__()
1443
+ self.action_name = action_name
1444
+ self.predicate = predicate or self.is_disassembly_widget
1445
+ self.widget_populator = widget_populator or self._default_populator
1446
+ self.category = category
1447
+
1448
+ @classmethod
1449
+ def is_disassembly_widget(cls, widget, popup, ctx) -> bool:
1450
+ """Return True if the given widget is a disassembly view."""
1451
+ return idaapi.get_widget_type(widget) == idaapi.BWN_DISASM
1452
+
1453
+ def term(self) -> None:
1454
+ idaapi.unregister_action(self.action_name)
1455
+
1456
+ @staticmethod
1457
+ def _default_populator(instance, widget, popup_handle, ctx) -> None:
1458
+ if instance.predicate(widget, popup_handle, ctx):
1459
+ args = [widget, popup_handle, instance.action_name]
1460
+ if instance.category:
1461
+ args.append(f"{instance.category}/")
1462
+ idaapi.attach_action_to_popup(*args)
1463
+
1464
+ def finish_populating_widget_popup(self, widget, popup_handle, ctx=None) -> None:
1465
+ return self.widget_populator(self, widget, popup_handle, ctx)
1466
+
1467
+
1468
+ class SigMakerPlugin(idaapi.plugin_t):
1469
+ """IDA Pro plugin class implementing signature generation and search."""
1470
+
1471
+ flags = idaapi.PLUGIN_KEEP
1472
+ comment = f"{PLUGIN_NAME} v{PLUGIN_VERSION} for IDA Pro by {PLUGIN_AUTHOR}"
1473
+ help = "Select location in disassembly and press CTRL+ALT+S to open menu"
1474
+ wanted_name = PLUGIN_NAME
1475
+ wanted_hotkey = "Ctrl-Alt-S"
1476
+
1477
+ ACTION_SHOW_SIGMAKER: str = "pysigmaker:show"
1478
+
1479
+ def init(self) -> int:
1480
+ self._hooks = self._init_hooks(_PopupHook(self.ACTION_SHOW_SIGMAKER))
1481
+ self._register_actions()
1482
+ return idaapi.PLUGIN_KEEP
1483
+
1484
+ def _init_hooks(self, *hooks) -> typing.Tuple[idaapi.UI_Hooks, ...]:
1485
+ for hook in hooks:
1486
+ hook.hook()
1487
+ return hooks
1488
+
1489
+ def _deinit_hooks(self, *hooks) -> None:
1490
+ for hook in hooks:
1491
+ hook.unhook()
1492
+
1493
+ def _register_actions(self) -> None:
1494
+ self._deregister_actions()
1495
+ idaapi.register_action(
1496
+ idaapi.action_desc_t(
1497
+ self.ACTION_SHOW_SIGMAKER,
1498
+ "SigMaker",
1499
+ _ActionHandler(self.run),
1500
+ self.wanted_hotkey,
1501
+ "Show the signature maker dialog.",
1502
+ 154,
1503
+ )
1504
+ )
1505
+
1506
+ def _deregister_actions(self) -> None:
1507
+ idaapi.unregister_action(self.ACTION_SHOW_SIGMAKER)
1508
+
1509
+ def run(self, ctx) -> None:
1510
+ """Entry point called when the user activates the plugin."""
1511
+ with SignatureMakerForm() as form:
1512
+ form.Compile()
1513
+ ok = form.Execute()
1514
+ if not ok:
1515
+ return
1516
+
1517
+ action = form.rAction.value # type: ignore
1518
+ output_format = form.rOutputFormat.value # type: ignore
1519
+ wildcard_operands = bool(form.cGroupOptions.value & 1) # type: ignore
1520
+ continue_outside_of_function = bool(form.cGroupOptions.value & 2) # type: ignore
1521
+ wildcard_optimized = bool(form.cGroupOptions.value & 4) # type: ignore
1522
+
1523
+ # Create SigMakerConfig
1524
+ config = SigMakerConfig(
1525
+ output_format=SignatureType.at(int(output_format)),
1526
+ wildcard_operands=wildcard_operands,
1527
+ continue_outside_of_function=continue_outside_of_function,
1528
+ wildcard_optimized=wildcard_optimized,
1529
+ )
1530
+
1531
+ try:
1532
+ if action == 0:
1533
+ ea = idaapi.get_screen_ea()
1534
+ signature = SignatureMaker().make_signature(ea, config)
1535
+ signature.display(config)
1536
+ elif action == 1:
1537
+ ea = idaapi.get_screen_ea()
1538
+ signatures = XrefFinder().find_xrefs(ea, config)
1539
+ signatures.display(cfg=config)
1540
+ elif action == 2:
1541
+ start, end = self.get_selected_addresses(idaapi.get_current_viewer())
1542
+ if start and end:
1543
+ signature = SignatureMaker().make_signature(start, config, end=end)
1544
+ signature.display(config)
1545
+ else:
1546
+ idaapi.msg("Select a range to copy the code!\n")
1547
+ elif action == 3:
1548
+ input_signature = idaapi.ask_str(
1549
+ "", idaapi.HIST_SRCH, "Enter a signature"
1550
+ )
1551
+ if input_signature:
1552
+ searcher = SignatureSearcher.from_signature(input_signature)
1553
+ results = searcher.search()
1554
+ results.display()
1555
+ else:
1556
+ idaapi.msg("No signature entered!\n")
1557
+ else:
1558
+ idaapi.msg("Invalid action!\n")
1559
+ except Unexpected as e:
1560
+ idaapi.msg(f"Error: {str(e)}\n")
1561
+ except Exception as e:
1562
+ print(e, os.linesep, traceback.format_exc())
1563
+ return
1564
+
1565
+ def term(self) -> None:
1566
+ self._deregister_actions()
1567
+ self._deinit_hooks(*self._hooks)
1568
+
1569
+ @staticmethod
1570
+ def get_selected_addresses(
1571
+ ctx,
1572
+ ) -> typing.Tuple[typing.Optional[int], typing.Optional[int]]:
1573
+ """Return the start and end of the selection or current line."""
1574
+ is_selected, start_ea, end_ea = idaapi.read_range_selection(ctx)
1575
+ if is_selected:
1576
+ return start_ea, end_ea
1577
+ p0, p1 = idaapi.twinpos_t(), idaapi.twinpos_t()
1578
+ idaapi.read_selection(ctx, p0, p1)
1579
+ p0.place(ctx)
1580
+ p1.place(ctx)
1581
+ if p0.at and p1.at:
1582
+ start_ea = p0.at.toea()
1583
+ end_ea = p1.at.toea()
1584
+ if start_ea == end_ea:
1585
+ start_ea = idc.get_item_head(start_ea)
1586
+ end_ea = idc.get_item_end(start_ea)
1587
+ return start_ea, end_ea
1588
+
1589
+ start_ea = idaapi.get_screen_ea()
1590
+ try:
1591
+ end_ea = idaapi.ask_addr(start_ea, "Enter end address for selection:")
1592
+ finally:
1593
+ idaapi.jumpto(start_ea)
1594
+
1595
+ if end_ea and end_ea <= start_ea:
1596
+ print(
1597
+ f"Error: End address 0x{end_ea:X} must be greater than start address 0x{start_ea:X}."
1598
+ )
1599
+ end_ea = None
1600
+ if end_ea is None:
1601
+ end_ea = idc.get_item_end(start_ea)
1602
+ print(f"No end address selected, using line end: 0x{end_ea:X}")
1603
+
1604
+ return start_ea, end_ea
1605
+
1606
+
1607
+ def PLUGIN_ENTRY() -> SigMakerPlugin:
1608
+ """Entry point function required by IDA Pro to instantiate the plugin."""
1609
+ return SigMakerPlugin()
1610
+
1611
+
1612
+ # no cover: stop