0%

2024-强网拟态-wp-crypto

对称该不会做还是不会做TT

xor

题目:

1
2
mimic is a keyword.
0b050c0e180e585f5c52555c5544545c0a0f44535f0f5e445658595844050f5d0f0f55590c555e5a0914

将mimic和密文异或一下就行。



watermarking

题目描述:

1
计算太贵了!想跟我讲话,首先得买我的水印!

题目:

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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
from Crypto.Util.number import *
from Crypto.Cipher import AES
from hashlib import sha256
import socketserver
import signal
import os
import string
import random
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
from Crypto.Util.number import getPrime,getStrongPrime
from Crypto.Random import get_random_bytes
from sympy import nextprime





class Task(socketserver.BaseRequestHandler):
def _recvall(self):
BUFF_SIZE = 4096
data = b''
while True:
part = self.request.recv(BUFF_SIZE)
data += part
if len(part) < BUFF_SIZE:
break
return data.strip()

def send(self, msg, newline=True):
try:
if newline:
msg += b'\n'
self.request.sendall(msg)
except:
pass

def recv(self, prompt=b'> '):
self.send(prompt, newline=False)
return self._recvall()

def handle(self):
e=3
maxlens=4096
RSAparameter=None
signal.alarm(60)
watermark=os.urandom(512)

