네트워크 보안 위협과 방어
엿보기와 사칭, 중간자 공격을 TLS와 HSTS가 어떻게 막는지 정리하고, 계층별 DDoS와 반사 증폭 공격의 규모를 계산합니다. 처음 맞는 규칙을 따르는 방화벽 ACL과 WAF, 탐지 도구의 역할을 구분하고, 토큰 버킷으로 요청 수를 제한하는 방법과 여러 서버에서의 한계를 봅니다.
앞의 세 글에서 TLS와 인증서가 통신을 엿보거나 바꾸지 못하게 지키는 방법을 보았습니다. 하지만 네트워크를 노리는 공격은 그것만이 아닙니다. 서비스를 쓰지 못하게 트래픽을 쏟아붓거나, 허용된 포트로 들어와 애플리케이션의 약점을 파고들거나, 요청을 끝없이 보내 자원을 바닥내는 공격도 있습니다. 하나의 장비나 설정으로 이 모두를 막을 수는 없으므로, 보안은 여러 겹의 방어를 쌓고 공격을 알아채고 피해를 줄이고 복구하는 일을 함께 설계하는 것입니다.
엿보기, 사칭, 중간자 공격
같은 와이파이나 같은 스위치에 연결된 공격자는 평문으로 오가는 트래픽을 그대로 엿볼(sniffing) 수 있습니다. 여기에 3장 4편의 ARP 응답 위조나 8장 3편의 DNS 응답 위조처럼 다른 장비인 척하는 사칭(spoofing)을 더하면, 피해자의 트래픽이 공격자를 거쳐 가게 만들 수 있습니다. 두 당사자 사이에 끼어들어 엿보거나 바꾸는 이 공격을 중간자 공격(man-in-the-middle, MITM)이라고 합니다.
TLS는 트래픽이 공격자를 거쳐 가더라도 내용을 읽거나 바꾸지 못하게 합니다. 핵심은 암호화 자체보다 10장 2편에서 본 서버 인증입니다. 공격자는 진짜 서버의 개인 키가 없으므로, 클라이언트가 인증서의 사슬과 이름을 제대로 검증하는 한 진짜 서버인 척할 수 없습니다. 그래서 TLS가 무너지는 곳은 대개 검증이 무너지는 곳입니다. 사용자가 인증서 경고를 무시하고 넘어가거나, 코드가 검증을 꺼 두었거나, 기기에 공격자의 루트 인증서가 설치되어 있으면 공격자의 인증서도 통과합니다.
또 하나의 약점은 첫 접속입니다. 사용자가 주소창에 example.com만 치면 브라우저는 먼저 HTTP로 접속하고, 서버가 HTTPS로 리다이렉트합니다. 공격자가 이 첫 HTTP 응답을 가로채 리다이렉트를 지우고 계속 HTTP로 중계하면, 사용자는 HTTPS를 한 번도 쓰지 않게 됩니다. 이를 SSL 스트리핑(SSL stripping)이라고 합니다. HSTS(HTTP strict transport security, RFC 6797)는 서버가 HTTPS 응답에 Strict-Transport-Security 헤더를 실어 "앞으로 이 도메인에는 HTTPS로만 접속하라"고 브라우저에 기억시키는 방식으로 이를 막습니다.
def parse_hsts(value):
policy = {"max_age": None, "include_subdomains": False}
for directive in value.split(";"):
name, _, arg = directive.strip().partition("=")
if name.lower() == "max-age":
policy["max_age"] = int(arg.strip('"'))
elif name.lower() == "includesubdomains":
policy["include_subdomains"] = True
return policy
policy = parse_hsts("max-age=31536000; includeSubDomains")
known = {"example.com": policy} # 브라우저가 기억해 둔 HSTS 정책
def upgrade(host):
for known_host, p in known.items():
if host == known_host or (p["include_subdomains"] and host.endswith("." + known_host)):
return "https로 바꿔서 보낸다"
return "적힌 대로 http로 보낸다"
for host in ["example.com", "shop.example.com", "example.net"]:
print(host, upgrade(host))
print(f"{policy['max_age'] // 86400}일 동안 기억")example.com https로 바꿔서 보낸다
shop.example.com https로 바꿔서 보낸다
example.net 적힌 대로 http로 보낸다
365일 동안 기억정책을 기억한 브라우저는 사용자가 http://로 입력하거나 페이지의 링크가 http://여도 요청을 보내기 전에 HTTPS로 바꾸므로, 공격자가 끼어들 첫 HTTP 요청 자체가 생기지 않습니다. 다만 정책은 HTTPS 응답을 한 번 받은 뒤에야 기억되므로, 처음 방문하는 순간은 여전히 약합니다. 이를 메우려고 주요 브라우저는 HSTS를 쓰겠다고 신청한 도메인의 목록을 브라우저 안에 미리 넣어 두는데, 이를 HSTS preload라고 합니다. includeSubDomains를 켜면 모든 하위 도메인이 HTTPS를 지원해야 하므로, HTTP로만 운영하는 오래된 하위 도메인이 있다면 먼저 정리해야 합니다.
DDoS와 반사 증폭
DDoS(distributed denial of service)는 여러 곳에서 동시에 트래픽이나 요청을 쏟아부어 서비스를 쓰지 못하게 하는 공격입니다. 노리는 대상에 따라 세 가지로 나눌 수 있습니다. 회선의 대역폭을 채우는 대량 트래픽 공격(L3/L4), 6장 4편의 SYN Flood처럼 연결 상태를 기억하는 자원을 바닥내는 공격, 그리고 정상 요청처럼 보이지만 처리 비용이 큰 페이지를 반복해서 부르는 애플리케이션 공격(L7)입니다.
대량 트래픽 공격에서 자주 쓰이는 방법이 반사 증폭(reflection amplification)입니다. 공격자는 UDP 질의의 출발지 주소를 피해자의 주소로 위조해 공개 DNS 서버 같은 곳에 보내고, 서버는 그 출발지로, 곧 피해자에게 응답을 보냅니다. UDP에는 핸드셰이크가 없으므로 서버는 출발지가 진짜인지 확인하지 않습니다.
DNS 반사에서 요청과 응답의 목적지
피해자 주소를 질의의 출발지로 위조한 모형입니다. 가정한 메시지 크기와 질의율을 비교합니다. 각 단계의 값과 전환 이유를 비교합니다.
DNS 반사의 주소 관계
위조한 출발지 때문에, 응답은 피해자에게 갑니다
질의의 목적지는 DNS 서버, 응답의 목적지는 피해자입니다. 응답이 질의보다 클 때 증폭되며, 출발지 위조 차단과 외부 재귀 질의 제한은 서로 다른 지점의 방어입니다.
응답이 질의보다 클 때 증폭이 생기며, 크기 비율은 질의·응답에 따라 달라집니다. 출발지 위조 차단과 불필요한 외부 재귀 질의 제한은 서로 다른 지점의 방어입니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 위조 질의부터 증폭 범위까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | DNS 반사에서 요청과 응답의 목적지 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 위조 질의 피해자 주소를 질의의 출발지로 위조한 모형입니다. |
|
|
| 반사 응답 DNS가 질의에 적힌 출발지로 응답합니다. |
|
|
| 증폭 범위 가정한 메시지 크기와 질의율을 비교합니다. |
|
|
DNS 반사에서 요청과 응답의 목적지
피해자 주소를 질의의 출발지로 위조한 모형입니다. 가정한 메시지 크기와 질의율을 비교합니다. 각 단계의 값과 전환 이유를 비교합니다.
DNS 반사의 주소 관계
위조한 출발지 때문에, 응답은 피해자에게 갑니다
질의의 목적지는 DNS 서버, 응답의 목적지는 피해자입니다. 응답이 질의보다 클 때 증폭되며, 출발지 위조 차단과 외부 재귀 질의 제한은 서로 다른 지점의 방어입니다.
응답이 질의보다 클 때 증폭이 생기며, 크기 비율은 질의·응답에 따라 달라집니다. 출발지 위조 차단과 불필요한 외부 재귀 질의 제한은 서로 다른 지점의 방어입니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 위조 질의부터 증폭 범위까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | DNS 반사에서 요청과 응답의 목적지 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 위조 질의 피해자 주소를 질의의 출발지로 위조한 모형입니다. |
|
|
| 반사 응답 DNS가 질의에 적힌 출발지로 응답합니다. |
|
|
| 증폭 범위 가정한 메시지 크기와 질의율을 비교합니다. |
|
|
응답이 질의보다 크면 공격자의 트래픽이 부풀려집니다. 질의가 60바이트이고 응답이 1200바이트라면 1200 ÷ 60 = 20배이고, 초당 1000개의 질의만 보내도 피해자에게는 초당 1,200,000바이트가 쏟아집니다. 공격자가 보내는 것은 초당 60,000바이트뿐입니다. 이 비율은 프로토콜마다 달라서, 2018년에는 응답이 요청보다 수만 배 커질 수 있는 memcached 서버를 이용한 공격이 초당 1.35테라비트로 GitHub를 덮치기도 했습니다.
| 방어 | 계층 | 하는 일 | 주의할 점 |
|---|---|---|---|
| 출발지 위조 차단 | L3 | 통신사가 자기 고객의 주소가 아닌 출발지를 가진 패킷을 내보내지 않는다 | 모든 통신사가 해야 효과가 커진다 |
| 열린 리졸버 닫기 | L7 | 재귀 DNS가 자기 고객의 질의에만 답하게 한다 | 공개 응답이 필요한 권한 DNS에는 해당하지 않는다 |
| CDN과 DDoS 방어 서비스 | L3~L7 | 전 세계의 큰 용량으로 공격을 흡수하고 걸러 낸다 | 원본 서버의 주소가 드러나면 우회당한다 |
| 애니캐스트 | L3 | 같은 주소를 여러 곳에서 알려 공격을 나누어 받는다 | 지역마다 용량을 계획해야 한다 |
| SYN cookie | L4 | 연결 상태를 저장하지 않고 SYN Flood를 견딘다 | 다른 종류의 TCP 공격은 막지 못한다 |
| 요청 수 제한, WAF | L7 | 사용자나 주소마다 요청을 제한하고 공격 패턴을 거른다 | 정상 사용자가 함께 막힐 수 있다 |
| 블랙홀 라우팅 | L3 | 공격받는 주소로 가는 트래픽을 통째로 버린다 | 서비스도 함께 끊기는 최후의 수단이다 |
출발지 위조를 막는 것은 통신사가 고객의 망과 맞닿는 곳에서 하는 일이고(RFC 2827), 재귀 질의를 제한하는 것은 DNS 서버의 설정입니다(RFC 5358). 서로 다른 곳에서 하는 방어이므로 둘 다 필요합니다. 피해자가 되었을 때 회선이 이미 가득 찼다면 내 서버에서 할 수 있는 일은 거의 없으므로, 큰 서비스는 평소에 CDN이나 DDoS 방어 서비스 뒤에 서버를 두고 원본의 주소를 숨겨 둡니다.
더 깊이: 새 프로토콜은 증폭을 어떻게 막도록 설계하나
반사 증폭이 통하는 것은 UDP 서버가 출발지를 확인하지 않은 채 질의보다 큰 응답을 보내기 때문입니다. 그래서 UDP 위에 새로 만든 프로토콜은 처음부터 이를 막는 규칙을 둡니다. RFC 9000은 QUIC 서버가 클라이언트의 주소를 확인하기 전에는 그 주소에서 받은 바이트의 세 배보다 많이 보내지 못하게 하는데, 이를 증폭 방지 한도(anti-amplification limit)라고 합니다. 서버는 클라이언트가 Handshake 키로 보호한 패킷을 보내오면, 그 주소에서 실제로 패킷을 받고 있다고 보고 한도를 풉니다.
이 한도 때문에 클라이언트는 첫 Initial 패킷을 담은 UDP 데이터그램을 패딩으로 채워 1200바이트 이상으로 보내야 합니다. 클라이언트가 크게 보낼수록 서버도 인증서처럼 큰 핸드셰이크 데이터를 주소를 확인하기 전에 더 많이 보낼 수 있기 때문입니다. 공격자가 출발지를 위조해 1200바이트를 보내도 서버의 응답은 3600바이트를 넘지 못하므로, 앞의 DNS처럼 수십 배로 부풀리는 데는 쓸 수 없습니다. 서버가 핸드셰이크를 시작하기 전에 주소부터 확인하고 싶다면, 토큰을 담은 Retry 패킷을 보내 클라이언트가 그 토큰과 함께 다시 접속하게 할 수도 있습니다.
방화벽과 ACL
방화벽(firewall)은 정해 둔 규칙에 따라 트래픽을 허용하거나 막습니다. 규칙의 목록을 ACL(access control list)이라고 하며, 출발지와 목적지의 주소, 포트, 프로토콜로 패킷을 고릅니다. 패킷마다 따로 판단하는 단순한 ACL과 달리, 상태 기반 방화벽은 5장 3편에서 본 것처럼 연결을 기억해 두었다가 안에서 나간 요청의 응답만 들여보냅니다. 대부분의 방화벽은 위에서부터 처음 맞는 규칙을 따르고, 어느 규칙에도 맞지 않으면 막습니다.
from ipaddress import ip_address, ip_network
rules = [ # 위에서부터 처음 맞는 규칙을 따른다
("허용", "10.0.0.0/8", 22),
("차단", "0.0.0.0/0", 22),
("허용", "0.0.0.0/0", 443),
]
def decide(src, port):
for action, network, rule_port in rules:
if ip_address(src) in ip_network(network) and port == rule_port:
return action
return "차단(기본)" # 어느 규칙에도 맞지 않으면 막는다
for src, port in [("10.1.2.3", 22), ("203.0.113.7", 22), ("203.0.113.7", 443), ("203.0.113.7", 3306)]:
print(src, port, decide(src, port))10.1.2.3 22 허용
203.0.113.7 22 차단
203.0.113.7 443 허용
203.0.113.7 3306 차단(기본)사내망에서 오는 SSH는 첫 규칙에 걸려 허용되고, 바깥에서 오는 SSH는 둘째 규칙에서 막히며, 웹은 누구에게나 열려 있고, 데이터베이스 포트처럼 적어 두지 않은 것은 기본 규칙으로 막힙니다. 필요한 것만 열고 나머지는 모두 막는 이 기본 차단(default deny)이 방화벽 설정의 출발점입니다. 규칙의 순서가 결과를 바꾸므로, 넓은 범위의 차단 규칙이 좁은 범위의 허용 규칙보다 위에 오지 않도록 주의해야 합니다.
방화벽은 열어 둔 포트 안에서 일어나는 일까지는 막지 못합니다. 443번 포트를 열어 두었다면, 그 포트로 들어오는 SQL 삽입이나 인증 우회 공격은 방화벽의 입장에서는 정상적인 HTTPS 연결입니다. 게다가 내용이 TLS로 암호화되어 있으므로, 안을 들여다보려면 TLS를 풀었다가 다시 암호화하는 장비와 그 장비의 인증서를 모든 기기에 배포하는 일이 필요하고, 개인정보와 성능의 부담도 따릅니다.
WAF와 탐지 도구
WAF(web application firewall)는 HTTP 수준에서 요청의 URL과 헤더, 쿠키, 본문을 보고 알려진 웹 공격의 패턴을 걸러 냅니다. 보통 TLS를 끝내는 로드 밸런서나 CDN에 함께 두어 복호화된 요청을 봅니다. 하지만 WAF가 아는 것은 공격의 생김새이므로, 정상 요청과 똑같이 생긴 비즈니스 로직의 남용이나 권한 검사의 누락은 막지 못하고, 규칙을 잘못 맞추면 정상 요청을 막기도 합니다. WAF는 애플리케이션의 보안을 대신하는 것이 아니라 시간을 벌어 주는 방어의 한 겹입니다.
| 도구 | 보는 곳 | 하는 일 | 한계 |
|---|---|---|---|
| IDS | 네트워크의 트래픽 | 침입의 징후를 탐지해 알린다 | 보통 직접 막지는 않는다 |
| IPS | 트래픽이 지나가는 길 | 탐지한 공격을 그 자리에서 막는다 | 잘못 탐지하면 정상 트래픽을 막는다 |
| WAF | HTTP 요청 | 웹 공격의 패턴을 거른다 | 로직의 취약점은 막지 못한다 |
| SIEM | 모든 기록 | 여러 장비의 기록을 모아 엮어 보고 경보를 낸다 | 기록의 품질과 규칙의 조정에 성능이 달렸다 |
| EDR | 서버와 단말 | 기기 안의 수상한 행동을 탐지하고 대응한다 | 네트워크의 대량 트래픽은 다루지 않는다 |
요청 수를 제한하는 토큰 버킷
애플리케이션 수준의 공격과 남용을 막는 기본 장치는 요청 수 제한(rate limiting)입니다. 가장 널리 쓰는 방식인 토큰 버킷(token bucket)은 사용자마다 토큰을 담는 통을 두고, 요청마다 토큰 하나를 꺼내 쓰며, 시간이 흐르면 정해진 속도로 토큰을 다시 채웁니다. 통의 크기만큼은 한꺼번에 몰려와도 받아 주고, 그 뒤로는 채우는 속도에 맞춰서만 받아 줍니다. 다음 코드는 시각을 인자로 받아 같은 상황을 언제나 똑같이 재현합니다.
class TokenBucket:
def __init__(self, capacity, refill_per_sec, now=0.0):
self.capacity, self.rate = capacity, refill_per_sec
self.tokens, self.last = capacity, now
def allow(self, now):
self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate) # 흐른 시간만큼 채운다
self.last = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False
bucket = TokenBucket(capacity=20, refill_per_sec=10)
burst = [bucket.allow(0.0) for _ in range(25)]
print("t=0에 25개:", burst.count(True), "허용,", burst.count(False), "거부")
print("t=0.15:", bucket.allow(0.15), f"남은 토큰 {bucket.tokens:.1f}")
print("t=0.15 다시:", bucket.allow(0.15))t=0에 25개: 20 허용, 5 거부
t=0.15: True 남은 토큰 0.5
t=0.15 다시: False한꺼번에 온 25개 가운데 통에 든 20개만큼 허용하고, 거부한 요청은 토큰을 음수로 만들지 않습니다. 0.15초 뒤에는 0.15 × 10 = 1.5개가 채워져 하나를 허용하고 0.5개가 남으며, 같은 순간의 다음 요청은 토큰이 하나에 못 미쳐 거부됩니다. 거부한 요청에는 9장 2편의 429 Too Many Requests와 Retry-After로 답합니다. 실제 서버에서는 시각으로 time.monotonic() 같은 단조 시계를 써야 시스템 시계를 고쳤을 때 토큰이 한꺼번에 생기거나 사라지지 않습니다.
이 코드는 프로세스 하나의 메모리에 통을 둡니다. 서버가 여러 대라면 서버마다 통이 따로 생기므로, 정해 둔 한도의 몇 배가 허용됩니다. 그래서 운영 환경에서는 Redis 같은 공유 저장소에 통을 두고, 채우기와 꺼내기를 한 번의 원자적 연산으로 처리해 동시에 온 요청이 같은 토큰을 두 번 쓰지 않게 합니다. 무엇을 기준으로 통을 나눌지도 정해야 합니다. 주소를 기준으로 하면 회사나 통신사의 NAT 뒤에 있는 많은 사용자가 하나의 통을 나누어 쓰게 되고, 프록시가 붙인 X-Forwarded-For 헤더를 믿으려면 그 헤더를 우리 프록시가 붙였는지 확인해야 합니다. 로그인한 사용자라면 사용자 ID를 기준으로 하는 편이 정확합니다.
정리
- 엿보기와 사칭을 이용한 중간자 공격은 TLS의 서버 인증이 막으며, 인증서 경고를 무시하거나 검증을 끄거나 공격자의 루트 인증서가 설치되면 이 방어가 무너집니다.
- HSTS는 브라우저가 처음부터 HTTPS로 요청하게 해 SSL 스트리핑을 막고, 첫 방문의 빈틈은 HSTS preload로 메웁니다.
- DDoS는 대역폭, 연결 상태, 애플리케이션의 처리 비용을 노리며, 반사 증폭은 출발지를 위조한 UDP 질의로 큰 응답을 피해자에게 보냅니다.
- 출발지 위조 차단과 열린 리졸버 닫기, CDN과 애니캐스트, SYN cookie, 요청 수 제한은 서로 다른 곳에서 일하는 방어이므로 겹쳐서 씁니다.
- 방화벽은 처음 맞는 규칙과 기본 차단으로 노출을 줄이지만, 열어 둔 포트 안의 공격은 WAF와 애플리케이션의 보안이 맡습니다.
- 토큰 버킷은 통의 크기만큼 몰린 요청을 받고 채우는 속도로 제한하며, 여러 서버에서는 공유 저장소와 원자적 연산이 필요합니다.
연습 문제
1. 증폭의 규모
어느 프로토콜의 질의가 64바이트이고 응답이 3200바이트입니다. 공격자가 피해자의 주소로 출발지를 위조한 질의를 초당 5000개 보내면, 증폭 비율과 피해자에게 쏟아지는 양, 공격자가 보내는 양은 각각 얼마인가요? 헤더는 무시합니다.
해설 보기
증폭 비율은 3200 ÷ 64 = 50배입니다. 피해자에게는 초당 3200 × 5000 = 16,000,000바이트, 약 128Mbps가 가고, 공격자가 보내는 양은 초당 64 × 5000 = 320,000바이트, 약 2.56Mbps입니다. 공격자는 가정용 회선 하나로 기업의 회선을 채울 수 있는 셈입니다. 이런 서비스를 운영한다면 응답을 보낼 상대를 제한하거나, 같은 주소로 가는 응답의 속도를 제한하거나, 인터넷에 열 필요가 없다면 아예 닫아 두어야 합니다.
2. 규칙의 순서
본문의 ACL에서 첫 두 규칙의 순서를 바꾸어 ("차단", "0.0.0.0/0", 22)가 맨 위에 오게 하면, 10.1.2.3에서 22번 포트로 오는 요청은 어떻게 되나요? 또 맨 마지막의 기본 규칙을 "허용"으로 바꾸면 203.0.113.7의 3306번 포트 요청은 어떻게 되나요?
해설 보기
10.1.2.3도 0.0.0.0/0에 속하므로 맨 위의 차단 규칙에 먼저 걸려 사내망의 SSH까지 막힙니다. 처음 맞는 규칙을 따르므로 아래의 허용 규칙에는 닿지 않습니다. 좁은 범위의 예외는 넓은 범위의 규칙보다 위에 두어야 합니다. 기본 규칙을 허용으로 바꾸면, 적어 두지 않은 3306번 포트가 인터넷 전체에 열려 데이터베이스가 그대로 드러납니다. 필요한 것을 빠뜨렸을 때 막히는 쪽이 안전하므로, 기본 규칙은 차단으로 두고 필요한 포트만 적어서 엽니다.
3. 서버마다 따로 있는 통
서버 A와 B가 같은 사용자를 위해 각자 capacity=20인 토큰 버킷을 메모리에 두고 있습니다. 같은 순간에 그 사용자의 요청이 A로 20개, B로 20개 오면 모두 몇 개가 허용되나요? 사용자마다 20개라는 한도는 지켜지나요?
해설 보기
각 서버의 통이 20개씩 허용하므로 모두 40개가 허용됩니다. 서버마다의 계산은 맞지만 두 통이 상태를 나누지 않으므로, 서버가 n대라면 한도도 n배가 되어 사용자마다 20개라는 정책은 지켜지지 않습니다. 정책을 지키려면 두 서버가 같은 공유 저장소의 통을 보고, 토큰을 채우고 꺼내는 일을 하나의 원자적 연산으로 처리해야 합니다. 로드 밸런서가 한 사용자를 언제나 같은 서버로 보내게 하는 방법도 있지만, 서버가 늘거나 줄 때 한도가 흔들립니다.
다음 장 소켓 API 기본에서는 지금까지 본 TCP와 UDP를 프로그램에서 직접 다루는 소켓 API의 구조와 주요 함수를 살펴봅니다.