Hand-coding a Linux ELF in raw machine code

Announce at start: "I'm using the hand-coding-elf-linux skill to write this as raw machine code."

Core rule: Author the bytes. You write instruction encodings and ELF header bytes directly. A mnemonic (mov eax,1, svc #0) may appear ONLY as a trailing annotation for the reader — never as the thing you author and then assemble. Producing a .c/.s or running a compiler/assembler/linker on your logic is the failure this skill prevents. xxd, printf, head -c are byte-layers, not source. Think in machine code, not assembly.

Linux is the easy case

Linux has no code signing and no mandatory dynamic linker. A static ET_EXEC ELF that makes raw syscalls just runs — no signing step, no dynamic linker, no per-OS executable metadata. That makes the whole job: ELF header + one PT_LOAD segment + your machine code.

Minimal static ELF64

Two structures, then code. Non-PIE ET_EXEC loaded at a fixed 0x400000, so absolute addresses are known at build time.

ELF64 header (64 bytes):

fieldbytesvalue
e_ident167F 45 4C 46 then 02(64-bit) 01(LE) 01 00(SysV) 00, +7 zero
e_type20200 = ET_EXEC
e_machine2x86-64 = 3E00 (0x3E); aarch64 = B700 (0xB7)
e_version401000000
e_entry80x400078 = load addr + sizeof(headers) = 0x400000 + 0x40 + 0x38
e_phoff80x40 (64)
e_shoff80 (no sections)
e_flags40
e_ehsize24000 (64)
e_phentsize23800 (56)
e_phnum20100 (1)
e_shentsize/e_shnum/e_shstrndx2+2+20,0,0

Program header `PT_LOAD` (56 bytes, ELF64 field order): p_type=1, p_flags=5 (R+X — note flags come second in ELF64), p_offset=0, p_vaddr=0x400000, p_paddr=0x400000, p_filesz=<file size>, p_memsz=<file size>, p_align=0x1000. Map the whole file from offset 0. Code starts at file offset 0x78 (= entry).

p_offset and p_vaddr must be congruent mod p_align — both are 0 mod 0x1000 here, so fine.

The two syscall ABIs (get these exactly right)

Syscall numbers differ wildly between arches — this is the #1 source of wrong behavior.

x86-64arm64 (aarch64)
syscall nr inraxx8
args inrdi, rsi, rdx, r10, r8, r9x0, x1, x2, x3, x4, x5
trap instructionsyscall = 0F 05svc #0 = D4000001
write164
exit6093
return valueraxx0

x86-64 instructions you need

mov r32, imm32 is B8+r then 4-byte LE immediate (eax=0,ecx=1,edx=2,ebx=3,esp=4,ebp=5,esi=6,edi=7):

B8 01000000   mov eax,1            ; write
BF 01000000   mov edi,1            ; fd=stdout
BE <addr32>   mov esi,&msg         ; abs addr works because ET_EXEC is non-PIE
BA 0E000000   mov edx,14           ; len
0F 05         syscall
B8 3C000000   mov eax,60           ; exit
BF 00000000   mov edi,0            ; status
0F 05         syscall

The string address is absolute: 0x400000 + file_offset_of_msg, written little-endian into the BE immediate. Note the string's offset is constant as long as the code in front of it is fixed-length — changing the message's text or length does NOT move the string (it always starts right after the same block of code), so the BE immediate stays the same; only edx (the length) changes. (If you ever make it PIE, swap to a RIP-relative lea; for hand-coding, stay non-PIE.)

arm64 instructions you need

ARM64 instructions are fixed 32-bit, written little-endian (encoding 0xAABBCCDD → bytes DD CC BB AA). Encoding formulas (Rd = register number 0–31, hw = which 16-bit slot):

opformula (OR the pieces)
movz Xd,#imm16,lsl#(16*hw)`0xD2800000 \(hw<<21) \(imm16<<5) \Rd`
movk Xd,#imm16,lsl#(16*hw)`0xF2800000 \(hw<<21) \(imm16<<5) \Rd`
adr Xd,label`0x10000000 \(immlo<<29) \(immhi<<5) \Rd, with off = label - addr(adr), immlo = off & 3, immhi = (off>>2) & 0x7FFFF`
svc #imm16`0xD4000001 \(imm16<<5)`

adr x1,msg is PC-relative (offset from the adr instruction to the string), so no absolute address is needed and it survives any load address. Syscall numbers ≤ 0xFFFF (like 64/93) load with a single movz; larger values need a movz for the low half then movk for the next.

D2800020   movz x0,#1           ; fd=stdout
100000E1   adr  x1,msg          ; &msg (PC-relative, off 0x1C here)
D28001C2   movz x2,#14          ; len
D2800808   movz x8,#64          ; write
D4000001   svc  #0
D2800000   movz x0,#0           ; status
D2800BA8   movz x8,#93          ; exit
D4000001   svc  #0

(movz x8,#64 = 0xD2800000 | (64<<5) | 8; movz x8,#93 likewise.)

Build

Lay bytes with xxd -r -p (ignores whitespace — but it also eats stray hex letters, so put NO comment text in the hex stream). No padding is needed: header + program header + code + string are contiguous. Working, tested builds for both arches:

chmod +x the output. Inspect with readelf -h / readelf -l (or file).

Verify with qemu

qemu-user runs a foreign-arch Linux binary on any host by emulating the CPU and translating Linux syscalls.

On a Linux host: install qemu-user-static, then just:

qemu-x86_64 ./hello_x64      # or qemu-x86_64-static
qemu-aarch64 ./hello_arm64

A native-arch binary runs directly; only the foreign one needs the explicit qemu prefix (binfmt_misc usually makes even that automatic).

On a macOS host (no qemu-user, only qemu-system-*): run inside a Linux container — see verify-with-qemu.sh, which uses Docker + qemu-user-static. Do NOT reach for qemu-system-* (full-machine emulation needs a kernel+rootfs); qemu-user is the right tool for a single static binary.

Always ship a portable runner (host arch ≠ target arch)

The host you build on is frequently a different arch than the binary (e.g. authoring an x86-64 ELF on an arm64 Mac). A bare ./bin then fails confusingly, and ad-hoc docker run … apt-get install qemu-user-static … one-liners reinstall qemu on every run and spew debconf/platform noise. So always drop a `run-elf.sh` next to the binary — a single command that runs it regardless of host.

Copy run-elf.sh into the project (or generate an equivalent). It:

./run-elf.sh ls_x64            # works on Linux x86-64, Linux arm64, or macOS — same command
./run-elf.sh ls_x64 | cat -v   # make ANSI escapes visible as text

Before you finish: hand the user the commands

After the binary builds and you've verified it, END the run by giving the user copy-pasteable commands to run/test/verify it themselves. Use the REAL filename and arch you produced, not placeholders.

If you shipped `run-elf.sh` (recommended — see above), the one command that works on any host is `./run-elf.sh <bin>`. Lead with that, then offer the per-host raw commands below for users who want to see what's underneath or don't have it:

# inspect what you built
file <bin>
readelf -h <bin>      # header: arch, entry, type
readelf -l <bin>      # the PT_LOAD segment

# run it
./<bin>; echo "exit=$?"                 # Linux, matching arch (native)
qemu-x86_64  ./<bin>; echo "exit=$?"    # Linux, x86-64 binary via qemu-user (apt install qemu-user-static)
qemu-aarch64 ./<bin>; echo "exit=$?"    # Linux, arm64 binary via qemu-user

# macOS host (no qemu-user): run it in a Linux container via qemu-user-static
docker run --rm -v "$PWD":/w -w /w debian:stable-slim sh -c \
  'apt-get update -qq >/dev/null && apt-get install -y -qq qemu-user-static >/dev/null && \
   chmod +x <bin> && qemu-x86_64-static ./<bin>; echo "exit=$?"'   # swap qemu-aarch64-static for an arm64 binary

State the expected result too (e.g. prints ... and exits with code N), so the user can tell at a glance whether their run matches.

Common mistakes