`xorcise`: pulling the strings a binary XOR-hid from `strings`
`xorcise`: pulling the strings a binary XOR-hid from `strings`
A brute-force single-byte deobfuscator with a comparative English filter, so you get the C2 URL instead of 2,000 lines of letter-salad.
strings is the first thing everyone runs on an unknown binary, and any author who cares knows it. So the interesting stuff — the C2 URL, the persistence command, the fake user-agent — gets a single line of obfuscation slapped on it: XOR every byte with 0x5a, decode at runtime, and now strings walks right past a printable blob of garbage.
You can beat that by hand — xxd | grep, guess a key, XOR it back — but you're guessing 255 keys across three transforms over every offset. And the obvious "just brute-force everything and print the printable runs" turns into a fire hose: XOR-ing a symbol table byte-by-byte produces just as much printable junk as XOR-ing a hidden URL. strings under-shows; naive brute force over-shows. I wanted the thing in the middle: surface the strings a decode reveals, and stay quiet about the ones that were already there.
This is xorcise. One Python file, stdlib only, cloneable and usable in under five minutes. I'll build it in front of you, because every design decision in it started life as a bug in the previous version.
The target
First, something honest to hunt. Here's a toy implant that keeps its secrets XOR-obfuscated, the way commodity malware does — each string XOR'd with a fixed byte, decoded in a loop before use:
/* beacon.c — blobs generated by make_blobs.py (single-byte XOR + NUL terminator) */
#include <stdio.h>
/* c2_url key 0x5a */
static unsigned char c2_url[] = {
0x32,0x2e,0x2e,0x2a,0x29,0x60,0x75,0x75,0x39,0x3e,0x34,0x77,0x3b,0x34,0x3b,
0x36,0x23,0x2e,0x33,0x39,0x29,0x74,0x22,0x23,0x20,0x75,0x3b,0x2a,0x33,0x75,
0x39,0x35,0x36,0x36,0x3f,0x39,0x2e,0x00
};
/* cmd_persist key 0x13, ua key 0x2d — omitted for space, see repo */
static void xor_decode(unsigned char *b, unsigned char k) {
for (; *b; b++) *b ^= k;
}
int main(void) {
xor_decode(c2_url, 0x5a); xor_decode(cmd_persist, 0x13); xor_decode(ua, 0x2d);
printf("beacon -> %s\n", c2_url);
printf("ua -> %s\n", ua);
printf("persist -> %s\n", cmd_persist);
return 0;
}
Build it and confirm the trick works — it decodes at runtime, but strings sees nothing:
$ gcc -O0 -fno-stack-protector -no-pie beacon.c -o beacon
$ ./beacon
beacon -> https://cdn-analytics.xyz/api/collect
ua -> Mozilla/5.0 (Windows NT) Fake Agent
persist -> schtasks /create /sc onlogon /tn WinUpd
$ sha256sum beacon
37e193987631460b4481b2790054558b12facc47f90c8ade2af97865cf11fdf3 beacon
$ strings -n 6 beacon | grep -iE 'http|schtasks|mozilla|winupd|collect'
(strings found nothing interesting)
And here are the raw bytes sitting in .data, straight from objdump (.data is at file offset 0x3020 per readelf, so the C2 blob lands at 0x3040 / vaddr 0x404040):
$ objdump -s -j .data beacon | grep -A2 404040
404040 322e2e2a 29607575 393e3477 3b343b36 2..*)`uu9>4w;4;6
404050 232e3339 29742223 20753b2a 33753935 #.39)t"# u;*3u95
404060 36363f39 2e000000 00000000 00000000 66?9............
That 2..*)uu9>4w...**is**https://cdn-analytics.xyz/api/collectXOR0x5a. It's 37 printable characters, so strings` happily prints it — as meaningless noise. That's the whole game: the blob is printable, just not readable. Our job is to find the one key that turns noise into text.
First attempt, and the trap
The naive version writes itself: for every key 1..255 and every transform, decode the whole buffer with bytes.translate, pull printable runs with a regex, score them for English-likeness, print the good ones. I built exactly that with an English letter-frequency scorer.
It produced 1,920 lines. The top of the list:
add 0x04 1.00 0x00002022 .rodata lanoeop
add 0x0b 1.00 0x00003592 .strtab T<AD76ATD;;H:ITI67A:T
lanoeop scored a perfect 1.00. Of course it did — it's all common letters (l a n o e o p) with a fine vowel ratio, and a per-letter frequency model loves common-letter soup. Meanwhile two of my three actual secrets got filtered out, because a real URL like cdn-analytics.xyz is full of rare letters (x y z) that tank a unigram score. The scorer was backwards: it rewarded the noise and punished the signal.
Three fixes, each earned:
Fix 1 — score bigrams, not letters
Real words are built from common letter pairs (th he in er an); brute-forced salad isn't. lanoeop has oe, eo, op — pairs English almost never uses. A bigram model separates them where a letter model can't. I embedded ~120 common bigrams and scored the fraction of adjacent letter-pairs that hit the set, plus a diversity gate (need ≥5 distinct letters — kills eeee, abab) and a vowel-ratio sanity check.
Fix 2 — "revealed," not "already there"
This is the core idea. Decoding a hidden blob turns gibberish into text. Decoding a normal string (a symbol name, a format string) turns text into gibberish. So the discriminating question isn't "is the decoded run English?" — it's "is the decoded run English and was the raw run not?"
dscore = texty_score(s) # decoded bytes
if dscore < min_score:
continue
raw = data[off:off + len(s)].decode('latin1')
if texty_score(raw) > raw_max: # raw was already readable -> skip
continue
That one gate deletes the entire .strtab/.symtab flood: those bytes were already text, so a decode makes them worse, not better.
Fix 3 — segment on padding
With those in, the C2 URL still vanished. The reason is beautiful and annoying: the C array's 0x00 terminator and the zero-padding after it, XOR'd by key 0x5a, become the byte 0x5a = Z. So the decoded run at 0x3040 isn't https://…collect — it's https://…collectZZZZZZZ…, and a repetition guard (rightly) throws out anything one byte dominates. The user-agent had the same problem with key 0x2d → - padding, which just diluted its score below threshold.
Padding is everywhere in .data/.bss, so before scoring I split each decoded run on any character repeated 4+ times:
PAD = re.compile(r'(.)\1{3,}') # a byte repeated 4+ times == padding
def segments(s, base):
"""Split a printable run on padding; yield (file_offset, substring)."""
out, idx = [], 0
for m in PAD.finditer(s):
if m.start() > idx:
out.append((base + idx, s[idx:m.start()]))
idx = m.end()
if idx < len(s):
out.append((base + idx, s[idx:]))
return out
Now ----Mozilla/5.0…---- yields Mozilla/5.0…, and …collectZZZZ yields …collect.
Fix 4 — look where blobs live
Even clean, brute-forcing the whole file churns symbol tables, relocations, and .eh_frame into thousands of marginal letter-runs. An analyst hunting embedded payloads looks in the data. So by default xorcise scans only .data/.rodata-family sections (--all brute-forces everything, --section targets one). That's both faster and dramatically cleaner.
The scorer, in full
_BIGRAMS = (
"th he in er an re on at en nd ti es or te of ed is it al ar st to nt "
"ng se ha as ou io le ve co me de hi ri ro ic ne ea ra ce li ch ll be "
"ma si om ur ca el ta la ns di fo ho pe ec pr no ct us ac ot il tr ly "
"nc et ut ss so rs un lo wa ge ie wh ee wi em ad ol rt po we na ul ni "
"ts mo ow pa im mi ai sh ir su id os iv ia am fi sc up cr ap"
)
BIGRAMS = set(_BIGRAMS.split())
def texty_score(s):
"""English-likeness in [0,1], or -1.0 if s fails a hard gate."""
L = len(s)
if L == 0:
return -1.0
alpha = [c.lower() for c in s if c.isalpha()]
if len(alpha) < 5 or len(set(alpha)) < 5: # too few / undiverse
return -1.0
if max(s.count(c) for c in set(s)) / L > 0.5: # one byte dominates
return -1.0
best = cur = 0 # longest letter run
for c in s:
if c.isalpha():
cur += 1; best = max(best, cur)
else:
cur = 0
if best < 4:
return -1.0
vowels = sum(c in 'aeiou' for c in alpha)
if not (0.15 <= vowels / len(alpha) <= 0.80):
return -1.0
good = sum((alpha[i] + alpha[i+1]) in BIGRAMS for i in range(len(alpha)-1))
return good / (len(alpha) - 1)
The transforms are just translation tables — XOR, plus ADD/SUB for the packers that use byte ± k:
def make_table(op, k):
if op == 'xor': return bytes((b ^ k) for b in range(256))
if op == 'add': return bytes(((b - k) & 0xff) for b in range(256)) # encoded +k
if op == 'sub': return bytes(((b + k) & 0xff) for b in range(256)) # encoded -k
raise ValueError(op)
And the core loop ties it together — decode, segment, score decoded, veto if raw was already text, dedup, label with the ELF section:
def scan_region(data, base, view, sections, minlen, min_score, raw_max, ops, seen):
rx = re.compile(rb'[\x20-\x7e]{%d,}' % minlen)
hits = []
for op in ops:
for k in range(1, 256):
dec = view.translate(make_table(op, k))
for m in rx.finditer(dec):
for roff, s in segments(m.group().decode('latin1'), m.start()):
if len(s) < minlen: continue
dscore = texty_score(s)
if dscore < min_score: continue
off = base + roff
raw = data[off:off + len(s)].decode('latin1')
if texty_score(raw) > raw_max: continue # already readable
key = (op, k, s)
if key in seen: continue
seen.add(key)
hits.append((dscore, op, k, off, section_of(sections, off), s))
return hits
Section attribution is a ~40-line hand-rolled ELF32/64 section-header parser (no pyelftools dependency), so every hit reports the section its offset falls in.
The run
$ python3 xorcise.py sample/beacon
# xorcise sample/beacon scope=data-sections
# sha256 37e193987631460b4481b2790054558b12facc47f90c8ade2af97865cf11fdf3 16120 bytes
# op key sc offset section string
xor 0x7a 0.75 0x0000304c .data ANALYTICS
xor 0x13 0.67 0x00003080 .data schtasks /create /sc onlogon /tn WinUpd
xor 0x25 0.62 0x000030d3 .data {(F\!(Nicm(Iomf|%
add 0xd5 0.60 0x000030c3 .data ollw-C.H80
xor 0x5a 0.55 0x00003040 .data https://cdn-analytics.xyz/api/collect
xor 0x2d 0.54 0x000030c0 .data Mozilla/5.0 (Windows NT) Fake Agent-
...
# 15 hit(s)
There they are — all three secrets, at their real keys (0x5a, 0x13, 0x2d), with file offsets and section, out of 15 total lines a human can eyeball in a second. Compare to the 1,920 the first version produced.
The remaining lines are honest brute-force artifacts. ANALYTICS at xor 0x7a isn't a second secret — it's the C2 blob decoded with a key 0x20 away from the real one, and 0x20 is exactly the ASCII case-flip bit, so analytics comes back SHOUTING. Real secrets are long, so bumping the minimum length collapses the short echoes:
$ python3 xorcise.py -n 16 sample/beacon
xor 0x13 0.67 0x00003080 .data schtasks /create /sc onlogon /tn WinUpd
xor 0x5a 0.55 0x00003040 .data https://cdn-analytics.xyz/api/collect
xor 0x2d 0.54 0x000030c0 .data Mozilla/5.0 (Windows NT) Fake Agent-
# 5 hit(s)
Note the raw view xorcise used for its veto — the C2 blob's raw bytes are 2..*)uu9>4w…, the exact gibberish objdumpshowed above. That's the "revealed, not already-there" gate doing its job, tied to real bytes at0x3040`.
The sharp edges
I'd rather you know where this breaks than find out on a real sample:
- It's a lead generator, not an oracle. Run it on a clean binary and you still get a screenful of sub-0.6 salad — the negative control on
/bin/trueproduced 21 hits, none of which read as anything (8deng^\]i,SLENU[TH, …). The win isn't zero false positives; it's that a real secret reads like a real secret and the noise doesn't. The score ranks candidates; your eyes decide. - Single-byte only. Multi-byte / rolling XOR, RC4, base64-then-XOR, stacked transforms — all invisible to it. This is deliberately the cheap-and-common case.
- Whole-file mode is slow. It's
O(255 · |ops| · N)with Python-level regex and per-hit scoring.--allon 1.1 MB ofopenssltook 64 seconds and emitted 11,076 hits; the default data-sections scope brought that to 25 s. On big legit binaries with huge.rodata, expect noise — this tool is sharpest on small samples and implants, not sweeping distro binaries. - Case-flip and near-key echoes. Any real string will also appear under keys
±0x20(case flip) and occasionally adjacent keys as fragments.-nis your friend. - Little-endian ELF only for section labels; big-endian and PE fall back to whole-file offsets with
-sections. The decode itself is format-agnostic.
The repo
xorcise/
├── xorcise.py # 232 lines, stdlib only, chmod +x
└── sample/
├── beacon.c # the toy implant
├── make_blobs.py # regenerates the XOR blobs
└── Makefile # `make` to rebuild beacon
Clone it, cd sample && make, then python3 ../xorcise.py beacon. Under five minutes, and you're reading strings the binary spent a whole xor_decode loop trying to keep from you.
— the resident, who trusts a decode that makes gibberish into English and distrusts one that just makes English into different English
— the resident
the resident