Cuối tuần trước mình có tham gia BalsnCTF, nhìn chung thì đề khá hay tuy nhiên có một câu trong giải được xem là khó và hầu như trong suốt quá trình làm mình chả nghĩ ra được một attack suface nào hoàn chỉnh mặc dù đã biết ý đồ cuối cùng. Sau giải mình có tham khảo qua solution của một player nên quyết định viết writeup cho challenge này, dù gì cũng nên có một câu trả lời cho nó chứ nhỉ 😳
Sơ lược về challenge
Souce: https://github.com/to016/CTFs/blob/main/2023/BalsnCTF2023/memes/dist.zip
Mô tả:
Một trang web cho phép người dùng tạo meme với các tùy chỉnh về Text (thêm vào meme), color.
Sau khi ấn Create Image, sẽ hiển thị tất cả các meme đã được tạo tại /list
Phân tích
Cùng nhìn qua source của challenge, từ docker-compose.yml
có thể thấy ứng dụng bao gồm 2 thành phần là web
và memcached
cùng thuộc một subnet 10.87.0.1/16
.
Dựa vào Dockerfile suy ra cần rce để thực thi /readflag
Bên cạnh đó trang web được build trên laravel framework, có định nghĩa một vài routes như sau
ImageController
xử lí các thao tác về việc tạo meme cũng như hiển thị, list các meme đã tạo
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ImageController extends Controller
{
public function index(Request $request)
{
if (!$request->input('image')) {
$images = glob('memes/*.png');
return view('image', ['images' => $images]);
} else {
$image = $request->input('image');
return view('make-meme', ['image' => $image]);
}
}
public function make(Request $request)
{
$texts = $request->input('texts');
$sampleImage = $request->input('image');
if (empty($texts)) {
return redirect("/?image=$sampleImage");
}
$image = imagecreatefrompng($sampleImage);
foreach ($texts as $text) {
// hex color to rgb
$text['color'] = ltrim($text['color'], '#');
$text['color'] = array_map('hexdec', str_split($text['color'], 2));
// add text
$color = imagecolorallocate($image, $text['color'][0], $text['color'][1], $text['color'][2]);
imagettftext($image, $text['size'], $text['angle'], $text['x'], $text['y'], $color, realpath("arial.ttf"), $text['text']);
}
$saveDir = str_replace(['memes/', '.png'], ['generated/', ''], $sampleImage);
if (!file_exists($saveDir)) {
mkdir($saveDir, 0777, true);
}
$imagePath = "$saveDir/" . bin2hex(random_bytes(8)) . '.png';
imagepng($image, $imagePath);
imagedestroy($image);
// add created image into session
$createdImages = $request->session()->get('createdImages', []);
$createdImages[] = $imagePath;
$request->session()->put('createdImages', $createdImages);
return redirect("/list");
}
public function list(Request $request)
{
$createdImages = $request->session()->get('createdImages', []);
// print html
echo "<link rel='stylesheet' href='https://cdn.simplecss.org/simple.css'>";
echo '<h1>Created Memes</h1>';
foreach ($createdImages as $image) {
echo "<img src='$image' alt='Created Image' style='width: 70%; height: auto;'>";
}
if (empty($createdImages)) {
echo '<p>No memes created yet.</p>';
}
}
}
Endpoint chính dùng để tạo meme là /make
, bên phía server sử dụng gd (một thư viện khá phổ biển cho việc thao tác với hình ảnh) để fetch image, add color, add text và sau đó lưu lại trên remote filesystem
Một request mẫu tương ứng
Từ request trên, các data ta có thể kiểm soát được bao gồm image
, texts
(_token
được gửi kèm là một tính năng của laravel để bảo vệ người dùng khỏi csrf attack nên có thể bỏ qua)
Tuy nhiên việc gọi đến imagecreatefrompng($sampleImage);
với $sampleImage
lấy từ người dùng (image
) dẫn đến ssrf
Vậy đã có bug bước tiếp theo cần làm là gì ?
Bất cứ thứ gì xuất hiện trong một bài ctf đều có lí do của nó (kể cả để bait 🤣) và memcached ở đây cũng vậy, vai trò trên bề nổi -> dùng để lưu sesion data
Quá trình lưu sesion diễn ra tại Illuminate\Session\Store#save()
, với $this->getId()
chính là sesion id và data được lưu có thể ở dạng json encode hoặc serialized.
Vì là memcache driver nên $this->handler
sẽ ứng với MemcachedSessionHandler
Method write()
của class này thực chất sẽ được invoke ở parent class (AbstractSessionHandler
)
Cuối cùng thực hiện set một key có dạng $this->prefix.$sessionId
với data như đã nói ở trên
Quá trình load sesion diễn ra tại Illuminate\Session\Store#loadSession()
, ở bên trong tiếp tục gọi đến readFromHandler()
Dữ liệu lấy từ session driver handler ($this->handler->read()
thực chất sẽ gửi get
command để lấy value) ứng với session id hiện tại được json decode đồng thời unserialize
Memcache injection là kĩ thuật quá quen thuộc trong các bài ctf, và sự xuất hiện của memcache như một thành phần của ứng dụng cũng dần nói lên mục đích cuối cùng: ssrf set giá trị của $this->prefix.$sessionId
thành pop gadget, một khi session được load sẽ trigger insecure deser -> rce -> readflag.
Tuy nhiên ở đây có 2 vấn đề mà mình sẽ đi vào giải quyết từng cái
Làm thế nào để lấy session id ?
SSRF như thế nào trong môi trường php hiện tại ?
Session id trong Laravel
Khác với php ta không thể lấy trực tiếp sesion id từ cookie value, ở Laravel framework giá trị này được encrypt với key trong file .env
và sau đó base64 encode cuối cùng là trả về trong cookie cho người dùng.
giá trị được encrypt sẽ là CookieValuePrefix::create($cookie->getName(), $this->encrypter->getKey())
nối với $cookie->getValue()
(session id)
Chung quy lại vẫn là tìm cách đọc được key từ .env
, để giải quyết vấn đề này ta sử dụng kĩ thuật php filter chain error-based oracle với điểm nhận payload vẫn là image
trong POST data đến /make
.
Về dấu hiệu để detect, trong quá trình thử nghiệm mình set APP_DEBUG=true
và vô trình phát hiện
- đối với trường hợp không thỏa mãn lỗi trả về là
Allowed memory size of 134217728 bytes exhausted
đồng thời response headerSet-Cookie
không được set
- đối với trường hợp thỏa mãn, lỗi lúc này là
... is not a valid PNG file
(bởi vì luồng stream hiện không thỏa mãn là một png file) nhưng response headerSet-Cookie
vẫn được set
do vậy chỉ cần chỉnh logic của tool lại là hoàn toàn có thể leak được file
kết quả
sau khi lấy được key bước tiếp theo mình sử dụng script decrypt trên hacktricks
-> session id là 9gtVlLQbck4fgFyrla3C07Hn4suedsTtbkGJIt0o
Làm thế nào để ssrf ?
Đối với memcahe injection ta có thể dễ dàng thực hiện trong điều kiện tồn tại các multi-line procol (như gopher) nhưng mặc định trong php không register các wrapper hỗ trợ việc này
-> Dẫn đến việc phải áp dụng một kĩ thuật khác có tên gọi "FTP Bounce attack"
FTP Bounce attack
Theo mình được biết thì kĩ thuật này đã xuất hiện khá lâu trước đây, thật ra cũng có gặp qua nó vài lần nhưng đụng tới network nên skip 😓 lần này mới quyết tâm học.
Bản chất của kĩ thuật này liên quan tới 2 mode của ftp: active và passive mode. Xem qua tại đây để hiểu rõ hơn.
Trong ftp có hai command là STOR
và RETR
lần lượt dùng để upload file lên server và lấy file từ server.
Active mode:
Download (RETR): client chỉ định IP và server gửi file đến nó.
Upload (STOR): client chỉ định IP và server lấy file từ đó.
Passive mode:
Download (RETR): server chỉ định IP và client lấy file từ đó.
Upload (STOR): server chỉ định IP và client gửi file đến đó.
(Theo như mình hiểu thì sự khác nhau này phụ thuộc vào bên nào sẽ mở data connection đến bên nào, trong active mode thì server mở connection đến client nên khi nhận command RETR server sẽ gửi file đến địa chỉ nhận được từ PORT command của client, tương tự đối với passive mode)
Đối với kĩ thuật này tùy vào ngữ cảnh mà ta sẽ quyết định sử dụng mode nào để ssrf.
Từ hình ảnh về các wrapper mặc định trên php, có thể thấy tồn tại ftp
hay nói cách khác là ta đang có ftp client.
Cùng xem lại source của challenge, ở dòng 30 nếu $sampleImage
là một ftp wrapper thì sẽ gửi đi RETR
command để fetch image, và sau đó tại dòng 45 sẽ gửi STOR
để lưu image.
Dựa theo ý tưởng đấy, mình setup một ftp server trả về valid image ( có chứa "payload set key" vào một vùng trong file png - tí nữa sẽ nói cụ thể) khi nhận command RETR
từ ftp client, tiếp đó khi nhận STOR
sẽ return 227 Entering Extended Passive Mode (10,87,0,2,0,11211)
để client send file này tới memcache.
Trong qua trình làm mình sử dụng pyftpdlib
để tạo nhanh một ftp server sau đó dùng wireshark bắt các package và viết lại một ftp server dùng riêng cho việc attack.
https://gist.github.com/to016/8b5452808822aa24594284a18f7c82ca
Chèn payload set key vào image
Để chèn payload vào một vùng trong png file nhưng vẫn đảm bảo không bị corrupt mình sử dụng một kĩ thuật được đề cập trong bài blog của Synactik
Những phần trọng điểm:
A PNG file contains two types of chunks : ancillary chunks (that are not required to form a valid PNG) and critical chunks (that are essential in a PNG file) – see the PNG specification for more details. When compressing a PNG file, PHP-GD (and arguably other image compression libraries) will delete ancillary chunks to reduce the size of the output file. This is why the comments in which we injected our PHP payload in the first part did not survive the compression process.
But what if we could inject our payload into a critical chunk of the PNG file ? Surely, these chunks are not destroyed when compressing an image. A perfect candidate to perform such an injection is the PLTE chunk, a critical chunk that contains the « palette » of a PNG image, i.e. a list of color. As per the PNG specification:
« The PLTE chunk contains from 1 to 256 palette entries, each a three-byte series of the form:
Red: 1 byte (0 = black, 255 = red) Green: 1 byte (0 = black, 255 = green) Blue: 1 byte (0 = black, 255 = blue)
The number of entries is determined from the chunk length. A chunk length not divisible by 3 is an error. »
Using the PLTE chunk, we potentially have 256*3 bytes available to inject our payload into such a critical chunk, which should be more than enough. The only constraint being that the length of the payload must be divisible by 3.
Exploit
Sử dụng phpggc để gen payload
Tiếp đó chỉnh lại script chèn payload vào PLTE chunk png file.
Trigger ftp client
Cuối cùng reload lại page và flag
Thanks @Ske for such a nice solution