谁动了我的MC?

直接用strings看一下内核版本,当然也可以用vol的banners插件

飞书文档 - 图片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
安装对应版本的内核镜像
sudo apt-get install linux-image-5.4.0-205-generic

安装对应版本的内核头文件
sudo apt-get install linux-headers-5.4.0-205-generic

安装对应版本的内核模块
sudo apt-get install linux-modules-5.4.0-205-generic

安装对应版本的驱动
sudo apt-get install linux-modules-extra-5.4.0-205-generic

查看已经安装的内核版本
dpkg -l |grep linux-image

查看当前 GRUB 菜单项:

1
grep menuentry /boot/grub/grub.cfg

根据输出确定你想要启动的内核菜单项。假设Ubuntu, with Linux 5.4.0-205-generic 的索引是 1>5,其中1表示 Advanced options for Ubuntu 菜单的索引,5表示新内核版本在 Advanced options for Ubuntu 菜单中的索引(从 0 开始)。

通过修改 GRUB 配置文件,可以设置默认启动的内核版本:

1
sudo nano /etc/default/grub

找到GRUB_DEFAULT项将其修改为 GRUB_DEFAULT="1>5",更新 GRUB 配置并重启:

1
2
sudo update-grub
sudo reboot

查看当前内核版本:

1
uname -r

/boot目录下找到对应内核版本的System.map-5.4.0-205-generic文件

1
2
3
4
5
6
7
apt install build-essential dwarfdump

cd volatility2/tools/linux

make

zip ./Ubuntu-20.04.6-live-server.zip ./module.dwarf /boot/System.map-5.4.0-205-generic

将制作好的profile放到volatility2/volatility/plugins/overlays/linux下,用–info能查看到就是成功了。

img

linux_recover_filesystem恢复整个文件系统,这需要一点时间,主要是看opt/mcsmanager/daemon/data/InstanceData/底下的文件恢复的差不多(基本不再增加)以后就可以停下了。

img

第一问要找服务器面板的密码,在opt/mcsmanager/web/data/User这个路径下有一个json文件,里面存储了面板的用户信息,里面有密码的密文

img

从开头的$2a$10可以看出来这是bcrypt,用给的字典爆破一下很快就能得到密码明文I0am0alone

img

接下来两问得放一起看

可以看出服务器用了ftbbackups模组,保留了十个备份的世界方便回档,在opt/mcsmanager/daemon/data/InstanceData/e00336260129441a9b74844d485b2cd6/bakcups这个路径下,挑一个能够打开的用MC进去看一下。版本从其他地方很容易就能看出来是java版1.21

不难找到这座房子,就在出生点附近,后面有一格岩浆。由于在MC中,岩浆会使附近烧起来,所以我们可以推断出岩浆就是起火源。

img

F3查看坐标(Block)是-405,63,132

img

接下来就是找出是谁放的这桶岩浆,由于volatility恢复出的日志不全,前面一大半明显是缺失了

img

这里可以使用古法取证 :D

我们知道,在MC中,当你第一次拿起岩浆可以获得一个叫做hot stuff(中文:热腾腾的)的成就,我们直接用010在1.mem中搜索一下就能找到对应的用户Nathan,这是预期的解法,也应该是最简单的解法了:)当然也可以去world文件夹中找具体的玩家数据等

也许有的师傅会发现Ethan曾经造出过打火石,但显然根据前面进世界所见那是个迷惑选项 :D

img

nctf{I0am0alone_Nathan_-405_63_132}

X1crypsc

此题为与校队密码手Hibiscus共同出题

