Search

[Jeanne d'Hack CTF 2026] Write-Up 작성 및 후기

Categories
Tags
작성일
2026/01/31
1 more property
목차보기

Introduction

2026년도 첫 번째 CTF로 Jeanne d'Hack CTF 2026 에 참여했습니다.
해당 CTF는 프랑스에서 개최되는 CTF로, 아쉽게 이번 대회에서는 전체 15위를 기록했지만..
Web 카테고리에서는 모든 문제를 해결할 수 있어서 조금의 만족감을 얻을 수 있었습니다.
특히 이번 Web 카테고리 문제는 12개가 출제되었고, 한 문제를 제외한 모든 문제가 블랙박스 형태로 출제되어, 소스코드 분석 없이 문제를 해결해야 하는 특징이 있었습니다.

Challenges

Introduction Intro - T-rex game

Jeanne d'Arc conquered her enemies through cunning and strategy, not brute force alone. Like her, you face an impossible challenge: the T-rex runner game is unwinnable through reflexes alone, you'll have to be clever!
Plain Text
복사

Description

문제에서 제공된 URL에 접속하면 Chrome 브라우저의 오프라인 공룡 게임이 표시됩니다.
문제 설명에는 게임 플레이가 아닌 다른 방법을 유도하고 있습니다.
the T-rex runner game is unwinnable through reflexes alone, you'll have to be clever!
Plain Text
복사

Solution

위 문제 URL의 소스코드를 살펴보니 index.js 에서 게임과 관련된 소스코드를 확인할 수 있었습니다.
그리고 게임 종료 시 API 요청이 발생되는 것을 확인할 수 있었고,
해당 API 요청 로직은 index.js 에 구현되어 있는 것을 발견할 수 있었습니다.
소스코드에는 게임 종료 시 발생하는 API /result 의 요청 데이터가 { "game": "fail" } 로 고정되어 있는걸로 보아 이 값을 다른 값(ex. success, win, pass 등)으로 변경하여 요청을 시도하면 될 것 같습니다.

Exploit

API /result 의 요청 데이터를 { "game": "win" } 으로 변경하여 요청을 수행한 결과 다음과 같이 응답데이터에서 플래그를 획득할 수 있었습니다.

Easy Retrobugging

Nobody ever was able to reach a million points... Will you be the first to achieve this legendary feat? This Web challenge is entirely client-side. Download the archive below, then open the index.html file to start playing!
Plain Text
복사

Description

해당 문제에서는 아래와 같이 게임을 플레이할 수 있는 웹 소스코드를 제공해주고 있습니다.
문제 설명에는 ‘지금까지 100만점을 달성한 사람이 아무도 없다.’라고 나와 있어 100만점을 달성하면 플래그가 나올 것으로 추측됩니다.

Solution

처음 게임 소스코드를 먼저 살펴보았고 이중 secureFlagDisplay 라는 함수를 발견하게 되었습니다.
그러나 해당 코드가 정의되어 있는 부분은 다음과 같이 난독화 되어있었고,
위 난독화 코드를 온라인 자바스크립트 복호화 사이트(https://deobfuscate.relative.im/)에서 복호화한 후 분석을 시도했습니다.
이후 분석한 코드를 바탕으로 플래그로 보이는 로직을 실행했는데 다음과 같이 Fake 플래그를 던져주고 있었고,
결국 게임 점수 100만점을 획득하기 위해 점수를 쌓는 로직을 수정하는 방법으로 방향을 바꿨습니다.

Exploit

게임에서 점수는 10점씩 증가하는 것을 확인할 수 있습니다. 그리고 이 로직은 변수 난독화가 존재하지 않아 소스코드에서 쉽게 찾을 수 있었습니다.
이후 score += 10; 코드를 score += 999999; 으로 변경하고 플레이를 수행했는데, 점수만 늘어나고 플래그는 노출되지 않고 있습니다.
이에 앞에서 발견했던 secureFlagDisplay 함수의 호출 조건문 score > 999999 && score == (previousScore + 10)score > 10 으로 변경하였고,
다시 게임을 플레이하니 아래와 같이 플래그가 등장했습니다.

Easy No share - Level 1

Our enemies hosted a file sharing system which on surface exposes content for gaming enthusiasts. However in reality, a private share contains cracked games, destructive universal exploits and forbidden binaries. Jeanne requests your talents to uncover the secrets behind this share. A knight of the order already succeeded to exfiltrate the deny function in use, this is your turn now to act. Finish the work, complete the mission. def deny(share): return 'secret.pacman' in share.lower()
Plain Text
복사

Description

문제에서 제공하는 URL에 접속하면 다음과 같이 public.pacmansecret.pacman 을 확인할 수 있습니다
이중 public.pacman 경로는 다음과 같이 접근이 가능하지만 secret.pacman 은 접근이 거부되는 것을 확인할 수 있습니다.
그리고 폴더를 접근할 때는 API /api/folder 를 요청하고 있으며,
파일을 다운로드할 때는 API /api/download 를 요청하는 것을 확인하였습니다.

Solution

문제 설명을 보면 아래의 접근을 제한하는 코드를 제공하고 있습니다.
def deny(share): return 'secret.pacman' in share.lower()
Python
복사
이때, API /api/folder 의 URL 파라미터 sharelocalhost 를 전달했을 때, 다음과 같이 Secrets 폴더가 조회되는 것을 확인했습니다.
따라서, 접근 권한을 확인한 URL 파라미터 sharelocalhost 를 입력하면 정책을 우회할 수 있으며, URL 파라미터 path 에 절대경로를 작성하는 방식으로 접근을 할 수 있었습니다.

Exploit

Medium No share - Level 2

After the first breach of their file sharing system, our enemies have strengthened their defenses. This enhanced file sharing system now includes further validation mechanisms to prevent unauthorized access to their secret vault. def deny(share): return 'secret.pacman' in share.lower() or 'localhost' in share or '1' in share or ':' in share
Plain Text
복사

Description

앞서 푼 ‘No share - Level 1’ 문제와 동일하지만 기존 접근 제한 로직이 수정 되었습니다.
def deny(share): return 'secret.pacman' in share.lower() or 'localhost' in share or '1' in share or ':' in share
Python
복사

Solution

localhost 입력을 차단하고 있으나 로컬 호스트 IP 주소 127.0.0.1 을 반환하는 도메인 서비스를 이용하여 우회할 수 있습니다.
이 중 도메인 localtest.me 로 DNS A 레코드 질의를 수행하면 응답 데이터가 로컬호스트 IP 주소를 반환합니다.
따라서, 접근 제한을 검사하는 URL 파라미터 sharelocaltest.me 를 전달하는 방식으로 접근 제한 로직을 우회할 수 있습니다.

Exploit

Hard No share - Level 3

After multiple breaches of their file sharing system, our enemies have implemented wider security measures. This third iteration of their defense system now features refined filtering mechanisms that target specific IP address patterns. The administrators believe they have finally closed all loopholes by disabling DNS resolutions. # DNS resolution is now disabled def deny(share): return 'secret.pacman' in share.lower() or 'localhost' in share or '.1' in share or ':' in share
Plain Text
복사

Description

해당 ‘No share - Level 3’ 문제 설명에는 DNS 조회를 비활성화했다고 나와있습니다.
# DNS resolution is now disabled def deny(share): return 'secret.pacman' in share.lower() or 'localhost' in share or '.1' in share or ':' in share
Python
복사

Solution

그러나 이를 우회할 수 있는 방법으로, 0.0.0.0 주소를 입력할 수 있습니다.
해당 주소는 로컬 시스템의 모든 네트워크 인터페이스를 의미하며, 특정 컨텍스트에서는 127.0.0.1과 동일하게 작동합니다. 이를 통해 .1 문자열 필터링을 우회하면서도 로컬호스트에 접근할 수 있습니다.

Exploit

Easy HTTP Methods Dungeon

🏰 Welcome to the Dungeon of HTTP Methods! The Mystical Guardian of the Ancient Gate only allows those who have mastered the art of HTTP methods to pass. To unlock the portal, you must execute a secret sequence of HTTP methods in the exact correct order.
Plain Text
복사

Description

문제에서 제공하는 URL에 접속하면 다음과 같이 HTTP 메서드 순서를 따라야 한다며 패턴을 찾아 올바른 순서를 실행하라고 나와있습니다.
위 페이지 하단의 ‘HTTP METHODS ARSENAL’ 영역에서 아무 HTTP 메서드를 클릭하면 해당 메서드로 요청이 발생됩니다.
이때, 올바르지 않은 패턴의 메서드를 요청할 경우 ‘ WRONG METHOD!’ 메시지가 나오며,
올바른 패턴의 메서드를 요청하면 다음과 같이 그 다음 메소드와 함께 진행률을 확인할 수 있습니다.

Solution

Burp Suite를 이용해서 각 HTTP Method를 요청하였고 이 중 첫 시작은 TRACE 메서드인 것을 확인했습니다.
이후 그 다음 메서드는 PATCH 를 가르키고 있으며, 이때 클라이언트의 메서드 입력 정보를 기억하기 위해 세션 쿠키 session 을 응답하고 있습니다.
이에 다음 메서드인 PATCH 와 새로 응답된 세션 쿠키 session 을 요청하면 다음과 같이 다음 순서의 메서드 정보를 확인할 수 있습니다.

Exploit

위 해결 과정을 반복하다보면 다음과 같이 최종 단계에서 플래그를 확인할 수 있습니다.

Easy Ocarina - Level 1

In medieval France, before Joan of Arc became a famous warrior, she discovered an ocarina which had the power to open mysterious doors and reveal secret paths, but only when the right songs were played perfectly. To prove she was worthy of its power, Jeanne must complete 99 musical tests. Each test requires her to listen carefully to a heavenly song and then play it back exactly as she heard it. The songs get longer and more difficult with each test, starting with just one note and growing to 99 notes in the final challenge. Your mission is to help her master the ocarina by repeating each sacred melody perfectly. But be careful, if you take too long to play back a song, or if you make a mistake, Jeanne will have to start over from the beginning.
Plain Text
복사

Description

해당 문제에서 제공하는 URL에 접속하면 다음과 같이 ‘음표 순서를 듣고 정확하게 따라하세요.’ 라는 문구가 등장하며, 각 라운드당 음표가 하나씩 추가되고 5초 안에 음표를 완성해야 한다고 나와있습니다.
위 화면에서 Start 버튼을 클릭하면 현재 라운드 표시와 함께 음표에 해당하는 위치(화살표)와 사운드가 재생됩니다. 해당 음표의 위치를 클릭하면 라운드가 증가되고, 음표도 함께 증가되는 것을 알 수 있습니다.

Solution

문제에서 제공하는 URL에 접속했을 때 송수신되는 네트워크 패킷을 살펴보면, 첫 화면(URL /)에서 start 버튼을 클릭했을 때, 웹 소켓이 연결(URL /ws 요청)되는 것을 확인할 수 있습니다.
그리고 웹 소켓 서버에서 클라이언트로 현재 라운드 표시와 함께 음표에 해당하는 데이터("data": ["E"])를 전송하고 있으며,
해당 음표를 클라이언트가 정확하게 따라할 경우 다시 웹 소켓 서버로 동일한 데이터("sequence": ["E"])를 전송하는 것을 확인했습니다.
이후 웹 소켓 서버는 클라이언트가 전달한 음표 데이터를 검증하고, 그 결과가 옳바른 경우 "data": {"correct": true, "nextRound": 2} 를 전달하고 있습니다.
따라서, 웹 소켓 서버가 전달하는 데이터를 클라이언트가 동일하게 전달하고 최종 라운드가 99까지 있으므로 서버로부터 전달되는 데이터를 99번까지 수행한다면 플래그가 나오는 문제로 추측됩니다.

Exploit

앞서 설명한 내용을 아래의 스크립트로 구현했습니다.
 스크립트 코드
이후 해당 스크립트의 결과에서 플래그가 출력되는 것을 확인했습니다.

Medium Ocarina - Level 2

After proving her worth with the basic trials, Jeanne discovered that the most sacred melodies were protected by ancient mystical barriers. The divine songs now reach her ears in a transformed state. Their true beauty hidden behind layers of mystical protection that only the worthy can unravel.
Plain Text
복사

Description

앞서 풀었던 문제 ‘Ocarina -Level 1’과 동일한 형태의 문제이지만, 해당 문제는 서버가 클라이언트에게 음표 데이터를 암호화된 데이터로 전달하고 있습니다.

Solution

소스코드 정적분석을 통해 살펴본 결과, 해당 페이지에 로드된 자바스크립트 파일 game.js 에서는 아래와 같이 서버로부터 전달받은 데이터를 함수 processSequence 의 인자로 전달하고 있었으며,
함수 processSequence 의 로직에서 복호화 하는 로직을 발견할 수 있었습니다.
그리고 복호화 함수 crypto.decrypt 는 자바스크립트 파일 crypto.js 에 구현되어 있었고, 이때 비밀키와 함께 암/복호화 코드를 발견할 수 있었습니다.
해당 코드를 Python으로 마이그레이션을 수행하면, 다음의 코드로 구현됩니다.
 스크립트 코드
따라서, 해당 문제는 서버로부터 전달받은 데이터를 위 함수 decrypt 로 복호화 한 뒤, 다시 데이터를 함수 encrypt 로 암호화하여 전송하는 방식으로 해결할 수 있습니다.

Exploit

최종 코드는 다음과 같습니다.
 스크립트 코드
위 스크립트 코드를 실행하면 다음과 같이 플래그가 출력되는 것을 확인할 수 있습니다.

Hard Ocarina - Level 3

Having proven herself worthy of the mystic melodies and conquered the cryptographic barriers, Jeanne now faces her ultimate trial. The sacred guardian of the ocarina has awakened, demanding not only musical mastery but also proof of her true identity and understanding of the ancient protocols.
Plain Text
복사

Description

Ocarina - Level 3 에서는 ‘Start’ 버튼 클릭 시 secret key를 입력하라는 폼이 등장합니다.
임의의 값(ex. test)을 입력하고 플레이를 하면 게임이 바로 끝나면서 ‘Encryption error!’ 문구가 등장합니다.

Solution

결과적으로 Secret Key를 알아내야 하는데, 이 값은 좀 전에 메인 화면 우측 하단의 버튼에서 힌트를 얻었습니다.
우측 하단의 버튼을 클릭하면 아래와 같이 이름을 입력하라는 폼이 등장하며,
이 폼에서 ‘Save’ 버튼을 클릭하면 요청 데이터에는 입력한 값을 포함한 URL /set-name 요청 발생하고 응답 데이터에는 입력한 값에 대한 암호화된 데이터를 반환하는 것을 발견했습니다.
따라서 소켓 서버에서 전달해주는 음표 데이터를 URL /set-name 요청 데이터에 담아 전송하고 응답 데이터는 암호화된 데이터를 반환해주므로 그 값을 다시 소켓 서버로 전달하는 방식으로 문제를 해결할 수 있습니다.

Exploit

위 솔루션에 의해 최종적으로 아래의 스크립트가 완성됩니다.
 스크립트 코드

Easy NeoPixel

NeoPixel is an online video game publisher. After a security audit and several fixes, the team is confident everything is now under control.
Plain Text
복사

Description

해당 문제에서 제공하는 URL에 접속하면 아래의 페이지가 등장합니다.
그리고 위 페이지 중 로그인 페이지에서만 API(/login) 요청이 발생했습니다.

Solution

로그인 요청 API의 요청 데이터에 존재하지 않는 사용자의 정보를 입력할 경우 응답 데이터에서 NoSQL 에러가 발생하는 것을 발견했습니다.
이에 로그인 요청 데이터 username 에 admin을 입력하고 password 에 아래의 NoSQL Injection 페이로드를 전달했습니다.
{ "$ne": null }
JSON
복사

Exploit

NoSQL Injection 페이로드를 전달한 요청의 응답은 아래와 같이 정상적인 응답 데이터를 확인할 수 있었고,
리다이렉트된 프로필 페이지(/profil)에서 플래그를 확인할 수 있었습니다.

Hard Cybergames Store

189aXA is an online video game store that looks ordinary at first glance. Browse the site, observe its features, and uncover what lies behind the storefront.
Plain Text
복사

Description

해당 문제도 아래와 같이 여러 페이지로 구성되어 있습니다.
이 중 트리거가 될 만한 위치는 API 요청이 발생했던 로그인 페이지(login.html)로 판단했으며, 로그인 API /login_handler.php 요청 시 username, password 말고도 login_type 이 전달되는 것을 확인했습니다.
또한, 조회되는 페이지 중 propos.html 에서는 다음과 같이 LDAP 로그인 출시 예정이라고 나와있었는데,
로그인 API /login_handler.php 의 요청 데이터 login_type 에 ‘ldap’ 를 전달하니 다음과 같이 login_ldap.php 로 다시 리다이렉션 되는 것을 발견하게되었습니다.
따라서, 로그인 요청은 API /login_handler.php 요청이 먼저 발생되고 이때 전달한 요청 데이터 login_type 에 따라 ‘classic’ 이면 /login_classic.php 요청이, ‘ldap’ 이면 /login_ldap.php 요청이 발생됨에 따라 해당 위치를 이용하여 푸는 것을 짐작할 수 있었습니다.

Solution

login_typeldap 로 변경하면 API /login_ldap.php 요청이 발생됩니다.
/login_ldap.php 요청이 LDAP와 관련되어 있으므로 LDAP Injection 페이로드인 와일드카드(*)를 삽입해봤습니다.
그 결과, 아래와 같이 로그인이 성공적으로 이루어졌으며, profile.php 로 리다이렉션되는 것을 발견하게되었습니다.
그리고 위 응답 데이터 중 쿠키 token 은 JWT 형식으로, 다음과 같습니다.
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqd3QtbWFuaWEtcGFydC0yIiwiYXVkIjoidXNlcnMiLCJpYXQiOjE3NzAzMDE4NTAsImV4cCI6MTc3MDMwNTQ1MCwic3ViIjoiMTU0NiIsInJvbGUiOiJ1c2VyIiwidXNlcm5hbWUiOiJQNGw0ZDFuXzFtcDNyMTRsIn0.mRK6vkgxA4J-JB9gd4rRWEc4Cmv5lq1YTMS5S1amCXY
Plain Text
복사
// HEADER { "alg": "HS256", "typ": "JWT" } // PAYLOAD { "iss": "jwt-mania-part-2", "aud": "users", "iat": 1770301850, "exp": 1770305450, "sub": "1546", "role": "user", "username": "P4l4d1n_1mp3r14l" }
JSON
복사
이때, JWT 페이로드의 role 값이 ‘user’ 로 나와있어서 어드민 계정으로 로그인하기 위해 username 에 ‘admin*’ 을 입력하고 password 에 ‘*’를 입력하여 다시 로그인을 수행하니 다음과 같이 role 이 ‘admin’ 이고 username 이 ‘adminldaptest’인 계정의 인증 토큰을 획득할 수 있었습니다.
{"iss":"jwt-mania-part-2","aud":"users","iat":1770302243,"exp":1770305843,"sub":"1546","role":"admin","username":"adminldaptest"}
JSON
복사
이후 어드민 계정으로 로그인 했을 때, 프로필 페이지 profile.php 의 응답에 폼 요청(admin/adafg541/21232f297a57a5a743894a0e4a801fc3login.php)을 발견했습니다.
해당 admin/adafg541/21232f297a57a5a743894a0e4a801fc3login.php 요청을 수행하면 다음과 같이 네비게이션 메뉴에 어드민 메뉴가 조회되고 새로운 어드민 로그인 폼 요청(21232f297a57alogin_handler.php)이 조회됩니다.
여기서 Admin panel 메뉴로 이동하면 ‘no username’이 나오게 되는데, 앞서 살펴본 새로운 어드민 로그인 폼 요청을 수행해야 하는 것으로 판단됩니다.
이에 다시 어드민 계정 'adminldaptest’ 의 비밀번호를 알아내기 위해 조금전 LDAP Injection 기법을 이용한 아래의 스크립트를 이용했습니다.
 스크립트 코드
그 결과, 조금 전 확인한 어드민 계정 adminldaptest 의 비밀번호는 abcabce 인 것을 확인했고 다시 어드민 로그인 페이지로 이동하여 로그인을 수행하니 다음의 페이지 결과를 확인할 수 있었습니다.
여기서는 role 이 ‘sqladmin’, ‘xmladmin’, ‘ldapadmin’ 이어야 하는데,
방금 로그인한 응답 데이터 중 JWT의 페이로드를 살펴보니 role 이 ‘superadmin’이고 kid 에 경로로 보이는 ‘admin/adafg541/key/public.pem’을 확인할 수 있었습니다.
// Header {"typ":"JWT","alg":"HS256"} // Payload {"iss":"jwt-mania-part-2","aud":"users","iat":1770304078,"exp":1770307678,"sub":"666","role":"superadmin","username":"adminldaptest","kid":"admin\/adafg541\/key\/public.pem"}
JSON
복사
그리고 위 페이로드 값 중 경로로 보이는 곳을 요청하면 다음과 같이 공개키를 확인할 수 있었습니다.
따라서, JWT 페이로드 중 role 을 각 권한(’sqladmin’, ‘xmladmin’, ‘ldapadmin’)으로 변경하고 이때 서명키를 방금 위에서 확인한 공개키를 이용하는 JWT Algorithm Confusion 공격을 수행했습니다. 이를 위해 JWT 헤더의 alg를 'HS256'으로 설정합니다.
스크립트 코드

Exploit

따라서 위 스크립트 결과를 이용하여 페이지를 재요청하면 각각 롤에 대한 Admin Panel 링크를 확인할 수 있으며,
이 중 role 이 ‘xmladmin’ 인 JWT에서 플래그를 획득할 수 있었습니다.

Insane World of Shellcraft

World of Shellcraft is a web challenge where every command is a spell. Explore the realm, test your actions, and uncover paths that were never meant to be walked. Hint : The vulnerability is NOT on the game.
Plain Text
복사

Description

해당 문제에서 제공하는 링크에 접속하면 여러 페이지들이 존재합니다.
이때 회원 가입 페이지 register.php 에 임의의 사용자 정보(username foovar123, password foovar123)를 입력하여 회원가입을 수행하면 프로필 페이지 profile.php 로 리다이렉션 되며, 다음과 같이 프로필 이미지를 업로드한 폼이 표시됩니다.
이때, 프로필 이미지를 업로드하면 다음과 같이 경로 ‘/avatars/{username}.jpg’ 형태로 이미지가 저장되는 것을 확인할 수 있습니다.

Solution

회원가입 시 입력한 username 이 파일로 저장되는 것을 통해 확장자(.php)를 포함하는 username 으로 회원가입을 시도했습니다.
username foovar123.php
그러나 이미지 업로드 경로는 username 에서 dot(.)를 빈값으로 치환하고 끝에 .jpg 가 추가되어 웹 쉘은 업로드가 불가능했습니다.
이에 파일명의 길이는 제한되는 것을 이용하여 길이가 긴 username 의 사용자를 생성하여 업로드를 다시 시도했습니다.
그 결과, 다음과 같이 응답 데이터에서는 에러 구문이 포함되었고, 업로드된 파일의 최종 위치는 rename 함수로 부터 이루어지는 것을 발견하게 되었습니다.
또한, rename 함수의 첫 번째 인자는 매 업로드 요청 시 마다 변하지 않았고 username 의 매핑값(?)으로 확인되며, 이 인자 값은 /var/www/html 에 저장되어 조회도 가능합니다.

Exploit

따라서, username 의 길이가 아주 긴 사용자로 프로필 이미지를 업로드하면 rename({username 매핑값}.jpg, {username}.png) 이 호출 되므로 업로드 요청 시 {username 매핑 값}.jpg 를 요청을 수행했습니다.
다만, 이때는 첫 번째 인자가 업로드한 파일의 확장자(.png)로 확인되는데, 이 값을 업로드할 때 filename 확장자를 .php 로 변경하여 요청을 수행하니 다음과 같이 첫 번째 인자에 .php 가 기록되는 것을 발견할 수 있었습니다.
그리고 다시 업로드하는 과정에서 rename 함수 첫 번째 값(.php)을 요청하니 아래와 같이 플래그를 발견할 수 있었습니다.