self.send(b"welcome to my proxy encryption program with watermarking")
messages=[]
while 1:
self.send(b"1 for free encrypt with time limit")
self.send(b"2 for check your watermarking ")
self.send(b"plz input function code")
try:
mode=int(self.recv())
except:
self.send(b"plz input right function code")
continue
if mode==1:
if maxlens<=0:
self.send(b"sorry, no free chance, try last time~")
continue
self.send(b"now choose your n bitlens")
self.send(b"1. 1024 2. 2048 3. 4096")
try:
mode=int(self.recv())
except:
self.send(b"plz input right function code")
continue
if mode==1:
newparameter=1024
if mode==2:
newparameter=2048
if mode==3:
newparameter=4096
if newparameter!= RSAparameter:
RSAparameter=newparameter
p=getStrongPrime(RSAparameter//2)
q=getStrongPrime(RSAparameter//2)
n=p*q
phi=(p-1)*(q-1)
self.send(b"your n is changed to")
self.send(hex(n).encode())
try:
rsa_key = RSA.construct((n, e))
public_key = rsa_key.publickey().export_key()
cipher = PKCS1_v1_5.new(RSA.import_key(public_key))
self.send(b"plz input your message with hex")
message=self.recv().decode()
#print(message,len(message))
messages.append(message)
message=watermark[:RSAparameter//16]+bytes.fromhex(message)
#print(message)

c = (cipher.encrypt(message)).hex().encode()
#print(c)
self.send(c)
e=nextprime(e)
maxlens-=RSAparameter
except:
self.send(b"wrong~ try again")
continue
elif mode==2:
if maxlens>0:
self.send(b"sorry, not even yet~")
continue
self.send(b"now give me your message without free chance")
e=65537
d=pow(e,-1,phi)
rsa_key = RSA.construct((n, e,d))
public_key = rsa_key.publickey().export_key()
cipher = PKCS1_v1_5.new(rsa_key)

cts=self.recv().decode()
cts=bytes.fromhex(cts)
try:
p = (cipher.decrypt(cts,b"bad cipertext"))
print(p)
except Exception as e:
print(e)
self.send(b"wrong~ try again")
continue
if p[:RSAparameter//16]!=watermark[:RSAparameter//16]:
self.send(b"not used the watermark!")
continue
if p[RSAparameter//16:].hex() in messages:
self.send(b"not cost!")
continue
f=open("./flag","rb")
flag=f.read()
f.close()
self.send(b"cong! your flag is "+flag)



self.request.close()


class ThreadedServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
pass


class ForkedServer(socketserver.ForkingMixIn, socketserver.TCPServer):
pass


if __name__ == "__main__":
HOST, PORT = '0.0.0.0', 9999
print("HOST:POST " + HOST+":" + str(PORT))
server = ForkedServer((HOST, PORT), Task)
server.allow_reuse_address = True
server.serve_forever()

题目比较长,在连接上靶机后,靶机会先生成一个512字节的随机串作为watermark,主要的交互功能有:

  • 输入1,可以选择公钥n的比特长度为:

    • 1024
    • 2048
    • 4096

    靶机会生成对应比特长度的公钥n,此后可以输入一串消息,靶机会对他做:

    • 在前面填上watermark对应长度的前缀(取决于n的比特长度)
    • 然后做pkcs#1 v1.5的填充
    • 最后用e进行加密并给出密文。e初始值为3,每加密一次都会变成下一个素数

    此外还需要注意交互限制:

    • 可以多次交互,但公钥总比特数不能超过4096
    • 如果连续两次交互选择比特数相同,则公钥n不会改变,但是e会
  • 输入2,可以发送一个密文,靶机会用上一次输入1产生的公钥n对他进行解密以及pkcs#1 v1.5解填充,如果满足如下条件则获得flag:

    • pkcs#1 v1.5解填充成功
    • 解密出的明文前缀需要是watermark前面对应的部分
    • 解密出的明文去掉watermark前面部分后不能是发送过的消息

看源码可以知道,pkcs#1 v1.5的填充是这样的:

image-20241020114950263

也就是说,假设n的字节数为k,那么待加密明文对应的字节数要小于等于k-11才行,因为至少要留出11个字节做前面的填充。而前面的填充为:

1
b'\x00\x02' + ps + b'\x00'

其中ps是urandom产生的对应长度的字节流。

我们的目的是求出watermark,比较自然的想法是取两次2048的公钥长度,由于长度一致,他们就对应同一个2048bit的公钥n。此时假设我们输入两个长为117的消息,那么被加密的两次明文就分别是:

1
2
b'\x00\x02' + ps1 + b'\x00' + watermark[:128] + msg1
b'\x00\x02' + ps2 + b'\x00' + watermark[:128] + msg2

此时ps1和ps2都是8字节的小量,所以比较自然的想法是联立两式,消掉watermark之后二元copper求解。先将两次加密的式子表示出来:

resultant一下会发现一个很大的问题,联立消元之后虽然的确是ps1、ps2组成的度为15的多项式,然而项实在太多了,对应的用copper去做的话会有非常多的shift,完全做不了。而直接用这个多项式去格的话界是完全不够的,所以要想其他办法。

而可以发现,如果两次msg取的完全一样的话就有:

此时一个技巧是可以把如下式子看作一个变量:

那么就有:

这样换元之后消元带来的一个好处在于,消元得到的是关于小量t的单元方程,可以很有效的减少多项式项的个数,所以单元copper就可以解出t来。

解出t之后做个多项式gcd自然就有S,也就求出了水印,然后随意构造个消息发送回去即可。

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
from Crypto.Util.number import *
from pwn import *
from os import urandom

sh = remote("pwn-2419eb29ff.challenge.xctf.org.cn", 9999, ssl=True)
sh.recvuntil(b"plz input function code\n")
sh.sendline(b"1")
sh.sendline(b"2")
sh.recvuntil(b"your n is changed to\n")
n = eval(sh.recvline().strip().decode())
msg1 = urandom(117)
sh.recvuntil(b"plz input your message with hex\n")
sh.sendline(msg1.hex().encode())
m1 = bytes_to_long(msg1)
c1 = int(sh.recvline().strip().decode()[2:], 16)


sh.recvuntil(b"plz input function code\n")
sh.sendline(b"1")
sh.sendline(b"2")
msg2 = msg1
sh.recvuntil(b"plz input your message with hex\n")
sh.sendline(msg2.hex().encode())
m2 = bytes_to_long(msg2)
c2 = int(sh.recvline().strip().decode()[2:], 16)

length = 8
prefix = bytes_to_long(b"\x00\x02" + b"\x00"*254)
PR.<e,x> = PolynomialRing(Zmod(n))
f1 = (x)^3 - c1
f2 = (x + 256^(128 - 3 - length + 128 + 1)*e)^5 - c2
g = f1.sylvester_matrix(f2,x).det()
g = str(g).replace("^", "**")

PR1.<e> = PolynomialRing(Zmod(n))
g = eval(g)
g = g.monic()
res = g.small_roots(X=256^8, beta=1, epsilon=0.05)
e = int(res[0])

if(len(bin(e)) > 100):
e = n-e

PR.<x> = PolynomialRing(Zmod(n))
f1 = (x)^3 - c1
f2 = (x + 256^(128 - 3 - length + 128 + 1)*e)^5 - c2

def gcd(g1, g2):
while g2:
g1, g2 = g2, g1 % g2
return g1.monic()
m = -gcd(f1, f2)[0]
watermark = long_to_bytes(int(m), 256)[11:11+128]
print(watermark)


sh.recvuntil(b"plz input function code\n")
sh.sendline(b"2")
sh.recvuntil(b"now give me your message without free chance\n")

msg = b"\x00\x02" + urandom(8) + b"\x00" + watermark + urandom(117)
ct = pow(bytes_to_long(msg), 65537, n)
sh.sendline(long_to_bytes(ct).hex().encode())

sh.recvuntil(b"cong! your flag is ")
print(sh.recvline())


#flag{6nxOugSZh4ORWpYcB8pB3BOBDsgpJaJW}



CFBchall

题目描述:

1
遭遇过几次拖库之后,我们直接删除了数据库!请你保存好生成的token,不要篡改哦~

题目:

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
from flask import Flask, render_template, request, jsonify
from Crypto.Cipher import AES
import os
from string import printable
app = Flask(__name__)

# 初始化全局变量
def initialize_globals():
global key, iv,register_open, login_attempts
key = os.urandom(16)
iv = os.urandom(16)
register_open = True
login_attempts = 500

initialize_globals()

def encrypt(data, key):
#iv = os.urandom(16)
cipher = AES.new(key, AES.MODE_CFB, iv=iv)
ct_bytes = cipher.encrypt(data.encode('utf-8'))
return ct_bytes.hex()

def decrypt(ct, key):
ct_bytes = bytes.fromhex(ct)
#iv = ct_bytes[:16]
cipher = AES.new(key, AES.MODE_CFB, iv=iv)
data = cipher.decrypt(ct_bytes[:])
#print("data:",data)
return data

@app.route('/', methods=['GET', 'POST'])
def index():
global register_open, login_attempts
message = ""

if request.method in ['POST']:
if request.is_json:
data = request.get_json()
action = data.get('action')

if action == 'register':
if not register_open:
message = "Registration function is closed"
else:
username = data.get('username')
password = data.get('password')
if len(password)<8:
message = "Registration failed, password too short"
elif not set(password+username)<=set(printable):
message = "Registration failed, illegal character"
elif 'admin' in username:
message = "Registration failed, you are not an admin"
else:
token = encrypt(f"{username}\x00{password}\x01\x02\x03", key)
register_open = False
message = f"Please save your token: {token}"

elif action == 'login':
if login_attempts <= 0:
message = "Too many attempts"
else:
username = data.get('username').encode()
password = data.get('password').encode()
token = data.get('token')

try:
decrypted = decrypt(token, key)
#print(decrypted)
token_username, *_, token_password = decrypted.split(b'\x00')
#print(token_username,",",token_password)
assert(token_password[-3:]==b"\x01\x02\x03")
token_password=token_password[:-3]
if username == token_username and password == token_password:
if username == b'admin':
#print(token_password)
if password == b'123456':
f=open("./flag","r")
flag=f.read()
#print(flag)
f.close()
message = "You have logged in with admin privileges, here is your flag: "+flag
else:
message = "Admin login failed, please try again"
else:
message = "You have logged in as a regular user"
else:
message = "Login failed, please try again"
except:
message = "Login wrong, please try again"
finally:
login_attempts -= 1

elif action == 'restart':
initialize_globals()
message = "System has been restarted. AES key and function counts have been reset."
else:
message = "Unsupported Media Type: Content-Type must be application/json"

return jsonify({'message': message})
return render_template('index.html', message=message)

if __name__ == '__main__':
app.run(host="0.0.0.0")

题目基于AES的CFB分组模式,简单叙述下题意:

  • 靶机初始会随机生成key和iv,有500次login的机会,用完之后必须要刷新key和iv才能继续登录

  • 用register的话,可以选择username和password注册,限制如下:

    • username不能是admin
    • password长度要大于等于8
    • username和password都必须是可打印字符

    符合上述限制的话,靶机就会生成如下消息:

    1
    f"{username}\x00{password}\x01\x02\x03"

    并用当前的key和iv,基于AES的CFB模式加密,发送给我们密文,作为token

  • 用login,可以输入username、password和token尝试登录,当且仅当满足下列条件时获得flag:

    • username是admin
    • password是123456

我们的任务是对token做篡改,使得其解密后是我们需要的内容,而这种篡改很类似于CBC的字节翻转,似乎并没有多大难度。

但实际尝试的时候,发现CFB和CBC模式在pycryptodome的实现有很大不同,CFB模式并没有完全按照分组来,而是有一个参数为segment_size,搜索一下发现了这篇文章:

Flash CTF - Cleverly Forging Breaks - MetaCTF

里面有对实际实现的详细解释:

image-20241020121830210

简而言之,这种实现的影响在于——修改当前分组第i个字节的密文,那么解密有如下影响:

  • 当前分组第i个字节以及当前分组后续的所有字节
  • 下一个分组的所有字节

所以直接字节翻转不太行得通,而一个漏洞点在于他split的方式:

1
2
decrypted = decrypt(token, key)
token_username, *_, token_password = decrypted.split(b'\x00')

这样split的话相当于是把一串消息的第一个\x00和最后一个\x00内部的内容全部去掉,只留下头和尾作为token_username和token_password,一个具体例子就是:

1
2
3
4
5
if(1):
t = b"msg\x00msg\x00msg\x00123456\x01\x02\x03"
token_username, *_, token_password = t.split(b"\x00")
print(token_username)
print(token_password)
1
2
b'msg'
b'123456\x01\x02\x03'

这提供了一个很重要的思路就是:我们只需要修改两个字节使得其为\x00,那么中间任何内容都会被去掉,也就是说我们实际上只需要完成两个字节的篡改即可!

基于这个思路,可以构造如下username和password,问号代表任意字节的意思:

1
2
admiN + ??...?? (pad to 45 bytes)
??123456

此时显然是满足题目要求的,那么靶机加密的就是如下明文,共四个分组:

1
admiN??...? | ??...? | ??...?\x00?? | 123456\x01\x02\x03

我们就能获取这个明文对应的CFB密文,接下来就是如何篡改他。

part1

首先是要把用户名改成admin,这很简单,由于CFB的解密方式,只需要对密文第五个字节对应做异或就可以修改正确。

part2

修改了第一个分组的第五字节之后:

  • 第一个分组的后续字节全部会被影响
  • 第二个分组的所有字节全部会被影响
  • 第三、四个分组不受影响

所以此时解密出来的明文应该是:

1
admin??...? | ??...? | ??...?\x00?? | 123456\x01\x02\x03

由于split的漏洞,我们现在只需要把下面两个@的位置改成\x00即可:

1
admin@?...? | ??...? | ??...?\x00?@ | 123456\x01\x02\x03

第一个位置修改很简单,虽然我们不知道具体该改成多少,但是爆破256个字符总有一个会将他修改为\x00,所以我们可以认为在256次login内,一定有一次解密会是:

1
admin\x00?...? | ??...? | ??...?\x00?@ | 123456\x01\x02\x03

part3

此时只剩最后一步,就是把最后一个@改成\x00即可。而为了修改它,最简单的办法是随机修改第二个分组的最后一个字节,那么有1/256的概率可以得到:

1
admin\x00?...? | ??...? | ??...?\x00?\x00 | 123456\x01\x02\x03

就可以通过login得到flag了。

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
from Crypto.Util.number import *
from tqdm import *
import requests
import json


while(1):
base_url = "http://node7.anna.nssctf.cn:27901/"

##################################################################### restart
msg = {'action': 'restart'}
response = requests.post(base_url, json=msg)

##################################################################### register
username = 'admiN' + "1"*(11+16+13)
password = 'ab123456'
msg = {'action': 'register', 'username': username, 'password': password}
response = requests.post(base_url, json=msg)
token = (json.loads(response.text))["message"].strip("Please save your token: ").strip().zfill(2*(len(username)+len(password)+4))
token = bytes.fromhex(token)

#####################################################################
for i in trange(256):
token_ = token[:4] + long_to_bytes(token[4] ^ ord("N") ^ ord("n")) + long_to_bytes(i,1) + token[6:]
msg = token_
data = {'action': 'login', 'username': 'admin', 'password': 'ab123456', 'token': msg.hex()}
response = requests.post(base_url, json=data)
res = (json.loads(response.text))["message"]
if("Login failed" not in res):
for j in trange(256):
msg = token_[:31] + long_to_bytes(token_[31] ^ j) + token_[32:]
data = {'action': 'login', 'username': 'admin', 'password': '123456', 'token': msg.hex()}
response = requests.post(base_url, json=data)
res = (json.loads(response.text))["message"]
if("failed" not in res):
print(res)
exit()


#NSSCTF{72e6da1f-88ec-4aef-84dd-9c635dae2294}

revenge

春哥改了一下出了个revenge,revenge里做了如下限制:

1
2
elif action == 'restart':
message = "Uh-oh, this function is no longer available :("

也就是说开启容器后,总共就只有777次机会可以尝试login,用完了这个容器就完了,必须要新开容器才行。

硬碰运气的方法当然还可以用,只是会麻烦很多,777次机会里总共可以完成3次完整的256字节爆破,而平均需要256次才会成功一次,所以要碰运气的话平均需要反复关闭/打开容器$\frac{256}{3}$次,多少还是笨了一点,所以要想其他办法才行。

而注意到每次尝试login,返回信息都会根据具体情况有很大的不同:

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
elif action == 'login':
if login_attempts <= 0:
message = "Too many attempts"
else:
username = data.get('username').encode()
password = data.get('password').encode()
token = data.get('token')

try:
decrypted = decrypt(token, key)
#print(decrypted)
token_username, *_, token_password = decrypted.split(b'\x00')
#print(token_username,",",token_password)
assert(token_password[-3:]==b"\x01\x02\x03")
token_password=token_password[:-3]
if username == token_username and password == token_password:
if username == b'admin':
#print(token_password)
if password == b'123456':
f=open("./flag.txt","r")
flag=f.read()
f.close()
message = "You have logged in with admin privileges, here is your flag: "+flag
else:
message = "Admin login failed, please try again"
else:
message = "You have logged in as a regular user"
else:
message = "Login failed, please try again"
except:
message = "Login wrong, please try again"

而revenge的思路就是利用这些返回信息的差异去分别爆破username和password,为此依然还是做如下构造:

1
2
admiN + ??...?? (pad to 45 bytes)
ab123456

此时靶机加密的是:

1
admiN??...? | ??...? | ??...?\x00ab | 123456\x01\x02\x03

修改密文使得N为n,并且爆破n后面的一个字节,那么在256次机会里,一定会有一次满足解密得到的结果为:

1
admin\x00?...? | ??...? | ??...?\x00ab | 123456\x01\x02\x03

这个时候注意,我们不拿admin和123456去直接login,而是用admin和ab123456去login!,那么在爆破得到上述信息时,返回的消息应该是:

1
"Admin login failed, please try again"

此时我们保持该密文不变,去爆破第二组的最后一个字节使得解密后为:

1
admin\x00?...? | ??...? | ??...?\x00a\x00 | 123456\x01\x02\x03

并且拿admin和123456去login就可以拿到flag了,这样做在最差的情况也只需要256+256次,所以777次完全足够。

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
from Crypto.Util.number import *
from tqdm import *
import requests
import json


while(1):
base_url = "http://node6.anna.nssctf.cn:21152/"

##################################################################### restart
msg = {'action': 'restart'}
response = requests.post(base_url, json=msg)

##################################################################### register
username = 'admiN' + "1"*(11+16+13)
password = 'ab123456'
msg = {'action': 'register', 'username': username, 'password': password}
response = requests.post(base_url, json=msg)
token = (json.loads(response.text))["message"].strip("Please save your token: ").strip().zfill(2*(len(username)+len(password)+4))
token = bytes.fromhex(token)

#####################################################################
for i in trange(256):
token_ = token[:4] + long_to_bytes(token[4] ^ ord("N") ^ ord("n")) + long_to_bytes(i,1) + token[6:]
msg = token_
data = {'action': 'login', 'username': 'admin', 'password': 'ab123456', 'token': msg.hex()}
response = requests.post(base_url, json=data)
res = (json.loads(response.text))["message"]
if("Login failed" not in res):
for j in trange(256):
msg = token_[:31] + long_to_bytes(token_[31] ^ j) + token_[32:]
data = {'action': 'login', 'username': 'admin', 'password': '123456', 'token': msg.hex()}
response = requests.post(base_url, json=data)
res = (json.loads(response.text))["message"]
if("failed" not in res):
print(res)
exit()


#NSSCTF{0508d58c-5db6-45e2-a610-aaba783639b1}