前言

参加 Hackergame 的这段时间大概是我最近以来学到东西最多的一段时间。

———— 某群友

因为水平太菜,最终没能还是没能留在前 100 名。不过毕竟第一次参加这样的比赛,本来也就是奔着图一乐去的。嗯,,也可以说想看看自己的到底有多少水平吧。现在看来自己不会的东西还很多,尤其对于几乎从未接触过的二进制领域,几乎束手无策。

看了排行榜也是真的赞叹:这些大佬怎么什么都会???

一些解题过程

签到

打开页面,随便画几下,发现第三个限时 0.1 秒就几乎画不出来了。一开始想着应该是要改掉限制时间?看了一下代码,发现用的 Vue,不是很想分析。不过猜测应该是在前端判断数字的,先点下提交抓个包看看吧。

一点提交,立马发现地址栏后面多了个 ?result=2?7?,这?判断方式这么简单的吗?把 result 的值改成 2022,成功拿到 flag。

猫咪问答喵

第一题,直接搜 USTC NEBULA,找到文章 中国科学技术大学星云(Nebula)战队在第六届强网杯再创佳绩,翻到最下面就能看到这个队伍的简介。

第二题,打开 USTC LUG 官网,站内搜索 “软件自由日”。点开文章。没有?算了换个方法搜一下。最后找到 LUG Wiki 上的页面:https://lug.ustc.edu.cn/wiki/lug/events/sfd/,下面就有 Slides 的链接。根据题目来看明显是《GNOME Wayland 使用体验:一个普通用户的视角》这个了。点开往下翻,根据题干找到第 15 页:“Qt 程序在 GNOME Wayland 下效果不如 GTK” 右边的图片。这个软件显然是剪辑软件 Kdenlive 了,正好我电脑里也装了。

第三题,谷歌搜索之后找到这篇回答:https://support.mozilla.org/bm/questions/1052888,下面就有答案,最后的版本是 12。

第四题,这题属实是费了我不少时间,不过最后勉强算是找到了最符合描述的 commit:https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=dcd46d897adb70d63e025f175a00a89797d31a43 。根据 commit message 中的描述:Account for the additional stack space in bprm_stack_limits(). Inject an empty string when argc == 0 (and set argc = 1). 大致可以判断出来。

第五题,这题更是花了我更多时间。不过我一开始方向错了,想着是不是根据提供的 MD5 值可以反向计算域名?于是去差了好多关于 ED25519 算法和密钥指纹的文章。最后得出结论:算不出来。那这题怎么搞?把 MD5 放到谷歌搜一下看看吧,不过很显然搜不出来任何东西。最后在我绞尽脑汁快要放弃的时候,突然想到一个东西:GitHub!对!放到 GitHub 上搜试试!果不其然,能搜到两个文件,下面随便截取一段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"ts": "2020-09-16T13:08:58.933098Z",
"uid": "Cjmfpo49s3lei7CBla",
"id.orig_h": "192.168.4.49",
"id.orig_p": 39550,
"id.resp_h": "205.166.94.16",
"id.resp_p": 22,
"version": 2,
"auth_success": true,
"auth_attempts": 2,
"direction": "OUTBOUND",
"client": "SSH-2.0-OpenSSH_7.4p1 Raspbian-10+deb9u7",
"server": "SSH-2.0-OpenSSH_8.0",
"cipher_alg": "chacha20-poly1305@openssh.com",
"mac_alg": "umac-64-etm@openssh.com",
"compression_alg": "none",
"kex_alg": "curve25519-sha256",
"host_key_alg": "ssh-ed25519",
"host_key": "e4:ff:65:d7:be:5d:c8:44:1d:89:6b:50:f5:50:a0:ce",
"hasshVersion": "1.0",
"hassh": "0df0d56bb50c6b2426d8d40234bf1826",
}

不过这个文件里也没给域名啊。好像是给了一个公网IP?IP 有什么用呢?难不成能查到对应的域名?等等。。,有个地方,说不定能查到,那就是 ipinfo.io 打开网站输入 IP,还真有!得到域名:sdf.org

