Unpacking a Commercial Miner: How SRBMiner-MULTI 3.7.3 Hides Its Dev-Fee Logic Behind Zstandard

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.


0. Scope, legality, and why this is worth writing up

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.

Everything below was performed:

  • statically only β€” the binary was never executed during analysis,
  • on my own licensed copy of the software,
  • for personal research.

If you're reproducing this, use a binary you have the right to analyse. Don't redistribute modified builds.

The artefacts this article produces

Artefact Size SHA-256
SRBMiner-MULTI (original) 21,059,328 B 6ec332bc3e4a085baa11a0013c5bfe7197106edd41c57a608f603f24f2326303
srb_payload.bin (decompressed, /tmp) 89,819,092 B 6c076a7c9e81fa4703b3327cedb53c2475e6fbf098e47edc7c543cfcb299def7
SRBMiner_Unpacked (static unpack) 110,881,348 B e3f28e88717f532b6e84df348b72f929d4f2a6fb9877ba984fcdbe88077a2720

1. The toolkit

You don't need anything exotic. This entire investigation was done with tools that are already on most Linux boxes:

Tool Used for
file Quick format/architecture identification
readelf ELF headers, program headers, section headers β€” the single most important tool here
objdump Disassembly of arbitrary byte ranges (including the raw payload)
strings String extraction
xxd Hex dumps to eyeball magic bytes
python3 + zstandard Entropy math, magic scanning, and actually decompressing the payload
Ghidra (via a headless MCP bridge) Decompilation, xrefs, and function recovery on the unpacked payload
draw.io Diagrams (the ones in this post are generated from XML)

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.


2. First contact β€” "not stripped" is a lie

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:

  • ET_DYN β†’ it's a PIE. Every address you see is file-relative and gets relocated at load time.
  • Entry point 0x1370d60 β†’ that's far into the file (file size is 21,059,328 = 0x1415700), i.e. ~93% of the way in. A normal _start is near the start of .text. This one being this late is itself a smell.
  • Section headers start at 21056960 (0x1414dc0) β€” almost at the very end of the file.

Architecture diagram: shipped file β†’ embedded Zstd frame β†’ loader stub β†’ real program in memory

Figure 1 β€” the overall shape of the packing. Everything is inside the one file; nothing is downloaded at run time.

2.1 The symbol table is hollow

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.

2.2 The section table is a mess β€” in a very specific way

$ 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:

  1. There is no .rodata section at all. No read-only data. For a C++ program of this complexity that's essentially impossible unless the data is hidden elsewhere.
  2. Seven different .text sections, interleaved with .dynstr, .gnu.version, etc. Normal compilers emit one .text. This interleaving is the signature of a tool that is sculpting the section table rather than letting the linker do it.
  3. Section [16] is .bss, is NOBITS, and is flagged AX β€” allocated and executable β€” with a size of 0x55a87d4 = 89,819,092 bytes.

That third point is the whole ballgame. .bss conventionally holds zero-initialised writable data (WA flags, not AX). Here we have:

  • .bss: NOBITS (takes up zero bytes in the file), AX flags (executable), 89,819,092 bytes of virtual space.

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.


3. Hunting for strings β€” and finding nothing

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:

  • No readable fee/brand strings anywhere,
  • No .rodata,
  • .symtab present but hollow,
  • An executable .bss reservation of 89 MB,
  • An entry point buried at 93% of the file.

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?"


4. Entropy analysis β€” the smoking gun

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.

Flow diagram of the investigation methodology, from

Figure 2 β€” the investigation path. The "Yes" branch on the first decision never fired.


5. Finding the compression magic

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.

Memory map diagram showing the .bss region as shipped versus after static unpacking

Memory map diagram showing the .bss region as shipped versus after static unpacking

Figure 3 β€” where the data physically lives. The .bss reservation and the decompressed payload occupy the same address range.


6. The size match that proves everything

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.

The frame layout is:

+------+---------+----------+
| Magic| FHD     | (opt)    |
| 4 B  | 1 B     | ...      |
+------+---------+----------+
                ^ Frame_Header_Descriptor

The Frame_Header_Descriptor tells you which optional fields are present (per RFC 8878):

