Ret to Argc

What happens when you return from a bare program?

x86_64-unknown-linux-gnu

Crash Demo

_start
// ret.c
int _start() {
  return 0;
}
$ gcc -nostdlib -static -o ret ret.c
$ ./ret
Segmentation fault (core dumped)
$ echo $?
139
retargc

What happens in a Normal C Program

_start__libc_start_mainmainexitmain_start_start__libc_start_mainmainmain__libc_start_mainexit_start__libc_start_main

What ret Sees at _start

ret_start[RSP]RIP[RSP]argc_startargc

At that point, the stack looks like this:

High    | AUX, Strings, ...      |
        | NULL                   |
  ^     | envp[n]...envp[0]      |
  |     | NULL                   |
        | argv[n]...argv[0]      |
[RSP]   | argc                   |
Low     | (Undefined)            |
ret
  1. the kernel jumps to _start
  2. _start executes ret
  3. the CPU loads RIP <- [RSP]
  4. here [RSP] is argc
  5. in the demo, argc = 1, so the CPU tries to jump to address 0x1
SIGSEGV

Can argc Be a Valid Return Address?

argc_startargc
  1. How many arguments can we pass to _start?
  2. What is the lowest valid executable address we have in the process?

How Many Arguments Can We Pass?

On a typical Linux system:

  • argument is capped to 1/4 of the process stack limit
  • the default stack limit is often 8 MiB (which is configurable via ulimit -s)
  • each argument costs at least 9 bytes of stack space (a pointer plus a null terminator)
230,000

What is the lowest valid executable address?

0x4000004,194,304230,000vm.mmap_min_addr655360x100000x10000vm.mmap_min_addr0x00x10000

Building the Demo

0x10000exit(42)ret

bare_ret.s

; bare_ret.s
.intel_syntax noprefix
.global _start

.equ TARGET, 0x10000

.section .text
_start:
    mov rax, 9        ; sys_mmap
    mov rdi, TARGET
    mov rsi, 4096
    mov rdx, 7        ; PROT_READ | PROT_WRITE | PROT_EXEC
    mov r10, 0x32     ; MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED
    mov r8, -1
    xor r9d, r9d
    syscall

    cmp rax, -4095
    jae .failed

    ; b8 3c 00 00 00   mov eax, 60
    ; bf 2a 00 00 00   mov edi, 42
    ; 0f 05            syscall
    movabs rbx, 0x002abf0000003cb8
    mov qword ptr [TARGET + 0], rbx
    mov dword ptr [TARGET + 8], 0x050f0000

    ret

.failed:
    mov eax, 60
    mov edi, 1
    syscall

launcher.c

// launcher.c
#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define TARGET_ARGC 0x10000

int main(void) {
    const char *prog = "./bare_ret";
    const size_t argc_target = TARGET_ARGC;

    char **argv = calloc(argc_target + 1, sizeof(char *));
    if (!argv) {
        perror("calloc argv");
        return 1;
    }

    argv[0] = (char *)prog;
    for (size_t i = 1; i < argc_target; i++) {
        argv[i] = (char *)"";
    }
    argv[argc_target] = NULL;

    char *envp[] = { NULL };
    execve(prog, argv, envp);

    fprintf(stderr, "execve failed: %s\n", strerror(errno));
    free(argv);
    return 1;
}
$ gcc -nostdlib -static -o bare_ret bare_ret.s
$ gcc -o launcher launcher.c
$ ./launcher && echo $?
42

What Actually Happens

_start42
  1. launcher allocates an argv array with 0x10000 entries, counting the program name
  2. execve starts bare_ret with argc = 0x10000
  3. the kernel places that argc value at [RSP] before entering _start
  4. _start calls mmap to create an executable page at 0x10000
  5. _start writes machine code for exit(42) into that page
  6. _start executes ret
  7. the CPU pops 0x10000 from [RSP] into RIP
  8. execution continues at 0x10000, which performs the exit(42) syscall

And that is the end of the story.

Edited on 2026-04-09