
2025년 4월 11일, Cloudflare는 자사 프록시 Pingora에 대한 제보를 받았습니다. 캐시에 응답이 이미 있을 때 프록시가 요청 본문을 끝까지 읽지 않고 넘어갔고, 읽지 않은 데이터가 재사용되는 연결에 남았습니다. 그 데이터가 다음 요청의 일부로 읽히면서 오리진으로 가는 헤더와 URL이 바뀔 수 있었습니다. Cloudflare는 22시간 안에 완화 조치를 넣고 4월 19일 패치를 배포했습니다(CVE-2025-4366). 악용 증거는 없었지만, 요청 밀반입 연구를 이끈 PortSwigger는 이 한 건이 2,400만 개가 넘는 웹사이트를 사이트 전체 탈취 위험에 노출했다고 백서에 적었습니다(PortSwigger의 주장이고, Cloudflare 포스트모템에는 이 수치가 없습니다). 프록시 코드 한 줄이 "요청이 어디서 끝나는가"를 서버마다 다르게 만들었습니다.
이 시리즈의 공격을 실제 쇼핑몰 코드에서 직접 재연하고 막아 보는 인프런 강의가 열렸습니다. 실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩 · 이 글과 이어지는 수업: 11-1, 11-2, 11-3, 11-5
이 글의 범위. 세 기법의 원리와 방어를 다룹니다. 공격 요청 원문, 밀반입 요청 예시, 우회용 URL 문자열, 재현 절차와 도구 사용법은 싣지 않습니다. 원리는 그림과 말로 설명하고, 코드와 설정은 모두 방어 쪽이며 호스트·주소·도메인은 가짜 값입니다. 사실관계는 각 연구 기관·벤더의 공개 자료(2026-11-01 기준)를 따르고, 원문을 직접 확인하지 못한 항목은 [추정]으로 표시합니다.
| 기법 | 한 줄 정의 | 대표 사례 | 막는 핵심 | 가장 가까운 OWASP 2025 |
|---|---|---|---|---|
| 파서 차이(URL·경로 해석 혼동) | 검사하는 코드와 사용하는 코드가 같은 입력을 다른 파서로 읽어, 검사한 대상과 실제 대상이 달라진다 | Apache HTTP Server Confusion Attacks(DEVCORE, 2024-08), URL 파서 16종 비교 연구(Claroty·Snyk, 2022-01) | 한 번 파싱한 결과 객체로 검사하고 같은 객체로 사용, 정규화 뒤 검사 | A01 접근 통제 실패, A05 인젝션 |
| HTTP 요청 밀반입(디싱크) | 앞단 프록시와 뒷단 서버가 요청의 끝을 다르게 판단해, 한 요청 안에 다른 요청이 숨어 들어간다 | PortSwigger "HTTP/1.1 must die"(2025-08), Akamai CVE-2025-32094, Cloudflare Pingora CVE-2025-4366 | 프록시→백엔드 구간 HTTP/2, 모호한 요청 거부 | A02 보안 설정 오류, A06 안전하지 않은 설계(느슨) |
| SMTP 밀반입 | 송신 메일 서버와 수신 메일 서버가 메일 본문의 끝을 다르게 해석해, 정상 서버를 거친 위조 메일이 SPF를 통과한다 | SEC Consult 공개(2023-12-18): Exchange Online, GMX·Ionos, Cisco, Postfix·Sendmail | 수신 쪽 bare LF 거부·정규화, MTA 패치, DMARC | A07 인증 실패(느슨) |
세 기법은 대상 프로토콜이 다를 뿐 구조가 같습니다. 같은 바이트를 두 프로그램이 읽고, 둘의 해석이 어긋나는 지점에서 생깁니다. 그래서 이 글에서는 셋을 한 편으로 묶었습니다. 다음 편에서 다룰 웹 캐시 속임도 캐시와 오리진 사이의 같은 문제입니다.
요즘 웹 요청 하나는 보통 여러 프로그램을 거칩니다. 브라우저에서 출발한 요청은 CDN, 로드밸런서, 리버스 프록시를 지나 Spring이나 Node 앱 서버에 닿습니다. 앱 안에서도 입력 검증 코드와 실제로 외부에 요청을 보내는 HTTP 클라이언트는 서로 다른 라이브러리일 때가 많습니다. 메일도 같습니다. 우리 서버가 보낸 메일은 송신 메일 서버(MTA)를 거쳐 상대 회사의 수신 MTA로 들어갑니다.
이 프로그램들은 각자 자기 파서를 갖고 있습니다. 파서는 바이트 덩어리를 "이건 호스트, 이건 경로", "요청은 여기까지", "메일은 여기서 끝" 같은 구조로 바꾸는 부분입니다. 표준 문서(RFC)가 규칙을 정해 두었지만, 실제 구현은 조금씩 다릅니다. 이유는 대개 선의에서 나옵니다.
한 프로그램만 놓고 보면 모두 합리적인 선택입니다. 문제는 두 프로그램이 같은 입력에 대해 다른 결론을 낼 때 생깁니다. 앞 프로그램이 검사하고 뒤 프로그램이 실행하는 구조라면, 공격자는 앞에서는 무해하게 보이고 뒤에서는 다른 뜻이 되는 입력을 만들 수 있습니다. 각 프로그램을 따로 테스트해서는 이 틈이 보이지 않습니다.

