ENKI REDTEAM CTF 2026 엔키화이트햇 채용 연계형 해킹 대회

0. 개요

  • 문제 이름 : Catllery
  • 환경 :
    • 외부 공개 서비스 : Next.js
    • 내부 서비스 : Flask
    • 데이터 저장소 : Redis
    • 내부 구조 : Client -> Next.js -> Flask -> Redis
  • 목표 :
    • VIP 좌석 예약 검증 로직을 우회해,
      최종적으로 진짜 플래그가 포함된 이미지(real_flag.png)를 획득하는것이다.

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 플래그에 속지 않고 진짜 플래그가 들어있는 이미지를 회수하는 것이었다.

정리:

  1. lookup.lua의 tonumber() 비교 취약점으로 ticket_no=1e309를 이용해 VIP 조회를 우회한다.
  2. /api/mypage에서 보이는 FLAG{…}는 mask_flag_holder_name()에 의해 랜덤 생성된 미끼다.
  3. 진짜 플래그는 /internal/get-image가 반환하는 real_flag.png에 있다.
  4. 직접 내부 주소로는 접근할 수 없으므로 _next/image와 DNS rebinding을 조합해 내부 Flask에 도달한다.
  5. 이미지를 열어 진짜 플래그 ENKI{Th1s_1s_R34L_FL4G_XD}를 얻는다.