第六题,直接去中科大信息中心的网站看看:https://ustcnet.ustc.edu.cn/ ,找到文章:关于实行新的网络费用分担办法的通知,然后发现不对?等会,再往前翻翻,找到 2003 年的文章:关于实行新的网络费用分担办法的通知,这个才是对的。不过 2003 年竟然就有校园网了,甚至还能访问国际网络,不愧是中科大啊。(羡慕.jpg)

家目录里的秘密

VSCode

在 user/.config/Code/User/History/2f23f721/DUGV.c 就能找到 flag 了。

Rclone

这个 flag 虽然很好找,就在 rclone.conf 里,但看到 pass 后面的一大长串总感觉不太对。于是去看了一下 Rclone 配置文件的格式,果然有加密。那么就得想办法解密了。谷歌搜索 “rclone pass decrypt”,找到论坛帖子:How to retrieve a ‘crypt’ password from a config file。里面的代码就是解密代码了,只需要把字符替换上去然后用 golang 运行一下就能拿到 flag。

粘一下代码:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package main

import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"log"
)

// crypt internals
var (
cryptKey = []byte{
0x9c, 0x93, 0x5b, 0x48, 0x73, 0x0a, 0x55, 0x4d,
0x6b, 0xfd, 0x7c, 0x63, 0xc8, 0x86, 0xa9, 0x2b,
0xd3, 0x90, 0x19, 0x8e, 0xb8, 0x12, 0x8a, 0xfb,
0xf4, 0xde, 0x16, 0x2b, 0x8b, 0x95, 0xf6, 0x38,
}
cryptBlock cipher.Block
cryptRand = rand.Reader
)

// crypt transforms in to out using iv under AES-CTR.
//
// in and out may be the same buffer.
//
// Note encryption and decryption are the same operation
func crypt(out, in, iv []byte) error {
if cryptBlock == nil {
var err error
cryptBlock, err = aes.NewCipher(cryptKey)
if err != nil {
return err
}
}
stream := cipher.NewCTR(cryptBlock, iv)
stream.XORKeyStream(out, in)
return nil
}

// Reveal an obscured value
func Reveal(x string) (string, error) {
ciphertext, err := base64.RawURLEncoding.DecodeString(x)
if err != nil {
return "", fmt.Errorf("base64 decode failed when revealing password - is it obscured? %w", err)
}
if len(ciphertext) < aes.BlockSize {
return "", errors.New("input too short when revealing password - is it obscured?")
}
buf := ciphertext[aes.BlockSize:]
iv := ciphertext[:aes.BlockSize]
if err := crypt(buf, buf, iv); err != nil {
return "", fmt.Errorf("decrypt failed when revealing password - is it obscured? %w", err)
}
return string(buf), nil
}

// MustReveal reveals an obscured value, exiting with a fatal error if it failed
func MustReveal(x string) string {
out, err := Reveal(x)
if err != nil {
log.Fatalf("Reveal failed: %v", err)
}
return out
}

func main() {
fmt.Println(MustReveal("YOUR PSEUDO-ENCRYPTED PASSWORD HERE"))
}

HeiLang

