Skip to content
Back to all posts

HTB: Jail

· 26 min insane Linux Jail

A stack buffer overflow with socket-reuse shellcode, NFS SUID escalation via raw syscall assembly, an rvim Python escape, and PwnKit combine for a four-stage privilege escalation on CentOS 7.

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
PortServiceProduct / VersionNotes
22SSHOpenSSH 6.6.1CentOS 7
80HTTPApache 2.4.6 (CentOS)ASCII art jail cell
111rpcbind
2049NFSTwo exported shares
7411jailCustom binaryAuthentication service
20048mountd

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

AttributeValue
TypeStack buffer overflow
CWECWE-120 (Buffer Copy without Checking Size)
Buffer size16 bytes
Offset to EIP28 bytes
ASLRDisabled (exact address leaked via DEBUG)
NXDisabled (-z execstack)
CanaryAbsent

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:

  1. Bind shell shellcode: avoids the IP entirely but requires a second connection, which complicates firewall traversal.
  2. XOR-encoded reverse shell: encode the shellcode to avoid 0x0a, with a decoder stub. Adds complexity and size.
  3. 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

AttributeValue
CVECVE-2021-4034
Componentpolkit pkexec 0.112
TypeMemory corruption via argc=0
CVSS v37.8 (AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)
PrerequisiteAny 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):

  1. dup2(4, 0): redirect stdin to the socket
  2. dup2(4, 1): redirect stdout to the socket
  3. dup2(4, 2): redirect stderr to the socket
  4. execve("/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_squash bypass: UID 0 is mapped to nfsnobody, preventing direct root file creation via NFS.
  • suexec capabilities: cap_setuid+ep is set on suexec, but it requires membership in the apache group. Neither frank nor adm qualifies.
  • CGI/PHP webshell: Apache document root is write-protected.
  • /opt/logreader modification: ACLs block both NFS-based writes and adm access.
  • Cron job hijack: no root crontab exists.
  • jail.c backdoor: 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

PhaseMITRE ATT&CKDetection
Initial AccessT1190 Exploit Public-Facing AppBuffer overflow on custom jail binary (port 7411)
ExecutionT1059.004 Unix Shell/bin/sh spawned via socket-reuse shellcode
Privilege EscalationT1548 Abuse Elevation ControlNFS SUID binary creation via no_all_squash
Privilege EscalationT1059.006 Pythonrvim :py escape to execute commands as adm
Privilege EscalationT1068 Exploitation for Priv EscPwnKit CVE-2021-4034 pkexec argc=0
PersistenceT1098.004 SSH Authorized KeysED25519 key written to frank’s authorized_keys
Credential AccessT1552.001 Credentials in FilesHardcoded 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

PriorityActionEffortImpact
P0Upgrade polkit to version patched for CVE-2021-4034LowCritical
P0Add nosuid option to NFS exportsLowCritical
P0Fix buffer overflow in jail binary (use strncpy with bounds check)LowCritical
P1Remove source code and binaries from web-accessible directoryLowHigh
P1Remove hardcoded credentials from jail binaryLowHigh
P1Upgrade OpenSSH to current versionMediumHigh
P2Enable ASLR and compile with stack canaries and NXLowMedium
P2Restrict rvim Python support (compile vim without +python)MediumMedium
P3Migrate from CentOS 7 to a supported distributionHighHigh

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

  1. Bad byte analysis is essential for exploit development. The 0x0a byte 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.

  2. NFS misconfigurations compound. no_all_squash alone is not exploitable without the missing nosuid flag. 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.

  3. 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 :py in 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.

  4. 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.