Overview
Jail is a retired Insane-rated Linux machine running CentOS 7 with kernel
3.10.0-514. Six ports are exposed, the most interesting being a custom binary
(jail) on port 7411 that implements a simple authentication protocol with a
textbook stack buffer overflow. The binary is compiled with -z execstack and
no stack canaries, on a system with ASLR disabled.
The exploit development is where the real difficulty sits. My attacker IP
(10.10.14.84) contains the byte 0x0a (decimal 10, ASCII newline), which
happens to be the protocol’s delimiter character. Standard reverse shell
shellcode embeds the IP address directly and gets truncated at the newline byte.
The solution is socket-reuse shellcode: rather than connecting back, it
redirects stdin/stdout/stderr to the existing socket file descriptor, reusing
the connection already established by the exploit.
Privilege escalation spans three users across four techniques. NFS with
no_all_squash and no nosuid flag allows writing a SUID binary as frank
(uid 1000) from the attacker’s machine. Compiling with raw x86_64 syscalls and
-nostdlib -static avoids glibc version mismatches between attacker (glibc
2.34+) and target (glibc 2.17). From frank, a restricted vim (rvim) escape
via Python’s :py command provides access as adm. Finally, PwnKit
(CVE-2021-4034) exploits memory corruption in pkexec when invoked with
argc=0 to achieve root.
What makes this box distinctive is the breadth of techniques required. Binary exploitation, cross-compilation, NFS abuse, restricted shell escapes, and a kernel-adjacent polkit exploit all appear in a single chain.
Reconnaissance
I start with a service-version scan:
nmap -sC -sV -T4 10.129.13.13
| Port | Service | Product / Version | Notes |
|---|---|---|---|
| 22 | SSH | OpenSSH 6.6.1 | CentOS 7 |
| 80 | HTTP | Apache 2.4.6 (CentOS) | ASCII art jail cell |
| 111 | rpcbind | ||
| 2049 | NFS | Two exported shares | |
| 7411 | jail | Custom binary | Authentication service |
| 20048 | mountd |
The NFS exports are immediately interesting:
showmount -e 10.129.13.13
/opt (rw,sync,root_squash,no_all_squash)
/var/nfsshare (rw,sync,root_squash,no_all_squash)
Two properties matter here. no_all_squash preserves UIDs from the mounting
client, meaning files created as uid 1000 on my machine appear as uid 1000 on
the target. The absence of the nosuid flag means the target kernel honours
the SUID bit on files within the NFS mount. Together, these allow SUID binary
creation that executes as the mapped user.
Attack Surface Analysis
Port 80 serves ASCII art. Directory enumeration with gobuster finds
/jailuser/dev/ containing three files: jail (32-bit ELF), jail.c (source
code), and compile.sh (build script). Serving the source alongside the binary
is a clear signal that exploit development is the intended path.
The compile script reveals the security posture of the binary:
gcc -o jail jail.c -m32 -z execstack
No PIE, no stack canaries, executable stack. The binary implements a simple
line-based protocol: DEBUG enables debug mode, USER admin sets the username,
PASS <password> authenticates with a hardcoded password (1974jailbreak!).
The vulnerability sits in the auth() function:
int auth(char *username, char *password) {
char userpass[16];
strcpy(userpass, password); // No bounds check on 16-byte buffer
}
The DEBUG command prints the exact address of userpass:
Debug: userpass buffer @ 0xffffd610. This confirms ASLR is disabled; the
address is static across invocations.
Vulnerability Analysis
Buffer overflow in jail binary
| Attribute | Value |
|---|---|
| Type | Stack buffer overflow |
| CWE | CWE-120 (Buffer Copy without Checking Size) |
| Buffer size | 16 bytes |
| Offset to EIP | 28 bytes |
| ASLR | Disabled (exact address leaked via DEBUG) |
| NX | Disabled (-z execstack) |
| Canary | Absent |
The offset to EIP is 28 bytes: 16 bytes for userpass, plus 12 bytes of
saved frame pointer and alignment padding. I confirmed the offset with a cyclic
pattern in GDB. The absence of all three standard mitigations (ASLR, NX, stack
canaries) makes this a textbook case for stack shellcode injection.
Bad byte problem
The attacker IP 10.10.14.84 contains 0x0a (decimal 10, ASCII newline). The
jail binary uses strtok with \n as delimiter, so any shellcode containing
0x0a bytes is truncated before reaching the overflow. This rules out standard
reverse shell shellcode that embeds the IP address directly.
I considered three approaches:
- Bind shell shellcode: avoids the IP entirely but requires a second connection, which complicates firewall traversal.
- XOR-encoded reverse shell: encode the shellcode to avoid
0x0a, with a decoder stub. Adds complexity and size. - Socket-reuse shellcode: redirect I/O to the existing socket file descriptor. No IP needed, smallest payload, reuses the already-established connection.
Socket reuse is the cleanest option. It produces 33 bytes of shellcode with no bad bytes.
CVE-2021-4034: PwnKit
| Attribute | Value |
|---|---|
| CVE | CVE-2021-4034 |
| Component | polkit pkexec 0.112 |
| Type | Memory corruption via argc=0 |
| CVSS v3 | 7.8 (AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H) |
| Prerequisite | Any local user account |
The root cause is an out-of-bounds read in pkexec’s argv handling. When
invoked with an empty argv array (argc=0), pkexec reads argv[1] from what
is actually envp[0] (the environment pointer sits immediately after argv in
memory). The resolution through g_find_program_in_path() writes back to the
same memory location, overwriting envp[0] and injecting a GCONV_PATH
environment variable. The subsequent error message handling loads a gconv module
from the attacker-controlled path, executing a malicious shared library
constructor as root.
Exploitation
Step 1: Socket-reuse shellcode for nobody shell
The shellcode performs four syscalls using file descriptor 4 (the socket):
dup2(4, 0): redirect stdin to the socketdup2(4, 1): redirect stdout to the socketdup2(4, 2): redirect stderr to the socketexecve("/bin/sh", NULL, NULL): spawn shell
I determined the file descriptor number by examining the jail binary’s socket
handling. It calls accept() and stores the return value. On a typical Linux
system with stdin (0), stdout (1), stderr (2), and the listening socket (3),
the accepted connection is fd 4.
The exploit payload:
buf_addr = 0xffffd610
ret_addr = buf_addr + 28 + 4 + 16 # 0xffffd640
payload = b"PASS " + b"A"*28 + struct.pack("<I", ret_addr) \
+ b"\x90"*64 + shellcode + b"\n"
The 28-byte padding fills userpass and reaches the saved return address. The
overwritten return address points 16 bytes past EIP into a 64-byte NOP sled,
which absorbs any minor stack alignment variations. The protocol sequence:
connect, send DEBUG, send USER admin, send the overflow payload as PASS.
Shell as nobody (uid 99).
Step 2: NFS SUID escalation to frank
/var/nfsshare has mode 0731 with GID 1000. On the target, UID 1000 is
frank. The strategy: mount the NFS share on my machine, create a SUID binary
owned by uid 1000, then execute it on the target as nobody to get a shell as
frank.
The binary must work on CentOS 7 (glibc 2.17) when compiled on my machine (glibc 2.34+). Dynamic linking fails because the binary tries to load the attacker’s glibc version at runtime. Static linking with glibc still pulls in version-specific symbols. The solution is raw x86_64 syscalls with no libc dependency:
.global _start
_start:
# setresgid(1000, 1000, 1000)
mov $119, %rax
mov $1000, %rdi
mov $1000, %rsi
mov $1000, %rdx
syscall
# setresuid(1000, 1000, 1000)
mov $117, %rax
# ... same args ...
syscall
# execve("/bin/bash", ["/bin/bash", "/var/nfsshare/cmd.sh", NULL], NULL)
Compiled with gcc -nostdlib -static, placed on the NFS share with
chmod 4755. The SUID binary executes a script that writes an ED25519 SSH key
to frank’s .ssh/authorized_keys. I chose ED25519 over RSA because OpenSSH
6.6.1 does not support RSA-SHA2 signatures; RSA key authentication fails
silently against this version.
ssh -i jail_frank [email protected]
User flag obtained.
Step 3: rvim Python escape to adm
Frank has a sudo rule:
(adm) NOPASSWD: /usr/bin/rvim /var/www/html/jailuser/dev/jail.c
Restricted vim (rvim) blocks :!, :shell, and -S (scripting). These are
the standard escape vectors, and they are correctly disabled. However, CentOS
7’s vim package is compiled with +python/dyn (dynamic Python 2 support). The
:py command remains functional in restricted mode on this build because the
Python interface is loaded as a dynamic module and the restriction checks do not
cover it.
:py import os; os.execvp('/var/nfsshare/adm_enum.sh', ['sh'])
This replaces the rvim process with a shell running as adm. Modern vim
versions (8.2+) block :py in rvim; this escape is specific to CentOS 7’s
vim 7.4 build.
Automating this step requires handling two interactive prompts: a swap file
warning (the file was previously opened) and a TERM variable warning (the PTY
setup differs from a normal terminal). I used a PTY-based keystroke injection
script to navigate both prompts before sending the :py command.
Step 4: PwnKit for root
The adm user is a dead end for direct escalation. UID 3, GID 4; it matches
only other::--- on every critical ACL. I investigated eleven paths from adm
(detailed in Post-Exploitation) before returning to frank, who can run PwnKit.
The exploit creates a directory structure that matches the write-back path used
by g_find_program_in_path():
./GCONV_PATH=./ directory searched by PATH
./GCONV_PATH=./evildir fake executable found by g_find_program_in_path
./evildir/ directory pointed to by GCONV_PATH
./evildir/gconv-modules gconv configuration loading pwnkit module
./evildir/pwnkit.so malicious shared library
The shared library constructor calls setuid(0) and copies bash to
/tmp/rootbash with the SUID bit set. Every command in the constructor must
use absolute paths (/bin/cp, /bin/chmod) because PATH is corrupted to
GCONV_PATH=. during exploitation. Relative paths fail silently.
/tmp/rootbash -p -c 'cat /root/root.txt'
# [flag redacted]
Root flag obtained.
Post-Exploitation
CentOS 7 with kernel 3.10.0-514, SELinux enforcing (targeted policy) with
unconfined_t context for all user processes. The unconfined_t domain means
SELinux provides no meaningful confinement for this exploit chain.
Eleven dead-end approaches were investigated during the adm phase:
- DirtyCow (CVE-2016-5195): kernel patched at version 327; this system runs 514, which includes the fix.
- NFS
root_squashbypass: UID 0 is mapped tonfsnobody, preventing direct root file creation via NFS. - suexec capabilities:
cap_setuid+epis set on suexec, but it requires membership in theapachegroup. Neither frank nor adm qualifies. - CGI/PHP webshell: Apache document root is write-protected.
/opt/logreadermodification: ACLs block both NFS-based writes and adm access.- Cron job hijack: no root crontab exists.
jail.cbackdoor: writable by adm via the sudo rvim rule, but no compilation trigger or restart mechanism exists.
The most instructive dead end is the /opt/logreader/ ACL analysis. The
directory has POSIX ACLs granting frank r-x via a named user entry, root full
access via the owner entry, and other::---. The adm user (uid 3, gid 4)
matches only the other entry and has zero access. This is why I pivoted back
to frank for the PwnKit escalation.
Defensive Analysis
| Phase | MITRE ATT&CK | Detection |
|---|---|---|
| Initial Access | T1190 Exploit Public-Facing App | Buffer overflow on custom jail binary (port 7411) |
| Execution | T1059.004 Unix Shell | /bin/sh spawned via socket-reuse shellcode |
| Privilege Escalation | T1548 Abuse Elevation Control | NFS SUID binary creation via no_all_squash |
| Privilege Escalation | T1059.006 Python | rvim :py escape to execute commands as adm |
| Privilege Escalation | T1068 Exploitation for Priv Esc | PwnKit CVE-2021-4034 pkexec argc=0 |
| Persistence | T1098.004 SSH Authorized Keys | ED25519 key written to frank’s authorized_keys |
| Credential Access | T1552.001 Credentials in Files | Hardcoded credentials in jail binary source |
The custom binary on port 7411 is the primary detection point. Process
monitoring should flag any /bin/sh process spawned as a child of the jail
process; this pattern never occurs during legitimate operation. NFS share access
logging (via auditd rules on the NFS server) would detect the SUID binary
creation. For PwnKit, monitoring execve calls to pkexec with argc=0 via
auditd or eBPF is the definitive detection; Falco has a built-in rule for this
pattern.
Remediation
| Priority | Action | Effort | Impact |
|---|---|---|---|
| P0 | Upgrade polkit to version patched for CVE-2021-4034 | Low | Critical |
| P0 | Add nosuid option to NFS exports | Low | Critical |
| P0 | Fix buffer overflow in jail binary (use strncpy with bounds check) | Low | Critical |
| P1 | Remove source code and binaries from web-accessible directory | Low | High |
| P1 | Remove hardcoded credentials from jail binary | Low | High |
| P1 | Upgrade OpenSSH to current version | Medium | High |
| P2 | Enable ASLR and compile with stack canaries and NX | Low | Medium |
| P2 | Restrict rvim Python support (compile vim without +python) | Medium | Medium |
| P3 | Migrate from CentOS 7 to a supported distribution | High | High |
The NFS misconfiguration is the most impactful single fix. Adding nosuid to
both exports eliminates the SUID binary escalation path entirely, regardless of
the no_all_squash setting. The root_squash option is already present and
working correctly; it is nosuid that is missing. The PwnKit fix is equally
critical: polkit should be upgraded on every CentOS 7 system that has not
already applied the patch.
Key Takeaways
-
Bad byte analysis is essential for exploit development. The
0x0abyte in the attacker IP forced a fundamentally different shellcode strategy. Socket-reuse shellcode avoids embedding the IP entirely, producing a smaller and more reliable payload. Before writing any shellcode, always map the protocol’s delimiter and restricted byte set against the payload bytes. -
NFS misconfigurations compound.
no_all_squashalone is not exploitable without the missingnosuidflag. Both conditions must hold for SUID binary escalation. This is a common pattern in NFS pentesting: each export option looks benign in isolation, but the combination creates a privilege escalation primitive. Cross-compilation with raw syscalls (-nostdlib -static) eliminates glibc version dependencies between attacker and target. -
rvim restrictions are version-dependent. CentOS 7’s vim 7.4 build includes dynamic Python support that remains functional in restricted mode. Vim 8.2+ blocks
:pyin rvim. Always test the specific binary on the target rather than assuming restriction effectiveness from documentation. GTFOBins lists the escape, but knowing which vim versions are affected matters more than knowing the technique exists. -
PwnKit requires absolute paths in the payload. PATH is corrupted to
GCONV_PATH=.during the exploit. Every command in the shared library constructor must use absolute paths (/bin/cp,/bin/chmod), or the commands fail silently with no error output. This is the most common reason PwnKit exploits fail in practice.