scufFed

b01lers CTF 2025

3898 words | 19 minutes

b01lers CTF 2025 ran from 19 Apr to 21 Apr and although my team didn’t participate full force, I found that the rev challenges were still incredibly fun and interesting. I also tried some of the web challenges, but bar the trivial ones I didn’t really find much success so I don’t feel the need to document it :p

Reverse Engineering

Labyrinth

Is it just me or has there been an uptick in Graph Theory heavy rev challenges lately…?

Essentially, the challenge requires you to navigate a maze with some polymorphic properties. Each section of the maze has this sort of structure:

 1      FUN_001013b6:                                    
 2    PUSH       RBP
 3    MOV        RBP,RSP
 4    PUSH       0x0
 5    PUSH       0x0
 6    LEA        R13,[FUN_001013b6]
 7    CALL       read_input                      
 8    MOVZX      EAX,byte ptr [DAT_00106011]     
 9    MOV        R15,R13
10    ADD        R15,qword ptr [RSP]=>local_18
11    CMP        AL,0x0
12    JNZ        LAB_001013ed
13    ADD        R15,0x806
14    MOV        RAX,0x0
15    PUSH       R15
16    RET
17
18      LAB_001013ed:                                    
19    CMP        AL,0x1
20    JNZ        LAB_00101402
21    ADD        R15,0x1c80
22    MOV        RAX,0x1
23    PUSH       R15
24    RET
25
26      LAB_00101402:                                    
27    CMP        AL,0x2
28    JNZ        LAB_00101417
29    ADD        R15,0x1c80            
30    MOV        RAX,0x0
31    PUSH       R15
32    RET
33
34      LAB_00101417:                                    
35    CMP        AL,0x3
36    JNZ        LAB_0010142c
37    ADD        R15,0x1c80
38    MOV        RAX,0x0
39    PUSH       R15
40    RET
41
42      LAB_0010142c:                                    
43    JMP        exit_maze

read_input just reads in the characters ['L', 'D', 'U', 'R'], mapping it to [0, 1, 2, 3]. Afterwards, the chosen direction calculates $R15 + offset, where $R15 = 0x01013b6 at the start of the section, and jumps to it. All of these $R15 + offset work out to other similar blocks of code.

The win condition comes into play in exit_maze:

1        exit_maze:                                     
2    POP        R15
3    POP        RAX
4    LEAVE
5    RET

It necessates that POP RAX yields $RAX = 0x1 so that you can actually complete the maze. Of all the blocks that we can traverse to, there is only one which will allow us to fulfill this condition:

 1...
 2            LAB_00101d25:                    
 3    CMP        AL,0x1
 4    JNZ        LAB_00101d43
 5    ADD        R15,0x1c80
 6    MOV        RAX,0x1
 7    MOV        qword ptr [RSP + 0x8],0x1
 8    PUSH       R15
 9    RET
10...

So it seems straightforward enough, right? Just DFS/BFS from the first block until you reach this target block. However, there is a catch — that would be the polymorphic property I mentioned earlier.

Some blocks include chunks of code following this sort of pattern preceeding the routing logic (in pseudocode for added clarity):

 1...
 2  if (*(int *)(unaff_R13 + 0x619) == 0x17d4) {
 3     *(undefined4 *)(unaff_R13 + 0x619) = 0x1c80;
 4     *(undefined4 *)(unaff_R13 + 0xaed) = 0x1474;
 5     *(undefined4 *)(unaff_R13 + 0x18f0) = 0x1c80;
 6     *(undefined4 *)(unaff_R13 + 0x91f) = 0x17d4;
 7     *(undefined4 *)(unaff_R13 + 0x192f) = 0x22b;
 8  }
 9  else {
10     *(undefined4 *)(unaff_R13 + 0x619) = 0x17d4;
11     *(undefined4 *)(unaff_R13 + 0xaed) = 0x1c80;
12     *(undefined4 *)(unaff_R13 + 0x18f0) = 0x15b8;
13     *(undefined4 *)(unaff_R13 + 0x91f) = 0x1c80;
14     *(undefined4 *)(unaff_R13 + 0x192f) = 0x1c80;
15  }
16...

Recall that on chosing a direction, the following code block looks like this:

1...
2    CMP        AL,0x2
3    JNZ        LAB_00101417
4    ADD        R15,0x1c80  ; <== This constant is being changed!
5    MOV        RAX,0x0
6    PUSH       R15
7    RET
8...

