
Defeating highly encrypted and obfuscated code – tutorial (SRBMiner-MULTI 3.7.3)
TL;DR — SRBMiner-MULTI v3.7.3 for Linux is a 21 MB ELF that pretends to be "not stripped" but actually ships its entire real program as a single Zstandard-compressed frame embedded in .text. That frame decompresses to e…
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
TL;DR — SRBMiner-MULTI v3.7.3 for Linux is a 21 MB ELF that pretends to be "not stripped" but actually ships its entire real program as a single Zstandard-compressed frame embedded in .text. That frame decompresses to exactly 89,819,092 bytes, which is exactly what the binary declares as the size of an executable .bss section. Once you decompress it, the real strings, class names, and the dev-fee machinery all appear. This post walks through how I found it, how I proved it, and how I turned the shipped file into a statically unpacked ELF you can load straight into Ghidra.
Before anything else: the dev fee in SRBMiner-MULTI is the author's legitimate compensation for closed-source software. Nothing in this article removes it, and it shouldn't — that would just be software piracy with extra steps. We are simply analysising what methods they use to protect their devfee mining money - which they deserve.
This is a defensive / educational write-up. The interesting engineering question here is not "how do I turn off the fee" — it's "how does a real, shipping commercial protector hide its code, and what does that hide-and-seek look like from an analyst's chair?" The techniques (entropy analysis, packer fingerprinting, embedded-frame extraction, ELF surgery) are the same ones you use to analyse actual malware that does the same thing for far worse reasons — so this binary is a genuinely useful practice target that ships with a licence rather than a C2.
If you're reproducing this, use a binary you have the right to analyse. Don't redistribute modified builds.
You don't need anything exotic. This entire investigation was done with tools that are already on most Linux boxes:
Install the one non-obvious Python dependency:
pip install zstandard
Why zstandard and not the zstd CLI? Both work. I used the Python module because I wanted the declared decompressed size from the frame header as a variable I could assert against later, and because it made the "does the declared size match .bss?" check a one-liner.
Everything starts with the same three commands:
$ file SRBMiner-MULTI SRBMiner-MULTI: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, not stripped
That last phrase — "not stripped" — is the first red herring. A "not stripped" binary normally means you have a rich .symtab with function names. Let's check:
$ readelf -h SRBMiner-MULTI ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Type: DYN (Shared object file) Machine: Advanced Micro Devices X86-64 Entry point address: 0x1370d60 Start of program headers: 64 (bytes into file) Start of section headers: 21056960 (bytes into file) Number of program headers: 11 Number of section headers: 37
Key facts to note:
Figure 1 — the overall shape of the packing. Everything is inside the one file; nothing is downloaded at run time.
file said "not stripped", so let's look at .symtab directly:
$ readelf -sW SRBMiner-MULTI | head -20 Symbol table '.dynsym' contains 459 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.34 ...
All 459 dynamic symbols are UND — undefined imports from libc/pthread. And the static table?
$ readelf -sW SRBMiner-MULTI | grep -c ' FUNC ' 892 $ readelf -sW SRBMiner-MULTI | awk '$4=="FUNC" && $7!="UND"' | wc -l 1
892 FUNC symbols, but exactly one distinct function is defined — _start. Every other "symbol" is an import. (It shows up twice above because relf prints both tables, and — telling detail — they're the same size:)
$ readelf -sW SRBMiner-MULTI | grep -E '^Symbol table' Symbol table '.dynsym' contains 459 entries Symbol table '.symtab' contains 459 entries
.symtab is a 459-entry mirror of .dynsym. It exists so that file sees a static symbol table and reports "not stripped", while containing zero useful information. It's a deliberately hollowed-out table — enough to satisfy a naive check, useless to an analyst.
$ readelf -SW SRBMiner-MULTI [Nr] Name Type Address Off Size ES Flg Lk Inf Al [ 3] .text PROGBITS 000000000006c3f8 06c3f8 1301768 00 AX 0 0 1 [ 4] .dynstr STRTAB 000000000136db60 136db60 001404 00 A 0 0 1 [ 5] .text PROGBITS 000000000136ef64 136ef64 00022c 00 AX 0 0 1 [ 6] .gnu.version VERSYM 000000000136f190 136f190 000396 02 A 14 0 2 [ 7] .text PROGBITS 000000000136f526 136f526 0154ca 00 AX 0 0 1 [ 9] .text PROGBITS 00000000013849f0 13849f0 000960 00 AX 0 0 1 [11] .text PROGBITS 0000000001385580 1385580 002280 00 AX 0 0 1 [13] .text PROGBITS 0000000001387820 1387820 001a03 00 AX 0 0 1 [15] .text PROGBITS 000000000138bd2b 138bd2b 002545 00 AX 0 0 1 [16] .bss NOBITS 000000000138e270 000000 55a87d4 00 AX 0 0 1
Three things jump out:
That third point is the whole ballgame. .bss conventionally holds zero-initialised writable data (WA flags, not AX). Here we have:
So the binary reserves 89 MB of executable memory that doesn't exist on disk, at load address 0x138e270. Mark it with a big red pen — we'll come back to it.
Analyst's note: a .bss with X permission is one of the highest-signal packer indicators there is. Legitimate compilers basically never do this. Runtime code generation (JITs, some emulators) does, but for a miner that's suspicious. Combined with "no .rodata" and "seven .text sections", you can already be ~90% sure this thing unpacks something into memory at startup.
The obvious next move: find the fee strings. The software displays something like [2.00% fee] in its stats output, so surely the format string is in there somewhere.
$ grep -aob "2.00% fee" SRBMiner-MULTI $ grep -aob "2.00%" SRBMiner-MULTI $ grep -aob "devfee" SRBMiner-MULTI $ grep -aob "SRBMiner" SRBMiner-MULTI $ grep -aob "donation" SRBMiner-MULTI $ strings -a SRBMiner-MULTI | grep -i fee fee] # <-- garbage, matched inside compressed bytes 0FeE4( # <-- garbage $)51#PFee74l # <-- garbage
Nothing. Not the fee percentage, not the brand name, not the word "donation". The only fee matches are inside binary noise.
Negative results are data. Combine what we now know:
Every one of those is a packer indicator, and together they're conclusive. So the real question changes from "where's the string?" to "where's the compressed blob, and what is it?"
If code is hidden by compression or encryption, the statistical tell is entropy. English text and compiled code sit around 4.5–6.5 bits/byte; compressed or encrypted data sits at 7.9–8.0.
Let me compute Shannon entropy per section:
#!/usr/bin/env python3 """entropy_sections.py — Shannon entropy per ELF section.""" import math, subprocess, re, collections
def entropy(b: bytes) -> float: if not b: return 0.0 counts = collections.Counter(b) n = len(b) return -sum((c / n) * math.log2(c / n) for c in counts.values())
Ask readelf for section name / offset / size, skipping NOBITS (no file bytes)
out = subprocess.check_output(["readelf", "-SW", "SRBMiner-MULTI"], text=True) for line in out.splitlines(): m = re.match( r"\s*[\s*(\d+)]\s+(\S+)\s+(\S+)\s+([0-9a-f]+)\s+([0-9a-f]+)\s+([0-9a-f]+)", line, ) if not m: continue idx, name, typ, addr, off, size = m.groups() off, size = int(off, 16), int(size, 16) if typ == "NOBITS" or size < 4096: continue with open("SRBMiner-MULTI", "rb") as f: f.seek(off) data = f.read(size) print(f"[{idx:>2}] {name:<16} {typ:<9} size={size:>10} H={entropy(data):.6f}")
Output (trimmed):
[ 3] .text PROGBITS size= 19928936 H=7.998668 <-- !!! [16] .bss NOBITS size= 89819092 (no file bytes)
H = 7.998668 on a ~19 MB section. Out of a theoretical maximum of 8.0.
For calibration, a normal compiled .text section is typically 6.0–6.5. A value of 7.9987 is not "slightly noisy code" — it is compressed or encrypted data, full stop. And note which section it is: the big [3] .text, the one that is 19 MB of the 21 MB file.
Figure 2 — the investigation path. The "Yes" branch on the first decision never fired.
Now I need to know what the blob is. Rather than brute-forcing, I scanned the whole file for the magic numbers of every common compressor/archiver:
#!/usr/bin/env python3 """find_magics.py — scan for common compression/archive magics.""" import struct
MAGICS = { b"\x1f\x8b\x08": "gzip", b"\x28\xb5\x2f\xfd": "zstd", b"\xfd7zXZ\x00": "xz", b"BZh": "bzip2", b"\x04\x22\x4d\x18": "lz4 frame", b"\x89LZO\x00\r\n\x1a": "lzop", b"PK\x03\x04": "zip", b"7z\xbc\xaf\x27\x1c": "7z", b"Rar!\x1a\x07": "rar", b"MSCF": "cab", b"UPX0": "upx", b"UPX1": "upx", }
with open("SRBMiner-MULTI", "rb") as f: data = f.read()
for magic, name in MAGICS.items(): start = 0 while True: i = data.find(magic, start) if i < 0: break print(f"{name:<10} magic {magic.hex():<20} at file offset 0x{i:x}") start = i + 1
Output:
zstd magic 28b52ffd at file offset 0x6c400
One hit: Zstandard, at file offset 0x6c400.
That's inside the big .text section (which starts at 0x6c3f8 — note it's 8 bytes later, a classic alignment/marker gap). Let me look at the raw bytes around it:
$ xxd -s 0x6c3f0 -l 64 SRBMiner-MULTI 006c3f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 006c400: 28b5 2ffd 8050 d487 5a05 5400 0010 0000 (.ß.æ.°P..Z.T..... 006c410: 0100 fbff 39c0 024c 0000 0800 01fc fcff ....9À.L........ 006c420: 3910 024c 0000 0800 01fc fcff 3910 027c 9..L........9...
There it is: 28 b5 2f fd — the four-byte Zstandard frame magic — sitting right at 0x6c400, preceded by zero padding. This is a Zstandard frame embedded inside the ELF.
Figure 3 — where the data physically lives. The .bss reservation and the decompressed payload occupy the same address range.
Zstandard has an optional Frame_Content_Size field in the frame header that states the decompressed length up front. Let me parse it manually — it's a good exercise and it's how I got the smoking-gun number before decompressing anything.
+------+---------+----------+ | Magic| FHD | (opt) | | 4 B | 1 B | ... | +------+---------+----------+ ^ Frame_Header_Descriptor
The Frame_Header_Descriptor tells you which optional fields are present (per RFC 8878):
So for our descriptor 0x80: a 4-byte Frame_Content_Size follows the Window_Descriptor, there's no dictionary and no checksum. Let me verify with code and get the value:
#!/usr/bin/env python3 """zstd_header.py — parse the Zstandard frame header at a known offset.""" import struct
OFFSET = 0x6c400 with open("SRBMiner-MULTI", "rb") as f: f.seek(OFFSET) magic = f.read(4) fhd = f.read(1)[0] window_descriptor = f.read(1)[0] fcs = struct.unpack("<I", f.read(4))[0] # 4 bytes, since FCS_Field_Size==1 f.read(4) # dictionary id (size 0 -> nothing, but keep alignment explicit)
print(f"magic = {magic.hex()}") print(f"Header_Descriptor = 0x{fhd:02x} (FCS present={bool(fhd & 0x80)})") print(f"Window_Descriptor = 0x{window_descriptor:02x}") print(f"Frame_Content_Size = 0x{fcs:x} ({fcs} bytes)")
BSS_SIZE = 0x55a87d4 print(f".bss declared size = 0x{BSS_SIZE:x} ({BSS_SIZE} bytes)") print(f"MATCH: {fcs == BSS_SIZE}")
Output:
magic = 28b52ffd Header_Descriptor = 0x80 (FCS present=True) Window_Descriptor = 0x50 Frame_Content_Size = 0x55a87d4 (89819092 bytes) .bss declared size = 0x55a87d4 (89819092 bytes) MATCH: True
The Zstandard frame declares that it decompresses to exactly 89,819,092 bytes — which is exactly the size of the executable .bss section.
That is not a coincidence, and it is the single most important conclusion in this article. It means:
Once you've matched those two numbers, the whole structure of the packing is understood.
Now the actual extraction. Straightforward once you know the offset and the expected size:
#!/usr/bin/env python3 """extract_payload.py — decompress the em
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。