흔한 예부터 보겠습니다. 사용자가 이미지 주소를 입력하면 서버가 그 이미지를 내려받아 썸네일을 만드는 기능이 있다고 합시다. 서버가 아무 주소에나 요청을 보내면 내부망이나 클라우드 메타데이터 주소를 대신 조회해 주는 SSRF(서버 측 요청 위조)가 됩니다. 그래서 개발자는 검사 코드를 넣습니다. "URL을 파싱해서 호스트가 허용 목록에 있을 때만 요청한다."
여기서 틈이 생기는 방식은 이렇습니다.
두 라이브러리가 엇갈리는 지점은 URL 표준의 구석진 곳입니다. Claroty Team82와 Snyk이 2022년 1월에 Python urllib·urllib3, Java URL·URI, Node url·url-parse, Go net/url, PHP parse_url, curl 등 파서 16종을 비교했더니, 불일치는 다섯 유형으로 정리됐습니다. 이 글에서는 어떤 부분이 문제가 되는지만 짚겠습니다.
같은 문제가 웹 서버 내부에서도 일어납니다. 2024년 8월 9일 보안 회사 DEVCORE가 Black Hat USA 2024에서 공개한 Apache HTTP Server "Confusion Attacks"가 그 예입니다. Apache는 여러 모듈이 요청 정보를 담은 구조체 하나를 돌려 가며 고칩니다. 어떤 모듈은 한 필드를 "파일 시스템 경로"로 이해하고, 다른 모듈은 같은 필드를 "URL 경로"로 이해했습니다. DEVCORE는 이 혼동을 파일 이름 혼동, 문서 루트 혼동, 핸들러 혼동 세 계열로 정리했습니다. 한 모듈이 접근 통제를 위해 확인한 경로와 다른 모듈이 실제로 연 파일이 다를 수 있었다는 뜻입니다.
결과만 보면 OWASP Top 10:2025의 A01 접근 통제 실패(SSRF가 2025판에서 여기로 합쳐졌습니다)나 A05 인젝션에 해당합니다. 하지만 원인인 "파서 두 개가 같은 입력을 다르게 해석한다"는 어떤 항목의 이름에도 없습니다. Top 10은 앱 하나의 결함을 기준으로 위험을 분류하는데, 파서 차이는 두 구성 요소 사이에서만 생깁니다. 검사 코드만 떼어 놓고 보면 문제가 없고, HTTP 클라이언트만 떼어 놓고 봐도 문제가 없습니다. 이 글의 2장(요청 밀반입)과 3장(SMTP 밀반입), 다음 편의 캐시 속임도 모두 이 계열의 변형입니다.
① 코드: 한 번 파싱하고, 그 결과 객체로 검사하고, 같은 객체로 호출한다
방어의 첫 원칙은 간단합니다. 문자열을 두 번 파싱하지 않습니다. 파싱해서 얻은 객체를 검사에도 쓰고 호출에도 씁니다. 검사를 통과한 원래 문자열을 다시 넘기는 순간, 다음 라이브러리가 다르게 읽을 기회가 생깁니다.
// 방어 예시: 파싱 한 번, 같은 객체로 검사와 요청 (호스트는 가짜 값)
static final Set<String> ALLOWED_HOSTS = Set.of("img.partner.example.com");
URI uri = URI.create(userInput).normalize();
if (!"https".equals(uri.getScheme()) || uri.getUserInfo() != null
|| !ALLOWED_HOSTS.contains(uri.getHost())) {
throw new ForbiddenException();
}
InetAddress addr = InetAddress.getByName(uri.getHost());
if (addr.isSiteLocalAddress() || addr.isLoopbackAddress() || addr.isLinkLocalAddress()) {
throw new ForbiddenException();
}
HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NEVER) // 리다이렉트로 다른 곳에 가지 않게
.build();
HttpRequest req = HttpRequest.newBuilder(uri).GET().build(); // 문자열 재조립 없이 같은 URI 사용
Python이라면 urlsplit 결과로 검사하고, 요청할 때는 검사한 구성 요소로 다시 조립한 URL을 씁니다. 사용자 정보 부분이 있으면 아예 거부합니다. 이 자리를 정상적으로 쓰는 경우는 드물고, 파서 차이가 가장 자주 생기는 곳이기 때문입니다.
# 방어 예시: urlsplit 결과로 검사하고, 검사한 구성 요소로 다시 조립해 요청 (호스트는 가짜 값)
import ipaddress, socket
from urllib.parse import urlsplit, urlunsplit
import httpx
ALLOWED_HOSTS = {"img.partner.example.com"}
def fetch_image(url_text: str) -> bytes:
p = urlsplit(url_text)
if p.scheme != "https" or p.username is not None or p.password is not None:
raise PermissionError("scheme_or_userinfo")
host = (p.hostname or "").rstrip(".").lower()
if host not in ALLOWED_HOSTS:
raise PermissionError("host_not_allowed")
port = p.port or 443
for *_, sockaddr in socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP):
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
raise PermissionError("internal_ip")
safe_url = urlunsplit(("https", f"{host}:{port}", p.path or "/", p.query, ""))
r = httpx.get(safe_url, follow_redirects=False, timeout=5.0)
r.raise_for_status()
return r.content
두 예시 모두 DNS 해석 뒤의 실제 IP도 확인합니다. 호스트 이름이 허용 목록에 있어도, 그 이름이 가리키는 IP가 사설 대역이면 막아야 하기 때문입니다. 다만 이 코드에도 틈이 하나 남습니다. 검사할 때 해석한 IP와 HTTP 클라이언트가 접속할 때 다시 해석한 IP가 다를 수 있습니다(검사 시점과 사용 시점의 차이, TOCTOU). 이 틈은 아래 ④의 이그레스 프록시가 막습니다. DNS 쪽 문제는 04편 DNS 리바인딩에서 자세히 다룹니다.
② 코드: 경로는 정규화한 뒤에 검사한다
파일 경로도 같습니다. 순서는 "디코딩 → 정규화(.. 제거, 절대 경로로 변환) → 허용 범위 검사 → 사용"입니다. 검사를 먼저 하고 정규화를 나중에 하면, 검사한 경로와 연 경로가 달라집니다.
# 방어 예시: 정규화한 실제 경로가 허용 디렉터리 안에 있는지 확인한 뒤에 연다
from pathlib import Path
from urllib.parse import unquote
BASE = Path("/srv/app/user-files").resolve()
def open_user_file(rel: str):
decoded = unquote(rel) # 디코딩은 한 번만
if "%" in decoded or "\\" in decoded: # 남은 인코딩·백슬래시는 해석 차이를 낳으므로 거부
raise PermissionError("ambiguous_path")
target = (BASE / decoded).resolve() # 심볼릭 링크와 ..까지 풀어 실제 경로를 얻는다
if not target.is_relative_to(BASE):
raise PermissionError("outside_base")
return target.open("rb") # 검사한 그 Path 객체로 연다
③ 설정: 웹 서버와 프레임워크의 정규화 기본값을 지킨다
RewriteRule의 치환 대상에 사용자 입력(요청 경로나 쿼리 일부)이 들어가는 규칙은 하나씩 다시 검토합니다. 2.4.60의 일부 수정은 하위 호환을 깨므로, 업그레이드 전에 스테이징에서 재작성 규칙을 시험합니다.merge_slashes(연속 슬래시 합치기)처럼 앞단이 정규화한 경로와 뒷단이 받는 경로가 같은지 확인합니다. 앞단은 합치고 뒷단은 합치지 않으면, 앞단의 경로 기반 접근 통제와 뒷단의 라우팅이 서로 다른 경로를 보게 됩니다.StrictHttpFirewall은 기본값을 유지합니다. 인코딩된 슬래시, 세미콜론, 백슬래시 같은 모호한 경로 요소가 든 요청을 기본으로 거부합니다. 레거시 클라이언트 때문에 이 방화벽을 느슨하게 바꾼 코드가 있다면 그 이유가 아직 유효한지 확인합니다.④ 아키텍처: 아웃바운드는 이그레스 프록시를 거치게 한다
앱 코드의 파서가 아무리 조심해도 마지막 관문은 네트워크에 둡니다. 서버가 밖으로 보내는 요청은 모두 전용 이그레스(나가는 트래픽) 프록시를 거치게 하고, 거기서 IP 수준 허용 목록을 적용합니다. 이그레스 프록시는 앱이 어떤 라이브러리로 URL을 해석했는지와 상관없이 실제로 접속하려는 IP를 봅니다. 앱 서버 자체는 인터넷과 내부망에 직접 나가지 못하게 보안 그룹이나 네트워크 정책으로 막아 둡니다.

