Step 163. SSH 터널링과 포트 포워딩 — 암호화된 통로에 다른 길을 싣다

Step 163. SSH 터널링과 포트 포워딩 — 암호화된 통로에 다른 길을 싣다

Level 2 — 네트워크 공격과 MITM | 난이도 ★★★★☆ | 예상 소요 시간 4시간

전제: Step 29(SSH 원격 접속)와 Step 161(방화벽과 iptables)을 마쳤다. 소켓의 기본(Step 77~78)을 알면 원리 실습이 더 잘 잡힙니다.

  • 준비물: SSH 서버가 켜진 리눅스(랩 VM 또는 본인 WSL), ssh 클라이언트(윈도우 파워쉬 내장), 파이썬 3.
  • 주의: ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다. 포트 포워딩은 방화벽의 정책을 사실상 우회하는 기술이라, 회사망 등 남의 네트워크에서 임의로 쓰면 정책 위반을 넘어 침입이 됩니다. 이 챕터의 파이썬 포워더 출력과 ssh localhost 실패 메시지는 2026-09-09에 실측했고, SSH 접속 화면은 환경 의존적이라 출력 예시로 표시했습니다.

Step 29에서 여러분은 SSH를 "원격 터미널"로 썼습니다. 그런데 SSH 연결은 명령만 나르는 관이 아닙니다 — 그 암호화된 통로 안에 다른 프로그램의 통신을 실어 나를 수 있습니다. 이것이 포트 포워딩입니다. "밖에서는 막혔지만 안에서는 열린 서비스"에 접근하고, 내 트래픽을 다른 지점에서 나가게 하고, 심지어 방화벽 너머에서 역으로 손을 내밀게 하는 기술입니다. 침투 테스트의 피벗(pivot, 거점 삼아 내부로 이동)이 이 기술 위에 서 있습니다. 오늘은 세 가지 방향(-L, -R, -D)을 익히고, 그 원리를 40줄짜리 파이썬 포워더로 직접 증명합니다.


1. 학습 목표

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

  • 로컬(-L), 리모트(-R), 다이나믹(-D) 포워딩의 방향 차이를 그림으로 설명한다
  • -L로 방화벽 너머의 서비스를 localhost로 끌어온다
  • -D로 SSH 서버를 SOCKS 프록시로 만든다
  • 포트 포워딩의 본질(소켓 중계)을 파이썬으로 구현해 설명한다
  • GatewayPorts 등 -R의 제약과, 방어자가 터널을 탐지하는 관점을 안다

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

오늘의 도구 한눈에 보기

구분 내용
언어·환경 ssh 클라이언트(윈도우/리눅스 공통) + SSH 서버 1대 + 파이썬 3(원리 실습)
오늘의 명령 ssh -L 내포트:목적지:목적포트 계정@서버, ssh -R 원격포트:목적지:목적포트 계정@서버, ssh -D 내포트 계정@서버
필요한 개념 포트(Step 29), 리스닝과 연결, SOCKS 프록시(Step 162), 방화벽 인바운드/아웃바운드(Step 161)
오늘의 산출물 -L 터널 1개 + 동작하는 파이썬 포워더 + 세 방향 비교 표

2-1. 포트 포워딩이란 — 연결의 이어달리기

포트 포워딩(port forwarding)의 본질은 단순합니다. 어떤 프로그램이 내 8080번 포트에서 연결을 받아서, 그 데이터를 다른 주소:포트로 그대로 중계합니다. 중간 프로그램은 내용을 이해할 필요가 없습니다 — 받은 바이트를 저쪽에 밀어 넣고, 저쪽의 응답을 다시 이쪽으로 밀어 넣을 뿐입니다. SSH는 이 중계를 암호화된 자기 연결 안에서 합니다. 그래서 "터널"이라 부릅니다 — 겉에서 보면 SSH 연결 하나뿐인데, 그 안에 다른 통신이 지나갑니다.

2-2. -L (로컬 포워딩) — 문을 내 쪽에 연다

ssh -L 8080:localhost:80 user@server를 읽는 법은 이렇습니다. " 컴퓨터의 8080에 연결하면, SSH 서버(server) 너머에서 보이는 localhost:80으로 보내 줘." 문(리스닝 소켓)이 로컬에 생기니까 로컬 포워딩입니다.

