#!/usr/bin/env python3
"""Binary Ninja: decrypt ionCube's obfuscated static strings and rename them.

The loader keeps its literal C strings encrypted and decrypts them at runtime
via `ic_static_str_decrypt` (0x50ca50, reached through the thunks 0x438340 /
0x438760).  The cipher is a trivial rolling XOR:

    L        = buf[0]                                 # plaintext length
    key16    = 16 bytes @ ic_static_str_key16 (0x62dcc0)
    plain[i] = key16[(i + L) & 0xf] ^ buf[1 + i]      for i in 0 .. L-1

Verified: 0x546640 -> "Error during decryption",
          0x547a30 -> "%s %s::%s is deprecated".

The strings are *not* named on a fresh binary, so we don't rely on names: every
call site of a decryptor is scanned and the constant pointer it passes in `x0`
is the encrypted string.  Run it from the Binary Ninja Python console:

    exec(open('/home/jvoisin/.binaryninja/plugins/bn_decrypt_strings.py').read())

Each string gets its exact text as a comment and a data symbol `s_<plaintext>`.
"""

KEY16_ADDR = 0x62DCC0
KEY16_FALLBACK = bytes.fromhex("2568d3c228f2592e94eef291ac139695")

# Decryptor entry points (fixed addresses in this loader build); callers pass
# the encrypted-string pointer in x0.  Addresses work without prior renaming.
DECRYPTOR_ENTRIES = (0x50CA50, 0x50D460, 0x438340, 0x438760)


def decrypt(bv, addr, key16):
    """Return the decrypted string at `addr`, or None if it looks invalid."""
    length = bv.read(addr, 1)
    if not length:
        return None
    length = length[0]
    cipher = bv.read(addr + 1, length)
    if length == 0 or len(cipher) != length:
        return None
    plain = bytes(key16[(i + length) & 0xF] ^ c for i, c in enumerate(cipher))
    printable = sum(0x20 <= b < 0x7F or b in (9, 10, 13) for b in plain)
    return plain if printable / length >= 0.9 else None


def symbol_name(plain):
    """Turn decrypted bytes into a valid `s_...` identifier."""
    ident = "".join(c if c.isalnum() else "_" for c in plain.decode("latin1"))
    return "s_" + "_".join(filter(None, ident.split("_")))[:48]


def string_pointers(bv):
    """Yield every constant pointer passed to a decryptor call site (x0)."""
    from binaryninja import RegisterValueType
    const = (RegisterValueType.ConstantPointerValue, RegisterValueType.ConstantValue)
    seen = set()
    for entry in DECRYPTOR_ENTRIES:
        for ref in bv.get_code_refs(entry):
            func = ref.function
            if func is None:
                continue
            val = func.get_reg_value_at(ref.address, "x0")
            if val.type in const and val.value not in seen:
                seen.add(val.value)
                yield val.value


def apply(bv):
    from binaryninja import Symbol, SymbolType

    key16 = bv.read(KEY16_ADDR, 16) or KEY16_FALLBACK
    count = 0
    for addr in sorted(string_pointers(bv)):
        plain = decrypt(bv, addr, key16)
        if not plain:
            continue
        text = plain.decode("latin1")
        bv.set_comment_at(addr, '"%s"' % text.replace("\n", "\\n"))
        bv.define_user_symbol(
            Symbol(SymbolType.DataSymbol, addr, symbol_name(plain)))
        print("  %#010x  %r" % (addr, text))
        count += 1
    print("\n[+] decrypted & renamed %d strings" % count)
    return count


try:
    apply(bv)
except NameError:
    print("run this from the Binary Ninja Python console (needs `bv`)")