HTTP 요청 밀반입(HTTP Request Smuggling)은 디싱크(desync, 동기화 어긋남)라고도 부릅니다. 이해하려면 두 가지 사실을 알아야 합니다.
첫째, 프록시와 백엔드는 연결을 재사용합니다. 사용자마다 백엔드로 새 TCP 연결을 여는 것은 비쌉니다. 그래서 CDN이나 로드밸런서는 백엔드와 연결 몇 개를 열어 두고, 여러 사용자의 요청을 한 연결에 차례로 실어 보냅니다. 한 연결 위에 사용자 1의 요청, 사용자 2의 요청, 사용자 3의 요청이 줄지어 흐릅니다. 백엔드는 이 바이트 흐름을 잘라 "여기까지가 요청 하나"라고 판단해야 합니다.
둘째, HTTP/1.1은 요청의 끝을 정하는 방법이 여러 개입니다. 요청 헤더는 빈 줄로 끝나므로 분명합니다. 모호한 쪽은 본문입니다. 본문 길이를 숫자로 적는 Content-Length 헤더가 있고, 본문을 조각으로 나눠 보내며 조각마다 길이를 적는 Transfer-Encoding: chunked 방식이 있습니다. 본문이 없다고 간주하는 경우가 있고, 클라이언트가 본문을 보내기 전에 서버 허락을 기다리는 Expect 처리도 있습니다. 표준(RFC 9112)은 길이 헤더와 조각 전송이 함께 오면 어떻게 처리할지 정해 두었지만, 모든 구현이 표준을 똑같이 따르지는 않습니다.
두 사실을 합치면 문제가 보입니다. 앞단은 "사용자 1의 요청은 여기까지"라고 판단해 전체를 한 요청으로 백엔드에 넘깁니다. 그런데 백엔드는 다른 규칙으로 읽어서 요청이 더 일찍 끝났다고 판단합니다. 그러면 사용자 1(공격자)이 보낸 바이트 중 일부가 처리되지 않고 연결에 남습니다. 백엔드는 그 남은 조각을 다음 요청의 시작으로 봅니다. 곧이어 같은 연결로 사용자 2의 정상 요청이 들어오면, 백엔드는 "공격자가 남긴 조각 + 사용자 2의 요청"을 하나의 요청으로 처리합니다.
결과는 이렇습니다.
HTTP/2는 이 모호함이 없습니다. HTTP/2는 데이터를 프레임 단위로 보내고 프레임마다 길이를 적습니다. 요청의 끝도 프레임 플래그로 분명히 표시합니다. 다만 앞단이 브라우저와는 HTTP/2로 통신하고, 백엔드로는 HTTP/1.1로 바꿔서(다운그레이드해서) 보내면 모호함이 되살아납니다. 앞단이 HTTP/2 요청을 HTTP/1.1 바이트로 다시 쓰는 과정에서 길이 정보가 엇갈릴 수 있기 때문입니다.
가장 가깝게는 A02 보안 설정 오류나 A06 안전하지 않은 설계입니다. 하지만 각 서버는 "자기 기준으로는" 올바르게 동작합니다. 앞단 프록시만 테스트해도, 백엔드만 테스트해도 결함이 나오지 않습니다. 취약점은 특정 앞단과 특정 백엔드의 조합에서만 생깁니다. 앱 코드의 결함을 중심으로 분류하는 Top 10에는 이런 조합의 문제가 들어갈 자리가 없습니다. PortSwigger는 2025년 연구에서 한 걸음 더 나가, 밀반입의 원인을 개별 구현의 버그보다 HTTP/1.1이라는 프로토콜 자체의 결함으로 봤습니다.
Expect 헤더 처리와 본문이 있는 OPTIONS 요청을 전달하는 방식 때문에, 요청 경로에 있던 Akamai 서버 두 대가 같은 요청을 서로 다르게 파싱했습니다. 같은 회사의 서버끼리도 해석이 어긋날 수 있다는 사례입니다. Akamai는 플랫폼 전체에 수정을 배포했고 악용 증거는 없다고 밝혔습니다. 연구 원문에 따르면 Akamai 고객사들이 이 문제로 74건, 총 22.1만 달러의 바운티를 지급했습니다.① 아키텍처: 프록시→백엔드 구간을 HTTP/2로
가장 근본적인 방어입니다. 앞단에서 백엔드로 가는 구간을 HTTP/2(가능하면 TLS 위의 h2)로 바꾸면 요청 경계가 프레임 길이로 정해지므로 모호함이 사라집니다. PortSwigger가 2025년 8월 기준으로 정리한 업스트림 HTTP/2 지원 현황은 다음과 같습니다.
| 지원 | 제품 |
|---|---|
| 업스트림 HTTP/2 지원 | HAProxy, F5 BIG-IP, Google Cloud Load Balancer, Imperva, Apache(실험적) |
| 당시 미지원 | nginx(proxy_pass), Akamai, CloudFront, Fastly |
| 내부 HTTP/1 사용 | Cloudflare |
# 방어 예시(가짜 값): HAProxy에서 백엔드로 HTTP/2 over TLS
backend app
server app1 10.0.0.5:8443 ssl verify required ca-file /etc/haproxy/ca.pem alpn h2
여기서 국내 개발자가 자주 놓치는 지점이 있습니다. "우리 사이트는 HTTP/2를 쓴다"는 말은 대개 브라우저와 CDN 사이 이야기입니다. CDN에서 로드밸런서로, 로드밸런서에서 Spring이나 Node 서버로 가는 구간이 HTTP/1.1이라면 그 구간이 공격면입니다. 우리 서비스의 각 홉이 어떤 프로토콜로 통신하는지 그림으로 그려 보는 것부터 시작합니다.
또 하나는 홉 수와 구현 종류를 줄이는 것입니다. CDN, WAF, 로드밸런서, 사이드카 프록시, 앱 서버가 모두 다른 회사의 HTTP 구현이라면, 인접한 두 홉마다 해석이 어긋날 기회가 생깁니다. 파서의 다양성이 곧 공격면입니다. 꼭 필요하지 않은 프록시 단계는 없애고, 가능하면 같은 계열의 구현을 씁니다.
② 설정: 모호한 요청은 고치지 말고 거부한다
앞단과 뒷단 모두 "모호한 요청은 거부" 모드로 둡니다. 관대하게 고쳐서 넘기는 동작이 바로 해석 차이를 만들기 때문입니다. AWS ALB를 쓴다면 PortSwigger가 명시적으로 권고한 두 설정이 있습니다.
# 방어 예시(가짜 값): AWS ALB에서 모호한 요청과 잘못된 헤더를 거부
resource "aws_lb" "app" {
name = "app-alb-example"
load_balancer_type = "application"
subnets = ["subnet-0fake0001", "subnet-0fake0002"]
desync_mitigation_mode = "strictest" # 표준에 어긋나는 요청은 모두 거부
drop_invalid_header_fields = true # 형식이 잘못된 헤더 필드는 버림
}
앱 서버에서도 마지막 줄을 하나 더 둘 수 있습니다. 아래 미들웨어는 본문이 없어야 하는 메서드에 본문 관련 헤더가 있으면 400으로 거부합니다. 앞단 설정을 대신하지는 못하지만, 앞단 설정이 바뀌거나 빠졌을 때 알아챌 수 있는 장치입니다.
# 방어 예시: 본문이 없어야 하는 메서드에 본문 헤더가 오면 거부하는 ASGI 미들웨어
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
class RejectBodyOnSafeMethods:
def __init__(self, app, logger):
self.app, self.log = app, logger
async def __call__(self, scope, receive, send):
if scope["type"] == "http" and scope["method"] in SAFE_METHODS:
names = [k.lower() for k, _ in scope["headers"]]
values = {k.lower(): v for k, v in scope["headers"]}
has_te = b"transfer-encoding" in names
has_body_len = values.get(b"content-length", b"0") not in (b"", b"0")
if has_te or has_body_len or names.count(b"content-length") > 1:
self.log.warning("body_on_safe_method", extra={"path": scope["path"]})
await send({"type": "http.response.start", "status": 400,
"headers": [(b"connection", b"close")]})
await send({"type": "http.response.body", "body": b""})
return
await self.app(scope, receive, send)
거부할 때 Connection: close를 붙여 그 연결을 닫는 것도 중요합니다. 해석이 의심스러운 요청을 받은 연결은 다음 요청에 재사용하지 않는 편이 안전합니다.
③ 코드: HTTP 파서와 프록시를 직접 만들지 않는다
사내 API 게이트웨이나 프록시를 직접 만드는 팀이 있습니다. 소켓에서 바이트를 읽어 헤더를 직접 나누는 코드라면 거의 확실하게 해석 차이가 생깁니다. 검증된 라이브러리(서버 프레임워크나 프록시 엔진) 위에 올리고, RFC 9112의 "길이 헤더와 조각 전송 헤더가 함께 오면 거부하거나 한쪽으로 정규화한다" 규칙이 지켜지는지 회귀 테스트로 고정합니다. 라이브러리를 업그레이드할 때마다 이 테스트가 돌게 하면, 동작이 바뀌었을 때 바로 알 수 있습니다.
④ 패치와 공지 구독
Akamai, Cloudflare 사례처럼 밀반입은 CDN·프록시 제품 쪽에서 고쳐야 하는 경우가 많습니다. 우리가 쓰는 프록시, 로드밸런서, 오픈소스 게이트웨이의 보안 공지를 구독하고, 공지가 나오면 버전을 확인하는 절차를 둡니다.
# 탐지 예시: 분 단위로 프록시와 백엔드의 요청 수를 비교 (로그 형식은 가짜 예시)
from collections import Counter
def per_minute(lines, ts_index):
return Counter(line.split()[ts_index][:16] for line in lines) # "2026-11-01T10:15"
def compare(proxy_lines, backend_lines, tolerance=0.02):
p, b = per_minute(proxy_lines, 0), per_minute(backend_lines, 0)
for minute in sorted(set(p) | set(b)):
diff = abs(p[minute] - b[minute])
if p[minute] and diff / p[minute] > tolerance:
print(f"[mismatch] {minute} proxy={p[minute]} backend={b[minute]}")
헬스 체크나 앞단에서 바로 응답한 요청(캐시 적중, 차단)은 백엔드에 가지 않으므로, 비교하기 전에 두 로그에서 같은 범위의 요청만 남겨야 합니다.