용도의 핵심: 서버 입장의 localhost:80은 방화벽 밖에서는 절대 안 보이는 서비스(내부 관리 페이지, DB 등)일 수 있습니다. 하지만 SSH(22번)만 열려 있다면, 그 한 줄로 내부 서비스가 내 8080에 나타납니다. Step 161의 방화벽이 "22만 open"이었던 서버의 이야기와 정확히 이어집니다 — 방화벽이 막아도, 열린 문(SSH)을 통해 다른 문에 닿는 것입니다.

2-3. -R (리모트 포워딩) — 문을 저쪽에 연다

ssh -R 9000:localhost:22 user@server는 반대입니다. "저쪽(server)의 9000에 연결하면, 내 쪽의 localhost:22로 보내 줘." 문이 리모트에 생깁니다.

왜 이게 필요할까요? 내 컴퓨터가 NAT나 방화벽 뒤에 있어서 아무도 나에게 접속을 못 걸 때(Step 161의 BlockInbound), 내가 먼저 밖의 서버로 나가서 거기에 문을 만들어 두면, 상대는 그 문으로 거슬러 들어올 수 있습니다. 나가는 연결은 대개 자유롭다는 점을 이용한 고전적 우회 — 동시에 정당한 원격 지원 기술이기도 합니다.

2-4. -D (다이나믹 포워딩) — 문을 만능으로

ssh -D 1080 user@server는 목적지를 고정하지 않습니다. 내 1080에 SOCKS 프록시(Step 162)가 생기고, 연결할 때마다 "이번에는 저기로 가 줘"라고 지정할 수 있습니다. 모든 트래픽이 SSH 서버를 출구로 나가게 되니, 사실상 간이 VPN입니다. proxychains에 socks5 127.0.0.1 1080을 적으면 임의의 프로그램이 이 터널을 탑니다.

2-5. 세 방향 한눈에 비교

옵션 문이 열리는 곳 데이터의 출구 대표 용도
-L 내 컴퓨터 SSH 서버 쪽 방화벽 안의 서비스를 내 localhost로
-R SSH 서버 내 컴퓨터 쪽 NAT 뒤의 내 서비스를 밖에 노출
-D 내 컴퓨터 SSH 서버 쪽(목적지 가변) 간이 VPN / 프록시

기억법: 글자가 문의 위치입니다. L은 Local에 문, R은 Remote에 문.


3. 따라 하기

3-0. 오늘 환경 점검 — SSH 서버가 있는가

본격 실습 전에 서버 생존을 확인합니다 (Step 29의 방법).

ssh localhost

출력 (2026-09-09 실측, sshd가 설치되지 않은 WSL):

ssh: connect to host localhost port 22: Connection refused

읽는 법: "Connection refused"는 주소까지는 갔는데 22번 문이 닫힌 것 — SSH 서버가 없다는 뜻입니다(Step 29 벽 2와 같습니다). 여러분의 랩 VM에 openssh-server가 켜져 있다면 3-1~3-4를 그대로 진행하세요. 없다면 명령 출력을 예시로 읽고, 3-2의 파이썬 실습으로 원리를 확실히 잡으면 됩니다 — 터널의 본질은 SSH가 아니라 중계이기 때문입니다.

3-1. -L 실습 — 내부 서비스를 내 손으로

랩 시나리오: SSH 서버(192.168.56.103) 안에 localhost에만 묶인 웹 서비스(80)가 있습니다.

입력 (내 컴퓨터):

ssh -L 8080:localhost:80 lee@192.168.56.103

로그인된 채로 두고, 브라우저에서 http://127.0.0.1:8080을 엽니다.

출력 예시: 서버 내부의 페이지가 내 브라우저에 렌더링됩니다.

읽는 법: 내 8080으로 들어간 요청이 암호화된 SSH 안을 타고 192.168.56.103까지 간 뒤, 거기서 localhost:80으로 나왔습니다. 서버의 방화벽에는 80번 규칙이 없어도 됩니다 — 바깥에서 본 것은 22번으로의 SSH 연결 하나뿐이니까요. 세부주소를 바꾸면(-L 3306:db.internal:3306처럼) 같은 원리로 내부 DB에도 닿습니다.