题目完整源码如下:

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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
from random import *
import time
import pyfiglet
import os
import hashlib
text = "X1crypsc"
ascii_art = pyfiglet.figlet_format(text)
print(ascii_art)
time.sleep(1)
print('[+]I want to play a game.\n')
time.sleep(1)
print('[+]If you win the game, I will give you a gift:)\n')
time.sleep(1)
print('[+]But try to beat the monster first:)\n')
time.sleep(1)
print('[+]Good luck!\n')
print('[+]You got a weapon!\n')
damage_rng = ()
def regenerate_damage():
global damage_rng
base = getrandbits(16)
add = getrandbits(16)
damage_rng = (base ,base + add)
monster_health = getrandbits(64)
menu = '''
---Options---
[W]eapon
[A]ttack
[E]xit
'''
regenerate_damage()
print(menu)
HP = 3
while True:
if monster_health <= 0:
print('[+] Victory!!!')
break
if HP <= 0:
print('[!] DEFEAT')
exit(0)
print(f'[+] Monster current HP:{monster_health}')
print(f'[+] Your current HP: {HP}')
opt = input('[-] Your option:')
if opt == 'W':
print(f'[+] Current attack value: {damage_rng[0]} ~ {damage_rng[1]}')
if input('[+] Do you want to refresh the attack profile of the weapon([y/n])?') == 'y':
regenerate_damage()
print(f'[+] New weapon attack value: {damage_rng[0]} ~ {damage_rng[1]}')
elif opt == 'A':
print('[+] The monster sensed of an imminent danger and is about to teleport!!\n')
print('[+] Now you have to aim at the monster\'s location to hit it!\n')
print('[+]Input format: x y\n')
x,y = map(int,input(f'[-] Provide the grid you\'re willing to aim:').split())
if [x,y] == [randrange(2025),randrange(2025)]:
dmg = min(int(randint(*damage_rng) ** (Random().random() * 8)),monster_health)
print(f'[+] Decent shot! Monster was hevaily damaged! Damage value = {dmg}')
monster_health -= dmg
else:
print("[+] Your bet didn't pay off, and the monster presented a counterattack on you!")
HP -= 1
elif opt == 'E':
print('[+] Bye~')
exit(0)
else:
print('[!] Invalid input')
print('[+]Well done! You won the game!\n')
print('[+]And here is your gift: you got a chance to create a time capsule here and we\'ll keep it for you forever:)\n')
keep_dir = '/app/user_file/'
class File:
def __init__(self):
os.makedirs('user_file', exist_ok=True)
def sanitize(self, filename):
if filename.startswith('/'):
raise ValueError('[!]Invalid filename')
else:
return filename.replace('../', '')
def get_path(self, filename):
hashed = hashlib.sha256(filename.encode()).hexdigest()[:8]
sanitized = self.sanitize(filename)
return os.path.join(keep_dir, hashed, sanitized)
def user_input(self):
while True:
filename = input('[-]Please enter the file name you want to create: ')
data = []
while True:
line = input('[-]Now write something into the file (or type "exit" to finish writing): ')
if line.lower() == 'exit':
break
data.append(line)
another_line = input('[-]Write in another line? [y/n]: ')
if another_line.lower() != 'y':
break
try:
path = self.get_path(filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w') as f:
for line in data:
f.write(line)
f.write('\n')
print(f'[+]Your file has been successfully saved at {path}, we promise we\'ll never lose it :)')
except:
print(f'[+]Something went wrong, please try again.')
while True:
ask = input('[-]Create more files? [y/n]: ')
if ask.lower() == 'y':
break
elif ask.lower() == 'n':
exit(0)
else:
print('[!]Invalid input, please try again.\n')
file = File()
file.user_input()
exit(0)

一阶段解析

MT19937的伪随机和线性变换理解。做出本题甚至不需要你有关于逆向MT19937相关的知识

核心逻辑梳理

打怪的核心逻辑:

  • 怪兽的血量是getrandbits(64)
  • 可以无限地洗炼武器的属性,每次会调用getrandbits(16)生成两个随机数,分别作为武器基础伤害下限、上下限之差
  • 怪兽在即将受到攻击时会闪现至(randrange(2025),randrange(2025))处,你需要预判怪兽的最终位置
  • 攻击怪兽时会用全局的randint从武器的基础伤害中随机取值,并乘以一个Random新实例的(默认转化为0-1间的float)幂数

显然我们的目的即为通过不断洗炼武器来收集足够多的随机数,以预测后面的随机数。

Random库相关

Random库的绝大多数函数所依赖的函数就是getrandbits。如randint的调用链就是randint -> _randbelow -> getrandbits

getrandbits(n)函数的特性:

  • 若 n = 32,则会将MT19937对应下标的状态值extract后直接输出;
  • 若 n < 32,则会将getrandbits(32)的结果截断后输出(高位优先,如n=160x12345678会被截断为0x1234);
  • 若 n > 32,则会多次调用getrandbits(32),按后一次输出的结果在高位拼凑而成。

getrandbits(64)确实是两个 getrandbits(32) 拼接而成,但后者并不是两个 getrandbits(16) 拼接而成,即getrandbits(32)是每次extract的最小单元。

伪随机逆向之没有MT19937的MT19937

广义上来说,MT19937的系统的状态构成就是624*32=19968个二进制位,或者 $$Z_{2}$$ 下的一个维度为19968的向量。

而MT19937的所有变换都是线性的,意即,MT19937的所有方法(__init__twistextract)都可以视为一个既有向量(或其一部分)和一个矩阵在 $$Z_{2}$$ 下做乘法的结果。

相对地,非线性变换则指不能被表示成矩阵乘法的一种变换。

作为参考,AES中,ShiftRowsMixColumns 这两种操作都是线性变换,起到扩散(Diffusion)的作用;而 SubBytes 则是典型的非线性变换,起到混淆(Confusion)的作用。

认识到线性变换这一特性的作用就在于,我们可以在不获得连续的19968个状态分量(传统的MT19937逆向)的情况下依然能够预测随机数。

假设存在 $$Z_{2}$$ 下的一个初始向量 $$v_{19968}$$ ,其中每一个维度都是MT19937的初始状态(624个32位数展开而得),经过“某种”变换(任意次数的状态旋转、提取、截取等等)后,由输出位经过特定方式排列的结果是结果向量 $$v^{‘}_{19968}$$。由于这种变换是线性的,因此存在一个19968*19968的矩阵 M,满足 $$v^{‘} = v \cdot M$$。

此时只需要找到这个变换矩阵 M,即可通过 $$v^{‘}$$ 反推出 v。

而在上述执行的变换确定的情况下,通过打黑盒即可确定 M。具体地,构造一个全零的、19968维的向量 v,依次让第 i 位为1,每次执行和题目相同的变换(重复getrandbits操作)并记录结果,获得的19968个二进制位即为 M 的第 I 行。

M 构造完成后再和题目交互,得到向量后直接solve_left就可以得到MT19937的初始状态;将之代入Random的新实例中,和题目以相同的方式运行一遍,即可来到和交互环境中的MT19937相同的状态。随后将本地和远程的PRNG同步,即可开挂把怪打掉。

这个矩阵 M 是19968*19968的,构造之非常耗时和烧内存,由于该矩阵是确定的,因此建议只构造一次并将之存储起来,需要重新打/debug时再加载;可能需要虚拟内存(否则Windows下挂WSL的sage可能会崩)

一阶段exp

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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
#sage
__import__('os').environ['TERM'] = 'xterm'

from Crypto.Util.number import *
from pwn import *
from sage.all import *
from random import *
from time import time

io = process(['python3','task.py'])
t0 = time()

io.recvuntil(b':')
monster_hp = int(io.recvline().strip().decode())

whatls = []
whatls.extend(int(i) for i in bin(monster_hp)[2:].zfill(64))

io.sendlineafter(b'option:',b'W')
io.recvuntil(b':')
n1 = int(io.recvuntil(b'~',drop=True).strip().decode())
n2 = int(io.recvline().strip().decode()) - n1
whatls.extend(int(i) for i in bin(n1)[2:].zfill(16))
whatls.extend(int(i) for i in bin(n2)[2:].zfill(16))

io.sendlineafter(b'?',b'y')
io.recvuntil(b':')
n1 = int(io.recvuntil(b'~',drop=True).strip().decode())
n2 = int(io.recvline().strip().decode()) - n1
whatls.extend(int(i) for i in bin(n1)[2:].zfill(16))
whatls.extend(int(i) for i in bin(n2)[2:].zfill(16))

for _ in range(620):
io.sendlineafter(b'option:',b'W')
io.sendlineafter(b'?',b'y')
io.recvuntil(b':')
n1 = int(io.recvuntil(b'~',drop=True).strip().decode())
n2 = int(io.recvline().strip().decode()) - n1
whatls.extend(int(i) for i in bin(n1)[2:].zfill(16))
whatls.extend(int(i) for i in bin(n2)[2:].zfill(16))

weapon_data = [int(''.join(map(str,whatls[-32:-16])),2),int(''.join(map(str,whatls[-16:])),2)]
weapon_data[1] += weapon_data[0]

'''
# map a linear transformation matrix
# compute for first time only, afterwards comment this section for memory & time conservation
mt = []

for i in range(19968):
f_stats = [0] * 19968
f_stats[i] = 1

state = [int(''.join(map(str,f_stats[i*32:(i+1)*32])),2) for i in range(624)]

r = Random()
r.setstate((3,tuple(state+[624]),None))

vc = []
vc.extend(int(i) for i in bin(r.getrandbits(64))[2:].zfill(64))
for _ in range(622): # 624 - 2 = 622
vc.extend(int(i) for i in bin(getrandbits(16))[2:].zfill(16))
vc.extend(int(i) for i in bin(getrandbits(16))[2:].zfill(16))

mt.append(vc)

save(mt,'mt.sobj')
'''

t0 = time()
mt = load('mt.sobj') #matrix(GF(2),...)
breakpoint()
resvec = vector(GF(2),whatls)
init = mt.solve_left(resvec)

impl_state = [int(''.join(map(str,init[i*32:(i+1)*32])),2) for i in range(624)]

rn = Random()
rn.setstate((3,tuple(impl_state+[624]),None))

for _ in range(1244): # 622*2
rn.getrandbits(16)

while True:
x_grid = rn.randrange(2025)
y_grid = rn.randrange(2025)
io.sendlineafter(b'option:',b'A')
io.sendlineafter(b'aim:',f'{x_grid} {y_grid}'.encode())
rn.randint(weapon_data[0],weapon_data[1])
io.recvline()
if b'Victory' in io.recvline():
break

print(f'time = {time() - t0:.2f}s')
io.interactive()

二阶段

成功进入第二阶段,这部分在题目中没有给出源码

img

其实经过简单尝试就能发现,这里只是过滤了../而且要求文件名不能以/开头,我们通过双写....//就能绕过做到目录穿越。这里我们可以向定时任务写入反弹shell的命令。但是有一点要注意的是,定时任务的命令结尾必须以换行符结束,可以参考这篇文章。因此,我们在输入内容后要记得再换一下行,输入一个空格(什么都不写是换不成功的)。

其实也可以直接去改task.py,但这里就展示一下定时任务的做法了

img

img

flag在题目进程的环境变量里

img