메일 서버끼리는 SMTP라는 텍스트 프로토콜로 대화합니다. 송신 서버가 "보내는 사람은 누구", "받는 사람은 누구"를 알린 뒤 데이터 전송을 시작하면, 본문을 줄 단위로 보내고 약속된 줄바꿈 시퀀스로 "본문 끝"을 표시합니다. 본문 끝 표시 뒤에는 다음 SMTP 명령이 올 수 있습니다. 같은 연결로 메일 여러 통을 이어서 보낼 수 있기 때문입니다.
RFC 5321은 이 줄바꿈으로 표준 CRLF(캐리지 리턴 + 라인 피드)만 허용하라고 정합니다. 그런데 일부 서버는 오래된 클라이언트를 배려해 "관대하게" 변형된 줄바꿈(LF만 있거나 CR만 있는 것, bare LF·bare CR)도 줄의 끝으로 인정합니다.
여기서 HTTP 밀반입과 똑같은 구조가 나옵니다.
두 번째 메일이 위험한 이유는 SPF 때문입니다. SPF는 "이 도메인의 메일은 이 IP들에서만 나간다"를 DNS에 적어 두고, 수신 서버가 보낸 IP를 대조하는 장치입니다. 두 번째 메일은 정상 송신 서버의 IP에서 왔으므로, 그 송신 서비스를 쓰는 다른 도메인 명의로 위조해도 SPF를 통과합니다. 수신자 입장에서는 정상 검증을 거친 메일로 보입니다.
결과는 발신자 위장이므로 A07 인증 실패에 느슨하게 닿습니다. 하지만 웹 앱 바깥, 메일 서버(MTA)끼리 프로토콜을 해석하는 문제라서 웹 앱 위험 목록인 Top 10의 범위 밖입니다. 그런데 백엔드 개발자와 무관한 이야기는 아닙니다. 회원가입 인증 메일, 비밀번호 재설정 메일, 주문 알림을 보내는 서비스라면 우리 도메인이 위조 대상이 될 수 있고, 사내에 오래된 메일 릴레이를 운영한다면 그 서버가 위조 메일의 경유지가 될 수 있습니다.
smtpd_forbid_bare_newline 옵션을 추가했습니다. 3.9에서 기본 활성화됐고, 이전 지원 브랜치에는 패치 릴리스로 옵션이 들어갔습니다(정확한 버전 번호는 원문 미확인 [추정]).① 설정: 수신 MTA는 bare LF·bare CR을 거부하거나 정규화한다
자체 Postfix를 운영한다면 버전을 확인하고 옵션을 켭니다.
# 방어 예시: Postfix 버전과 bare newline 처리 설정 확인
postconf mail_version
postconf smtpd_forbid_bare_newline
# /etc/postfix/main.cf (방어 예시)
# 표준이 아닌 줄바꿈을 정규화한다. 옵션이 없는 버전이면 먼저 패치 릴리스로 올린다
smtpd_forbid_bare_newline = normalize
Cisco 이메일 보안 장비를 쓴다면 CR·LF 처리 옵션이 기본값("Clean")으로 남아 있는지 확인하고 재검토합니다. 벤더가 기본값을 바꾸지 않았으므로, 운영자가 확인하지 않으면 그대로 남습니다.
② 코드: 앱이 보내는 메일의 본문·헤더에서 CR·LF를 정규화한다
앱이 메일을 보낼 때(Spring JavaMailSender, Python smtplib 등) 사용자 입력이 제목이나 본문에 들어간다면, 줄바꿈 문자를 정리합니다. 헤더 값에는 줄바꿈이 아예 없어야 하고, 본문 줄바꿈은 한 형식으로 통일한 뒤 라이브러리가 표준 형식으로 바꾸게 둡니다. 이 처리는 별개 문제인 "메일 헤더 인젝션"(제목 칸에 줄바꿈을 넣어 수신자나 헤더를 추가하는 공격)도 함께 막습니다.
# 방어 예시: 헤더 값의 줄바꿈은 거부하고, 본문 줄바꿈은 한 형식으로 통일한다 (주소는 가짜 값)
import os, smtplib
from email import policy
from email.message import EmailMessage
def header_value(v: str) -> str:
if "\r" in v or "\n" in v:
raise ValueError("newline_in_header")
return v
def body_text(v: str) -> str:
return v.replace("\r\n", "\n").replace("\r", "\n") # 섞인 줄바꿈을 LF 하나로
def send_notice(to_addr: str, subject: str, body: str):
msg = EmailMessage(policy=policy.SMTP) # 보낼 때 표준 CRLF로 직렬화
msg["From"] = "no-reply@mail.example.com"
msg["To"] = header_value(to_addr)
msg["Subject"] = header_value(subject)
msg.set_content(body_text(body))
with smtplib.SMTP("smtp.mail-provider.example", 587, timeout=10) as s:
s.starttls()
s.login("app@mail.example.com", os.environ["SMTP_PASSWORD"])
s.send_message(msg)
SMTP 비밀번호 같은 시크릿(코드와 로그 밖에 숨겨야 하는 값)은 위처럼 환경 변수나 시크릿 관리 서비스에서 읽고, 코드에 적지 않습니다.
③ 아키텍처: 자체 MTA보다 패치가 확인된 발송 서비스, 그리고 DMARC
p=none(관찰) → p=quarantine(격리) → p=reject(거부) 순서로 단계적으로 올립니다. DMARC는 SPF·DKIM 결과와 보이는 발신 도메인이 일치하는지 확인하게 하는 장치입니다. 다만 SEC Consult는 DMARC만으로는 막히지 않는 경우가 있다고 분명히 적었습니다. 수신 쪽 정규화와 함께 써야 합니다.; 방어 예시(가짜 도메인): 관찰 단계로 시작하고, 보고서를 확인하며 reject까지 올린다
_dmarc.example.com. 3600 IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.com"

