这次的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))
|
T
,V
,W
均为对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
包含两个成员AA
和AB
,其中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
加载的就是这张图片:
后面两关代码结构与第一关类似,检查函数分别是Bi
和BZ
,第二关的检查与路径无关,仅判断途经的点是否合法,对每个点都判断一下是否合法就能找出路径。第三关的检查比较有意思:
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
和Bg
分别是当前步和上一步的向量,Bh
是二者的外积,回忆了一下高中数学向量叉乘,若Bf
和Bg
平行,结果的z分量一定是0,Bf
和Bg
夹角的正负决定了结果的z分量正负。那么就搞清楚这个检查的逻辑了:直着走不受限制,顺时针(或者逆时针)转弯时,当前座标必须是Ba
(或者Bb
)数组中的某个值。从终点往回逆推就可以找出正确路径。
通关
不得不佩服出题人的闲情逸致,搞出一个如此强悍的图形学游戏。前两关都可以直接在游戏里通过,从F12里查看loopback[130:142]
可以获得前两关的密钥,第三关故意把地图反射到水面上增加干扰。我又不得不研究了一下AO
函数,直接根据路线算出第三关的密钥。
密钥输到javascript里,flag就直接alert出来了。
flag:pctf{ok_but_this_is_the_last_one_i_promise_T__T}