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:
- Keep track of all the gates in the graph
- When traversing the graph, keep note of which gates you’ve entered (with a mask)
- The neighbours that each node has changes based on the gate mask
- When you re-enter a gate, flip the bit on the mask
- Traverse as per normal and terminate when you reach the target node
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:
- Pattern match on
JMP exit_maze
to identify each block (this behaves like a delimeter for the blocks) - For each block, solve for the edges by matching on
ADD R15, offset
(the order of the if statements is invariant, so we can just… do that). The result of0x01013b6 + offset
should work out to the block addresses we just calculated (with a few bytes offset since it actually jumps to theCALL read_input
instruction in each block) - If there are more than 4
ADD R15, offset
statements, that means it’s a gate. The nature of each gate is that on first entrance, it will always flip (since the if statement at the start will yieldTrue
). Each of these blocks hasADD R15, offset
followed byMOV R14D, new_offset
, thenMOV [R15], R14D
, so we can once again perform some pattern matching to isolate the edge change logic.
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:
___.__
now holds an array (also callede
)___()
callede.shift
, which pops the first element frome
___._()
is now a reference toNumber()
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:
- Create an array of BitVectors in z3
- Push them onto a stack
- Reimplement all the stack operations as they are in the original file
- Every time there is an equality check, add that as a constraint and push 1 to the stack to simulate it passing and returning
true
- Make sure that the final
pop
performed would yieldtrue
with this - Solve for the z3 constraints we’ve accrued
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:
- When the program starts up, a random seed is generated by using the current time
- This seed is used to generate a map, which is a 10x10 grid. When a grid element is
0x1
, walking into it will kill you. - After every movement, the map is updated based on the nature of the row
- Some rows have cars which rotate clockwise or counterclockwise
- Some rows will turn into all
0x1
every 7 moves which we call “lasers” - Some rows are just perma blank. In particular, the first and last rows are always blank.
- The goal is to travel from (0,0) to (9,9) without dying.
- Also you have to do this 10 times.
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}