Info

This is the English translation of my original writeup.

In PlaidCTF 2021, I solved the challenge `The Watness III`, then I spent my time on another challenge `dr`. Unfortunately, the regular expression algorithm in `dr` is too complex to understand, thus I failed to solve it.

There was a similar challenge in PlaidCTF 2020, `The Watness II`, I solved that last year when I was a member of A*0*E, which was a reverse challenge of HyperCard game on the m68k platform. I thought that this challenge has the same game logic with as previous one, so I spend some time finding the color logic used in `The Watness II`. But the result shows that this challenge is different from that one.

## Program Structure

The first part of main.js implements an AES decryption algorithm, the second part contains two GLSL shader codes. The JavaScript code uses WebGL to load these two shaders to draw the game. There is an event loop in JavaScript, which captures the mouse and keyboard events, and exchanges data with shader codes.

The JavaScript code gets data from the shader code through the function `readPixels`:

 `````` 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 `````` ``````const x = r => { const o = Array.from(new Array(50), (() => [0, 0, 0, 0])), n = r - i; if (i = r, o = g(C + .5), o = g(C + .5), C = [0, 0], m.has("ArrowUp") && (o = h), m.has("ArrowDown") && (o = h), m.has("ArrowLeft") && (o = h), m.has("ArrowRight") && (o = h), o = y ? h : d, y = !1, o = b ? h : d, b = !1, t.set("dt", n), t.set("time", r / 1e4), t.set("loopback", p.concat(o)), e.draw(), p = e.readPixels(0, 0, 200), !_ && p) { const e = p.slice(130, 148).map((([e]) => e)), t = l.decode(e); console.log(t), alert(t), _ = !0 } requestAnimationFrame(x) }; ``````

The length of the array `loopback` is 250, and the first 200 bytes come from the array `p`, which is the data read from the shader code. The last 50 bytes is the array `o`, which is used for writing data into the shader’s memory. The JavaScript code stores the keyboard event into `o[0:4]`, i.e. `loopback[200:204]`, and mouse event into `o[4:6]`, i.e. `loopback[204:206]`. `loopback` is the completion flag, and `loopback[130:148]` is the decryption key.

The JavaScript reads data from the pixels of the WebGL canvas, while the shader code writes data to the array `loopback` by changing the color of the pixels, which is actually done by setting `gl_FragColor`.

 `````` 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 `````` ``````#define G(H, I) J(loopback[int(H)].xyz) * I #define K(H) loopback[int(H)].x #define L(H) loopback[int(H)].xy #define M(H) loopback[int(H)].xyz #define N(H) (loopback[int(H)].x == 0 ? false : true) #define O(H, I) vec2(G(H, I), G(int(H) + 1, I)) #define P(H, exp) \ if (gl_FragCoord.y <= 1. && gl_FragCoord.x > H && \ gl_FragCoord.x <= H + 1.) { \ gl_FragColor = exp; \ return; \ } #define Q(H, R, I) P(H, vec4(S((R) / (I)), 0.)) #define T(H, R) P(H, vec4(float(R) / 255., 0, 0, 0)) #define U(H, R) P(H, vec4(float(R.xy) / 255., 0, 0)) #define V(H, R) \ P(H, vec4(float(R.x) / 255., float(R.y) / 255., float(R.z) / 255., 0)) #define W(H, R) P(H, vec4(R ? 1. : 0., 0, 0, 0)) ``````

The shader code writes data to the array `loopback` by function `T`,`V`,`W`, and reads data by `M`. Once we find this trick, we can get the logic of the game.

## Logic

The variable `CP` in function `main` is used for recording the current level. There are three levels, and if all three levels are cleared, the program will set the completion flag at `loopback`. The function `main` selects different render functions by `CP`.

 ``````1 2 3 4 5 6 `````` `````` if (CP == 0) Cs = Cl(Bo); if (CP == 1) Cs = Ce(Bo); if (CP == 2) Cs = CN(Bo); ``````