3-2. 원리 증명 — 40줄짜리 파이썬 포워더

-L이 SSH 없이도 가능한 "그냥 중계"임을 몸으로 확인합니다. 아래 파일을 만드세요 (외부망이 아닌 내 컴퓨터 안에서만 동작합니다).

입력: port_forwarder.py

"""미니 포트 포워더 — SSH -L의 원리 모형."""
import socket, sys, threading

LISTEN_PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
TARGET_HOST = sys.argv[2] if len(sys.argv) > 2 else "127.0.0.1"
TARGET_PORT = int(sys.argv[3]) if len(sys.argv) > 3 else 8000

def relay(src, dst, tag):
    try:
        while True:
            data = src.recv(4096)
            if not data:
                break
            print(f"[{tag}] {len(data)}바이트 중계")
            dst.sendall(data)
    except OSError:
        pass
    finally:
        src.close(); dst.close()

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", LISTEN_PORT))
server.listen(5)
print(f"[포워더 기동] 127.0.0.1:{LISTEN_PORT} -> {TARGET_HOST}:{TARGET_PORT}")
while True:
    client, addr = server.accept()
    print(f"[연결 수락] {addr[0]}:{addr[1]}")
    target = socket.create_connection((TARGET_HOST, TARGET_PORT))
    threading.Thread(target=relay, args=(client, target, "클라->서버"), daemon=True).start()
    threading.Thread(target=relay, args=(target, client, "서버->클라"), daemon=True).start()

터미널 셋이 필요합니다.

# 터미널 1 — "내부 서비스" 역할 (목적지)
echo "<h1>secret internal page</h1>" > index.html
python -m http.server 8000

# 터미널 2 — 포워더 ("터널" 역할)
python -u port_forwarder.py 8080 127.0.0.1 8000

# 터미널 3 — 사용자 역할
curl http://127.0.0.1:8080/

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

# 터미널 3
<h1>secret internal page</h1>

# 터미널 2 (포워더 로그)
[포워더 기동] 127.0.0.1:8080 -> 127.0.0.1:8000
[연결 수락] 127.0.0.1:50231
[클라->서버] 78바이트 중계
[서버->클라] 186바이트 중계
[서버->클라] 30바이트 중계

읽는 법: curl은 8080에 갔는데, 응답은 8000의 서버에서 왔습니다. 포워더의 로그가 모든 바이트의 왕복을 기록했습니다 — 요청 78바이트가 넘어가고 응답 186+30바이트가 돌아왔습니다. 이 프로그램이 SSH와 다른 점은 딱 하나, 암호화와 인증이 없다는 것뿐입니다. ssh -L은 이 중계 루프를 암호화된 연결 안에서 돌리는 것이라고 이해하면, 터널이 더는 마법이 아닙니다.

3-3. -D 실습 — SSH 서버를 SOCKS 프록시로

입력:

ssh -D 1080 lee@192.168.56.103

접속을 유지한 채, 프록시를 쓰는 쪽에서 확인합니다.

curl --proxy socks5://127.0.0.1:1080 http://내부주소/

출력 예시: SSH 서버의 위치에서만 보이던 주소의 응답이 돌아옵니다.

읽는 법: -L과 달리 목적지를 명령줄에 적지 않았습니다 — curl이 프록시에게 매번 목적지를 알려 줍니다. proxychains의 설정 파일 끝에 socks5 127.0.0.1 1080을 추가하면, proxychains nmap ...처럼 임의 도구의 트래픽을 이 터널로 보낼 수 있습니다(Step 162의 체인과 연결되는 지점입니다).

3-4. -R 실습 — 문을 저쪽에 만들기

랩 두 대로 합니다. 내 랩의 A(내부 역할)에서 밖의 B(SSH 서버 역할)로:

입력 (A에서):

ssh -R 9000:localhost:22 lee@B의주소

이제 B에서 ssh -p 9000 lee@127.0.0.1을 하면, A의 SSH로 도착합니다.

