228 lines
7.5 KiB
Python
228 lines
7.5 KiB
Python
"""
|
|
IDA Pro script to find and decrypt XOR-obfuscated strings.
|
|
|
|
Pattern:
|
|
struct EncStr {
|
|
char data[N]; // encrypted bytes at offset 0
|
|
char flag; // decrypted flag at offset N
|
|
// 7 bytes padding
|
|
QWORD key; // XOR key at offset N+8
|
|
};
|
|
|
|
Decryption: byte[i] = ~(byte[i] ^ (key >> (8 * (i & 7))))
|
|
|
|
Usage: Run in IDA Pro via File > Script File, or paste into the Python console.
|
|
"""
|
|
|
|
import idc
|
|
import idaapi
|
|
import idautils
|
|
import struct
|
|
|
|
|
|
def decrypt_string(ea, length, key_offset):
|
|
flag = idc.get_wide_byte(ea + length)
|
|
|
|
enc = [idc.get_wide_byte(ea + i) for i in range(length)]
|
|
|
|
# If already plaintext (flag=0 but looks like ASCII), return as-is
|
|
if flag != 1:
|
|
try:
|
|
raw = bytes(enc).rstrip(b'\x00').decode('utf-8', errors='replace')
|
|
# Heuristic: if >80% printable, it's already plaintext
|
|
printable = sum(0x20 <= b < 0x7F or b in (0x00, 0x0A, 0x0D) for b in enc)
|
|
if printable / len(enc) > 0.7:
|
|
raw = bytes(enc).rstrip(b'\x00').decode('utf-8', errors='replace')
|
|
return raw + " [plaintext]"
|
|
except:
|
|
pass
|
|
|
|
key = idc.get_qword(ea + key_offset)
|
|
decrypted = bytearray()
|
|
for i, b in enumerate(enc):
|
|
kb = (key >> (8 * (i & 7))) & 0xFF
|
|
decrypted.append((~(b ^ kb)) & 0xFF)
|
|
|
|
try:
|
|
decrypted = decrypted[:decrypted.index(0)]
|
|
except ValueError:
|
|
pass
|
|
|
|
return decrypted.decode("utf-8", errors="replace")
|
|
|
|
|
|
def extract_key_offset_from_function(func_ea):
|
|
"""
|
|
Find the key offset by looking for:
|
|
mov rdx, [rax+Xh] or mov rdx, [rcx+Xh]
|
|
inside the decryption loop (the qword key load).
|
|
We want the one where the offset > length (so we skip
|
|
the encrypted byte loads which use byte ptr).
|
|
"""
|
|
func = idaapi.get_func(func_ea)
|
|
if not func:
|
|
return None
|
|
|
|
end_ea = func.end_ea
|
|
ea = func_ea
|
|
|
|
while ea < end_ea:
|
|
mnem = idc.print_insn_mnem(ea)
|
|
if mnem == "mov":
|
|
op0 = idc.print_operand(ea, 0)
|
|
op1 = idc.print_operand(ea, 1)
|
|
# Looking for: mov rXX, qword ptr [rXX+offset]
|
|
# i.e. a qword load (no "byte ptr") with a positive offset
|
|
if (op0 in ("rdx", "rcx", "rsi") and
|
|
"[" in op1 and
|
|
"+" in op1 and
|
|
"byte ptr" not in op1 and
|
|
"rsp" not in op1 and
|
|
"rbp" not in op1):
|
|
val = idc.get_operand_value(ea, 1)
|
|
if 0 < val < 0x10000:
|
|
return val
|
|
ea = idc.next_head(ea)
|
|
|
|
return None
|
|
|
|
|
|
def find_decrypt_functions():
|
|
"""
|
|
Heuristic: scan all functions whose name starts with 'decrypt_string'
|
|
or matches the known pattern. Adjust the name prefix to match yours.
|
|
"""
|
|
results = []
|
|
for func_ea in idautils.Functions():
|
|
name = idc.get_func_name(func_ea)
|
|
if "decrypt_string" in name.lower():
|
|
results.append(func_ea)
|
|
return results
|
|
|
|
|
|
def extract_length_from_function(func_ea):
|
|
func = idaapi.get_func(func_ea)
|
|
if not func:
|
|
return None
|
|
|
|
end_ea = func.end_ea
|
|
ea = func_ea
|
|
length = None
|
|
|
|
while ea < end_ea:
|
|
mnem = idc.print_insn_mnem(ea)
|
|
|
|
# Pattern 1: cmp [reg+imm], 1 (flag byte check at offset = length)
|
|
if mnem == "cmp":
|
|
op1 = idc.print_operand(ea, 1)
|
|
op0 = idc.print_operand(ea, 0)
|
|
if op1 == "1" and "[" in op0 and "+" in op0:
|
|
val = idc.get_operand_value(ea, 0)
|
|
if 0 < val < 0x10000:
|
|
length = val
|
|
break
|
|
|
|
# Pattern 2: cmp [rbp+var], N where next insn is jle/jg
|
|
# loop runs 0..N so string length = N+1, flag is at offset N+1
|
|
if mnem == "cmp":
|
|
op1_val = idc.get_operand_value(ea, 1)
|
|
next_ea = idc.next_head(ea)
|
|
next_mnem = idc.print_insn_mnem(next_ea)
|
|
if next_mnem in ("jle", "jng") and 0 < op1_val < 0x10000:
|
|
length = op1_val + 1 # loop is 0..N inclusive → N+1 bytes
|
|
break
|
|
|
|
# Pattern 3: mov byte ptr [rax+N], 1 (writing the flag after loop)
|
|
if mnem == "mov":
|
|
op0 = idc.print_operand(ea, 0)
|
|
op1 = idc.print_operand(ea, 1)
|
|
if op1 == "1" and "byte ptr" in op0 and "+" in op0:
|
|
val = idc.get_operand_value(ea, 0)
|
|
if 0 < val < 0x10000:
|
|
length = val # flag written at [base+length]
|
|
break
|
|
|
|
ea = idc.next_head(ea)
|
|
|
|
return length
|
|
|
|
|
|
def find_xref_args(func_ea):
|
|
"""
|
|
For each call site of func_ea, try to find the struct base address
|
|
passed as the first argument (rdi / rcx on x86-64).
|
|
Returns a list of (call_ea, struct_ea) tuples where struct_ea could
|
|
be resolved statically (e.g. LEA rdi, [rip+offset]).
|
|
"""
|
|
xrefs = []
|
|
for xref in idautils.XrefsTo(func_ea, idaapi.XREF_FAR):
|
|
call_ea = xref.frm
|
|
# Walk back a few instructions to find the LEA/MOV loading the arg
|
|
ea = call_ea
|
|
for _ in range(8):
|
|
ea = idc.prev_head(ea)
|
|
mnem = idc.print_insn_mnem(ea)
|
|
if mnem in ("lea", "mov"):
|
|
reg = idc.print_operand(ea, 0)
|
|
if reg in ("rdi", "rcx"): # first arg (SysV / Win64)
|
|
struct_ea = idc.get_operand_value(ea, 1)
|
|
if struct_ea and struct_ea != idc.BADADDR:
|
|
xrefs.append((call_ea, struct_ea))
|
|
break
|
|
return xrefs
|
|
|
|
|
|
def run():
|
|
print("=" * 60)
|
|
print("IDA String Decryptor")
|
|
print("=" * 60)
|
|
|
|
decrypt_funcs = find_decrypt_functions()
|
|
if not decrypt_funcs:
|
|
print("[!] No decrypt_string* functions found.")
|
|
print(" Edit the name filter in find_decrypt_functions() to match yours.")
|
|
return
|
|
|
|
print(f"[+] Found {len(decrypt_funcs)} decrypt function(s).\n")
|
|
|
|
for func_ea in decrypt_funcs:
|
|
func_name = idc.get_func_name(func_ea)
|
|
length = extract_length_from_function(func_ea)
|
|
key_offset = extract_key_offset_from_function(func_ea)
|
|
|
|
if length is None or key_offset is None:
|
|
print(f"[-] {func_name}: could not extract length={length} key_offset={key_offset}")
|
|
continue
|
|
|
|
print(f"[*] {func_name} @ 0x{func_ea:X} length={length} key@+{key_offset}")
|
|
xrefs = find_xref_args(func_ea)
|
|
if not xrefs:
|
|
print(f" [!] No static call sites found (possibly indirect call).")
|
|
continue
|
|
|
|
for call_ea, struct_ea in xrefs:
|
|
try:
|
|
plaintext = decrypt_string(struct_ea, length, key_offset)
|
|
print(f" call @ 0x{call_ea:X} struct @ 0x{struct_ea:X} => \"{plaintext}\"")
|
|
|
|
# Optional: write a comment at the call site
|
|
idc.set_cmt(call_ea, f'decrypt => "{plaintext}"', 0)
|
|
|
|
# Optional: rename the struct label if it has a generic name
|
|
current_label = idc.get_name(struct_ea)
|
|
if current_label and current_label.startswith("unk_"):
|
|
new_name = "enc_" + "".join(
|
|
c if c.isalnum() or c == "_" else "_"
|
|
for c in plaintext[:24]
|
|
)
|
|
idc.set_name(struct_ea, new_name, idc.SN_NOWARN)
|
|
|
|
except Exception as e:
|
|
print(f" call @ 0x{call_ea:X} struct @ 0x{struct_ea:X} => ERROR: {e}")
|
|
|
|
print()
|
|
|
|
print("Done.")
|
|
|
|
|
|
run() |