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[10] = g(C[0] + .5),
        o[11] = g(C[1] + .5),
        C = [0, 0],
        m.has("ArrowUp") && (o[2] = h),
        m.has("ArrowDown") && (o[3] = h),
        m.has("ArrowLeft") && (o[0] = h),
        m.has("ArrowRight") && (o[1] = h),
        o[4] = y ? h : d, y = !1,
        o[5] = 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[150][0]) {
        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[150] 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[150]. 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}