Files
ctf/2026/cscg/rev/connivance/decrypt_strings.py
2026-03-22 21:53:40 +01:00

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()