출력 예시: B의 콘솔에 A의 프롬프트가 뜹니다.

읽는 법: A는 방화벽 뒤에 있어서 아무도 A로 접속을 못 거는데, A가 먼저 나간 연결 위에 B에 문(9000)이 생겼습니다. 기본적으로 이 문은 B의 localhost에만 묶입니다 — 다른 컴퓨터에서도 B의 9000을 두드리게 하려면 B의 sshd_configGatewayPorts yes가 필요합니다(보안상 기본값은 off — 문을 함부로 대외 개방하지 않겠다는 뜻입니다).

3-5. 방어자의 눈 — 터널은 어떻게 보이는가

뒤집어 봅니다. 방화벽 로그에는 "22번으로의 긴 SSH 연결 하나"만 보입니다. 그 안에 무엇이 실렸는지는 암호화되어 모릅니다. 그래서 방어는 다른 신호를 봅니다 — 비정상적으로 오래 살아 있는 SSH 세션, 접속되지 않은 계정의 SSH 로그인, 내부에서 외부로의 상시 SSH 연결. 공격 기술을 배운 뒤에 "그래서 로그에는 뭐가 남는가"를 묻는 습관이, 오늘 배운 도구를 방어 지식으로 바꿉니다.


4. 미션과 연습문제

미션 — 터널 설계와 원리 증명

  1. 3-2의 파이썬 포워더를 실행해 로그(연결 수락 + 양방향 바이트 중계)를 캡처하세요. 8000을 9000 등 다른 포트로 바꿔도 동작함을 확인하세요.
  2. 랩에 SSH 서버가 있다면 ssh -L 8080:localhost:80(또는 띄운 서비스 포트)으로 터널을 만들고, 브라우저 또는 curl로 127.0.0.1:8080에 접근한 기록을 남기세요. 없다면 3-0의 refused 메시지와 함께 "왜 안 됐는가"를 한 줄로 적으세요.
  3. 시나리오 설계: "고객사 방화벽이 외부 SSH 접속을 전부 막지만, 내부에서 밖으로 나가는 443은 허용한다. 내부 서버의 원격 지원을 어떻게 설계할 것인가?" — -R과 GatewayPorts의 관점으로 3~5줄로 적으세요.
  4. 세 방향 비교 표(2-5)를 보지 않고 다시 그리세요.

연습문제

문제 1. -L-R에서 "문(리스닝 소켓)이 열리는 곳"이 각각 어디인가요? 이 차이가 용도의 차이를 어떻게 만드나요?

문제 2. 3-2의 파이썬 포워더와 ssh -L의 차이 두 가지를 드세요. 그리고 왜 그 차이가 "남의 네트워크에서 함부로 쓰면 안 되는 이유"와 연결되나요?

문제 3. -D-L보다 유연한 이유는 무엇인가요? 대신 -D를 쓰려면 응용 프로그램 쪽에 무엇이 필요한가요?

문제 4. -R으로 만든 문이 기본적으로 상대방의 localhost에만 묶이는 이유(GatewayPorts의 기본값)를 보안 관점에서 설명하세요.


5. 모범 답안과 완료 기준

미션 모범 답안

1번: 실측 로그의 핵심은 "연결 수락 → 클라->서버 중계 → 서버->클라 중계"의 3단계입니다. 포트를 바꿔도 동작하는 이유는, 포워더가 포트 번호를 설정값으로만 쓰고 내용은 전혀 해석하지 않기 때문입니다.

2번 성공 기록 예: "ssh -L 8080:localhost:8000 접속 유지 중, curl 127.0.0.1:8080이 8000 서버의 페이지를 반환." 실패 기록 예: "이 환경엔 sshd가 없어 ssh: connect to host localhost port 22: Connection refused(2026-09-09 실측) — 서버가 22번에서 듣고 있지 않다는 뜻."

3번 답 예: 내부 서버가 지원 업체의 중계 서버로 먼저 나가는 SSH(-R) 연결을 맺어 문을 만들어 둡니다 — 나가는 443(또는 22)은 방화벽이 허용하므로 성립합니다. 지원자는 중계 서버를 통해 그 문으로 들어옵니다. GatewayPorts를 켜면 문이 중계 서버의 localhost를 넘어 대외에 노출되므로, 접근 제어(포트·키·기간 제한)와 함께 써야 하고, 작업이 끝나면 터널을 반드시 끊습니다.