세 기법을 나란히 놓으면 방어도 같은 모양입니다. 코드에서는 한 번 해석한 결과를 끝까지 쓰고, 설정에서는 모호한 입력을 고쳐 주는 대신 거부하고, 아키텍처에서는 해석하는 곳의 수를 줄이거나 해석 방식이 분명한 프로토콜로 바꿉니다. 탐지는 "두 지점의 숫자가 맞는가"를 보는 일입니다. 앱이 검사한 목적지와 이그레스 프록시가 본 목적지, 프록시가 넘긴 요청 수와 백엔드가 처리한 요청 수, 우리가 보낸 메일과 DMARC 보고서에 찍힌 메일을 비교합니다.
RewriteRule을 검토했다strictest, 잘못된 헤더 버림)오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.
실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩
서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.
백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지
수치와 사실은 2026년 11월 1일 기준 공개 자료입니다. 원문을 직접 확인하지 못한 항목(CVE-2024-38475의 KEV 등재일, SMTP 밀반입 관련 CVE 번호와 Postfix 옵션 도입 버전)은 본문에 [추정]으로 표시했습니다. "2,400만 개 이상 웹사이트" 수치는 PortSwigger 백서의 주장이며 Cloudflare 포스트모템에는 없습니다.
이전 글 바이낸스 64억 달러 탈취 해부: EIP-7702 위임과 서명 시스템 장악, 세탁 경로, 그리고 서버·클라이언트 방어법
다음 글 남의 계정 페이지가 캐시에 남는다: 캐시 속임과 캐시 오염 막기
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록