Bits Field Value in 0x80 Meaning
7–6 FCS_Field_Size 0b10 = 2 FCS field is 4 bytes
5 Single_Segment_flag 0 no single-segment mode β†’ Window_Descriptor present
4 Unused 0 β€”
3 Reserved 0 β€”
2 Content_Checksum_flag 0 no checksum
1–0 Dictionary_ID_flag 0 no dictionary

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:

  1. The blob is the program.
  2. The loader stub decompresses it straight into the pre-reserved .bss region.
  3. The .bss "section" is really a program image placeholder β€” the loader tells the OS "reserve me 89 MB of executable, zero-filled memory" and then fills it with the decompressed program.

Once you've matched those two numbers, the whole structure of the packing is understood.


7. Extracting the payload

Now the actual extraction. Straightforward once you know the offset and the expected size:

#!/usr/bin/env python3
"""extract_payload.py β€” decompress the embedded Zstandard frame."""
import zstandard, hashlib

SRC          = "SRBMiner-MULTI"
FRAME_OFFSET = 0x6c400
EXPECTED     = 0x55a87d4          # from the frame header / .bss size
OUT          = "/tmp/srb_payload.bin"

with open(SRC, "rb") as f:
    data = f.read()

frame = data[FRAME_OFFSET:]       # frame runs to EOF (nothing meaningful follows)

dctx = zstandard.ZstdDecompressor()
out = dctx.decompress(frame, max_output_size=EXPECTED + 4096)

assert len(out) == EXPECTED, (len(out), EXPECTED)

with open(OUT, "wb") as f:
    f.write(out)

print(f"decompressed : {len(out):,} bytes (0x{len(out):x})")
print(f"sha256       : {hashlib.sha256(out).hexdigest()}")

Output:

decompressed : 89,819,092 bytes (0x55a87d4)
sha256       : 6c076a7c9e81fa4703b3327cedb53c2475e6fbf098e47edc7c543cfcb299def7

Gotcha worth knowing: passing max_output_size matters. Without it, ZstdDecompressor.decompress() has to guess, and for large frames the Python binding can throw ZstdError: Unknown frame descriptor if you hand it a stream it can't frame properly. I also found stream_reader(..., read_across_frames=False) less reliable here than the direct .decompress() call once the size is known. If you hit that error, supply the expected size.

7.1 Before and after

The contrast is stark. Before:

$ strings -a SRBMiner-MULTI | grep -i "devfee" | wc -l
0

After:

$ strings -a /tmp/srb_payload.bin | grep -i "devfee" | sort -u
SRB/devfee/v4/aead
SRB/devfee/v4/chunk-key
SRB/devfee/v4/chunk-nonce
SRB/devfee/v4/selector
Devfee TLS peer authentication failed
conflicting current devfee v4 runtime identity
current algorithm has no stable devfee v4 identity
duplicate devfee v4 algorithm identity
invalid devfee v4 algorithm identity

And the brand/version strings appear too β€” SRBMiner-MULTI 3.7.3, SRBMiner-MULTI Stats, and the fee display:

$ strings -a /tmp/srb_payload.bin | grep -A2 -B2 "%.2f"
...
%.2f
[
%
 fee]
Algorithm/s         : %s

There's the [ … % fee] display, built from four separate literal fragments. Which brings us to the address mapping.

7.2 The payload β†’ virtual-address formula

This is the single most useful thing to memorise for the rest of the analysis:

real_vaddr = 0x138e270 + payload_file_offset

where 0x138e270 is the start address of the .bss section (where the payload lands in memory).

Worked example β€” the "[…% fee]" fragments. In the payload they sit at 0xd58cd6:

$ python3 -c "
data = open('/tmp/srb_payload.bin','rb').read()
for label, off in [('\"[%.2f% fee]\"', 0xd58cd6)]:
    print(label, hex(off), '->', hex(0x138e270+off))
"
"[%.2f% fee]" 0xd58cd6 -> 0x20e6f46

And so on for each fragment:

Payload offset Virtual address String
0xd58cd6 0x20e6f46 "%.2f"
0xd58cdb 0x20e6f4b "["
0xd58cdd 0x20e6f4d "%"
0xd58cdf 0x20e6f4f " fee]"

8. Proving the payload is the real program

Extracting a payload and extracting the program are different claims. Here's the proof. Disassemble _start (at 0x13884e7):

$ objdump -d --start-address=0x13884e7 --stop-address=0x1388500 SRBMiner-MULTI
00000000013884e7 <_start>:
 13884e7:  f3 0f 1e fa          endbr64
 13884eb:  e8 60 dd 0f 00       call   0x1486250
 13884f0:  f4                   hlt
 13884f1:  66 2e 0f 1f 84 00 00 cs nopw 0x0(%rax,%rax,1)
 13884f6:  e9 b5 66 69 00       jmp    0x1a1ebb0        # <-- !!!