검증하는 법: ① 포워더 로그에 양방향 중계가 찍혔는가. ② -L 시도의 성공 또는 실패 원인이 기록됐는가. ③ 시나리오 답이 "방화벽을 뚫는다"가 아니라 "허용된 출구를 이용한다"로 설계됐는가. ④ 표를 백지에서 재현했는가.

연습문제 해답

문제 1 해답. -L은 내 컴퓨터에, -R은 SSH 서버(상대방)에 문이 열립니다. 문이 내 쪽에 있으면 "내가 저쪽 네트워크의 자원을 가져오는" 모양이 되고(-L), 저쪽에 있으면 "저쪽 사람들이 내 자원에 오는" 모양이 됩니다(-R). 방향이 아니라 문의 위치가 본질입니다.

문제 2 해답. 차이는 암호화와 인증입니다. 파이썬 포워더는 내용이 평문으로 지나고 누가 연결해도 받아들이지만, ssh -L은 인증된 사용자의 암호화된 통로 안에서만 중계합니다. 같은 "중계"라도 SSH는 감사 로그(누가, 언제 접속했는가)가 남고 권한이 묶입니다 — 그래서 허가받지 않은 터널은 탐지와 책임 추적의 대상이 되는 것입니다.

문제 3 해답. -L은 목적지가 명령에 고정되지만, -D는 연결마다 목적지를 정할 수 있기 때문입니다(SOCKS). 대신 응용 프로그램이 SOCKS 프록시를 말할 줄 알아야 합니다 — curl의 --proxy, 브라우저의 프록시 설정, 또는 proxychains처럼 대신 말해 주는 도구가 필요합니다.

문제 4 해답. -R의 문을 대외에 열면(GatewayPorts yes), 그 서버에 접속 가능한 누구나 터널 안쪽의 내 네트워크로 들어오는 통로가 됩니다. 기본값 off는 "만든 사람의 그 컴퓨터에서만 쓰는 문"으로 제한해, 터널이 곧바로 공개 관문이 되는 사고를 막는 안전장치입니다.

완료 기준 체크리스트

  • [ ] -L/-R/-D에서 문이 열리는 위치를 각각 설명할 수 있다
  • [ ] -L 명령의 주소 해석(내포트:서버입장의목적지:포트)을 읽을 수 있다
  • [ ] 파이썬 포워더를 실행하고 중계 로그를 해석할 수 있다
  • [ ] 포트 포워딩의 본질이 "소켓 중계"이고 SSH는 거기에 암호화·인증을 얹은 것임을 설명할 수 있다
  • [ ] -D가 SOCKS 프록시를 만든다는 것과, 응용 프로그램의 SOCKS 지원이 필요함을 안다
  • [ ] GatewayPorts의 기본값과 그 보안상 이유를 설명할 수 있다
  • [ ] 방화벽 로그에 터널이 어떻게 보이는지(긴 SSH 세션 하나)를 안다

6. 흔한 실수와 해결

벽 1. "ssh: connect to host … port 22: Connection refused"

증상: 접속이 즉시 거부됩니다 (2026-09-09 이 환경에서도 재현됨).
원인: 주소까지는 도달했지만 상대의 22번 문이 닫혀 있습니다 — SSH 서버가 안 켜져 있습니다. 터널 이전에 SSH 접속 자체의 문제입니다.
해결: 상대에서 sudo systemctl status ssh로 확인하고 켜세요(Step 29 벽 2). refused는 "터널이 막힌 게 아니라 대문이 닫힌 것"입니다.

벽 2. "터널을 만들었는데 127.0.0.1:8080이 안 열려요"

증상: curl이 Connection refused를 냅니다.
원인: 둘 중 하나입니다. ① ssh 세션이 끊겼다 — 터널은 ssh 접속이 살아 있는 동안만 존재합니다. ② -L의 목적지를 "내 입장"으로 적었다 — localhost는 항상 SSH 서버 입장의 localhost입니다.
해결: ssh 세션 유지를 확인하고, 목적지를 서버 입장으로 다시 읽으세요. -N(터미널 없이 터널만)과 -f(백그라운드) 옵션을 쓰면 실수로 세션을 닫는 일이 줄어듭니다.

