Last active
July 30, 2026 22:36
-
-
Save ysf/c1b8cc85f4063367fddb85c443589f5b to your computer and use it in GitHub Desktop.
ChaCha20 string decoder for validator.malware strings.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| ChaCha20 string decoder for validator.malware strings | |
| python3 decode.py validator.malware | |
| python3 decode.py validator.malware --json > strings.json | |
| python3 decode.py validator.malware --keys | |
| at launch the malware decrypts all strings at once. each string has its own | |
| ChaCha20 key and nonce. I found 4 tables of 154 entries in .rodata with ciphertext: | |
| The table layout is located structurally. Keys, nonces, and ciphertext are | |
| always read from the supplied sample, so per-build re-encryption is supported. | |
| block counter starts at 0 for every entry, to decrypt do: | |
| plain(i) = ChaCha20(key=key[i], counter=0, nonce=nonce[i]) | |
| XOR ciphertext[off[i] : off[i]+len[i]] | |
| The corresponding code lives at (fileoffsets): | |
| 0x34d0 get_str(i) | |
| 0x3240 func that decrypts the table of strings | |
| 0x3204 ChaCha20 quarter round | |
| """ | |
| import argparse | |
| import hashlib | |
| import json | |
| import struct | |
| import sys | |
| N = 154 | |
| OFF_DELTA, NONCE_DELTA, KEY_DELTA, CIPHER_DELTA = 0x140, 0x280, 0x760, 0x1AA0 | |
| BSS_BASE = 0x40C300 | |
| IMAGE_BASE = 0x400000 | |
| MASK = 0xFFFFFFFF | |
| def _rotl(v, c): | |
| return ((v << c) | (v >> (32 - c))) & MASK | |
| def _quarter_round(s, a, b, c, d): | |
| s[a] = (s[a] + s[b]) & MASK; s[d] = _rotl(s[d] ^ s[a], 16) | |
| s[c] = (s[c] + s[d]) & MASK; s[b] = _rotl(s[b] ^ s[c], 12) | |
| s[a] = (s[a] + s[b]) & MASK; s[d] = _rotl(s[d] ^ s[a], 8) | |
| s[c] = (s[c] + s[d]) & MASK; s[b] = _rotl(s[b] ^ s[c], 7) | |
| def chacha20_block(key, counter, nonce0, nonce1): | |
| state = [0x61707865, 0x3320646E, 0x79622D32, 0x6B206574] | |
| state += list(struct.unpack("<8I", key)) | |
| state += [counter, nonce0, nonce1, 0] | |
| w = state[:] | |
| for _ in range(10): | |
| _quarter_round(w, 0, 4, 8, 12) | |
| _quarter_round(w, 1, 5, 9, 13) | |
| _quarter_round(w, 2, 6, 10, 14) | |
| _quarter_round(w, 3, 7, 11, 15) | |
| _quarter_round(w, 0, 5, 10, 15) | |
| _quarter_round(w, 1, 6, 11, 12) | |
| _quarter_round(w, 2, 7, 8, 13) | |
| _quarter_round(w, 3, 4, 9, 14) | |
| return struct.pack("<16I", *[(w[i] + state[i]) & MASK for i in range(16)]) | |
| def locate_layout(blob): | |
| if not blob.startswith(b"\x7fELF"): | |
| raise ValueError("sample is not an ELF file") | |
| candidates = [] | |
| for len_t in range(0, len(blob) - CIPHER_DELTA, 16): | |
| off_t = len_t + OFF_DELTA | |
| lengths = struct.unpack_from(f"<{N}H", blob, len_t) | |
| offsets = struct.unpack_from(f"<{N}H", blob, off_t) | |
| total = offsets[-1] + lengths[-1] | |
| if ( | |
| offsets[0] == 0 | |
| and 0 < min(lengths) | |
| and max(lengths) <= 4096 | |
| and all(offsets[i + 1] == offsets[i] + lengths[i] for i in range(N - 1)) | |
| and len_t + CIPHER_DELTA + total <= len(blob) | |
| ): | |
| candidates.append(( | |
| len_t, | |
| off_t, | |
| len_t + NONCE_DELTA, | |
| len_t + KEY_DELTA, | |
| len_t + CIPHER_DELTA, | |
| )) | |
| if len(candidates) != 1: | |
| raise ValueError(f"expected one table, found {len(candidates)}") | |
| return candidates[0] | |
| def decode(blob): | |
| len_t, off_t, nonce_t, key_t, cipher = locate_layout(blob) | |
| u16 = lambda off: struct.unpack_from("<H", blob, off)[0] | |
| u32 = lambda off: struct.unpack_from("<I", blob, off)[0] | |
| entries = [] | |
| for i in range(N): | |
| key = blob[key_t + i * 32: key_t + i * 32 + 32] | |
| off, length = u16(off_t + i * 2), u16(len_t + i * 2) | |
| n0, n1 = u32(nonce_t + i * 8), u32(nonce_t + i * 8 + 4) | |
| ct = blob[cipher + off: cipher + off + length] | |
| plain, counter = b"", 0 | |
| while len(plain) < length: | |
| ks = chacha20_block(key, counter, n0, n1) | |
| chunk = ct[len(plain):len(plain) + 64] | |
| plain += bytes(a ^ b for a, b in zip(chunk, ks)) | |
| counter += 1 | |
| entries.append({ | |
| "index": i, | |
| "length": length, | |
| "offset": off, | |
| "runtime_address": f"{BSS_BASE + off:#x}", | |
| "ciphertext_address": f"{IMAGE_BASE + cipher + off:#x}", | |
| "key_address": f"{IMAGE_BASE + key_t + i * 32:#x}", | |
| "key_hex": key.hex(), | |
| "nonce_hex": struct.pack("<II", n0, n1).hex() + "00000000", | |
| "text": plain.rstrip(b"\x00").decode("utf-8", "replace"), | |
| }) | |
| return entries | |
| def main(): | |
| ap = argparse.ArgumentParser(description="decode validator.malware string table.") | |
| ap.add_argument("sample", help="path to sample") | |
| ap.add_argument("--json", action="store_true", help="emit json") | |
| ap.add_argument("--keys", action="store_true", help="dump every key and nonce") | |
| args = ap.parse_args() | |
| blob = open(args.sample, "rb").read() | |
| digest = hashlib.sha256(blob).hexdigest() | |
| entries = decode(blob) | |
| if args.json: | |
| json.dump({"sha256": digest, "entries": entries}, sys.stdout, indent=2) | |
| print() | |
| return | |
| for e in entries: | |
| print(f"[{e['index']:3d}] {e['runtime_address']} {e['text']!r}") | |
| if args.keys: | |
| print(f"\tkey {e['key_hex']}") | |
| print(f"\tnonce {e['nonce_hex']} counter 0") | |
| if __name__ == "__main__": | |
| main() |
ysf
commented
Jul 29, 2026
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment