GeekGame3 Writeup
参赛ID:Lysithea 分数:3886 [TOC]
misc+tutorial
一眼盯帧(签到)
用一个支持逐帧查看GIF的图片浏览器(我用的HoneyView),把每帧上的字符逐个抄下来 ,是一串英文乱码synt{jrypbzrarjcynlref}。考虑到题面说答案是有意义的英文(以及签到题的难度),首先尝试的是最常用的凯撒密码(字母移位加密),移动13位即可解出flag{welcomenewplayers}
历年最简单签到题(确信)
小北问答(信息收集)
今年不搞花里胡哨的了,挺好的。
- 北大HPC的非交互式提交任务的方式
访问hpc官网的使用教程,提交作业章节,提到了sbatch运行作业,和salloc交互运行作业。虽然没有明说,但是sbatch应该就是非交互式的。
- Redmi K60 Ultra开源的Linux内核版本号
卡最久的题。首先在搜索Redmi K60 Ultra open source Linux kernel能搜到小米的github仓库,master分支下能看到各个开源版本的汇总表,最后一行能看到K60 Ultra的分支名称是corot-s-oss。切到这个分支,点开commits,发现只有最新的commit来自小米,前面的都是上游项目的。
Linux内核版本在项目的主Makefile文件开头,几个环境变量的定义中,分别是x,y,z版本号。之所以能确信这是版本号,其实还和下面踩的坑2有关。
# SPDX-License-Identifier: GPL-2.0
VERSION = 5
PATCHLEVEL = 15
SUBLEVEL = 78
EXTRAVERSION =
NAME = Trick or Treat
这题踩了两个坑浪费了相当多时间
- 尝试根据tag名t-alps-release-t0.mp1.tc8sp2-V1.14和来源mtk(mediatek,也是一家手机公司),去寻找他们的开源代码,但是没找到这个tag对应的release(而且说实话就算找到了也大概率不会解决问题,除非他们把kernel version直接写在readme里)
- 如果没注意到只有最新的一个commit来自小米,往前翻到大概9800+个commit,会看到一个地方突然commit格式变得自由散漫了起来,还有个超明显的
Linux 5.15.31,当时以为这就是红米从Linux Kernel继承的原点。实际上,MTK的Android内核本身也是基于Linux的魔改,也是需要开源的,这个可能是MTK项目组从Linux Kernel继承的commit。
- Apple Watch Series 8 蜂窝 41mm 内部 识别号
直接搜这些关键词和 Identifier,在github gist有人统计了这些数据,可以直接查到。Cellular是蜂窝的意思,这里应该是指类似4G流量这样的蜂窝网络技术。
- 被平台ban掉的用户昵称可用字符总数
正确解法是去xmcp老师开源的guidingstar比赛平台的github仓库里找源码。注意这个平台分为前端后端,我们找的显然是后端的python代码。看看commit,按题面要求找到10.1的commit,一番寻找找到平台用户信息设置相关的代码,即src/store/user_profile_store.py
很显然平台代码的大部分逻辑可以直接搬下来在本地运行,只需要把一些不必要的依赖去掉就行了(不过有个unicategories的库感觉可能会影响结果,我自己安装了)。然后就如提示所说,不同版本运行结果是不一样的。在平台Readme里提到Python的最低版本是3.8,于是我就在自己的anaconda上用3.8-3.12所有大版本都跑了一遍,结果是3.12是4636个,3.11是4587个,3.10/3.9是4472个,3.8是4445个。我是从新版本到旧版本开始试的,很不幸的生产环境用的是最旧的版本,笑死。
PS:能想到平台开源这点也是因为赛前刷比赛群,xmcp老师发了一个有人扫平台的截图,然后说了一句【平台都开源的,想扫的话这边建议本地慢慢扫呢】,笑死
PPS:其实平台的昵称前端那里还是有BUG。如果名字带【ℒ𝓎𝓈𝒾𝓉𝒽ℯ𝒶】这样的Unicode花体字母,实际上一个 字符是占两个字符位置的,但是总字符数显示还是按一个字符算的,这就导致我这个ID只能再额外写4个字符就输不进去了,但是显示字符数还是12/20。应该不是安全问题(……吧?)
- 2011.1年Bilibili游戏区下的子分区
看到题面第一想到的是archive.org,很不幸的是2011.1虽然有一个条目,但是却提示Directory Listing Denied。然后灵感乍现,莫非这个时候bilibili还叫mikufans?于是去中文维基百科上搜索bilibili的历史,意外发现b站在这个时候的域名是bilibili.us,(mikufans是2009年,2011.6改成bilibili.tv,而2014.9才改成.com域名的)
用bilibili.us在archive.org上搜索,你会发现2011年这段时间快照其实非常多,随便点进去一个,找到游戏区链接,最上面就可以看到所有分区了。(我猜很多人的第一反应是:怎么这么少?这真的是所有的了吗?)
- 照片中建筑物的官网域名?
GeekGame第一次出OSINT照片开盒题?拿下来首先看元数据,时间戳很新,所以不排除最近修改的可能性,EXIF等等信息也被抹掉了。然后尝试直接用Google Lens截取图片背景部分(没被旗子遮挡的部分)进行搜索,直接搜出了卢森堡音乐厅,对应官网是philharmonie.lu。这其实就是正解,这样特征明显的大型建筑物不太可能会有第二个。
一开始我还不太信,因为这个前景的赞助商怎么看都是科技公司,应该和音乐厅扯不上关系?所以尝试通过赞助商名字搜索对应的会展名称,没得到特别可靠的结果。最后还是关注到背景小货车上的文字,尽管被树遮挡了一部分,剩下的好像是sound.lu字样,恰好卢森堡国的顶级域名也是lu ,这就交叉验证了,这个建筑物确实就是卢森堡音乐厅。
Z公司的服务器(zmodem, jpeg格式)
(flag2解了一半)
这题给了一个流量包和一个终端。流量包里只有一个会话,是192.168.23.179和192.168.16.1双端通信,全部是TCP协议的流量。比较明显的特征是,有个前者(服务端)发送给后者(客户端)的带flag.jpg字符串的包,和一个有大量数据的包,里面能找到jpeg文件头ffd8和文件尾ffd9,所以这应该是客户端向服务端发起的一次文件请求下载过程。
这时候再看终端,就会发现远端返回的报文和流量包里服务端的一模一样。我们可以把流量包里发送的流量重放一次,这次得到的不再是图片文件,而是明文的flag1,镶嵌在前后位置的协议头尾之间。
flag1明文传输是一个很重要的信息,这意味着我们可以把流量包里ffd8到ffd9之间的数据复制出来(更好的是这两个标志都只出现过一次),这一定包含了完整的flag.jpg的信息。但是dump出来之后,会发现图片无法打开,用010 Editor查看会发现除了ffd8以外其他所有section全部对不上,文件中包含了大量的18字节和4X字节,这明显是不正常的。
00000000 ff d8 ff e0 18 40 18 50 4a 46 49 46 18 40 18 41 |.....@.PJFIF.@.A|
00000010 18 41 18 41 18 40 60 18 40 60 18 40 18 40 ff db |.A.A.@`.@`.@.@..|
00000020 18 40 43 18 40 18 42 18 41 18 41 18 42 18 41 18 |.@C.@.B.A.A.B.A.|
00000030 41 18 42 18 42 18 42 18 42 18 42 18 42 18 42 18 |A.B.B.B.B.B.B.B.|
00000040 42 18 43 18 45 18 43 18 43 18 43 18 43 18 43 18 |B.C.E.C.C.C.C.C.|
文件第一个非正常段是ffe0,即APP0,这个段包含了一个JFIF字符串,但是会发现它的位置似乎不对,如果把所有18都去掉就对了,此外JFIF应该以00结尾,但是这里确实18 40,到这个时候,差不多也该猜到18其实是类似转义符的作用,不会占有位置,而是会把后面的字符减去0x40。类似的过程,我们会发现0x18会出现在0x40-0x5f, 0xc0-0xdf, 0x69这些字符的前面。处理这些转义之后,我得到的是下面这张图。

虽然能打开但就像被磨坏了的CD一样。jpeg之所以会出现这种情况,是因为其并非按像素存储,而是采取霍夫曼编码,每个数据字节影响的不只是一个像素,所以文件中是存在一些坏数据需要处理的。这可能说明我的程序还有BUG/我对协议的理解有误。(如果其他人做出来的也是这种坏图,我在想有没有拼图大师能把flag给拼出来)
另外,我在一阶段就查到了这种协议被称为ZMODEM,毕竟0x18转义是个鲜明的特征,很容易搜到。虽然协议上说的异或0x40,我个人认为和我这里的处理是等价的,因为0x18事实上没有出现在任何bit6=0的字符前面。写这个writeup的时候我在想,是不是可以用pwntools建立一个伪造的服务端,用一个真正的ZMODEM客户端连接它请求flag.jpg文件,通过重放服务端流量的方式,在客户端直接得到原始文件?似乎比手搓协议要更不容易出BUG。
PS:我一开始对这个题面的语文理解出了偏差,我以为flag1是黑客的方法,所以我是在用pwn的视角理解第一问的,看到周期性报文和返回那么多数据还以为是某种heartbleed。
猫咪状态监视器(shell源码阅读,python格式化字符串)
打开python服务端脚本,很容易发现STATUS的输入格式化字符串没有做必要的检查:
cmd = "/usr/sbin/service {} status".format(service_name)
尽管这个题是在shell=False环境下执行的,后面这些只会作为service的参数,但输入空格仍然可以同时控制多个参数位置。
接下来从Python角度没思路了,去看看service文件,这实际上是一段shell脚本。看manual我们知道,它实际上是用来执行/etc/init.d下方脚本的一段语法糖。我们比较关心参数处理部分,几个要点:
while [ $# -gt 0 ]; do
case "${1}" in
...
*)
...
elif [ -z "${SERVICE}" ]; then
SERVICE="${1}"
elif [ -z "${ACTION}" ]; then
ACTION="${1}"
else
OPTIONS="${OPTIONS} ${1}"
fi
shift
;;
esac
done
这里shift指令是抛弃第一个参数,把后面参数往前挪。这里的逻辑就是,把第一个参数作为SERVICE,第二个作为ACTION,其他的全部作为OPTIONS放在后面,因此通过插入空格我们可以控制ACTION参数。
run_via_sysvinit() {
# Otherwise, use the traditional sysvinit
if [ -x "${SERVICEDIR}/${SERVICE}" ]; then
exec env -i ... "$SERVICEDIR/$SERVICE" ${ACTION} ${OPTIONS}
else
echo "${SERVICE}: unrecognized service" >&2
exit 1
fi
}
case "${ACTION}" in
...
*)
# We try to run non-standard actions by running
# the init script directly.
run_via_sysvinit
;;
esac
这就发现,当ACTION并非预定义的几个标准选项时,service会认为这是自定义的action而运行/etc/init.d/{SERVICE} {ACTIONS} {OPTIONS},而这里对可执行文件路径是直接的简单拼接,因此可以直接用../../../绕过。最终payload为../../../../bin/cat flag.txt
基本功(ZIP已知明文攻击)
这个题考的是ZIP已知明文攻击,不过我也是看了这篇博客才知道原来不需要知道整个文件,只需要总共12个已知字节和位置(其中8个连续)就可以爆破出密钥(并非解压密码,但文件可以用密钥解密提取)。使用的工具为bkcrack,其实两个flag攻击方法都在那篇博客里写了。CPU比较好的话可能跑出密钥就几分钟。
第一个zip里包含一个名为chromedriver_linux64.zip的文件名,很明显这个zip是从ChromeDriver官网上下载的,尽管我们并不知道这个压缩包里是哪个版本,但是zip压缩包的文件头里会存储压缩包的文件名,并且是固定位置。值得注意的是,因为可以压缩之后改名,而压缩文件里存储的应该只是被压缩时的文件名,两者不一定相同,所以最好是下载一个版本看一下。可以用的文件名是chromedriver(连续12字节),但是不包含linux64后缀。
第二个zip里只有一个flag.pcapng流量包,可以用pcapng协议里提到的block头中,blocklength高2位起,byteorder magic, Major version, minor verision, section length, 共计18字节已知明文。得到的流量包里也能找到明文的flag2。
exp:
# flag1
echo -n chromedriver >plain1.txt
./bkcrack-1.5.0-win64/bkcrack.exe -C challenge_1.zip -c flag1.txt -p plain1.txt -o 30
bkcrack-1.5.0-Linux/bkcrack -C challenge_1.zip -c flag1.txt -k 66b9e8a6 35c9b17f eb1f8fab -d flag1.txt
# flag2
echo -n 00004D3C2B1A01000000FFFFFFFFFFFFFFFF | xxd -r -ps>pcap_plain
bkcrack-1.5.0-Linux/bkcrack -C challenge_2.zip -c flag2.pcapng -p pcap_plain -o 6
bkcrack-1.5.0-win64/bkcrack -C challenge_2.zip -c flag2.pcapng -k 1fd5d830 377b4c6d 8b05e89f -d flag2.pcapng
DarkRoom(报错源码泄露,时间盲注)
经典的终端AVG,流程很短,也不太容易死。为了防止迷路建议走一步画一步地图

