ENKI REDTEAM CTF 2026 엔키화이트햇 채용 연계형 해킹 대회
0. 개요
- 문제 이름 : Catllery
- 환경 :
- 외부 공개 서비스 : Next.js
- 내부 서비스 : Flask
- 데이터 저장소 : Redis
- 내부 구조 : Client -> Next.js -> Flask -> Redis
- 목표 :
- VIP 좌석 예약 검증 로직을 우회해,
최종적으로 진짜 플래그가 포함된 이미지(real_flag.png)를 획득하는것이다.
- VIP 좌석 예약 검증 로직을 우회해,
1. 전체 구조 분석
1-1. 서비스 구성
문제는 크게 두 계츠으로 나뉜다.
- web (Next.js) : 로그인, 회원가입, 좌석 조회, API 프록시 역할
- internal (Flask) : 실제 좌석 상태 조회, 예약 검증, 이미지 반환 로직 담당
-
Redis : 좌석 홀드 정보 (seathold:*) 저장
- 사용자는 직접 FLask에 접근할 수 없고, 보통 Next.js 를 통해서만 내부 API 결과를 보게된다.
1-2. 주요 라우트
Next.js 공개 API
- /api/signup
- /api/login
- /api/book
- /api/mypage
Flask 내부 API
- /internal/signup
- /internal/login
- /internal/all-seats
- /internal/book
- /internal/check-reservation
- /internal/get-image
취약점 분석
2-1. 취약점 : Lua tonumber() 비교
internal/lua/lookup.lua의 핵심 비교는 다음과 같다.
1
2
3
4
5
6
7
8
local req_ticket_no = ARGV[1]
local holder_ticket_no = redis.call("HGET", KEYS[1], "holder_ticket_no")
if holder_ticket_no == false then
return {}
end
if tonumber(req_ticket_no) ~= tonumber(holder_ticket_no) then
return {}
end
문제는 티켓 번호를 문자열 그대로 비교하지 않고 tonumber()로 숫자 비교한다는 점이다.
2-2. VIP 티켓 번호의 특징
Flask 초기화 로직을 보면 VIP 좌석은 시작 시점에 이미 점유되어 있다.
r.hset(f”seathold:VIP”, mapping={“holder_ticket_no”: VIP_TICKET_NO, “holder_name”: VIP_ID})
그리고 VIP_TICKET_NO는 매우 긴 숫자 문자열로 생성된다.
실제 분석 로그에서도 365자리 숫자로 확인되었다.
이렇게 긴 수는 Lua의 tonumber()에서 정상 정수처럼 처리되지 않고 사실상 overflow 성격을 띠게 된다.
2-3. 우회 원리
공격자가 ticket_no=1e309를 입력하면:
- tonumber(“1e309”) → 매우 큰 값, 사실상 inf
- tonumber(VIP_TICKET_NO) → 초대형 수 처리 과정에서 같은 방향의 overflow
결과적으로:
tonumber(“1e309”) == tonumber(VIP_TICKET_NO)
처럼 취급되어, 실제 VIP 티켓 번호를 몰라도 VIP 예약 조회가 성립한다.
즉, 공격에 사용되는 값은 ticket_no = 1e309 이다.
3. 가짜 플래그 트랩
이 문제를 헷갈리게 만드는 가장 큰 요소는 /api/mypage 응답에 보이는 FLAG{…} 문자열이다.
3-1. holder_name 마스킹 로직
internal/app.py 에는 다음 함수가 있다.
1
2
3
4
def mask_flag_holder_name(holder_name: str) -> str:
if not FLAG_RE.search(holder_name):
return holder_name
return FLAG_RE.sub(f"FLAG}", holder_name)
즉, holder_name 안에 FLAG{…} 패턴이 있으면 항상 랜덤한 가짜 플래그로 치환한다.
3-2. 왜 /api/mypage 값이 정답이 아닌가
/internal/all-seats에서는 각 좌석 정보를 만들 때:
- row[“holder_name”] = mask_flag_holder_name(str(row[“holder_name”]))를 수행한다.
따라서 /api/mypage에서 VIP 좌석의 holder_name에 보이는 FLAG{…}는 전부 미끼이며,
제출해도 정답이 될 수 없다.
실제로 여러 번 요청할 때마다 값이 바뀌는 현상도 이 마스킹 로직으로 설명된다.
4. 진짜 플래그 위치
정답은 JSON이 아니라 이미지에 있다.
internal/app.py의 /internal/get-image 라우트:
1
2
3
4
5
6
7
8
9
10
11
@app.get("/internal/get-image")
def get_image():
ticket_no = request.args.get("ticket_no", "")
if ticket_no == VIP_TICKET_NO:
return err("forbidden", 403)
reservations = reservations_for_ticket_no(ticket_no) if ticket_no else []
is_vip = any(str(row.get("seat")) == "VIP" for row in reservations)
path = REAL_FLAG_IMAGE if is_vip else FAKE_FLAG_IMAGE
return send_file(path, mimetype="image/png", max_age=0)
중요한것은:
- 정확히 VIP 티켓 문자열과 같은 값만 403으로 막고
- 1e309처럼 비교 우회 값은 막지 못하며
- VIP 판정이 성립하면 REAL_FLAG_IMAGE 를 반환 한다는 점이다.
즉, 진짜 플래그는 real_flag.png 안에 있다.
5. 공개 엔드포인트 분석
5-1. /api/mypage 의 역할
Next.js의 web/app/api/mypage/route.ts는 쿠키의 ticket_no를 읽어 내부 API 두 개를 병렬 호출한다.
1
2
3
4
const [{ res: checkRes, data: checkData }, { res: seatsRes, data: seatsData }] = await Promise.all([
internalJson(`/internal/check-reservation?${qs}`),
internalJson(`/internal/all-seats?${qs}`),
]);
즉, /api/mypage 는
- 내 예약 확인 (check-reservation)
- 전체 좌석 상태 (all-seats)
를 조합한 JSON만 반환한다.
여기에는 이미지가 포함되지 않는다.
5-2. /mypage 페이지가 없는 이유
web/app 구조를 보면 실제 페이지는 다음 정도로만 존재한다.
- page.tsx
- login/page.tsx
- signup/page.tsx
- seats/page. tsx
즉 /mypage 페이지는 없고, 존재하는 건 /api/mypage API 라우트 뿐이다.
6. Next.js 이미지 프록시 분석
web/next.config.ts 는 다음과 같다.
1
2
3
4
5
6
7
const nextConfig: NextConfig = {
devIndicators: false,
images: {
minimumCacheTTL: 0,
remotePatterns: [{ protocol: "http", hostname: "**" }],
},
};
즉, /_next/image는 모든 HTTP 호스트를 허용하도록 설정되어 이다.
이 설정은 원래 외부 이미지 로딩용이지만, 조건을 만족하면 내부망 접근의 발판이 될 수 있다.
7. SSRF 우회와 DNS Rebinding
7-1. 직접 내부 주소 접근은 차단된다.
처음에는 다음과 같은 방식이 직관적으로 떠오른다.
/_next/image?url=http://127.0.0.1:5000/internal/get-image?ticket_no=1e309
하지만 실제 서비스에서는 127.0.0.1, localhost 같은 내부 주소가 검증 단계에서 차단되어
바로 사용할수 없었고, 이 때문에 단순 SSRF 는 실패한다.
7-2. DNS Rebinding 으로 우회
최종적으로 성공한 방법은 DNS rebinding 도메인을 사용하는 것이었다.
분석 로그에 따르면 실제 리모트 풀이에서는 1u.ms 리바인딩 도메인을 사용해:
- 검증 단계에서는 허용된 외부 도메인처럼 보이게 하고
- 실제 fetch 시점에는 127.0.0.1:5000을 가리키게 만들어
- _next/image가 내부 /internal/get-image?ticket_no=1e309에 도달하도록 만들었다.
즉, 최종 체인은 다음과 같다.
/_next/image
↓
DNS rebinding domain
↓
127.0.0.1:5000/internal/get-image?ticket_no=1e309
↓
real_flag.png 반환
8. 최종 공격 흐름
전체 익스플로잇은 두 개의 취약점 체인이 연결된다.
8-1. 1단계 : VIP 판정 우회
- lookup.lua의 tonumber() 비교를 이용
- ticket_no=1e309 사용
- 실제 VIP 티켓 번호를 몰라도 VIP 좌석 조회 성립
8-2. 2단계 : 이미지 회수
- _next/image의 외부 호스트 허용 설정 이용
- DNS rebinding으로 내부 Flask에 연결
- /internal/get-image?ticket_no=1e309 호출
- real_flag.png 획득
9. 로컬 재현과 리모트 차이
9-1. 로컬 환경
로컬에서 real_flag.png를 회수해도 이미지 안 문자열이 redacted 형태로 보였고,
OCR 결과도 ENKI{REDACTED} 계열로 수렴했다.
이는 배포본 이미지 자체가 마스킹된 샘플이었기 때문이다.
9-2. 리모트 환경
리모트에서는 DNS rebinding 우회가 성공했고,
실제 remote_real_flag.png를 회수해 진짜 플래그를 확인했다.
10. 최종 플래그
최종 플래그는 다음과 같다.
1
ENKI{Th1s_1s_R34L_FL4G_XD}
11. 정리
이 문제의 핵심은 단순히 VIP 좌석을 조회하는 것이 아니라,
가짜 JSON 플래그에 속지 않고 진짜 플래그가 들어있는 이미지를 회수하는 것이었다.
정리:
- lookup.lua의 tonumber() 비교 취약점으로 ticket_no=1e309를 이용해 VIP 조회를 우회한다.
- /api/mypage에서 보이는 FLAG{…}는 mask_flag_holder_name()에 의해 랜덤 생성된 미끼다.
- 진짜 플래그는 /internal/get-image가 반환하는 real_flag.png에 있다.
- 직접 내부 주소로는 접근할 수 없으므로 _next/image와 DNS rebinding을 조합해 내부 Flask에 도달한다.
- 이미지를 열어 진짜 플래그 ENKI{Th1s_1s_R34L_FL4G_XD}를 얻는다.