这次的PlaidCTF中我只解出了一道Reverse题目The Watness III,剩余的时间花在了另一道题目dr的逆向上,然而最后也没逆出来其中的正则表达式的匹配机制。

PlaidCTF 2020也有一道类似的题目The Watness II,去年在A*0*E战队时就做过,是一道m68k架构的HyperCard程序逆向。这次我习惯性地以为这道题的判断逻辑和那道题一样,额外花了不少时间在寻找那道题的逻辑上。做出来之后才明白:这道题除了画风和那道题一样之外没有任何相似之处……

程序的结构

main.js前半部分实现了一个AES的解密算法,后半部分是两个GLSL shader代码。js通过WebGL加载shader,绘制出游戏场景。js中间有一个事件循环,监听鼠标和键盘事件,并和shader交换数据。

js通过readPixels从shader中获取数据,代码如下:

 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)
};

可以看到loopback数组总长为250,前面长度为200的部分作为p数组用来读数据,之后拼接的长度为50的o数组用来写数据。o[0:4]loopback[200:204]存放键盘按键,loopback[204:206]存放鼠标按键,loopback[150]是某个完成标志,loopback[130:148]是密钥。

由于js是从WebGL的画布上读取像素来更新loopback数组,因此在shader代码中,对loopback数组的修改反映在对像素颜色的修改上。即通过设置gl_FragColor来修改loopback数组。

 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))

TVW均为对loopback数组的修改,而M用来读取loopback数组。搞清楚这一点之后就可以看懂程序的逻辑。

关卡的逻辑

main函数中有一个重要的变量CP用来记录当前的关卡,一共3关,3关都过完就会设置loopback[150]的完成标志。main函数根据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);

返回的结果Cs包含两个成员AAAB,其中AB表示本关是否通过:

 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;
  }

Cs.AB不为0就表示本关通过,同时会更新loopback[130:148]保存的密钥,并把当前关卡数CP加1。继续研究第一关Cl函数,发现AB来自另一个函数Bj

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

显然检查过程就在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;
}

通过在javascript里添加console.log(p.concat(o))的方式查看loopback数组,就可以发现M宏读取的是玩家在地图上每一步的座标。学过OpenGL就可以知道texture2D用来从材质贴图中取出一个像素,这个检查就是把走过的每一条边映射到背景图的某个像素上,要求该像素alpha的值小于255。用python的PIL检查一下就可以发现背景图上有的像素alpha值是254。那么用深度优先搜索可以找出正确的路径。

js里introImage加载的就是这张图片:

后面两关代码结构与第一关类似,检查函数分别是BiBZ,第二关的检查与路径无关,仅判断途经的点是否合法,对每个点都判断一下是否合法就能找出路径。第三关的检查比较有意思:

 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;
      }
    }
  }
}

BfBg分别是当前步和上一步的向量,Bh是二者的外积,回忆了一下高中数学向量叉乘,若BfBg平行,结果的z分量一定是0,BfBg夹角的正负决定了结果的z分量正负。那么就搞清楚这个检查的逻辑了:直着走不受限制,顺时针(或者逆时针)转弯时,当前座标必须是Ba(或者Bb)数组中的某个值。从终点往回逆推就可以找出正确路径。

通关

不得不佩服出题人的闲情逸致,搞出一个如此强悍的图形学游戏。前两关都可以直接在游戏里通过,从F12里查看loopback[130:142]可以获得前两关的密钥,第三关故意把地图反射到水面上增加干扰。我又不得不研究了一下AO函数,直接根据路线算出第三关的密钥。

密钥输到javascript里,flag就直接alert出来了。

flag:pctf{ok_but_this_is_the_last_one_i_promise_T__T}