That jmp 0x1a1ebb0 is the moment of hand-off. Let's see where 0x1a1ebb0 falls:

$ python3 -c "
target = 0x1a1ebb0
base   = 0x138e270
print('target            :', hex(target))
print('.bss starts at     :', hex(base))
print('payload offset    :', hex(target - base))
print('inside .bss range  :', base <= target < 0x6936a44)
"
target            : 0x1a1ebb0
.bss starts at     : 0x138e270
payload offset    : 0x690940
inside .bss range  : True

0x1a1ebb0 is inside the .bss range, at payload offset 0x690940.

So the control flow is:

  1. The dynamic loader maps the file.
  2. .bss is reserved as 89 MB of zero-filled executable memory at 0x138e270.
  3. _start runs the (heavily obfuscated) loader stub, which decompresses the Zstandard frame into that region.
  4. The stub executes jmp 0x1a1ebb0, landing inside the freshly-decompressed program.
  5. From that point on, the real code runs.

The stub itself is deliberately unpleasant to read. The functions it calls are junk-filled trampolines β€” the decompiler output looks like this:

void FUN_01486250(undefined8 param_1,undefined8 param_2)
{
  FUN_014716c0(param_1,param_2,DAT_06b49480);
  return;
}

and

void FUN_0147170c(void)
{
  LOCK(); UNLOCK();
  LOCK(); UNLOCK();
  LOCK(); UNLOCK();
  LOCK(); UNLOCK();
  /* WARNING: Treating indirect jump as return */
  return;
}

Those LOCK/UNLOCK sequences are not real synchronisation β€” they're anti-disassembly filler (junk prefixes and opaque trampolines) designed to make linear disassembly produce garbage. This is exactly why you stop trying to read the stub and instead jump to the output of the stub: the decompressed payload. Ghidra's own note β€” "WARNING: Control flow encountered bad instruction data" β€” is the same signal.


9. Getting the payload into Ghidra

Analysing an 89 MB flat blob needs a real decompiler. The trick is that the payload has no ELF headers β€” it's raw x86-64 that was destined to be executed at 0x138e270. So you load it as a raw binary mapped at that address.

The mapping subtlety (important)

When I first loaded the payload into Ghidra I set the base to 0x138e270, and everything lined up with my offset arithmetic. Later, when I re-opened it with the entry point set to the true hand-off address 0x1a1ebb0, Ghidra mapped file offset 0 to 0x1a1ebb0 instead β€” a different base. Confusing, but manageable, because the two bases differ by a constant:

ghidra_base_0x1a1ebb0_addr = real_vaddr(base 0x138e270) + 0x690940

Pick one addressing convention and stick to it in your notes. I standardised on real_vaddr = 0x138e270 + payload_offset for everything in this article, because that's the address the code actually runs at.

Steps (Ghidra GUI)

  1. File β†’ Import File… β†’ select /tmp/srb_payload.bin.
  2. In the "Import" dialog, set:
    • Format: Raw Binary
    • Language: x86:LE:64:default (64-bit x86, little-endian)
    • Base Address: 0x138e270 (the runtime address β€” this is the whole point)
  3. Click OK, then Yes to analyse.
  4. Auto-analysis takes a while (tens of MB of unprotected x86-64). Let it finish β€” Analysis β†’ Auto Analyze.
  5. When it completes, Search β†’ For Strings… or use the Defined Strings window.

Steps (headless / scripted)

If you'd rather script it:

$GHIDRA_INSTALL_DIR/support/analyzeHeadless /tmp/ghidra_proj srb_payload \
  -import /tmp/srb_payload.bin \
  -processor x86:LE:64:default \
  -loader BinaryLoader \
  -loader-baseAddr 0x138e270 \
  -analysisTimeoutPerFile 1800 \
  -postScript your_postscript.java

Once it's in, the strings from Β§7 are all discoverable, and β€” crucially β€” cross-references become available. That's the real payoff: I could ask "what reads the "%.2f" at 0x20e6f46?" and get an answer.


10. What's actually in there: the dev-fee subsystem

With the payload in Ghidra + xrefs, the fee machinery fell out quickly.

10.1 The display path