So when you enter these blocks, it will act as a sort of “gate”, switching the edges between the different blocks, and on re-entry to the block, it will switch the edges back to the original state. This produces an interesting problem as once you enter a gate, it essentially changes the graph that you’re traversing.

This can be remedied, however. Consider the following:

As such, we are essentially traversing across $2^n$ graphs, where $n$ is the number of gates. You can BFS/DFS as per normal, just with the added detail of the gate mask. Thankfully, there are only 5 gates, which makes this managable. We can thus solve this challenge statically by constructing the graph by pattern matching against the assembly to form our blocks while noting down the state transitions.

Being perfectly honest the solve script looks terrible as I just whacked the script with Gemini, but the gist of it is as follows:

Our crappy BFS code looks a little like this:

 1adj_list = {} # node : [neigbours]
 2starts = [0x13c5] # node start addr
 3
 4"""
 5all_section_results.append({
 6    "section": i + 1,
 7    "start": section_start_addr + 5,
 8    "end": section_end_addr,
 9    'updates': gate_updates,
10    "target": target_offsets
11})
12"""
13for i in range(len(all_section_results)):
14    s = all_section_results[i]
15    starts.append(s['start'])
16
17starts.append(0x3036)
18adj_list[0] = [16, 0, 0, 0]
19adj_list[39] = []
20
21for i in range(len(all_section_results)):
22    s = all_section_results[i]
23    adj_list[i+1] = []
24    for t in s['target']:
25        adj_list[i+1].append(starts.index(t))
26
27adj_list[16].append(0)
28print(adj_list)
29gate = {}
30gates = {}
31
32# this is how we keep track of the changed edges
33for i in range(len(all_section_results)):
34    s = all_section_results[i]
35    if s['updates']:
36        gate[i+1] = []
37        gates[i+1] = (2 ** len(gates))
38        for j in range(len(s['updates'])//2):
39            k = 0
40            ignore = s['updates'][j]
41            while k < len(starts):
42                if ignore.addr_update < starts[k]:
43                    gate[i+1].append((k-1, ignore.direction, starts.index(ignore.val_update)))
44                    # alt_adj_list[k-1][ignore.direction] = starts.index(ignore.val_update)
45                    print(f"Changed {k-1} direction {ignore.direction} to point to {starts.index(ignore.val_update)} ({hex(ignore.val_update)})")
46                    break
47                k += 1
48
49def neighbours_by_state(node, state):
50    neighbours = adj_list[node].copy()
51    for s in state:
52        for change in gate[s]:
53            if change[0] == node:
54                neighbours[change[1]] = change[2]
55    if state != 0: print(node, state, neighbours)
56    return neighbours
57
58
59visited = set()
60q = [("", 0, set())]
61dirs = ["L", "D", "U", "R"]
62while len(q) > 0:
63    x = q[0]
64    q = q[1:]
65
66    s = x[2].copy()
67    bit = 0
68    for g in s:
69        bit += gates[g]
70    visited.add((x[1], bit)) # we do this since we can't hash a set
71    print(f"Added {x[1]} {bin(bit)}")
72
73    if x[1] in gate.keys():
74        if x[1] in s:
75            s.remove(x[1])
76        else:
77            s.add(x[1])
78    bit = 0
79    for g in s:
80        bit += gates[g]
81
82    if x[1] == 19: # node 19 happens to be our target
83        print("-"*30)
84        print("SOLUTION: " + x[0] + "D")
85        break
86    
87    neighbours = neighbours_by_state(x[1], s)
88    print(neighbours)
89    for i in range(len(neighbours)):
90        n = neighbours[i]
91        if (n, bit) not in visited:
92            q.append((x[0]+dirs[i], n, s))
93            print(f"Pushed {(x[0]+dirs[i], n)}")

The script as a whole can be viewed here. You can very clearly spot where the LMM code ends and my code begins. Anyways, the output of this script yields the path LLDRDLULLUDDULULDRRDDURLDRRDDRUUUDRRDLURD, which works!

Flag: bctf{anti_softlock_jumpscare}

Twitter Clone

This challenge was fucking fun and bloody funny.

We get two files, index.html and error.png. Opening the page we get the following:

Fig: This error real asf

This error page is fake (duh). Looking into index.html, however, we get… blasted.

Fig: What the fuck.

Erm, what the scallop? Scrolling up a bit, we find a partially obfuscated setup() function with the comment run this first!. Diligently following instructions, it seems we just have a flagchecker on hand.

Fig: How fun.

The Deobfuscation

Let’s start by deobfuscating the setup() function.

We start by unflattening the js and performing some basic deobfuscation to get something a bit more readable.

 1function setup(){
 2    { 
 3        let e = [];
 4        ___ = e.shift.bind(e),
 5        Object.defineProperty(___,"_",{value:Number}),
 6        Object.defineProperty(___,"__",{get:()=>e})
 7    }
 8
 9    {
10        d = [
11            ()=>_=_+_,
12            ()=>_=-_+_,
13            ()=>_=_*_,
14            ()=>{__=_; _=_%__},
15            ()=>_=_^_,
16            ()=>_,
17            ()=>_= ___.__[_],
18            ()=>_ = ___._(_==_),
19            ()=>{__=_;_=___._(_&&__)},
20            ()=>___() 
21        ],
22        e = Object.getOwnPropertyDescriptors;
23
24        Object.defineProperty(window,"_",{
25            get: () => ___.__.pop(),
26            set: e => ___.__.push(e)
27        });
28
29        for (const t of ["String", "Promise", "console", "Math"]){
30            let R = eval(t);
31            const o = e("S" == t[0] ? R.prototype : R); 
32            for(const e in o){
33                const n = o[e].value;
34                if(null != n) try{
35                    const o=(5*t.length + e.length + 7) % 11;
36                    Object.defineProperty(Object.prototype, e, {
37                        get:()=>(d[o]?.(), n)
38                    }), 
39                    Object.defineProperty(R, e, {get:()=>(d[o]?.(), n)})
40                } catch{}
41            }
42        }
43        
44        const l = eval("window"); 
45        for(const t in e(l)){
46            if(null === l[t] || void 0 === l[t]) continue;
47            const e = l[t];
48            try{
49                const R = t.length % 16;
50                Object.defineProperty(Object.prototype,t,{get:()=>( _=R , e)})
51            } catch{}
52        }
53    }
54}

Let’s break it down part by part:

1let e = [];
2___ = e.shift.bind(e),
3Object.defineProperty(___,"_",{value:Number}),
4Object.defineProperty(___,"__",{get:()=>e})

This creates a variable ___ with the following properties:

Don’t worry, this doesn’t get confusing at all (serious).

1Object.defineProperty(window,"_",{
2    get: () => ___.__.pop(),
3    set: e => ___.__.push(e)
4});

The above block of code is largely self explanatory.

 1for (const t of ["String", "Promise", "console", "Math"]){
 2    let R = eval(t);
 3    const o = e("S" == t[0] ? R.prototype : R); 
 4    for(const e in o){
 5        const n = o[e].value;
 6        if(null != n) try{
 7            const o=(5*t.length + e.length + 7) % 11;
 8            Object.defineProperty(Object.prototype, e, {
 9                get:()=>(d[o]?.(), n)
10            }), 
11            Object.defineProperty(R, e, {get:()=>(d[o]?.(), n)})
12        } catch{}
13    }
14}

So this is where it gets silly. In this loop, we start iterating through all the property descriptors of the chosen Objects, then it selects one of the functions from the d array based on the descriptor and Object name length. After selection, it defines it as a property on Object.prototype, making all objects possess the descriptor with the selected function.

This is how you can get statements like Object.atan.LN10.ceil... where atan, ceil are properties from Math.

1const l = eval("window"); 
2for(const t in e(l)){
3    if(null === l[t] || void 0 === l[t]) continue;
4    const e = l[t];
5    try{
6        const R = t.length % 16;
7        Object.defineProperty(Object.prototype,t,{get:()=>( _=R , e)})
8    } catch{}
9}

Lastly, we pull from the properties of window such that based on its length, when its called it pushes a number to the e array.

Looking at the functions in d, what we have here is actually… a stack machine! Every nonsensical sequence of methods is actually a sequence of functions being applied to the stack stored in ___.__. Just by eyeballing, we can assume that most of the actual flag checking logic will be performed with the ()=>_ = ___._(_==_)function, so we keep an eye out for that.

Inspecting the actual check(flag) function again we see that at the start we push all the characters making up flag onto the stack, and at the very end the line const result = _ ? "Correct!" : "Incorrect" simply pops the stack (recall our definition for _) and checks whether it is a non-zero value.

We can thus begin reimplementing the stack machine in another language so that we can more clearly breakdown and analyse the operations.

Reimplementation

We first start by just going into Browser Tools and spitting out all of the applicable property descriptors that we’ll be mapping based on the logic in the original program.

Fig: Pulling the object property descriptors.

Then, in python, we can just map them accordingly.

1promise = ['all', 'allSettled', 'any', 'race', 'reject', 'resolve', 'withResolvers', 'try', 'prototype', 'length', 'name']
2d = [x for x in range(10)]
3
4mappings = {}
5
6for e in promise:
7    o = (5*len("String") + len(e) + 7) % 11
8    if o > 9: mappings[e] = ''
9    else: mappings[e] = d[o]

With this, we can actually start printing some of the function blocks already just by reading a line from the original index.html, splitting on . and then going property by property. Here we read the first line:

 1f = open("./twitter-clone/index.html")
 2inst = f.readlines()
 3f.close()
 4for i in range(1):
 5    test = (inst[96+i].strip().split("."))
 6    for i in range(1, len(test)):
 7        if test[i] not in string + console + math + promise:
 8            print(f"Pushed {len(test[i]) % 16}")
 9        else:
10            print(funcs[mappings[test[i]]])

Which translates to:

Pushed 9
Pushed 12
()=>_=_*_
Pushed 0
()=>_= ___.__[_]
()=>_=_^_
...
Pushed 2
Pushed 9
Pushed 10
()=>_=_*_

Ok, cool! Reading a few more lines, it seems that basically every time ()=>_ = ___._(_==_) shows up, it is proceeded with ()=>{__=_;_=___._(_&&__)} (check for equality, push to stack, AND together). This lends more credit to our hypothesis that the flag checking logic is determined by these equality checks.

So, now we have a plan:

Our final script looks like this:

  1string = ['length', 'toString', 'valueOf', 'toLowerCase', 'toUpperCase', 'charAt', 'charCodeAt', 'codePointAt', 'at', 'substring', 'padStart', 'padEnd', 'includes', 'indexOf', 'lastIndexOf', 'startsWith', 'endsWith', 'trim', 'trimStart', 'trimEnd', 'toLocaleLowerCase', 'toLocaleUpperCase', 'localeCompare', 'repeat', 'normalize', 'match', 'matchAll', 'search', 'replace', 'replaceAll', 'split', 'substr', 'concat', 'slice', 'bold', 'italics', 'fixed', 'strike', 'small', 'big', 'blink', 'sup', 'sub', 'anchor', 'link', 'fontcolor', 'fontsize', 'isWellFormed', 'toWellFormed', 'constructor', 'trimLeft', 'trimRight']
  2promise = ['all', 'allSettled', 'any', 'race', 'reject', 'resolve', 'withResolvers', 'try', 'prototype', 'length', 'name']
  3console = ['assert', 'clear', 'count', 'countReset', 'debug', 'error', 'info', 'log', 'table', 'trace', 'warn', 'dir', 'dirxml', 'group', 'groupCollapsed', 'groupEnd', 'time', 'timeLog', 'timeEnd', 'exception', 'timeStamp', 'profile', 'profileEnd']
  4math = ['abs', 'acos', 'asin', 'atan', 'atan2', 'ceil', 'clz32', 'cos', 'exp', 'floor', 'imul', 'fround', 'f16round', 'log', 'max', 'min', 'pow', 'random', 'round', 'sin', 'sqrt', 'tan', 'log10', 'log2', 'log1p', 'expm1', 'cosh', 'sinh', 'tanh', 'acosh', 'asinh', 'atanh', 'hypot', 'trunc', 'sign', 'cbrt', 'sumPrecise', 'E', 'LOG2E', 'LOG10E', 'LN2', 'LN10', 'PI', 'SQRT2', 'SQRT1_2']
  5
  6funcs = ["()=>_=_+_", "()=>_=-_+_", "()=>_=_*_", "()=>{__=_; _=_%__}", "()=>_=_^_", "()=>_", "()=>_= ___.__[_]", "()=>_ = ___._(_==_)", "()=>{__=_;_=___._(_&&__)}", "()=>___()"]
  7
  8mappings = {}
  9
 10for e in string:
 11    o = (5*len("String") + len(e) + 7) % 11
 12    if o > 9: mappings[e] = ''
 13    else: mappings[e] = o
 14
 15for e in promise:
 16    o = (5*len("Promise") + len(e) + 7) % 11
 17    if o > 9: mappings[e] = ''
 18    else: mappings[e] = o
 19
 20for e in console:
 21    o = (5*len("console") + len(e) + 7) % 11
 22    if o > 9: mappings[e] = ''
 23    else: mappings[e] = o
 24
 25for e in math:
 26    o = (5*len("Math") + len(e) + 7) % 11
 27    if o > 9: mappings[e] = ''
 28    else: mappings[e] = o
 29
 30f = open("./twitter-clone/index.html")
 31inst = f.readlines()
 32f.close()
 33
 34import z3
 35
 36s = z3.Solver()
 37
 38bvs = [z3.BitVec(f"x_{x}", 64) for x in range(30)]
 39stack = []
 40stack += bvs
 41
 42for bv in stack:
 43    s.add(bv >= 32)
 44    s.add(bv <= 126)
 45
 46s.add(bvs[0] == ord('b'))
 47s.add(bvs[1] == ord('c'))
 48s.add(bvs[2] == ord('t'))
 49s.add(bvs[3] == ord('f'))
 50s.add(bvs[4] == ord('{'))
 51s.add(bvs[-1] == ord('}'))
 52
 53for i in range(110):
 54    test = (inst[96+i].strip().split("."))
 55    for i in range(1, len(test)):
 56        if test[i] not in string + console + math + promise:
 57            stack.append(len(test[i]) % 16)
 58        else:
 59            match mappings[test[i]]:
 60                case 0:
 61                    a = stack.pop()
 62                    b = stack.pop()
 63                    stack.append(a+b)
 64                case 1:
 65                    a = stack.pop()
 66                    b = stack.pop()
 67                    stack.append(-a+b)
 68                case 2:
 69                    a = stack.pop()
 70                    b = stack.pop()
 71                    stack.append(a*b)
 72                case 3:
 73                    a = stack.pop()
 74                    b = stack.pop()
 75                    stack.append(b % a)
 76                case 4:
 77                    a = stack.pop()
 78                    b = stack.pop()
 79                    stack.append(a^b)
 80                case 5:
 81                    a = stack.pop()
 82                    stack.append(a)
 83                case 6:
 84                    a = stack.pop()
 85                    stack.append(stack[a])
 86                case 7:
 87                    a = stack.pop()
 88                    b = stack.pop()
 89                    s.add(a == b)
 90                    stack.append(1) # assume True
 91                case 8:
 92                    a = stack.pop()
 93                    b = stack.pop()
 94                    stack.append(a) # assume True
 95                case 9:
 96                    stack = stack[1:]
 97
 98if s.check() == z3.sat:
 99    model = s.model()
100    print("satisfiable")
101    result = ''.join([chr(model[c].as_long()) for c in bvs])
102    print(result)

Amazingly, it ran first try. Hell yeah.

Flag: bctf{SubsCrib3_t0_mY_chaNn3L!}

segvroad

Have you ever wanted to play a version of Crossy Road that would SEGFAULT if you walked into a car? Well now you can!

The challenge itself is not particularly complex and it is once again…. mainly a graph theory challenge. Let’s set the preliminary framework by covering the program logic:

The start of the code (points 1 and 2) looks a little like this in the decompilation:

1gettimeofday((timeval *)&local_28,(__timezone_ptr_t)0x0);
2srandom(local_28 * 1000 + (int)(local_20 / 1000));
3uVar2 = random();
4userid = (undefined4)uVar2;
5printf("Welcome!\nuserid: %d\n",uVar2 & 0xffffffff);
6generate_map(userid);

The map generation logic is also fairly simple, using a set of bit operations to define which squares are blacked out, taking the userid and the solve_count as parameters. We can directly translate this logic into python without issue.

As for solving each map? We once again create a “stacked” map to traverse. Since we are working in a grid, our edges are simply the squares in the cardinal directions that we can travel in, so we don’t even have to write a separate function to get neighbours based on state like with Labyrinth.

We simple write a function to simulate a state transition based on the function itself from the original decompilation:

 1void update_map(int param_1)
 2
 3{
 4  uint row;
 5  int b;
 6  int c;
 7  int i;
 8  int j;
 9  
10  for (row = 0; (int)row < 10; row = row + 1) {
11    if (((&map_control)[(int)row] == '\x01') && ((row & 1) == 0)) {
12      left_shift(row);
13    }
14    else if ((&map_control)[(int)row] == '\x01') {
15      right_shift(row);
16    }
17    else if (((&map_control)[(int)row] == '\x02') && (counter % 7 == 3)) {
18      for (b = 0; b < 10; b = b + 1) {
19        (&grid_state)[(long)b + (long)(int)row * 10] = 1;
20      }
21    }
22    else if ((&map_control)[(int)row] == '\x02') {
23      for (c = 0; c < 10; c = c + 1) {
24        (&grid_state)[(long)c + (long)(int)row * 10] = 0;
25      }
26    }
27  }
28  for (i = 0; i < 10; i = i + 1) {
29    for (j = 0; j < 10; j = j + 1) {
30      if ((&grid_state)[(long)j + (long)i * 10] == '\x01') {
31        mprotect((void *)(mmap_reg + (long)(param_1 * i * 10) + (long)(param_1 * j)),(long)param_1,0
32                );
33      }
34      else {
35        mprotect((void *)(mmap_reg + (long)(param_1 * i * 10) + (long)(param_1 * j)),(long)param_1,3
36                );
37      }
38    }
39  }
40  return;
41}

There is a limit to our total number of movements (500 moves total, with this being checked every input), so for 10 maps, we stick to 50 moves or less. We thus implement BFS where we keep track of 4 variables in the queue: the current path string, the x and y coordinates and $t$, the current time/the number of moves made (which can be derived from the path string, but I’m just writing this based on how I wrote the code at the time).

The code looks like this (the map generation is excluded as it was shamefully LLM generated out of laziness, but the full source can be seen here)

 1if __name__ == "__main__":
 2    generator = MapGenerator()
 3
 4    # io = process(["qemu-aarch64", "-L", "/usr/aarch64-linux-gnu/", "segvroad"])
 5    io = remote("segvroad.atreides.b01lersc.tf", 8443, ssl=True)
 6    io.recvline()
 7    userid = int(io.recvline().split(b": ")[1][:-1])
 8    
 9    counter = 1
10    for solve_count in range(10):
11        print(f"--- Generating Map for User {userid}, Solve Count {solve_count} ---")
12        map_control, grid_state = generator.generate_map(userid, solve_count)
13
14        grid_states = []
15        grid_states.append(grid_state)
16
17        print("Map Control 1:", map_control)
18        print("Grid State 1 (10x10):")
19        for r in range(10):
20            start = r * 10
21            end = start + 10
22            row_data = grid_state[start:end]
23            map_state = map_control[r]
24            print(f"  Row {r}: {row_data} (map_control[{r}]: {map_state})")
25        print("-" * 30)
26
27        for i in range(1, 50):
28            grid_state = map_transition(map_control, grid_state, i)
29            grid_states.append(grid_state)
30        
31        queue = []
32        visited = [[False for _ in range(100)] for _ in range(50)]
33        queue.append(('', 0, 0, 0)) # (path, x, y, t)
34        sol = ''
35        while len(queue) != 0:        
36            p, x, y, t = queue.pop()
37            visited[t][x + y*10] = True
38            if x == 9 and y == 9:
39                sol = p
40                queue = []
41                break
42            if t == 35:
43                continue
44
45            g = grid_states[t+1] # next possible state
46
47            if not visited[t+1][min(x+1, 9) + y*10]:
48                visited[t+1][min(x+1, 9) + y*10] = True
49                if g[min(x+1, 9) + y*10] == 0:
50                    queue.append((p + 'd', min(x+1, 9), y, t+1))
51            
52            if not visited[t+1][max(x-1, 0) + y*10]:
53                visited[t+1][max(x-1, 0) + y*10] = True
54                if g[max(x-1, 0) + y*10] == 0:
55                    queue.append((p + 'a', max(x-1, 0), y, t+1))
56
57            if not visited[t+1][x + min((y+1), 9)*10]:
58                visited[t+1][x + min((y+1), 9)*10] = True
59                if g[x + min((y+1), 9)*10] == 0:
60                    queue.append((p + "w", x, min(y+1, 9), t+1))
61
62            if not visited[t+1][x + max(y-1, 0)*10]:
63                visited[t+1][x + max(y-1, 0)*10] = True
64                if g[x + max(y-1, 0)*10] == 0:
65                    queue.append((p + "s", x, max(y-1, 0), t+1))
66
67        print(f"Solution for Stage {solve_count}: {sol}")
68        for c in sol:
69            io.sendline(c.encode())
70        counter += len(sol)
71    
72    print(io.recvall())

Send that on remote and we get the flag!

Flag: bctf{to_get_to_the_flag_of_course!bcf77ccc0e23d0bf4fdd903bc008970e}