PlaidCTF 2021 Watness 3
Contents
Translation
This post is also available in Simplified Chinese.
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; it was a reverse challenge of a HyperCard game on the m68k platform. I thought that this challenge had the same game logic as the previous one, so I spent 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, and the second part contains two GLSL shader programs. The JavaScript code uses WebGL to load these two shaders and draw the game. There is an event loop in JavaScript which captures mouse and keyboard events and exchanges data with the shader code.
The JavaScript code gets data from the shader through the readPixels function:
| |
The length of the loopback array is 250. The first 200 bytes come from p, the data read from the shader. The last 50 bytes are the array o, used for writing data into the shader’s memory. The JavaScript code stores keyboard events in o[0:4], i.e. loopback[200:204], and mouse events in o[4:6], i.e. loopback[204:206]. loopback[150] is the completion flag, and loopback[130:148] is the decryption key.
JavaScript reads data from the pixels of the WebGL canvas, while the shader writes data to loopback by changing pixel colors—actually by setting gl_FragColor.
| |
The shader writes data to loopback with functions T, V, and W, and reads data with M. Once we find this trick, we can recover the game logic.
Logic
The variable CP in main records the current level. There are three levels; when all three are cleared, the program sets the completion flag at loopback[150]. The main function selects different rendering functions according to CP.
| |
The return value Cs has two members, AA and AB. AB indicates whether we have cleared the current level.
| |
If Cs.AB is nonzero, we have cleared the level. The decryption key at loopback[130:148] is updated, and CP, which stores the current level, is incremented by one. Looking into Cl, the function for the first level, shows that AB comes from another function, Bj:
| |
It is obvious that the checking logic is in Bj:
| |
By adding console.log(p.concat(o)) to the JavaScript event loop, we can inspect the loopback array. The macro M reads the player’s moves. If you have learned about OpenGL, you will notice that texture2D returns a pixel from a texture. Bj maps every edge of our path to a pixel in introImage, then checks whether that pixel’s alpha value is less than 255. Inspecting the image with the Python library PIL reveals that some pixels have an alpha value of 254. We can use depth-first search to find a possible path.

The introImage texture is this picture:

The other two levels have function structures similar to the first. Their checker functions are Bi and BZ. The checker for the second level checks only the points we pass, not the edges, so we can easily find a possible path. The third checker is more interesting:
| |
Bf and Bg are the vectors of the current and previous edges on our path. Bh is the cross product of these two vectors. If Bf and Bg are parallel, the result’s z component is zero. Whether the z component is positive or negative depends on the angle between them. We can now see 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 Ba or Bb. We can find the correct path backwards from the final goal.
Solution
The author of this challenge displays sophisticated computer-graphics programming skill. The first two levels can be cleared by playing the game, yielding their decryption keys. But the map in the third level is placed upside down to make it harder to play. I had to study the key-generation algorithm in AO and calculate the last decryption key directly.
We input the decryption key into the JavaScript code, and it displays the flag automatically.

Flag: pctf{ok_but_this_is_the_last_one_i_promise_T__T}