Cross-referencing 0x20e6f4f (" fee]") lands in one function, and the chain is readable:

$ objdump -D -b binary -m i386:x86-64 -M intel \
    --adjust-vma=0x138e270 \
    --start-address=0x16b22e0 --stop-address=0x16b2330 \
    /tmp/srb_payload.bin
 16b22ec:  4c 8d 05 53 4c a3 00   lea    r8,[rip+0xa34c53]   # 0x20e6f46  "%.2f"
 16b22f3:  ba 01 00 00 00         mov    edx,0x1
 16b22f8:  be 20 00 00 00         mov    esi,0x20
 16b22fd:  f2 0f 10 88 80 00 00 00  movsd  xmm1,QWORD PTR [rax+0x80]
 ...
 16b232d:  e8 ae db eb ff         call   0x156fee0         # snprintf-style helper

The fee percentage is a double (movsd, 8 bytes) loaded from [rax+0x80], where rax traces back to a pointer reachable from a global object. The on-screen [ X.XX% fee ] is assembled from the four literal fragments we found.

This is the display value. One important open question is whether the scheduling logic reads the same memory location. If it does, a single value patch changes both the display and the behaviour; if it doesn't, patching this value only changes what you're told, not what happens. Answering that requires finding the writer of that double and enumerating its other readers β€” a follow-on task, and conspicuously not something you should assume from the display path alone.

10.2 The interesting part: DEVFEE_V4

Here's where it stops being a simple counter. The payload contains a whole self-contained, encrypted subsystem:

$ strings -a /tmp/srb_payload.bin | grep -E "DEVFEE|DevFee"
*N9DEVFEE_V412_GLOBAL__N_115production_keysE
[N9DEVFEE_V412key_providerE
St23_Sp_counted_ptr_inplaceI22DevFeeConfigGenerationSaIS0_ELN9__gnu_cxx12_Lock_policyE2EE
Devfee TLS peer authentication failed

Those first two are C++ mangled (Itanium ABI) RTTI names. Demangled:

Mangled Meaning
N9DEVFEE_V412key_providerE DEVFEE_V4::key_provider
N9DEVFEE_V412_GLOBAL__N_115production_keysE DEVFEE_V4::(anonymous namespace)::production_keys
St23_Sp_counted_ptr_inplaceI22DevFeeConfigGeneration... a std::shared_ptr control block for class DevFeeConfigGeneration

And the string table gives us the cryptographic plumbing:

Virtual address String Interpretation
0x20e825e SRB/devfee/v4/selector HKDF info context
0x20e8275 SRB/devfee/v4/chunk-key HKDF info
0x20e828d SRB/devfee/v4/chunk-nonce HKDF info
0x353e024 SRB/devfee/v4/aead AEAD context
0x20cb550 Devfee TLS peer authentication failed dedicated TLS error path

Those SRB/devfee/v4/... labels are textbook HKDF domain-separation strings β€” they're not data, they're inputs to a key-derivation function. The aead string plus production_keys plus key_provider tell you the story: the dev-fee connection parameters (host, port, credentials) are stored encrypted and derived at runtime β€” there is nothing static to overwrite.

Diagram of the dev-fee subsystem: display path versus the encrypted DEVFEE_V4 machinery

Figure 4 β€” the two halves. A readable display path (left) and an encrypted, TLS-authenticated connection subsystem (right).

10.3 Why this is a much harder target than it looks

Put the pieces together:

  • No plaintext percentage. I scanned the payload for 2.00%, 2.0, devfee pool, and any wallet-looking string. Nothing. Whatever number drives the fee is either computed or stored encrypted.
  • No plaintext end-point. No domain, no IP, no wallet address for the dev pool anywhere in 89 MB. The aead/key_provider/production_keys combo means the connection material is decrypted only at runtime.
  • Dedicated TLS peer authentication for the dev-fee connection, with its own error path. So this isn't an unauthenticated stratum+tcp:// like the user's own pool β€” it's a pinned/authenticated channel, and the software handles a failed handshake as a first-class error condition.

A naive "NOP the branch" or "overwrite the pool string" patch doesn't apply cleanly here. The design is specifically resistant to the kind of static patch that works on simpler miners β€” which is precisely what makes it an interesting analysis target rather than a five-minute one.


11. Building a statically unpacked ELF

Reading a flat blob in Ghidra works, but it's friction: every tool needs the base address, strings tools need offset math, and you can't hand the file to readelf/objdump and get sensible output.

So I produced a statically unpacked copy β€” a valid ELF where the payload is present as real file bytes at its correct virtual address. No bytes were moved or overwritten; the file was extended and the headers surgically edited.

11.1 The design

The mapping insight from Β§2 makes this clean:

.bss section [16]
Virtual address 0x138e270
Size 0x55a87d4 (89,819,092)
Type NOBITS (0 file bytes)
Flags AX

The payload needs to end up at 0x138e270, size 0x55a87d4. .bss already declares exactly that virtual range. So all we need is to make those bytes exist in the file:

  1. Bump e_phnum from 11 to 12 (add one program header).
  2. Grow PT_PHDR by one entry size (p_filesz/p_memsz += 56) so it describes the bigger table.
  3. Shrink the first PT_LOAD's p_memsz from 0x6936a44 to 0x138e270 β€” that segment previously extended past its p_filesz into zero-fill to cover the .bss; that job now belongs to the new segment.
  4. Add a new PT_LOAD: vaddr = 0x138e270, filesz = memsz = 0x55a87d4, flags R+X, pointing at a new file offset.
  5. Convert section [16] from SHT_NOBITS β†’ SHT_PROGBITS and set its sh_offset to the new data. (sh_addr/sh_size were already correct.)
  6. Append the payload, page-aligned, at the end of the file.

Key detail: each PT_LOAD needs p_offset ≑ p_vaddr (mod p_align). Our p_vaddr is 0x138e270, so 0x138e270 % 0x1000 = 0x270, meaning the new file offset must also end in 0x270.

11.2 The script

#!/usr/bin/env python3
"""unpack_elf.py β€” turn the shipped ELF into a statically unpacked ELF."""
import struct, hashlib

SRC          = "SRBMiner-MULTI"
PAYLOAD      = "/tmp/srb_payload.bin"
OUT          = "SRBMiner_Unpacked"
VADDR        = 0x138e270
PAYLOAD_SIZE = 0x55a87d4
ALIGN        = 0x1000

data    = bytearray(open(SRC, "rb").read())
payload = open(PAYLOAD, "rb").read()
assert len(payload) == PAYLOAD_SIZE

e_phoff,   = struct.unpack_from("<Q", data, 32)
e_shoff,   = struct.unpack_from("<Q", data, 40)
e_phentsize, e_phnum, e_shentsize, e_shnum, e_shstrndx = \
    struct.unpack_from("<HHHHH", data, 54)
assert e_phentsize == 56 and e_shentsize == 64

# --- 1. one more program header -------------------------------------------
struct.pack_into("<H", data, 56, e_phnum + 1)

# --- 2. grow PT_PHDR -------------------------------------------------------
off0 = e_phoff
old_filesz, = struct.unpack_from("<Q", data, off0 + 32)
struct.pack_into("<Q", data, off0 + 32, old_filesz + e_phentsize)   # p_filesz
struct.pack_into("<Q", data, off0 + 40, old_filesz + e_phentsize)   # p_memsz

# --- 3. shrink the first PT_LOAD's memsz to its filesz ---------------------
off1 = e_phoff + e_phentsize
struct.pack_into("<Q", data, off1 + 40, VADDR)     # p_memsz = 0x138e270

# --- 4. work out where the payload goes (page-aligned, matching VADDR) -----
cur, mod = len(data), VADDR % ALIGN
new_offset = cur + (-cur % ALIGN) + (mod - (-cur % ALIGN) % ALIGN if False else 0)
# simpler & explicit:
new_offset = cur + ((mod - cur) % ALIGN)
assert new_offset % ALIGN == mod

# --- 5. append the new PT_LOAD entry --------------------------------------
struct.pack_into("<IIQQQQQQ", data, e_phoff + e_phnum * e_phentsize,
                 1,             # PT_LOAD
                 5,             # R | X
                 new_offset,    # p_offset
                 VADDR,         # p_vaddr
                 VADDR,         # p_paddr
                 PAYLOAD_SIZE,  # p_filesz
                 PAYLOAD_SIZE,  # p_memsz
                 ALIGN)         # p_align

# --- 6. .bss section [16]: NOBITS -> PROGBITS, point at new data ----------
soff16 = e_shoff + 16 * e_shentsize
struct.pack_into("<I", data, soff16 + 4,  1)            # sh_type = PROGBITS
struct.pack_into("<Q", data, soff16 + 24, new_offset)   # sh_offset

# --- 7. append pad + payload ---------------------------------------------
data += b"\x00" * (new_offset - len(data))
data += payload

open(OUT, "wb").write(data)
print(f"{OUT}: {len(data):,} bytes, payload at 0x{new_offset:x}")
print("sha256:", hashlib.sha256(data).hexdigest())

Result:

SRBMiner_Unpacked: 110,881,348 bytes, payload at 0x1416270
sha256: e3f28e88717f532b6e84df348b72f929d4f2a6fb9877ba984fcdbe88077a2720

11.3 Verifying it

$ readelf -lW SRBMiner_Unpacked | sed -n '1,25p'
Elf file type is DYN (Shared object file)
Entry point 0x1370d60
There are 12 program headers, starting at offset 64

Program Headers:
  Type   Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
  PHDR   0x000040 0x0000000000000040 0x0000000000000040 0x0002a0 0x0002a0 R   0x8
  LOAD   0x000000 0x0000000000000000 0x0000000000000000 0x138e270 0x138e270 R E 0x1000
  ...
  LOAD   0x1416270 0x000000000138e270 0x000000000138e270 0x55a87d4 0x55a87d4 R E 0x1000   <-- new

The new LOAD is there, correctly aligned (0x1416270 % 0x1000 = 0x270 = 0x138e270 % 0x1000), and the old one no longer over-extends. Then:

$ readelf -SW SRBMiner_Unpacked | grep '\[16\]'
  [16] .bss              PROGBITS        000000000138e270 1416270 55a87d4 00  AX  0   0  1

.bss is now PROGBITS with real file data at 0x1416270, at the correct address and size. And the payoff:

$ strings -a SRBMiner_Unpacked | grep -i devfee | sort -u
SRB/devfee/v4/aead
SRB/devfee/v4/chunk-key
SRB/devfee/v4/chunk-nonce
SRB/devfee/v4/selector
Devfee TLS peer authentication failed
...

All the hidden strings are now visible in a plain ELF, with no decompression step. You can hand SRBMiner_Unpacked straight to readelf, objdump, nm, Ghidra, or rizin and they'll all behave.


12. What the design tells us

Stepping back, the packing design is coherent and deliberate:

Design choice Purpose
Huff the .symtab with imports so file says "not stripped" Waste an analyst's first ten minutes
Delete .rodata entirely Kill naive string hunting at the root
Split .text into seven interleaved sections Break linear disassembly, obscure boundaries
Reserve an executable .bss for the payload Give the decompressor a home with zero on-disk footprint
Obfuscate the loader stub with junk LOCK prefixes / opaque trampolines Defeat disassemblers on the stub itself
Encrypt the dev-fee connection material (HKDF + AEAD) Defeat "just overwrite the pool string / wallet" patches
Give the dev-fee connection its own TLS peer authentication + error path Detect and handle tampering at the network layer

That last pair is what separates this from a hobby project. The packer protects against the casual analyst; the DEVFEE_V4 subsystem protects against the determined one. And note both are the same techniques you'd meet in commodity malware loaders: compression, entropy, obfuscated stubs, and encrypted configuration.


13. Methodology takeaways

If you take nothing else from this post:

  1. "Not stripped" is a claim, not a fact. Verify it β€” count defined symbols, don't trust the string.
  2. A NOBITS section with X permission is a packer flag. Grep your readelf -SW output for AX on .bss.
  3. Entropy is the fastest packer detector there is. ~20 lines of Python and you've got a verdict. H > 7.9 on a code section means compressed or encrypted, no further argument needed.
  4. Scan for magics before you brute-force anything. Finding the Zstandard header took seconds and settled the question of what the blob was.
  5. Size fields are free answers. The Zstandard frame told me it decompresses to 89,819,092 bytes before I decompressed a single byte; matching that against .bss proved the whole mechanism.
  6. Don't fight the stub β€” study its output. The loader was obfuscated specifically so you'd waste time on it. The jmp at the end of it points at everything you actually want.
  7. Negative results narrow the search. "There is no plaintext X anywhere in the file" is often more informative than a hit.
  8. Get the payload into a real decompiler. objdump gets you unstuck; Ghidra's xrefs get you answers.

Need an Android Developer or a full-stack website developer?

I specialize in Kotlin, Jetpack Compose, and Material Design 3. For websites, I use modern web technologies to create responsive and user-friendly experiences. Check out my portfolio or get in touch to discuss your project.