flag1要求到达终点时san值高于117%,如果按最优流程把道具全用了走到终点门口是90%的san,而根据源码player.py中部分内容可知,使用help命令有2/10的概率净增9点san值(10点增加+每回合1点损耗),因此只需要连续4次增加就可以达到通关条件,概率为1/625,考虑到终端3s一次连接(实际上考虑到建立连接和传输的时间大概5s的时间比较合理),1个小时内基本上就能roll到运气比较好的一次成功通关。
flag2需要进入所谓flag room进行getflag操作,然后程序二话不说开始让我们猜public key。这里偶然发现,当输入非数字时,会报出int类型转换错误,在终端打印出报错信息,leak出部分源码:
invalid literal for int() with base 10: 'a'
Traceback (most recent call last):
File "dark_room/player.py", line 249, in <module>
248: while flag_number:
249: choice = int(self.recv(b"Guess my public key (give me a number): ").decode())
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
250: if flag_number & 1:
251: p = getStrongPrime(2048)
252: q = getStrongPrime(2048)
253: flag_number >> 1
ValueError: invalid literal for int() with base 10: 'a'
进入二阶段之后,我才意识到这么一小段代码已经包含了足够的信息。首先,循环变量flag_name每次都会右移一位,而最终flag_name会移位为0而退出循环。另外,flag_number末位为1时,程序会执行两次getStrongPrime(2048)函数,这个函数应该来自Crypto.Util.numbers,生成这样大的随机质数需要显著长的时间,这就导致flag_number末位为1和为0时,终端返回数据的时间会有显著的不同(前者一般需要1s,后者在0.01s)。因此,考虑到flag_number在循环中不断右移,我们可以通过不断测量时间获得flag_number的每一位。把flag_number转换为int再转换为bytes,会发现这些bytes就是flag2的ASCII明文。
这种leak方法好像sqli里的时间盲注啊
Minecraft(?)
这个题我其实没什么好说的,我只做了第一问,我真的是来玩游戏来的,最近刚好开荒了1.18.2的群峦传说mod,电脑上有现成的HMCL客户端。
加载附件的存档文件打开地图之后,提示我们找钻石块。试了一下作弊指令和mod都可以用,用/gamemode creative可以无限拿物品和飞行,/gamemode spectator更是可以直接穿墙。一路跟着火把,找到地底岩浆区的钻石块,木牌子上就写着flag1。(大概看了下地图做的挺不错,要不是比赛任务重我真 的会正常地玩到通关)
我猜这个题是Linux俱乐部的某二级组织友情提供的。所以贵校Minecraft社什么时候成立呢(
Web
Emoji Wordle(HTTP无状态, JWT)
在开始打flag之前,我们首先可能需要一个emoji字符列表,可以从http://unicode.org/Public/emoji/3.0/emoji-data.txt这里找到,写一个简单的脚本把对应的范围dump出来
此外,由于HTTP是无状态协议,要实现这样的游戏,要么在前端判断(那样可以直接在前端改逻辑,是另一个故事),要么要通过cookie/session。这个题的网页cookie是JWT,分为head, body, key三部分,其中head记录了协议、加密(通常是HS256)等信息,body是数据本身,这两者都是base64编码后做一些字符替换,最后secret会包括一些验证信息。
flag1的JWT中,只包含了尝试次数,同时题面也说了答案是固定的,因为EMOJI总数本来就不多,暴力枚举并不困难。一个策略是,先64个不同的emoji为一组试探出结果的字符集,然后用64个相同的emoji爆破位置。一个trick是,我用的requests库,如果访问不带cookie,那剩余次数永远都是63次。
flag2更简单,因为JWT里直接存储了结果的明文。
{"alg":"HS256"}
{"data":{"level":"2","remaining_guesses":"8","target":"\uD83D\uDC55\uD83D\uDC67\uD83D\uDC74\uD83D\uDC7D\uD83D\uDC64\uD83D\uDC63\uD83D\uDC85\uD83D\uDC88\uD83D\uDC68\uD83D\uDC76\uD83D\uDC74\uD83D\uDC87\uD83D\uDC54\uD83D\uDC73\uD83D\uDC76\uD83D\uDC7A\uD83D\uDC86\uD83D\uDC86\uD83D\uDC74\uD83D\uDC78\uD83D\uDC88\uD83D\uDC7E\uD83D\uDC42\uD83D\uDC5C\uD83D\uDC56\uD83D\uDC6A\uD83D\uDC58\uD83D\uDC42\uD83D\uDC73\uD83D\uDC7E\uD83D\uDC61\uD83D\uDC78\uD83D\uDC5B\uD83D\uDC88\uD83D\uDC61\uD83D\uDC7D\uD83D\uDC84\uD83D\uDC77\uD83D\uDC79\uD83D\uDC82\uD83D\uDC43\uD83D\uDC43\uD83D\uDC7A\uD83D\uDC43\uD83D\uDC46\uD83D\uDC69\uD83D\uDC81\uD83D\uDC86\uD83D\uDC66\uD83D\uDC80\uD83D\uDC73\uD83D\uDC89\uD83D\uDC43\uD83D\uDC68\uD83D\uDC40\uD83D\uDC88\uD83D\uDC5D\uD83D\uDC7E\uD83D\uDC46\uD83D\uDC81\uD83D\uDC7B\uD83D\uDC58\uD83D\uDC63\uD83D\uDC48"},"nbf":1697278093,"iat":1697278093}
flag3在JWT存了一个seed字段,因而我们判断服务器远端是用这个seed来生成答案的。尽管尝试次数只有3次,但是请求一次拿到cookie之后,只要后续一直拿同一个cookie请求,剩余次数就永远是2次了,而且seed不变,退化到第一问,用相同脚本爆破即可。
{"alg":"HS256"}
{"data":{"level":"3","start_time":"1697278331515","remaining_guesses":"3","seed":"9.353972814870084E11"},"nbf":1697278331,"iat":1697278331}
看看我的exp
第三新XSS(CORS同源策略)
只做了第一问,xssbot会先访问admin的页面,设置cookie,再访问我的页面。通过iframe等方式,我们可以在自己的页面中嵌入admin的页面,那么xssbot在访问时就会带上cookie。一般来说,由于浏览器同源策略,随意iframe一个其他域名/端口/协议的网站是拿不到它的cookie的(不然就乱套了,cookie零元购)。但是同一个域名内部的恶意网页,同源策略默认大家都是自己人,就不会阻止访问,相当于少了一层保护。一般这个问题正确处理方法是设置cookie为httponly限制Javascript读取,或者用不同的子域名实现跨域。
<iframe src="/admin" id="admin"></iframe>
<script>
window.onload=()=>{
var ifr = document.getElementById('admin');
document.title = ifr.contentDocument.cookie;
}
</script>
非法所得(Clash for Windows, Clash配置, 漏洞复现)
本届比赛最喜欢的一道题,打穿之后感觉背后一凉,我本人也算是Clash For Windows (CFW)的重度用户,没想到这个项目的安全性这么离谱。这个题的Writeup我希望能给非专业人士科普来龙去脉,所以会写的很长,请谅解。
关于Clash, Clash for Windows
在正式开始之前,我有必要讲一下Clash和Clash For Windows的一些基本信息。众所周知,Clash是一个基于go语言的代理软件,会在本地开一个端口监听代理请求(实际上是多个,但我们一般只用mix port 7890)。接收到代理后,Clash可以通过自定义的规则rules(例如域名包含某个后缀DOMAIN-SUFFIX,或者IP满足某个网段IP-CIDR),决定这个请求是直连DIRECT还是走某个代理proxies。有时候,对于某些匹配条件我们希望在某些代理选项中手动/自动选择,因此Clash引入了proxy-groups,可以把规则指向一个group,并且可以通过一个内建的RESTful API(视情况,一般会开在9090端口)控制选择。另外,为了避免用户需要手动配置大量代理(方便代理商卖梯子给电脑小白) ,Clash还提供了proxy-providers功能,可以从本地或者远程的yaml文件一次性批量导入代理到某个proxy-groups。需要强调一点,Clash是一个纯命令行的程序,而Clash的控制只来自内建的RESTful API(即没有前端网页)。
Clash For Windows是为Clash包装的图形界面前端,本质是一个Clash配置文件的管理器。它本身是基于electron框架(我自己没深入了解,但应该可以看成浏览器+nodejs的结合),因而可以跨平台在Linux使用。作为前端它最主要的功能是可以在Profiles页面同时管理多套文件,同时在Proxies界面提供了方便的GUI操作代理的选择(后端是在调用Clash RESTful API)。CFW还有些更进阶的功能,比如用parsers在原有(通常是远端从代理商那里获取的)配置文件中动态添加项目,甚至执行nodejs代码来进行规则匹配识别,不过这个题不太涉及。CFW也有一个配置文件,即$HOME/.config/cfw-settings.yaml,包含的是用CFW管理Clash配置文件的信息,比如mixin,parser等等。Clash原本是开源软件,底层是go。但是Clash后来衍生出了一个闭源的Clash Premium版本,引入包括rule-provider等新的功能。Clash for Windows是基于Clash Premium的GUI客户端,底层是electron框架,一个基于浏览器+nodejs的跨平台桌面应用。这个继承关系还是很重要的,因为我们这里要打的是实际上CFW的漏洞,不涉及Clash内核本身,引入的恶意配置从Clash的角度看都是合法的。
以上所有配置信息的说明和语法,都可以在Clash和Clash For Windows的官方文档中找到。如果这些讲的有点抽象了,我在BBS上曾发过一个帖 子,讲如何在CFW配置parser,自动修改Clash配置文件让仅内网地址走北大VPN的openconnect代理,可以参考一下:https://bbs.pku.edu.cn/v2/post-read.php?bid=668&threadid=18595480。另外如果你是WallessPKU用户,它的配置文件就是最好的学习材料,本题所有用到的配置语法在那里都能找到(尤其是proxy-providers的使用)
题面环境分析
这个题的源码多到我需要给一个树才好说清楚:
├── Dockerfile
├── app
│ ├── cfw
│ │ ├── ... # not important
│ ├── conf.d
│ │ ├── cfw.conf
│ │ ├── openbox.conf
│ │ ├── ui.conf
│ │ ├── websockify.conf
│ │ ├── x11vnc.conf
│ │ └── xvfb.conf
│ ├── entrypoint.sh
│ ├── prepare_flag.mjs
│ ├── profiles
│ │ └── flag.yml
│ ├── readflag.c
│ ├── supervisord.conf
│ └── ui
│ ├── index.mjs
│ ├── package-lock.json
│ ├── package.json
│ └── static
├── cfw-settings.yaml
└── flags
├── flag
├── flag_easy
└── flag_veryeasy
本题线上环境包括三个主要模块,。第一个是用noVNC传输的CFW图形界面,但只有有限的控制,可以看主页,proxies,profiles,在profiles可以导入远端yaml配置文件,CFW的控制是用puppeteer实现的(别忘了electron也是浏览器)。导入的过程有外网环境。

另一个是puppeteer控制的chrome浏览器,可以输入并访问网址。这个puppeteer也是有外网环境的。

这两个后端受同一个前端服务器控制(nodejs-fastify),源码中相当多的部分是puppeteer如何控制noVNC和CFW的细节,但是也包含了这两处输入URL的过滤处理:
// clash import和chrome visit都调用
async function checkUrl(url, test = false) {
const u = new URL(url)
if (!['http:', 'https:'].includes(u.protocol)) {
throw new Error('Invalid URL')
}
if (test) {
await fetch(url)
}
return url
}
// 只在chrome visit时判断
if (!new URL(url).hostname.endsWith('.pku.edu.cn')) {
throw new Error('Only PKU website is allowed!')
}
可以看出,两种访问都只支持http/https协议,Clash import可以访问任意host,包括127.0.0.1内网host以及任意外网环境(反正校内的固定IP是可以,不知道校外的公网行不行),而Chrome还需要额外判断host结尾是北大域名,我认为多数参赛选手不大可能有一般的北大域名网页的控制权限。但是,不知道这是不是预期解的一部分,只host静态网页的情况下,隔壁【第三新XSS】的博客系统可以完美绕过第二条限制,我目前的理解是这是给没有校内IP,不能架服务器的选手留的后门。 (我一开始审计源码看错了,以为clash import也只能访问pku域名,找了半天绕过方式,写WP的时候发现根本没有判断,笑死)
