2023 熵密杯

  1. 初始谜题
  2. 第一关
  3. 第二关
  4. 后续关卡的猜测

今天和同事组队“金盾检测”一起参加了在河南郑州举办的首届熵密杯线下赛,是之前没有接触过的闯关赛制,有点类似渗透,关卡之间环环相扣,也都和密码学应用紧密相关。不过很遗憾在之前的比赛中对国密的接触并不多,因此有很多知识盲区,导致此次比赛也未能解出全部赛题,所以玩的也不是很尽兴,希望下次还能再来!

初始谜题

话不多说,我们先来看看赛题。首先是初始谜题,我们需要在靶场开启一个场景,会给到一个 ip 和 port;然后我们还可以下载到一个附件,里面包含一个客户端,我们在客户端里输入场景的 ip 和 port 就可以获得题目,题目使用 SM4 CBC-MAC 体制,计算了两个 32 字节消息的 MAC值,要求我们给出一个 64 字节的消息和对应的 MAC 值。具体内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
请输入谜题服务器IP地址(Please input Puzzle Server IP Address)
172.10.42.212
请输入谜题服务器端口号(Please input Puzzle Server Port Number)
8070
-----------------------------------------------------
MSG1:
e55e3e24a3ae7797808fdca05a16ac15eb5fa2e6185c23a814a35ba32b4637c2
MAC1:
0712c867aa6ec7c1bb2b66312367b2c8
-----------------------------------------------------
MSG2:
d8d94f33797e1f41cab9217793b2d0f02b93d46c2ead104dce4bfec453767719
MAC2:
43669127ae268092c056fd8d03b38b5b
-----------------------------------------------------
请输入您的MSG3(64字节,128个Hex,不要添加空格!)(Please input your 64bytes MSG3(64 bytes,128 hexs,don't using space)):

由于 SM4 CBC-MAC 的计算是需要密钥的,而我们没有密钥也就无法计算任意消息的 MAC 值,这里肯定是需要去特殊构造的。然而一开始并没有什么头绪,直到比我熟悉国密的队友提示说 SM4 CBC-MAC 的初始 iv 是全零。嗯?全零,!!!!思路来了。

我们知道 CBC 的模式大概是这样的

image-20230810225044525

而 SM4 CBC-MAC 是以最后一组的密文作为 MAC 值。于是对于这题而言,我们已知的信息是这样的

image-20230810225350649

既然没有密钥,那么这题肯定是要用现有的信息了。另外注意到,两个消息都是32字节,却让我们给一个64字节的消息,题目已经推着我们把两个消息合并到一起了。

两个消息合并,那么最后的 MAC 值就用 MAC2好了,于是我们就需要处理一下拼接处的问题。原本 MSG2 的第一分组的异或向量是全零,即有 $Enc(0 \oplus p_2) = c_1 $ (以上图为例)

如果直接将两个消息拼接计算 MAC 值,那么根据 CBC 的模式, MSG2 的第一分组的异或向量就是 MAC1,即有 $Enc(MAC_1\oplus p_2)$,那么这显然会影响到 ciphertext1,继而改变了最后的 MAC 值。所以我们要”消去“这个影响。改变的方法也很简单,我们只需要改变对应的 $p_2$ 为 $new \ p_2 = p_2 \oplus MAC_1$,即我们的明文消息为 $MSG_1||MAC_1\oplus \ p_2||p_3$,这样在两个消息拼接处,我们就有 $Enc(MAC_1 \oplus new \ p_2) = Enc(MAC_1 \oplus MAC_1 \oplus p_2) = Enc(0 \oplus p_2) = c_1$,于是最后消息的 MAC 值就是原来 MSG2 的 MAC 值: $MAC_2$

我们构造的消息:原始 MSG1:e55e3e24a3ae7797808fdca05a16ac15eb5fa2e6185c23a814a35ba32b4637c2

​ 计算 $MAC_1\oplus \ p_2$:

1
2
hex(0x0712c867aa6ec7c1bb2b66312367b2c8^0xd8d94f33797e1f41cab9217793b2d0f0)
=> '0xdfcb8754d310d88071924746b0d56238'

​ 在拼上 $p_3$:2b93d46c2ead104dce4bfec453767719

得到最终的消息:e55e3e24a3ae7797808fdca05a16ac15eb5fa2e6185c23a814a35ba32b4637c2dfcb8754d310d88071924746b0d562382b93d46c2ead104dce4bfec453767719

然后输出 $MAC_2$ 就能通过验证,如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
请输入您的MSG3(64字节,128个Hex,不要添加空格!)(Please input your 64bytes MSG3(64 bytes,128 hexs,don't using space)):
e55e3e24a3ae7797808fdca05a16ac15eb5fa2e6185c23a814a35ba32b4637c2dfcb8754d310d88071924746b0d562382b93d46c2ead104dce4bfec453767719
请输入您的MAC3(Please input your MAC3):
43669127ae268092c056fd8d03b38b5b
-----------------------------------------------------
**恭喜!谜题答案验证正确!(Congratulations!Puzzle verified correctly!)**
下面是您的战利品,请妥善记录后再关闭程序,(Your Spoils of war is follow,Please copy that before shutdown this program!)**
-----------------------------------------------------
Flag:
flag{N1lC9AuYQaPZo68G4ZSkw9PBgTMRFkkh}

-----------------------------------------------------
Gitea User Name:
TFCTEVTION
-----------------------------------------------------
Gitea Password:
#s@f3ty2024
-----------------------------------------------------
请按回车键结束谜题程序(Please press "Enter" key to end)

于是我们拿到了第一个 flag,以及一个 Gitea 的账号密码,我们根据网络拓扑,来到一个 Gitea 的登录页面,输入给到的账号密码可以进入一个仓库。其中有两个文件,一个是题目附件,一个是一份openssl 的源码。

第一关

题目附件包含一个加密的压缩包,里面含有一些数据包和 flag1。额外还给出了一个密文

1
2
<!!!!!!!!!!!!解密!解开我,你将获得全部信息!!!!!!!!!!!!!!!!!!>
6B562E2D3E7B6C61636078616C666C62

和加密的源代码

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
#include <stdio.h>

void reverseBits(unsigned char* password) {
int i, j;
unsigned char temp;

for (i = 0; i < 16; i++) {
temp = 0;
for (j = 0; j < 8; j++) {
temp |= ((password[i] >> j) & 1) << (7 - j);
}
password[i] = temp;
}
}

void swapPositions(unsigned char* password) {
int i;
unsigned char temp[16];
int positions[16] =
{
13, 4, 0, 5,
2, 12, 11, 8,
10, 6, 1, 9,
3, 15, 7, 14
};

for (i = 0; i < 16; i++) {
temp[positions[i]] = password[i];
}

for (i = 0; i < 16; i++) {
password[i] = temp[i];
}
}

void leftShiftBytes(unsigned char* password) {
for (int i = 0; i < 16; i++) {
password[i] = password[i] << 3 | password[i] >> 5;
}
}



void xorWithKeys(unsigned char* password, unsigned int round) {
int i;
for (i = 0; i < 16; i++) {
password[i] ^= (unsigned char)(0x78 * round & 0xFF);
}
}

void encryptPassword(unsigned char* password) {
int i;
unsigned int round;

for (round = 0; round < 16; round++) {
reverseBits(password);
swapPositions(password);
leftShiftBytes(password);
xorWithKeys(password, round);
}
}

int main() {
unsigned char password[17] = "1234567890";
printf("加密前的口令为:\n");
for (int i = 0; i < 16; i++) {
printf("%02X ", password[i]);
}
encryptPassword(password);
printf("加密后的口令为:\n");
for (int i = 0; i < 16; i++) {
printf("%02X ", password[i]);
}
printf("\n");
return 0;
}

一共十六轮,每一轮包含比特反转,位置置换,循环位移,密钥异或,计算单位是一个字节。可以看到都是分组密码的一些组件,我们对应的逆回去就可以了。

先逆密钥异或:加密是明文的每个字节异或 0x78 * round & 0xFF

1
2
3
4
5
6
void xorWithKeys(unsigned char* password, unsigned int round) {
int i;
for (i = 0; i < 16; i++) {
password[i] ^= (unsigned char)(0x78 * round & 0xFF);
}
}

那么解密就是把轮数 round 的值反着用一遍就好

1
2
3
4
newa= ""
for each in a:
newa += chr(ord(each)^((0x78*round)&0xff))
a = newa

接着循环移位,加密是每个字节向左循环移位 3 位

1
2
3
4
5
void leftShiftBytes(unsigned char* password) {
for (int i = 0; i < 16; i++) {
password[i] = password[i] << 3 | password[i] >> 5;
}
}

那我们右移回去

1
2
3
4
5
6
7
newa = ""

for each in a:
tmp = bin(ord(each))[2:].rjust(8,'0')
atmp = tmp[5:]+tmp[:5]
newa += chr(int(atmp,2))
a = newa

然后位置置换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void swapPositions(unsigned char* password) {
int i;
unsigned char temp[16];
int positions[16] =
{
13, 4, 0, 5,
2, 12, 11, 8,
10, 6, 1, 9,
3, 15, 7, 14
};

for (i = 0; i < 16; i++) {
temp[positions[i]] = password[i];
}

for (i = 0; i < 16; i++) {
password[i] = temp[i];
}
}

根据代码,举例当 i = 0,positions[i] 为 13,于是 temp[13] = password[0]。因此在解密的时候,明文的第 0 位,就是密文的第 positions[0],也就是第 13 位。于是

1
2
3
4
5
table = [13, 4, 0, 5,2, 12, 11, 8,10, 6, 1, 9,3, 15, 7, 14]
newa = ""
for i in range(16):
newa += a[table[i]]
a = newa

最后是比特反转,这个我们反逆回来就好了。整合一下

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
from Crypto.Util.number import *
a = long_to_bytes(0x6B562E2D3E7B6C61636078616C666C62).decode()
print(a)

for round in range(15,-1,-1):

newa= ""
for each in a:
newa += chr(ord(each)^((0x78*round)&0xff))
a = newa

#print(a)
newa = ""

for each in a:
tmp = bin(ord(each))[2:].rjust(8,'0')
atmp = tmp[5:]+tmp[:5]
newa += chr(int(atmp,2))
a = newa



table = [13, 4, 0, 5,2, 12, 11, 8,10, 6, 1, 9,3, 15, 7, 14]

newa = ""
for i in range(16):
newa += a[table[i]]
a = newa


newa = ""
for each in a:
abin = bin(ord(each))[2:].rjust(8,'0')
abinr = abin[::-1]
newa += chr(int(abinr,2))
a = newa
print(a)

运行后得到压缩包密码 pdksidicndjh%^&6,解密压缩包获得 flag:flag1{52e0acce-1e87-c966-43a4-59995df10b10},和两个流量包 数字签名系统调试数据包.pcapng、数字签名前置系统调试数据包.pcapng,和两份源代码 login.go、download.go

第二关

根据靶场上的网络拓扑,我们能进入数字签名前置系统

image-20230811001726815

可以看到需要输入用户名,证书,以及对应的私钥值。

根据上一关,我们已经拥有了这里的部分源码

login.go

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
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
func CertLogin(c *gin.Context, conf config.Config) {
randNumStr := c.PostForm("randNum")
if randNumStr == "" {
username := c.PostForm("username")
if username == "" {
c.JSON(http.StatusOK, gin.H{"msg": "登录失败,必须输入用户名", "code": 0})
return
}
certFile, err := c.FormFile("file")
if err != nil {
//fmt.Println(err)

c.JSON(http.StatusOK, gin.H{"msg": "登录失败,无效证书[代码:1001]", "code": 0})
return
}
if !strings.Contains(certFile.Header.Get("Content-Type"), "cert") {
c.JSON(http.StatusOK, gin.H{"msg": "登录失败,无效证书[代码:1002]", "code": 0})
return
}
srcCert, err := certFile.Open()
if err != nil {
//fmt.Println(err)
c.JSON(http.StatusOK, gin.H{"msg": "登录失败,无效证书[代码:1003]", "code": 0})
return
}
defer func(srcCert multipart.File) {
err := srcCert.Close()
if err != nil {
//fmt.Println(err)

}
}(srcCert)
certContent := make([]byte, certFile.Size)
_, err = srcCert.Read(certContent)
if err != nil {

c.JSON(http.StatusOK, gin.H{"msg": "登录失败,无效证书[代码:1004]", "code": 0})
return
}
certDERBlock, _ := pem.Decode(certContent)
if certDERBlock == nil {
c.JSON(http.StatusOK, gin.H{"msg": "登录失败,无效证书[代码:1005]", "code": 0})
return
}
cert, err := gmx509.ParseCertificate(certDERBlock.Bytes)
if err != nil {
//fmt.Println(err)

c.JSON(http.StatusOK, gin.H{"msg": "登录失败,无效证书[代码:1006]", "code": 0})
return
}
if cert.NotBefore.After(cert.NotAfter) {
//fmt.Println(err)

c.JSON(http.StatusOK, gin.H{"msg": "登录失败,无效证书[代码:1007]", "code": 0})
return
}
subjectName := cert.Subject.CommonName
if username != subjectName {
//fmt.Println(err)

c.JSON(http.StatusOK, gin.H{"msg": "登录失败,无效证书[代码:1008]", "code": 0})
return
}
serialNumber := cert.SerialNumber
if serialNumber.Cmp(big.NewInt(0)) == 0 {
//fmt.Println(err)

c.JSON(http.StatusOK, gin.H{"msg": "登录失败,无效证书[代码:1009]", "code": 0})
return
}
issuerName := cert.Issuer.CommonName
if issuerName != conf.IssuerName {
//fmt.Println(err)

c.JSON(http.StatusOK, gin.H{"msg": "登录失败,无效证书[代码:1010]", "code": 0})
return
}
haveExt := false
for _, ext := range cert.Extensions {
if ext.Id.String() == "1.2.3.4" {
if string(ext.Value) != conf.ExtValue {

c.JSON(http.StatusOK, gin.H{"msg": "登录失败,无效证书[代码:1011]", "code": 0})
return
}
haveExt = true
}
}
if !haveExt {
c.JSON(http.StatusOK, gin.H{"msg": "登录失败,无效证书[代码:1012]", "code": 0})
return
}
userFile, err := os.Open("userInfo.txt")
if err != nil {
//fmt.Println(err)
c.JSON(http.StatusOK, gin.H{"msg": "登录失败,系统有误[代码:1001]", "code": 0})
return
}
defer func(userFile *os.File) {
err := userFile.Close()
if err != nil {
//fmt.Println(err)

}
}(userFile)
var sysUserName string
for {
_, err := fmt.Fscanf(userFile, "%s\n", &sysUserName)
if err != nil {
c.JSON(http.StatusOK, gin.H{"msg": "登录失败,用户不存在", "code": 0})
return
}
if sysUserName == subjectName {
randNum := rand.Intn(1000000)
randNumStr := strconv.Itoa(randNum)
conf.Cache.Set(randNumStr, certContent, 0)
c.JSON(http.StatusOK, gin.H{"msg": "证书验证通过", "code": 1, "randNum": randNum})
return
}
}
}
signature := c.PostForm("signature")
if signature == "" {
c.JSON(http.StatusOK, gin.H{"msg": "登录失败,签名为空[代码:1001]", "code": 0})
return
}
certContent, flag := conf.Cache.Get(randNumStr)
if flag != true {
c.JSON(http.StatusOK, gin.H{"msg": "登录失败,签名错误[代码:1002]", "code": 0})
return
}
certDERBlock, _ := pem.Decode(certContent.([]byte))
if certDERBlock == nil {
c.JSON(http.StatusOK, gin.H{"msg": "登录失败,签名错误[代码:1003]", "code": 0})
return
}
cert, err := gmx509.ParseCertificate(certDERBlock.Bytes)
if err != nil {
c.JSON(http.StatusOK, gin.H{"msg": "登录失败,签名错误[代码:1004]", "code": 0})
return
}
pubKey := cert.PublicKey.(*ecdsa.PublicKey)
publicKey := sm2.PublicKey{}
publicKey.Curve = pubKey.Curve
publicKey.X = pubKey.X
publicKey.Y = pubKey.Y
signatureByte, err := hex.DecodeString(signature)
if err != nil {
c.JSON(http.StatusOK, gin.H{"msg": "登录失败,签名错误[代码:1005]", "code": 0})
return
}
l := len(signatureByte)
r := big.Int{}
s := big.Int{}
r.SetBytes(signatureByte[:l/2])
s.SetBytes(signatureByte[l/2:])
uid := []byte("1234567812345678")
verify := sm2.Sm2Verify(&publicKey, []byte(randNumStr), uid, &r, &s)
if verify != true {
c.JSON(http.StatusOK, gin.H{"msg": "登录失败,签名错误[代码:1006]", "code": 0})
return
}
username := cert.Subject.CommonName
generateToken(c, username, conf)
}
func generateToken(c *gin.Context, username string, conf config.Config) {
j := &utils.JWT{
SigningKey: []byte(conf.SignKey),
}
claims := utils.CustomClaims{
Name: username,
StandardClaims: jwtgo.StandardClaims{
NotBefore: time.Now().Unix() - conf.NotBeforeTime,
ExpiresAt: time.Now().Unix() + conf.ExpiresTime,
Issuer: conf.Issuer,
},
}

token, err := j.CreateToken(claims)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "登录失败,系统有误[代码:1002]",
})
return
}

c.JSON(http.StatusOK, gin.H{
"code": 1,
"msg": "登录成功",
"token": token,
})
return
}

download.go

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

func UploadFileList(c *gin.Context) {
files, err := ioutil.ReadDir("files")
if err != nil {
c.JSON(http.StatusOK, gin.H{"msg": "读取文件列表失败", "code": 0})
return
}
var fileNames []string
for _, file := range files {
fileNames = append(fileNames, file.Name())
}
c.JSON(http.StatusOK, gin.H{"msg": "读取文件列表成功", "code": 1, "data": fileNames})
return
}
func DownloadFile(c *gin.Context) {
fileName := c.Query("fileName")
filePath := "files/" + fileName
_, err := os.Stat(filePath)
if err != nil {
c.JSON(http.StatusOK, gin.H{"msg": "文件不存在", "code": 0})
return
}
c.File(filePath)
return

根据 download.go 应该是登录成功后的一个下载文件页面。所以目前我们需要将注意力放在login.go上。

具体流程为:

  1. 检查是否 post 了随机数 randNumStr,如果有则跳到第 16 步,否则继续下面的判断。
  2. 检查是否输入了用户名
  3. 检查是否上传了文件
  4. 检查上传的文件是否为证书类型
  5. 检查该证书文件能否打开
  6. 检查该证书文件能否读取
  7. 检查该证书文件能否以pem格式解析
  8. 检查该证书是否为 gmx509 格式(应该是,对go语言不是特别熟)
  9. 检查该证书是否在有效期内
  10. 检查 username 是否等于证书里的 CommonName
  11. 检查该证书的 serialNumber 是否为 0
  12. 检查该证书的 CommonName 是否为服务端配置文件中设定的 IssuerName
  13. 检查该证书是否有 “1.2.3.4” 类型的拓展,以及值是否和服务端配置文件中设置的相等。
  14. 检查该 username 是否为系统的注册用户
  15. 如果上面的检查都通过了则随机生成一个随机数返回给用户,并以该随机数和证书作为键值对存入字典 Cache 中。
  16. 读取用户传入的签名 signature
  17. 以随机数 randNumStr 作为键在 Cache 中获取对应证书
  18. 再次检查该证书文件能否以pem格式解析
  19. 再次检查该证书是否为 gmx509 格式
  20. 从证书中获取对应的 sm2 参数
  21. 十六进制解码用户的 signature,并获取对应的 r,s 值
  22. 使用公钥验证用户的签名

在上面任何一项检查不通过都会报错返回,并给出相应的错误代码(这实际上是不安全的,理论上应该只单纯的给出错误的返回,让用户无法判断错误点在哪,不过在这个场景中并不太能利用),全部通过则能登录成功。

然后在 数字签名前置系统调试数据包.pcapng 中我们看见了 admin1 的一个登录过程

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
POST /api/certLogin HTTP/1.1
Host: 192.168.11.153
Connection: keep-alive
Content-Length: 934
Accept: application/json, text/plain, */*
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36 Edg/114.0.1823.82
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarysSYBwFLJZmU0edVM
Origin: http://192.168.11.153
Referer: http://192.168.11.153/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6

------WebKitFormBoundarysSYBwFLJZmU0edVM
Content-Disposition: form-data; name="file"; filename="loginSM2.crt"
Content-Type: application/x-x509-ca-cert

-----BEGIN CERTIFICATE-----
MIIBpjCCAUugAwIBAgIRAIQyrttkXywULI3KhGNxYFYwCgYIKoEcz1UBg3UwPjEO
MAwGA1UEChMFQkNTWVMxDjAMBgNVBAMTBUJDU1lTMQ8wDQYDVQQqEwZHb3BoZXIx
CzAJBgNVBAYTAk5MMB4XDTIzMDcxNzAzMjMyNVoXDTI0MDcxNzAzMjMyNVowNTEV
MBMGA1UEChMMRXhhbXBsZSBJbmMuMQswCQYDVQQLEwJJVDEPMA0GA1UEAxMGYWRt
aW4xMFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAEdTKwJfeYUBsH3rw8KlRiabJX
z3KxNcH2MuYl7ol27RLS5/nVvvlrY2iw2Ylni+CS+htLoScXpEBsuMzkPjG3VKMz
MDEwDgYDVR0PAQH/BAQDAgKkMAwGA1UdEwEB/wQCMAAwEQYDKgMEBApxd2VydHl1
aW9wMAoGCCqBHM9VAYN1A0kAMEYCIQCP9wit9wKLNhDB7qzK50PuMldu0WhEFRuk
ZDXegNcpjQIhAK+fiviPZu53F7cAGck8VijLOJFfN0q7xJIqJ4T+nujr
-----END CERTIFICATE-----

------WebKitFormBoundarysSYBwFLJZmU0edVM
Content-Disposition: form-data; name="username"

admin1
------WebKitFormBoundarysSYBwFLJZmU0edVM--
HTTP/1.1 200 OK
Server: nginx/1.24.0
Date: Tue, 18 Jul 2023 01:33:13 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 54
Connection: keep-alive

{"code":1,"msg":"..................","randNum":415979}POST /api/certLogin HTTP/1.1
Host: 192.168.11.153
Connection: keep-alive
Content-Length: 368
Accept: application/json, text/plain, */*
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36 Edg/114.0.1823.82
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryywsxrJ41AnxgA0zr
Origin: http://192.168.11.153
Referer: http://192.168.11.153/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6

------WebKitFormBoundaryywsxrJ41AnxgA0zr
Content-Disposition: form-data; name="signature"

c4f6d124ebcf0969ae0d86f234680ef7730f62f83d5fa257f6734d80537d63eff7004f1339d2d13368f61ff8327c9e77d2c6a48e85c73a9d739811aeda5341ac
------WebKitFormBoundaryywsxrJ41AnxgA0zr
Content-Disposition: form-data; name="randNum"

415979
------WebKitFormBoundaryywsxrJ41AnxgA0zr--
HTTP/1.1 200 OK
Server: nginx/1.24.0
Date: Tue, 18 Jul 2023 01:33:13 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 213
Connection: keep-alive

{"code":1,"msg":"...............","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiYWRtaW4xIiwiZXhwIjoxNjg5NzMwMzkzLCJpc3MiOiJxZnpoZSIsIm5iZiI6MTY4OTY0Mzk5Mn0.XQf7xBf5bUswduZrX_GHvlpXFOH8G69NdB47lVlhBMs"}

我们可以看到 admin1 的证书,随机数 415979 以及对应的签名 c4f6d124ebcf0969ae0d86f234680ef7730f62f83d5fa257f6734d80537d63eff7004f1339d2d13368f61ff8327c9e77d2c6a48e85c73a9d739811aeda5341ac

在这里我们卡了很久,

一开始的想法,首先 SM2 是安全的,解 ECDLP 是不可能的了,不行;

这里只有一组签名数据,临时密钥重用攻击,不行;

用户的签名在网页前端进行,审计了一下签名的js代码,临时密钥的生成没有问题,不行;

image-20230811094842037

image-20230811094804023

最后我们注意到随机数的生成和 Cache 机制

1
2
3
4
5
6
7
if sysUserName == subjectName {
randNum := rand.Intn(1000000)
randNumStr := strconv.Itoa(randNum)
conf.Cache.Set(randNumStr, certContent, 0)
c.JSON(http.StatusOK, gin.H{"msg": "证书验证通过", "code": 1, "randNum": randNum})
return
}

由于随机数只有一百万个可能,如果说我们传入 admin1 的证书,并且当返回值正好是数据包中的 415979,那么由于我们也拥有对应的签名,我们就可以实现一次重入攻击,理论可行!

1
2
3
4
5
6
7
8
9
import requests

for _ in range(1000000):
burp0_url = "http://172.10.42.245:80/api/certLogin"
burp0_headers = {"Accept": "application/json, text/plain, */*", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36", "Content-Type": "multipart/form-data; boundary=----WebKitFormBoundaryuaSs8kkIgmx9BXiZ", "Origin": "http://172.10.42.245", "Referer": "http://172.10.42.245/", "Accept-Encoding": "gzip, deflate", "Accept-Language": "zh-CN,zh;q=0.9", "Connection": "close"}
burp0_data = "------WebKitFormBoundaryuaSs8kkIgmx9BXiZ\r\nContent-Disposition: form-data; name=\"file\"; filename=\"2.cer\"\r\nContent-Type: application/x-x509-ca-cert\r\n\r\n-----BEGIN CERTIFICATE-----\r\nMIIBpjCCAUugAwIBAgIRAIQyrttkXywULI3KhGNxYFYwCgYIKoEcz1UBg3UwPjEO\r\nMAwGA1UEChMFQkNTWVMxDjAMBgNVBAMTBUJDU1lTMQ8wDQYDVQQqEwZHb3BoZXIx\r\nCzAJBgNVBAYTAk5MMB4XDTIzMDcxNzAzMjMyNVoXDTI0MDcxNzAzMjMyNVowNTEV\r\nMBMGA1UEChMMRXhhbXBsZSBJbmMuMQswCQYDVQQLEwJJVDEPMA0GA1UEAxMGYWRt\r\naW4xMFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAEdTKwJfeYUBsH3rw8KlRiabJX\r\nz3KxNcH2MuYl7ol27RLS5/nVvvlrY2iw2Ylni+CS+htLoScXpEBsuMzkPjG3VKMz\r\nMDEwDgYDVR0PAQH/BAQDAgKkMAwGA1UdEwEB/wQCMAAwEQYDKgMEBApxd2VydHl1\r\naW9wMAoGCCqBHM9VAYN1A0kAMEYCIQCP9wit9wKLNhDB7qzK50PuMldu0WhEFRuk\r\nZDXegNcpjQIhAK+fiviPZu53F7cAGck8VijLOJFfN0q7xJIqJ4T+nujr\r\n-----END CERTIFICATE-----\r\n------WebKitFormBoundaryuaSs8kkIgmx9BXiZ\r\nContent-Disposition: form-data; name=\"username\"\r\n\r\nadmin1\r\n------WebKitFormBoundaryuaSs8kkIgmx9BXiZ--\r\n"
res = requests.post(burp0_url, headers=burp0_headers, data=burp0_data)
if '415979' in res.text():
print("[+] SUCCESS")

可惜,在赛场上运气不好,一直没有成功。而且成功后也面临一个问题,由于直接网页端访问需要我们输入私钥文件,因此我们后面也只能一直用脚本交互,会十分的不方便。于是我们再一次卡住了。

直到两点半放出提示:直接替换证书的公钥。

我们看到前面的检查,确实没有检查包括指纹、使用者密钥标识符、授权密钥标识符等上游信任链的问题。但是又一个问题出现了,我不熟悉 pem 文件的格式,所以并不知道怎么替换公钥。

于是使用笨方法,首先保存证书的 base64 编码,改后缀为 .cer,我们可以用 windows 直接打开看到

image-20230811100103015

现在回想起来,这就是一个很明显的提示了,明明不受信任的证书,在该前置系统上却能上传成功,说明检查的就不是很完整。随后看到详细信息,找到公钥

image-20230811100233032

是 04 75 … b7 54

我们再将上述 base64 编码解码再转为十六进制编码,得到

image-20230811100639552

于是我们本地生成一个私钥,然后将对应公钥替换进去就可以了。(后面再十六进制解码,换行换一下)

这里我生成的私钥为 $2^{256}-1$ ,对应的证书文件为

1
2
3
4
5
6
7
8
9
10
11
-----BEGIN CERTIFICATE-----
MIIBpjCCAUugAwIBAgIRAIQyrttkXywULI3KhGNxYFYwCgYIKoEcz1UBg3UwPjEO
MAwGA1UEChMFQkNTWVMxDjAMBgNVBAMTBUJDU1lTMQ8wDQYDVQQqEwZHb3BoZXIx
CzAJBgNVBAYTAk5MMB4XDTIzMDcxNzAzMjMyNVoXDTI0MDcxNzAzMjMyNVowNTEV
MBMGA1UEChMMRXhhbXBsZSBJbmMuMQswCQYDVQQLEwJJVDEPMA0GA1UEAxMGYWRt
aW4xMFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAEsyF9iEvBdea6azYOsObUOW6u
pyXD1m6Hv6W+tsDTRWulGZRFxUtWYCqmACXhkHv9JrMOhn22xYoDQmOuSi4nwqMz
MDEwDgYDVR0PAQH/BAQDAgKkMAwGA1UdEwEB/wQCMAAwEQYDKgMEBApxd2VydHl1
aW9wMAoGCCqBHM9VAYN1A0kAMEYCIQCP9wit9wKLNhDB7qzK50PuMldu0WhEFRuk
ZDXegNcpjQIhAK+fiviPZu53F7cAGck8VijLOJFfN0q7xJIqJ4T+nujr
-----END CERTIFICATE-----

image-20230811100940235

随后上传证书,私钥为

b64encode(long_to_bytes(2**256-1)) -> //////////////////////////////////////////8=

就可以登录成功,然后再次下载到一个加密的压缩包,和一个密文文件

1
2
<!!!!!!!!!!!!解密!解开我,你将获得全部信息!!!!!!!!!!!!!!!!!!>
2D303C3023614C226E436D20482C7D43

这里没给额外的加密代码,猜想应该还是用的前面的加密方式,所以我们用前面的解密脚本再次解密,得到压缩包 eufi*@(%$DKK884+ ,打开压缩包,获得 flag,flag2{7b84588a-0a54-5639-a828-9062e8a7f6c2}

做到这里就止步了,全场除了 0ops 和 AAA,其他队伍最多也就是到了这一步。遗憾的是前面两道题都是第四个解出来的没有拿到三血加分,第三道题刚开始思路歪了,提示放出来后众生平等,不过由于对 pem 文件不熟悉,openssl玩的也不是很溜,用笨方法慢了些,所以最后只拿了个第14。遗憾遗憾。

后续关卡的猜测

虽然没解出来后面的题目,但对后面的关卡也做了一定的探索。(不过由于是赛后总结的,没有环境,只好用口头叙述一下。整个比赛最有意思的应该也是在这里,可惜太菜了,体验不到)在通过第二关后,得到了一份火狐浏览器的代理工具和对应的操作指南,另外还有数字签名系统的签名和验签的 C 源码。然后登录数字签名前置系统后会有一个进入数字签名系统的链接,点进去是跨 B 段的,所以无法访问,要用代理。不过代理没有给用户名和密码,我们需要根据前面还没有用到的 数字签名系统调试数据包.pcapng 来进行分析。根据题目的提示,调试包中有两次握手,均采用 ECDH 来协商密钥,其中服务端的公钥不变,客户端的公钥变了。另外公告提示说服务端使用的私钥可以在前面 Gitea 中的 openssl 源码中获取(0ops在得知该消息后立刻拿到了一血,估计前面是卡在这了)。然后我们需要根据这个私钥计算会话的预主密钥,构造好文件导入 wireshark 配置,随后解密会话,就能拿到用户名和密码,进入数字签名系统,猜测里面应该会有一个 flag3。【比赛时由于不会解析椭圆曲线的私钥文件,另外也没有找到对应的私钥文件,遂放弃】至于大赛的最后目标:伪造一个能通过数字签名系统的签名。查看了一下得到的签名和验签的 C 代码,里面给出了msg1和msg2,以及msg1的签名,猜测我们要计算处 msg2 的签名。然后我看到了 Gitea 中的改动,出题人对 openssl 项目中的 crypto/rand/drbg_lib.c 文件中一个生成随机数的函数进行了修改,将原本生成32字节随机数写死了,

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
int RAND_DRBG_bytes(RAND_DRBG *drbg, unsigned char *out, size_t outlen)
{
unsigned char *additional = NULL;
size_t additional_len;
size_t chunk;
size_t ret = 0;

if (drbg->adin_pool == NULL) {
if (drbg->type == 0)
goto err;
drbg->adin_pool = rand_pool_new(0, 0, 0, drbg->max_adinlen);
if (drbg->adin_pool == NULL)
goto err;
}

additional_len = rand_drbg_get_additional_data(drbg->adin_pool,
&additional);

/* for ( ; outlen > 0; outlen -= chunk, out += chunk) {
chunk = outlen;
if (chunk > drbg->max_request)
chunk = drbg->max_request;
ret = RAND_DRBG_generate(drbg, out, chunk, 0, additional, additional_len);
if (!ret)
goto err;
} */

uint8_t rand0_32[32] = {0x67, 0xc6, 0x69, 0x73, 0x51, 0xff, 0x4a, 0xec, 0x29, 0xcd, 0xba, 0xab, 0xf2, 0xfb, 0xe3, 0x46, 0x7c, 0xc2, 0x54, 0xf8, 0x1b, 0xe8, 0xe7, 0x8d, 0x76, 0x5a, 0x2e, 0x63, 0x33, 0x9f, 0xc9, 0x9a};

for(int i=0;i<outlen;i++){
out[i] = rand0_32[i % 32];
}

ret = 1;

err:
if (additional != NULL)
rand_drbg_cleanup_additional_data(drbg->adin_pool, additional);

return ret;
}

猜测应该是在数字签名系统计算 msg1 签名,生成临时密钥的时候调用了这个函数。于是我们可以在已知 msg1 的临时密钥和签名的情况下恢复私钥,然后计算 msg2 的签名,通过系统的验证。

【老规矩,题目相关的附件,公众号后台回复关键字:2023熵密杯


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可联系QQ 643713081,也可以邮件至 643713081@qq.com

文章标题:2023 熵密杯

文章字数:5.8k

本文作者:Van1sh

发布时间:2023-08-11, 12:00:00

最后更新:2023-09-10, 22:39:16

原始链接:http://jayxv.github.io/2023/08/11/2023 熵密杯/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录
×

喜欢就点赞,疼爱就打赏