下载源代码,根据题目提示,只需要吧 getflag.hei.py 中的 A[x | y | z] = t 全部替换成 A[x] = t; A[y] = t; A[z] = t 的格式就行了。直接用编辑器的正则替换显然不太够,于是用 Node 写了个处理文本的脚本。很不幸解出 flag 后不小心手滑把文件夹删了,这里就懒得重新写了(

替换完成后运行脚本即可得到 flag。

Xcaptcha

到了我比较擅长的 web 题了!点开一看,不就是只有一秒时间,直接断点看看?然后发现断完改完值之后还是会报超时。大概又试了几次,发现还要点提交才行。算了,写个脚本让它自己算吧。打开油猴,新建脚本,自动获取页面内容然后计算:

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
// ==UserScript==
// @name Xcaptcha
// @namespace http://tampermonkey.net/
// @version 0.1
// @description try to take over the world!
// @author Revincx
// @match http://202.38.93.111:10047/xcaptcha
// @icon https://www.google.com/s2/favicons?sz=64&domain=93.111
// @grant none
// ==/UserScript==

(function() {

let labels = document.getElementsByTagName('label');
let inputs = document.getElementsByTagName('input');

for(let i = 0; i < 3; i++) {
let text = labels[i].textContent;
let nums = text.split(' ')[0].split('+');
let int1 = BigInt(nums[0]);
let int2 = BigInt(nums[1]);

let sum = int1 + int2;
let result = sum.toString();

inputs[i].value = result;
}

document.getElementById('submit').click();
})();

写完脚本后点开 captcha 就能拿到 flag 了。

另外这个验证图片上写着 “I am a robot”,果然还真是要用 “robot” 解决,人类反而不行 233

旅行照片 2.0

第一小题很简单,直接找个能看 EXIF 信息的工具就行了。我用的是 Linux 下的 exif 命令行工具。

第二小题就有点难度了,首先根据照片上建筑物的小字可以查出来这个建筑物是日本千叶市的 ZOZO 海洋球场。然后就是邮政编码,这里有个坑就是题目问的是拍摄者所在地的邮政编码,所以这个球场的邮政编码是不对的。用咕咕地图可以查到球场最近酒店位置,然后很容易看出来是在哪个酒店,因为就那么几个。接着直接在谷歌地图中点开酒店详情就可以看到邮政编码。

接着就是找航班了。这个也有点难度,使用 flightradar24 尝试了十多个航班全都不对。不过根据飞机的高度以及方向可以大致判断起飞机场是东京国际机场(HND),于是我在 fr24 上用脚本爬取了这个机场几乎所有的航班的数据(大概五百多条),导出成 json,然后再写个脚本去逐一尝试,大概跑了一百多条后成功获得正确结果。

贴一下脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import fetch from "node-fetch";
import { readFileSync } from 'fs'

let arr = JSON.parse(readFileSync('./flights.json').toString())

; (async () => {
for (let item of arr) {
let str = `1=2610021&2=2340x1080&3=HND&4=${item.dst}&5=${item.no}`
let base64 = Buffer.from(str, 'utf-8').toString('base64')
let resp = await fetch(`http://202.38.93.111:10055/${base64}.txt`)
if(resp.status == 404) {
console.log(`${item.no} ${item.dst} NG`);
}
else {
console.log(`${item.no} ${item.dst} YES`);
process.exit()
}
}
})()

猜数字

这题也是一开始绞尽脑汁毫无办法,最后无聊 F12 打开控制台看看,想着写个很大的值试试?或者夹点特殊字符进去看看?没想到这一试还真试出来了。当在输入框里随便写点字符串,提交的数值就会变成 NaN,然后就能比较通过!

再去看一下源码吧:

1
2
3
4
5
6
7
var guess = Double.parseDouble(event.asCharacters().getData());

var isLess = guess < this.number - 1e-6 / 2;
var isMore = guess > this.number + 1e-6 / 2;

var isPassed = !isLess && !isMore;
var isTalented = isPassed && this.previous.isEmpty();

这里解析提交数据用的是 Double.parseDouble,对于提交的 NaN 正好能正常解析。解析出来的 double 也是个 NaN。而在 Java 中,NaN 跟任何数值比较都会返回 false,这里自然就能判断成猜对了。

LaTeX 机器人

虽然对 LaTeX 表达式略知一二,但是实际上还是很少用,我知道的也就常用的那几条指令,所以解这道题的时候查了大量的资料,以至于现在再回去翻自己查过哪些东西都很艰难。

首先看下源码,整体逻辑不算复杂,就是通过读取输入和 base.tex 生成一个 result.tex 文件,然后调用 pdflatex 程序生成 PDF 文件,接着是把 PDF 转成 PPM 再转成 PNG。观察一下这个命令:

1
$ pdflatex -interaction=nonstopmode -halt-on-error -no-shell-escape result.tex

这里有一个没看懂的参数:--no-shell-escape。这个是干什么用的呢?看一下 manual 吧:

-no-shell-escape
Disable the \write18{command} construct, even if it is enabled in the texmf.cnf file.

大致意思是禁用 \write18 这个命令,那么这个命令是干什么的呢?

Issue a command to the operating system shell. The operating system runs the command and LaTeX’s execution is blocked until that finishes.

得,调用系统命令行,既然这样不就是明摆着把能注入的地方堵住嘛,那这题还能在哪注入呢?

在查询了大量的 LaTeX 资料之后,我搜到了一个叫 \include 的命令,这个命令可以用来包含其他的 Tex 文件。但是经过一番研究,并没有发现能通过 \include 命令实现注入的方法。

又在谷歌上乱搜一通后,我发现了 LaTeX 似乎还有其他能打开文件的命令:\openin,看来就是它了。

那么怎么用这个命令呢?急性子的我根本看不下去冗长的 Manual,直接去搜搜用法!很快就搜到了这篇文章:how to use to latex read all content of a file concluding some lines have “%”。然后就是照猫画虎写一下表达式:

1
2
3
4
5
\newread\file
\openin\file=/flag1
\read\file to\fileline
\fileline
\closein\file

成功拿到第一个 flag !对于第二个的特殊符号,上面 StackOverflow 帖子正好有提到:

As you said you can’t change the test.txt file you can do it deactivating % as a special character adding \catcode ``\%=12 after \begin{document}

这样的话,也只需要照葫芦画瓢,在前面加上 \catcode ``\#=12 \catcode ``\_=12 就行了。(因为这里的 ` 符号没办法转义所以写了两个,实际上只用写一个)

1
2
3
4
5
6
7
\catcode `\#=12
\catcode `\_=12
\newread\file
\openin\file=/flag2
\read\file to\fileline
\fileline
\closein\file

另外 flag 的两个大括号在 LaTeX 语法里也属于特殊字符,所以返回的图片里不会显示。

Flag 的痕迹

这题明明在比较靠前的位置,但几乎比赛时间过一大半了才解出来,原因是没思路。

打开 DokuWiki 的见面,除了首页比较显眼的正文,然后就能看到右下角的编辑时间:

start.txt · Last modified: 2022/10/21 02:53 (external edit)

值得注意的显然就是这个括号里的 externel edit 的提示了。首先去 DokuWiki 的官网看了一下使用说明,得知 DokiWiki 的修改记录(也就是 Revisons)都保存在 data/attic 文件夹里,页面的内容文件则是在 data/pages 文件夹里。根据题目的提示,小 Z 不小心提交了 flag 之后赶紧改好重新提交。也就是说 flag 就在历史记录里。但站点禁用了包括历史记录在内的大部分功能,那该怎么办呢?

思维简单粗暴的我首先想到了暴力解法:DokuWiki 的历史页面中,URL 参数有个 rev=[timestamp],也就是说最终的目的是要拿到提交 flag 那次记录的时间戳。既然题目中说了 “赶紧改好”,那么最后两次的编辑时间按理说很近吧。既然这样就写个脚本,遍历时间戳试试。

根据右下角的编辑时间,很快就可以试出来最后一次编辑的时间戳是 1666320802,那就用这个当作起始位置依次减一尝试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import fetch from 'node-fetch'

let start = 1666320801

;(async () => {
while (true) {
let resp = await fetch("http://202.38.93.111:15004/doku.php?id=start&rev=" + start, {
"referrerPolicy": "strict-origin-when-cross-origin",
"body": null,
"method": "GET"
});

let text = await resp.text()

if (text.indexOf('no_such_revision') != -1) {
console.log('No ' + start);
}
else {
console.log('Yes ' + start);
process.exit();
}
start--;
}
})()

然而,脚本跑了足足快三个小时,在这期间我都做出来了一道下面的题,仍然没有任何结果。看了一下输出,跑到的时间戳距离最后一次编辑的时间戳都快一天了,也就是说 flag 在 wiki 首页暴露了一天才被发现并修改。小 Z 同学你怎么回事?

无奈的我决定把 Dokuwiki 的源码下载下来看看,但毕竟是世界第一的 PHP 语言,从没学过 PHP 的我只能看个寂寞。

接着发现题目 wiki 上的首页 URL 路径就是 /doku.php,难不成其他目录都能访问?试了一下根目录里的 composer.json,果然能访问。那 data 文件夹呢?显然不行。原因很简单,data 文件夹有个叫 .htaccess 的文件,用来限制文件访问的,只有扩展名为 .php 的文件才能载入,其他的一律不行。

那该怎么办呢?我开始琢磨起来这个 wiki 页面有没有关于时间戳的线索,首先看到的就是右上角的 Media Manager 了。这里可以看到上传的媒体文件,但显然题目 wiki 上只有两个默认的文件,甚至上传的时间戳都跟首页的最后编辑日期一样。按理来说默认的资源文件的修改创建日期应该是 DokuWiki 安装的日期,这里很可能是故意修改了。

点开默认的媒体文件,发现 URL 路径末尾是 fetch.php,莫非通过这个可以获取到一些别的东西?去看了一下 fetch.php 的源码,没看懂。又尝试着构造一些特殊的 URl 参数,但似乎都不管用。

又过了两天,正当我对着 wiki 首页发呆绝对山穷水尽的时候,我又随手点开了源码中的 doku.php。随便翻翻吧。这一翻发现 doku.php 中 query 参数似乎还不少:

1
2
3
4
5
6
7
8
9
10
11
//import variables
$INPUT->set('id', str_replace("\xC2\xAD", '', $INPUT->str('id'))); //soft-hyphen
$QUERY = trim($INPUT->str('q'));
$ID = getID();

$REV = $INPUT->int('rev');
$DATE_AT = $INPUT->str('at');
$IDX = $INPUT->str('idx');
$DATE = $INPUT->int('date');
$RANGE = $INPUT->str('range');
$HIGH = $INPUT->param('s');

其中有一个参数引起了我的注意:DATE_AT。按理说看历史记录不是用 rev 参数吗?这个 at 参数是干啥的?下面的代码我也看不懂啊?

不管了,试试就知道了,打开题目 wiki 首页,加上 at 参数,随便写个日期看看。

嗯,似乎没啥用?不过大概可以推测是查看某一天的 wiki 页面了,那么它跟 rev 有什么关系呢?如果我遍历 at 参数能不能遍历出来有 flag 的页面呢?

要遍历的话应该需要一个临界值,比较大的值已经试过了,随便写一个比较小的值,不,很小的值看看。

我把 at 参数改成了 19700101,够小了吧。

接着页面上出现一个提示:

Page did not exist at 1970/08/17 00:15. It was subsequently created at 2022/10/08 10:20.

这是?创建日期?怎么跟最后编辑日期隔了这么长时间啊。点进去看看吧。

创建时的页面跟最后编辑时的一模一样,没有 flag。

但右下角告诉了我时间戳:1665224447。很可疑,要不,,再试试遍历时间戳,但这次从开始创建时的时间向上遍历。

很快啊,遍历了不到二十次,就找出来了下一次编辑的时间戳:1665224461

复制这个时间戳到 rev 参数,进去看看,成功拿到 flag !

不过话说这个日期也是 10/08,跟最后编辑日期 10/21 足足差了 13 天,也就是说这 flag 暴露了十三天,十三天啊十三天!小 Z 同学你怎么回事?

安全的在线测评(第一小题)

这个题跟 OJ 判题相关,但实际上我们学校的 OJ 我几乎没用过,所以根本不了解真正的判题逻辑是什么样。不过问题不大,看看这个题的代码吧。

大概看懂代码之后,发现题目的判题逻辑也不是很复杂,尤其是静态数据这个,输出样列不变,放在 static.out 里。那我运行的时候直接读这个文件然后打印出来不就行嘛。

上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdlib.h>

int main()
{
FILE *out = NULL;
char buff[255];
out = fopen("./data/static.out", "r");
fscanf(out, "%s", buff);
printf("%s\n", buff);
fgets(buff, 255, (FILE *)out);
// 因为是两行就再 fgets 一下吧
fgets(buff, 255, (FILE *)out);
printf("%s\n", buff);

fclose(out);

return 0;
}

成功拿到第一小题的 flag!

等会,flag 里说:the_compiler_is_my_eyes,什么意思?难道我的思路跟出题人想的不太一样?

emm 算了,第二小题看上去比较难就放弃吧~

大概过了两天之后,我在睡觉的时候忽然想到这句话:the compiler is my eyes …

到底是什么意思呢… 导致我那天夜里都没睡好(

第二天早上出题人托梦告诉我:恍然大悟,编译器怎么读文件?不就是 #include 嘛!而且还不受题目里 runner 用户的权限限制。

但是情况比我想的要复杂:dynamicX.out 文件里有两行数字,我尝试了各种方法也没能找到一个完美的方案用 #include 宏把这个文件包括到源码里并成功编译通过。要么就是报错程序中有游离的 “#” 号,要么就是报错数字周围缺少括号。

难道说这句话只是针对第一小题的吗,从报错里读到数字然后写到程序里输出吗…

(很遗憾,最后还是没能解决)

线路板

又是一个从未接触过的领域,这 gbr 文件是啥啊?没见过啊?

根据搜索可知,这是一种描述印刷电路板结构的格式,那么应该就有对应的软件。又乱搜了一通,发现这种格式编辑起来似乎有点麻烦,那有没有查看器啥的?

还真有,而且搜到的查看器还是 Online 的,都不用下载到本地,但是似乎还是看不到 flag。

又换了几个在线查看器,最后找到了这个:https://gerber-viewer.ucamco.com/,把 zip 拖进去虽然还是看不到,但是点击图标,其中有个是 Skeleton View,大概意思是骨架视图?算了不管了,进入这个视图之后就能勉强看见 flag:

就是有几个字母看不清,试了好几遍才试出来…

Flag 自动机

这题是我唯一一道做出来的二进制题,因为之前几乎没接触过这个领域,所以当时我电脑上连 IDA 都没有。在装好 IDA 满怀期待的打开后,我一脸懵逼:这玩意咋用啊?

虽然完全没学过但是仍然不能轻言放弃。在花了将近一天的时间琢磨了一下 IDA 的基础用法后,我最终还是勉强解出了这道题。下面来说一下我的过程:

首先虽然不太可能, flag 会不会以明文的形式写在代码中呢?IDA 有个 Strings View,点开看看:

首先引起我注意的就是这个 flag_machine.txt 了,自觉告诉我这里可能暗藏玄机。下面还有一个提示:

Hint: You don’t need to reverse the decryption logic itself.

意思就是说我不需要逆向解密逻辑。很显然,flag 就藏在这里,但是有加密。

回到这个可疑的 flag_machine.txt 上来,这个字符串是干什么用的呢?点开看看:

后面的 sub_401510 就是调用的函数名,可以点进去看看:

发现一个 “Congratulations” 显然是正确点击按钮的时候提示用的文本。不过上下全是汇编,这时候就需要反汇编看看了,按 F5:

注意这里的 switch 判断,联想到程序界面上有两个按钮,第一个按钮不让点,第二个按钮点了就退出。而这里的 case 2u 后面的 PostQuitMessage(0); 很可能就是在调用退出逻辑。而下面的 case 0x111u 说不定就是点击第一个按钮的逻辑了。那么我们就需要修改程序的判断逻辑,让这两个按钮的运行逻辑替换。

回到汇编界面,往上找:

注意左上的判断逻辑:

1
2
cmp     [ebp+Msg], 2
jz loc_40191C

这里比较 Msg 与立即数2,相等就跳转到 loc_40191C。而我们不想让他跳转到这里,而是想让它跳转到 case 111 后面之后的逻辑,也就是图中的 loc_4017E3。

选中 jz loc_40191C 这一行,点击菜单中的 Edit -> Patch Program -> Assemble。然后把 loc_40191C 修改为 loc_4017E3。点击 Apply patches to input file 保存修改。

然而这还不够,上面的反汇编里,在 case 111 下面还有一个判断:

1
2
if ( (_WORD)a3 == 2 )
PostQuitMessage(0);

虽然不知道是在判断什么,但是直觉告诉我这里的判断也需要修改。

回到汇编界面,修改 jnz short loc_4017FDjz short loc_4017FD,然后再修改 cmp ax, 3cmp ax, 2,保存。

运行程序,点击 “放手离开”,弹出窗口:

当场疑惑,难不成要用管理员权限运行这个玩意?但试了一下显然不行。继续看一下反汇编(上面的图里也有):

1
2
3
4
5
if ( (_WORD)a3 == 3 )
{
if ( lParam == 114514 )
{
...

这里判断了一下 lParam 是不是 114514,然后如果相等的话才会走下面的写入 flag_machine.txt 的逻辑,难不成是在判断用户的 ID?(这么臭的用户有存在的必要嘛2333)

好,那就把它的判断相等改成不相等。回到汇编界面,找到下面的代码:

1
2
cmp     [ebp+lParam], 1BF52h
jz short loc_401840

把第二行的 jz 改成 jnz,保存,运行,点击 “放手离开”:

成功拿到 flag!好耶!

flag 的内容里说我懂 Win32 API?抱歉真的不懂(菜.jpg)解题只能全靠猜(

微积分计算小练习

又到了我喜欢的 web 题了!这题是我自认为最合我口味的一题(因为下面的题都不会)。大概题目界面有六道微积分题目,一开始天真的我还真的以为跟微积分有关系,甚至还动用了我那“沉淀”了将近两年的数学基础去做了一下。(最后实在做不出来去用计算器了2333)。

点完提交之后才意识到似乎跟微积分没任何关系,分数多少都无所谓。倒是提交之后给了一个分享 URL,按照题目信息需要把这个 URL 粘贴到成绩提交处,然后就能提交成绩,这不是脱裤子放屁嘛。

那 flag 在哪?别急,看看源码:

1
2
3
print(' Putting secret flag...')
driver.execute_script(f'document.cookie="flag={FLAG}"')
time.sleep(1)

后端在使用一个 headless browser 请求这个分享链接,把 flag 放在了 cookie 里,然后读取页面上的 HTML 内容获取成绩。总感觉,这么做也太刻意了。。。

首先最可疑的东西就是这个分享 URL 了。URL 后面有一串神秘的 base64 字符串:MTAwOlJldmluY3g=。解码一看,好家伙,直接就是分数加上用户名,那么把这个值改了不就能改分数了嘛。

好,来看看前端是怎么处理这个 base64 字符串的:

1
2
3
4
5
6
7
8
9
10
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const result = urlParams.get('result');
const b64decode = atob(result);
const colon = b64decode.indexOf(":");
const score = b64decode.substring(0, colon);
const username = b64decode.substring(colon + 1);

document.querySelector("#greeting").innerHTML = "您好," + username + "!";
document.querySelector("#score").innerHTML = "您在练习中获得的分数为 <b>" + score + "</b>/100。";

这就是直接解码然后插入到 HTML 里嘛。等一下,它插入 HTML 用的是,,, innerHTML ?!这注入点不就来了嘛!

只需要写一段js代码读取 document.cookie 并写到分数上,然后想办法使用 HTML 标签触发代码,就可以拿到 flag。

比如这样写:

1
<img src=# onerror='document.querySelector("#greeting").innerHTML=document.cookie'>

然后跟用户名一起 base64 编码,替换掉分享链接上的 result 参数,提交!

成功拿到 flag!

光与影

上面的好几题都不会,这题看上去跟 web 有点关系,来看看吧。

打开页面就是加载一个 WebGL 动画,大概十几秒后,加载出了一个山的地形和空中漂浮的 flag。嗯,被一团不知道是什么东西挡住的 flag 内容。看来任务很简单,就是去掉挡住 flag 的这团东西:

怎么去掉呢?于是我开始琢磨这个动画是怎么做的。首先毫无疑问是 Canvas 标签,然后底层看上去是,,WebGL?完了,这我可完全不会啊。

不过仍然不能放弃,看看这个 WebGL 是怎么写的吧(在 fragment-shader.js 里):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Classic Perlin noise
float cnoise(vec2 P) {
vec4 Pi = floor(P.xyxy) + vec4(0.0, 0.0, 1.0, 1.0);
vec4 Pf = fract(P.xyxy) - vec4(0.0, 0.0, 1.0, 1.0);
Pi = mod289(Pi); // To avoid truncation effects in permutation
vec4 ix = Pi.xzxz;
vec4 iy = Pi.yyww;
vec4 fx = Pf.xzxz;
vec4 fy = Pf.yyww;

vec4 i = permute(permute(ix) + iy);

vec4 gx = fract(i * (1.0 / 41.0)) * 2.0 - 1.0 ;
vec4 gy = abs(gx) - 0.5 ;
vec4 tx = floor(gx + 0.5);
gx = gx - tx;

vec2 g00 = vec2(gx.x,gy.x);
vec2 g10 = vec2(gx.y,gy.y);
vec2 g01 = vec2(gx.z,gy.z);
vec2 g11 = vec2(gx.w,gy.w);
...

这都啥东西啊?在查了一会资料后,我差不多已经决定要放弃了。

第二天,再次打开这个 WebGL 的代码,开始琢磨其中的奥妙。仔细观察可以发现,这个代码中间有五个名字很莫名奇妙的函数:t1SDF、t2SDF … t5SDF。其中前四个函数都有一个共同点,那就是里面夹了一堆数据,而第五个几乎没有夹数据。再看下动画中间的构图:flag 四个字母和一个矩形的玩意,其中 flag 四个字母都是由一个一个点阵组成的。难不成跟前四个函数有关?如果推测合理的话,第五个函数的数据很少,说不定就是跟那个矩形有关?

为了验证猜测,我随便修改了 t5SDF 函数中的几个数字,然后让 WebGL 重新载入:

1
2
window.fragmentShader = "..." // 修改之后的 fragmentShader
main(window.vertexShader, window.fragmentShader);

果不其然,矩形的形状的发生了一些变化。那能不能把这个矩形去掉呢?也就是说,把 t5SDF 这个函数去掉?

看看 fragmentShader 里面哪些地方用到了 t5SDF:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float sceneSDF(vec3 p, out vec3 pColor) {
pColor = vec3(1.0, 1.0, 1.0);

vec4 pH = mk_homo(p);
vec4 pTO = mk_trans(35.0, -5.0, -20.0) * mk_scale(1.5, 1.5, 1.0) * pH;

float t1 = t1SDF(pTO.xyz);
float t2 = t2SDF((mk_trans(-45.0, 0.0, 0.0) * pTO).xyz);
float t3 = t3SDF((mk_trans(-80.0, 0.0, 0.0) * pTO).xyz);
float t4 = t4SDF((mk_trans(-106.0, 0.0, 0.0) * pTO).xyz);
float t5 = t5SDF(p - vec3(36.0, 10.0, 15.0), vec3(30.0, 5.0, 5.0), 2.0);

float tmin = min(min(min(min(t1, t2), t3), t4), t5);
return tmin;
}

只有这里了,首先把 t5SDF 函数删掉,然后对这个函数做一些修改:

1
2
3
4
-- float t5 = t5SDF(p - vec3(36.0, 10.0, 15.0), vec3(30.0, 5.0, 5.0), 2.0);

-- float tmin = min(min(min(min(t1, t2), t3), t4), t5);
++ float tmin = min(min(min(t1, t2), t3), t4);

(随手写的 diff,可能不太标准)

改完之后让 WebGL 重新载入,大概卡了十几秒之后,成功看到了矩形后面的 flag!

企鹅拼盘(前两小题)

这是唯一一个做出来一半的 math 题了,而且用的不是常规手法。

先看第一小题,让输入四个二进制数字,总共 2^4 = 16 种可能性,一个个试都能试出来。

第二小题,要输入 16 个二进制数字,总共 2^16 = 65536 种可能性,显然不能一个个试了。

不能?人不能,但是电脑可以啊!

好,开始看代码。首先这是一个用 Python 写的终端 GUI 程序,用的是 textual 框架。那么只需要想办法让它自己遍历输入就行了。

于是根据这个界面的逻辑写了一个 crack 函数,关于界面代码的逻辑分析有点复杂,现在想不起来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
async def crack(self, bitlength):
for i in range(2 ** bitlength):
raw_array = [int(x) for x in list('{0:0b}'.format(i))]
array = [0]*(bitlength - len(raw_array)) + raw_array
self.inbits = array
self.watch_pc(0)
await self.action_last()
self.watch_pc(len(self.branches))
if self.info.info['scrambled'] and self.info.info['pc'] == self.info.info['lb'] and len(self.info.info['inbits']) > 0 and self.info.info['ib'] < 0:
print(array)
break
else:
await self.action_reset()

然后只需要执行这个函数就行了。为了方便,我在界面的下方加上了 Crack 按钮,似得界面载入之后点击 Crack 就可以开始跑:

大概跑了十多分钟,第二小题的答案就出来了。

不过遗憾的是第三小题有 64 位,就算用电脑全天候的跑也需要三四天,于是就干脆放弃了。

结语

我太菜了.jpg