Balsn CTF 2023 - memes

Balsn CTF 2023 - memes

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à webmemcached 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 header Set-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 header Set-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à STORRETR 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