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.

PLT still used by the stub for mprotect
Figure 1. Stub still calls libc through PLT: mprotect@plt, memcpy@plt. Lazy bind applies to the packer path too.

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 fresh mmap with a later PROT_READ|PROT_EXEC and never both W and X.
  • CI: readelf -lGNU_STACK must be RW. readelf -d → record INIT_ARRAYSZ. Fail the build if mprotect is imported and there is no documented JIT.
  • Incident: catch syscall mprotect / bpftrace on mprotect with prot & 0x4 (EXEC) against a file-backed text VMA. Dump 64 bytes at addr before and after.
  • Triage template: .init_array slots, JUMP_SLOT names, two mprotect prot values, /proc/pid/maps line for .text after 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_array at 0x1d90, one slot → stub at 0x828
  • PT_GNU_STACK RW, INIT_ARRAYSZ=8
  • mprotect prot 7 then prot 5 on page 0xaaaaaaab0000, length 0x1000
  • real_payload bytes: udf before hit 2, stp x29,x30 after
  • Bug class: decode memcpy length > 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