The return value `CS` has two members, `AA` and `AB`. `AB` indicates whether we have cleared the current level.

 `````` 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 `````` `````` for (int AO = 0; AO < 6; AO++) { if (CP == 0 && Cs.AB) { T(130. + float(AO), BU(AO, 6)); } if (CP == 1 && Cs.AB) { T(136. + float(AO), BU(AO, 6)); } if (CP == 2 && Cs.AB) { T(142. + float(AO), BU(AO, 6)); } T(130. + float(AO), K(int(130.) + AO)); T(136. + float(AO), K(int(136.) + AO)); T(142. + float(AO), K(int(142.) + AO)); } vec3 Ap; vec2 Ao; t Cv = t(0, vec3(0, 0, -10), vec3(0, 3, 0), vec3(2, 0, 0), 1); if (Cs.AB && Ak(focus, surface_loc, Cv, Ao, Ap) >= 0.) { gl_FragColor = AH(gl_FragColor, vec4(1, 1, 1, 0.95)); } if (Cs.AB && AD(viewport_center, Cv.v) < 2.) { CP = CP + 1; } ``````

If `Cs.AB` is not zero, then we have cleared this level, and the decryption key at `loopback[130:148]` is updated, and the variable `CP`, which stores the current level, is incremented by 1. By looking into the function `Cl` of the first level, we can find that `AB` comes from another function `Bj`:

 ``````1 2 3 4 5 6 `````` ``````z Cl(vec2 Bo) { bool AA = N(120.); bool Bp = Bj(8); …… return z(AA, Bp); } ``````

It is obvious that the check logic is in `Bj`:

 `````` 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 `````` ``````bool Bj(int AU) { for (int AO = 0; AO < 100 - 1; AO++) { ivec3 Aj = M(int(0.) + AO); ivec3 Ag = M(int(0.) + AO + 1); if (Aj == ivec3(255, 255, 1)) { return false; } if (Aj == ivec3(AU - 1, AU - 1, 1)) { return true; } float Bk = (float(Aj.x) + float(Ag.x)) / (2. * float(AU)); float Bl = (float(Aj.y) + float(Ag.y)) / (2. * float(AU)); vec4 Bm = texture2D(introImage, vec2(Bk, Bl)); if (Bm.a >= 1.) { return false; } } return false; } ``````

By adding one line of code, `console.log(p.concat(o))`, in the JavaScript event loop, we can inspect the `loopback` array. We can find that the macro `M` reads the moves of the player. If you have learned about OpenGL, you will notice that `texture2D` is a function that returns a pixel from a texture. This function `Bj` maps every edge of our path to a pixel in the texture `introImage`, and then checks if that pixel’s alpha value is smaller than 255. After checking the picture by Python library PIL, we can find that some pixels’ alpha value is 254. We can use deep-first search to find a possible way. The texture `introImage` is this picture： The function structures of the other two levels are similar to the first level. The checker functions are `Bi` and `BZ`. The checker of the second level only checks the points we pass and do not check the edges, so we can easily find a possible way. The third checker function is a bit interesting:

 `````` 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 `````` ``````ivec3 Be = M(int(0.) + AO - 1); ivec3 Aj = M(int(0.) + AO); ivec3 Ag = M(int(0.) + AO + 1); if (Ag == ivec3(255, 255, 1)) { return false; } if (Ag == ivec3(AU - 1, AU - 1, 1) && Bc == 14 && Bd == 14) { return true; } vec3 Bf = vec3(Ag - Aj); vec3 Bg = vec3(Be - Aj); vec3 Bh = cross(Bf, Bg); if (Bh.z != 0.) { for (int BV = 0; BV < 14; BV++) { if (Bh.z < -0.5) { if (Bd >= 14) return false; if (BV == Bd) { if (Bb[BV] != Aj.xy) return false; Bd++; break; } } if (Bh.z > 0.5) { if (Bc >= 14) return false; if (BV == Bc) { if (Ba[BV] != Aj.xy) return false; Bc++; break; } } } } ``````

`Bf` and `Bg` are the vectors of the current edge and previous edge on our path, respectively. `Bh` is the outer product of these two vectors. If `Bf` and `Bg` are parallel, the z component of the result is zero. Whether the z component is positive or negative depends on the angle between `Bf` and `Bg`. Then we can figure out the logic: there is no restriction on walking straight, but when we turn clockwise (or counterclockwise), our position must be one of the positions in the array `Ba`(or array `Bb`). We can find the right way from the final goal step by step.

## Solution

The author of this challenge displays his sophisticated programming skill in computer graphics. The first two levels can be cleared by playing the game and we can get the decryption keys of these two levels. But the map in the third level is placed upside down to make it harder to play. I have to study the key generation algorithm in the function `AO`, and calculate the last decryption key directly.

We input the decryption key into the JavaScript code, and the JavaScript code will show the flag automatically. flag：`pctf{ok_but_this_is_the_last_one_i_promise_T__T}`