벽 3. "-L과 -R이 계속 헷갈려요"

증상: 명령을 짤 때마다 방향을 뒤집습니다.
원인: 데이터의 흐름으로 외우려 해서입니다. 흐름은 양방향이라 기억에 약합니다.
해결: "문이 어디 열리는가" 하나만 외우세요 — L은 Local에 문, R은 Remote에 문. 그리고 직접 한 줄씩 그려 보세요. 3-2의 포워더를 떠올리면, bind(문)가 어디 있었는지가 곧 답입니다.

벽 4. "-R로 문을 만들었는데 다른 컴퓨터에서 못 와요"

증상: 문을 만든 서버에서는 되는데, 제3자는 그 포트에 못 붙습니다.
원인: GatewayPorts 기본값(off) 때문에 문이 그 서버의 localhost에만 묶여 있습니다.
해결: 의도한 개방이라면 상대의 sshd_config에 GatewayPorts yes(또는 clientspecified)를 설정하고 sshd를 재시작합니다. 단, 그 문은 곧 공개 관문이라는 것을 3-4와 연습문제 4에서 확인한 뒤에 켜세요.

벽 5. "터널이 자꾸 끊겨요"

증상: 한참 두면 터널이 죽어 있습니다.
원인: 중간의 NAT/방화벽이 조용한 연결을 정리(타임아웃)합니다.
해결: keepalive를 켭니다 — 클라이언트 쪽 ~/.ssh/configServerAliveInterval 60(60초마다 생존 신호). 자동 재연결까지 필요하면 autossh 같은 도구를 씁니다.


7. 정리

오늘의 개념

개념 한 줄 설명
포트 포워딩 한 포트에서 받은 데이터를 다른 주소:포트로 중계하는 기술
SSH 터널 그 중계를 인증·암호화된 SSH 연결 안에서 하는 것
-L (로컬) 내 컴퓨터에 문 — 저쪽 네트워크의 자원을 내 손으로
-R (리모트) SSH 서버에 문 — 방화벽 뒤의 내 자원을 밖으로
-D (다이나믹) 내 컴퓨터에 만능 문(SOCKS) — 목적지는 매번 지정
GatewayPorts -R의 문을 localhost를 넘어 대외에 열지 결정하는 스위치(기본 off)
피벗(pivot) 장악한 지점을 거점 삼아 내부 네트워크로 이동하는 침투 기법
ServerAliveInterval 조용한 터널이 끊기지 않게 보내는 생존 신호

오늘의 명령어

명령 하는 일
ssh -L 8080:localhost:80 계정@서버 내 8080 → 서버 입장의 localhost:80
ssh -R 9000:localhost:22 계정@서버 서버의 9000 → 내 localhost:22
ssh -D 1080 계정@서버 내 1080에 SOCKS 프록시 열기
ssh -N -f -L ... 터미널 없이 터널만, 백그라운드로
curl --proxy socks5://127.0.0.1:1080 주소 다이나믹 터널 경유 요청
python port_forwarder.py 8080 127.0.0.1 8000 원리 모형 포워더 기동

명령어보다 중요한 감각

오늘의 핵심은 터널이 마법이 아니게 된 순간입니다. 40줄의 파이썬이 -L과 같은 일을 했고, 차이는 암호화와 인증뿐이었습니다. 그 한 끗이 "합법적인 관리 도구"와 "은밀한 침입 통로"를 가릅니다 — 그리고 그 구분은 기술이 아니라 허가에 있습니다.

Step 161에서 세운 방화벽이 "22만 open"이라고 대답했을 때, 그 한 줄이 오늘 배운 모든 터널의 출입구라는 것도 이제 보입니다. 방화벽은 문의 수를 줄이지만, 열린 문 하나가 얼마나 많은 것을 실어 나를 수 있는지를 배운 오늘 — 닫는 기술과 여는 기술이 같은 손에 들어온 날입니다.


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