Packers I actually reverse on Linux userland share a skeleton: a small stub runs before main, makes a page writable, paints decoded bytes, optionally drops W, then transfers control. This lab is that skeleton as a 70-line C program I own. It is not UPX, not a malware unpacker, and the blob is 32 bytes I assembled myself. Goal: find the stub via .init_array, break mprotect, dump the region after it goes r-x.
What the stub is allowed to be
The decoded payload is a function real_main that prints one line and returns 0. Encryption is XOR 0xA5 over a 32-byte buffer sitting in .data. No multi-layer VM, no stolen entrypoint into a third-party sample, no code that would decode a binary I do not own.
1/* pack_lab.c — toy stub, lab only */
2#define _GNU_SOURCE
3#include <stdio.h>
4#include <stdint.h>
5#include <string.h>
6#include <sys/mman.h>
7#include <unistd.h>
8
9static void real_payload(void)
10{
11 puts("payload-ok");
12}
13
14/* 32-byte XOR image of a tiny trampoline; filled at build by mkblob.py
15 or, for this note, by a constructor that encodes real_payload's first
16 32 bytes then overwrites them — see encode_once(). */
17static unsigned char blob[32];
18static int encoded;
19
20static void encode_once(void)
21{
22 unsigned char *p = (unsigned char *)(uintptr_t)&real_payload;
23 if (encoded) return;
24 memcpy(blob, p, 32);
25 for (int i = 0; i < 32; i++)
26 blob[i] ^= 0xA5;
27 memset(p, 0xCC, 32); /* poison until stub runs */
28 encoded = 1;
29}
30
31static void stub(void)
32{
33 long pagesz = sysconf(_SC_PAGESIZE);
34 uintptr_t addr = (uintptr_t)&real_payload;
35 uintptr_t page = addr & ~(uintptr_t)(pagesz - 1);
36 unsigned char *p = (unsigned char *)addr;
37
38 encode_once();
39
40 if (mprotect((void *)page, pagesz, PROT_READ | PROT_WRITE | PROT_EXEC) != 0)
41 return;
42 memcpy(p, blob, 32);
43 for (int i = 0; i < 32; i++)
44 p[i] ^= 0xA5;
45 if (mprotect((void *)page, pagesz, PROT_READ | PROT_EXEC) != 0)
46 return;
47}
48
49__attribute__((section(".init_array"), used))
50static void (*init_slot)(void) = stub;
51
52int main(void)
53{
54 real_payload();
55 return 0;
56}
Build:
1cc -O0 -fPIE -pie -fno-stack-protector -Wl,-z,lazy -o pack_lab pack_lab.c
2file pack_lab
3# pack_lab: ELF 64-bit LSB pie executable, ARM aarch64, dynamically linked, not stripped
encode_once runs inside stub, so a cold objdump of .text still shows the real real_payload bytes. To make the on-disk image look packed I also have a two-step build that runs the encoder at compile time and ships blob[] already filled, with .text pre-poisoned. The gdb session below uses that two-step binary so x/16i real_payload before stub is brk/int3 (0xCC on x86_64, 0xD4200020 udf on aarch64 — I switched the poison to udf #1 for ARM). The C above is the readable form.
File-level: .init_array, PT_DYNAMIC, PT_GNU_STACK
1$ readelf -S pack_lab | egrep 'init_array|text|plt'
2 [12] .init_array INIT_ARRAY 0000000000001d90 00000d90
3 0000000000000008 0000000000000008 WA 0 0 8
4 [14] .plt PROGBITS 00000000000006b0 000006b0
5 [15] .text PROGBITS 00000000000007a0 000007a0
6
7$ readelf -x .init_array pack_lab
8Hex dump of section '.init_array':
9 0x00001d90 28080000 00000000 (.......
10# 8-byte slot, file VA 0x828 = stub (confirm with nm)
11
12$ nm pack_lab | egrep 'stub|real_payload|main|init_slot'
130000000000000828 T stub
1400000000000008f0 T real_payload
150000000000000940 T main
160000000000001d90 D init_slot
One slot, one function. That is the whole .init_array on this binary. libc’s own frame_dummy / __libc_csu_init walk lives in the C runtime startup (_init / __libc_start_main); I still dump the array the file owns.
1$ readelf -d pack_lab | egrep 'INIT_ARRAY|NEEDED|FLAGS|GNU'
2 0x000000000000000c (INIT) 0x6a8
3 0x0000000000000019 (INIT_ARRAY) 0x1d90
4 0x000000000000001b (INIT_ARRAYSZ) 8 (bytes)
5 0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
6 0x000000006ffffffb (FLAGS_1) Flags: PIE
7
8$ readelf -l pack_lab | egrep 'GNU_STACK|GNU_RELRO|LOAD|PHDR'
9 Type Offset VirtAddr PhysAddr
10 LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
11 0x0000000000000c28 0x0000000000000c28 R E
12 LOAD 0x0000000000000d90 0x0000000000001d90 0x0000000000001d90
13 0x0000000000000298 0x0000000000000298 RW
14 GNU_RELRO 0x0000000000000d90 0x0000000000001d90 0x0000000000001d90
15 GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
16 0x0000000000000000 0x0000000000000000 RW
PT_GNU_STACK is RW, not RWX. The stub does not need an executable stack; it mprotects the text page. If I ever see GNU_STACK RWE on a sample, that is a separate finding (nested function trampolines or a sloppy packer). I record it even when it is clean, so the note is comparable to the next sample.
GNU_RELRO covers .init_array. After load the slot itself is read-only. The stub already ran by then — RELRO does not prevent constructors, it freezes the pointer table afterwards.
Disassembly of stub
1$ objdump -d pack_lab | sed -n '/<stub>:/,/<real_payload>/p'
20000000000000828 <stub>:
3 828: a9be7bfd stp x29, x30, [sp, #-0x20]!
4 82c: 910003fd mov x29, sp
5 830: 940000xx bl encode_once
6 834: 52800020 mov w0, #1 ; sysconf arg staged elsewhere
7 838: 97ffffxx bl 6d0 <sysconf@plt>
8 83c: 8a0003e1 bic x1, xpage, x0-1 ; page align (compiler form varies)
9 840: 52800007 mov w2, #7 ; PROT_READ|WRITE|EXEC = 7
10 844: aa0103e0 mov x0, x1 ; page
11 848: 52800022 mov w1, #0x1000 ; len, pagesz
12 84c: 97ffffxx bl 6e0 <mprotect@plt>
13 850: 91000000 add x0, xdst, #0 ; dest = real_payload
14 854: 91000001 add x1, xblob, #0
15 858: 52800402 mov w2, #32
16 85c: 97ffffxx bl 6f0 <memcpy@plt>
17 860: ; xor loop 32 times, 0xA5
18 880: 528000a2 mov w2, #5 ; PROT_READ|EXEC = 5
19 884: 97ffffxx bl 6e0 <mprotect@plt>
20 888: a8c27bfd ldp x29, x30, [sp], #32
21 88c: d65f03c0 ret
Imports the stub needs, from .rela.plt:
1$ readelf -r pack_lab | grep JUMP_SLOT
20000000000001f90 ... R_AARCH64_JUMP_SLOT mprotect@GLIBC_2.17 + 0
30000000000001f98 ... R_AARCH64_JUMP_SLOT memcpy@GLIBC_2.17 + 0
40000000000001fa0 ... R_AARCH64_JUMP_SLOT puts@GLIBC_2.17 + 0
50000000000001fa8 ... R_AARCH64_JUMP_SLOT sysconf@GLIBC_2.17 + 0
A packed sample that still has mprotect in .rela.plt is advertising the stub. Stripped names still show up in the dynamic reloc table. That is the hunting needle; I do not start by emulating 40 KB of UPX.
gdb: break mprotect, dump the r-x region
Two hits: RWX, then RX. I print the prot argument (x2 on aarch64, rsi/rdx on x86_64 — here w2) and hexdump real_payload.
1$ gdb -q ./pack_lab
2(gdb) set disable-randomization on
3(gdb) break mprotect
4(gdb) run
5Breakpoint 1, mprotect (addr=0xaaaaaaab0000, len=0x1000, prot=7) ; RWX
6(gdb) x/8i real_payload
7 0xaaaaaaab08f0 <real_payload>: udf #1
8 0xaaaaaaab08f4 <real_payload+4>: udf #1
9 ...
10(gdb) continue
11Breakpoint 1, mprotect (addr=0xaaaaaaab0000, len=0x1000, prot=5) ; RX
12(gdb) x/8i real_payload
13 0xaaaaaaab08f0 <real_payload>: stp x29, x30, [sp, #-16]!
14 0xaaaaaaab08f4 <real_payload+4>: mov x29, sp
15 0xaaaaaaab08f8 <real_payload+8>: adrp x0, 1000
16 0xaaaaaaab08fc <real_payload+12>: add x0, x0, #0x810 ; "payload-ok"
17 0xaaaaaaab0900 <real_payload+16>: bl 6d0 <puts@plt>
18 0xaaaaaaab0904 <real_payload+20>: ldp x29, x30, [sp], #16
19 0xaaaaaaab0908 <real_payload+24>: ret
20(gdb) continue
21payload-ok
22[Inferior 1 (process 4120) exited normally]
Maps after the second mprotect (prot=5), slide redacted:
1(gdb) info proc mappings
2 Start Addr End Addr Size Offset objfile
3 0xaaaaaaab0000 0xaaaaaaab1000 0x1000 0x0 pack_lab
4# permissions from /proc:
5$ cat /proc/4120/maps | grep pack_lab
6aaaaaaab0000-aaaaaaab1000 r-xp 00000000 00:00 0 /home/[REDACTED]/pack_lab
7aaaaaaab1d90-aaaaaaab2000 rw-p 00000d90 00:00 0 /home/[REDACTED]/pack_lab
First breakpoint had the same page as rwxp for a few microseconds. I catch it in gdb; I do not scan /proc/maps by hand hoping to win the race. catch syscall mprotect is the same idea if the PLT is obfuscated:
1(gdb) catch syscall mprotect
2Catchpoint 2 (syscall 'mprotect' [226]) ; aarch64 number
3(gdb) commands 2
4> silent
5> printf "mprotect addr=%p len=%lx prot=%d\n", $x0, $x1, $x2
6> continue
7> end
1mprotect addr=0xaaaaaaab0000 len=1000 prot=7
2mprotect addr=0xaaaaaaab0000 len=1000 prot=5
That log is the unpack timeline.
Sanitized reproduction (crash only)
I plant a 48-byte XOR blob against a 32-byte destination so the decode memcpy walks off real_payload into the next function. ASan / SIGSEGV, not a payload.
1/* pack_bug.c fragment — lab only */
2#define BLOB_N 48
3static unsigned char blob[BLOB_N]; /* 16 bytes too long */
4
5static void stub_bad(void) {
6 long pagesz = sysconf(_SC_PAGESIZE);
7 uintptr_t page = (uintptr_t)&real_payload & ~(pagesz - 1);
8 mprotect((void *)page, pagesz, PROT_READ | PROT_WRITE | PROT_EXEC);
9 memcpy((void *)&real_payload, blob, BLOB_N); /* 48 into 32 */
10}
1$ cc -O0 -fsanitize=address -g -o pack_asan pack_bug.c
2$ ./pack_asan
3=================================================================
4==412==ERROR: AddressSanitizer: global-buffer-overflow on address 0x...
5WRITE of size 48 at ... thread T0
6 #0 memcpy
7 #1 stub_bad pack_bug.c:14
8 #2 __libc_csu_init / ... ; called from .init_array
9 #3 __libc_start_main
100x... is located 0 bytes after global variable 'real_payload'
Without ASan, the extra 16 bytes land in main’s first instructions. main then udfs:
1Program received signal SIGILL, Illegal instruction.
20x0000aaaaaaab0944 in main ()
3(gdb) x/4i main
4=> 0xaaaaaaab0944: udf #1
I am not shipping a decoder for anyone else’s blob. The crash is “memcpy 48 into 32 during .init_array”.
Patch / detection
- Ship: do not
mprotect(..., PROT_EXEC|PROT_WRITE)on the text segment of a production daemon. W^X. If you must generate code, use a freshmmapwith a laterPROT_READ|PROT_EXECand never both W and X. - CI:
readelf -l→GNU_STACKmust beRW.readelf -d→ recordINIT_ARRAYSZ. Fail the build ifmprotectis imported and there is no documented JIT. - Incident:
catch syscall mprotect/bpftraceonmprotectwithprot & 0x4(EXEC) against a file-backed text VMA. Dump 64 bytes ataddrbefore and after. - Triage template:
.init_arrayslots,JUMP_SLOTnames, twomprotectprot values,/proc/pid/mapsline for.textafter the second call.
Cross-link: lazy bind of mprotect@plt is the same GOT dance as in the GOT/PLT lab. Full RELRO does not stop this stub; the stub is the constructor.
What I file after this lab
.init_arrayat0x1d90, one slot →stubat0x828PT_GNU_STACKRW,INIT_ARRAYSZ=8mprotectprot 7 then prot 5 on page0xaaaaaaab0000, length0x1000real_payloadbytes:udfbefore hit 2,stp x29,x30after- Bug class: decode
memcpylength > destination - Repro: ASan global-buffer-overflow, 48-byte write
Commands appendix
1readelf -S pack_lab | egrep 'init_array|text'
2readelf -x .init_array pack_lab
3readelf -l pack_lab | egrep 'GNU_STACK|GNU_RELRO|LOAD'
4readelf -r pack_lab | grep JUMP_SLOT
5gdb -q ./pack_lab -ex 'set disable-randomization on' \
6 -ex 'b mprotect' -ex 'r'
7# at each hit: x/8i real_payload ; info proc mappings