今天和同事组队“金盾检测”一起参加了在河南郑州举办的首届熵密杯线下赛,是之前没有接触过的闯关赛制,有点类似渗透,关卡之间环环相扣,也都和密码学应用紧密相关。不过很遗憾在之前的比赛中对国密的接触并不多,因此有很多知识盲区,导致此次比赛也未能解出全部赛题,所以玩的也不是很尽兴,希望下次还能再来!
初始谜题
话不多说,我们先来看看赛题。首先是初始谜题,我们需要在靶场开启一个场景,会给到一个 ip 和 port;然后我们还可以下载到一个附件,里面包含一个客户端,我们在客户端里输入场景的 ip 和 port 就可以获得题目,题目使用 SM4 CBC-MAC 体制,计算了两个 32 字节消息的 MAC值,要求我们给出一个 64 字节的消息和对应的 MAC 值。具体内容如下
12345678910111213141516 |
请输入谜题服务器IP地址(Please input Puzzle Server IP Address)172.10.42.212请输入谜题服务器端口号(Please input Puzzle Server Port Number)8070-----------------------------------------------------MSG1:e55e3e24a3ae7797808fdca05a16ac15eb5fa2e6185c23a814a35ba32b4637c2MAC1:0712c867aa6ec7c1bb2b66312367b2c8-----------------------------------------------------MSG2:d8d94f33797e1f41cab9217793b2d0f02b93d46c2ead104dce4bfec453767719MAC2: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 的模式大概是这样的
而 SM4 CBC-MAC 是以最后一组的密文作为 MAC 值。于是对于这题而言,我们已知的信息是这样的
既然没有密钥,那么这题肯定是要用现有的信息了。另外注意到,两个消息都是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$:
12 |
hex(0x0712c867aa6ec7c1bb2b66312367b2c8^0xd8d94f33797e1f41cab9217793b2d0f0)=> '0xdfcb8754d310d88071924746b0d56238' |
在拼上 $p_3$:2b93d46c2ead104dce4bfec453767719
得到最终的消息:e55e3e24a3ae7797808fdca05a16ac15eb5fa2e6185c23a814a35ba32b4637c2dfcb8754d310d88071924746b0d562382b93d46c2ead104dce4bfec453767719
然后输出 $MAC_2$ 就能通过验证,如下所示
12345678910111213141516171819 |
请输入您的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。额外还给出了一个密文
12 |
<!!!!!!!!!!!!解密!解开我,你将获得全部信息!!!!!!!!!!!!!!!!!!>6B562E2D3E7B6C61636078616C666C62 |
和加密的源代码
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576 |
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
123456 |
void xorWithKeys(unsigned char* password, unsigned int round) { int i; for (i = 0; i < 16; i++) { password[i] ^= (unsigned char)(0x78 * round & 0xFF); }} |
那么解密就是把轮数 round
的值反着用一遍就好
1234 |
newa= ""for each in a:newa += chr(ord(each)^((0x78*round)&0xff))a = newa |
接着循环移位,加密是每个字节向左循环移位 3 位
12345 |
void leftShiftBytes(unsigned char* password) { for (int i = 0; i < 16; i++) { password[i] = password[i] << 3 | password[i] >> 5; }} |
那我们右移回去
1234567 |
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 |
然后位置置换
12345678910111213141516171819 |
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 位。于是
12345 |
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 |
最后是比特反转,这个我们反逆回来就好了。整合一下
12345678910111213141516171819202122232425262728293031323334353637 |
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 = newatable = [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 = newanewa = ""for each in a:abin = bin(ord(each))[2:].rjust(8,'0')abinr = abin[::-1]newa += chr(int(abinr,2))a = newaprint(a) |
运行后得到压缩包密码 pdksidicndjh%^&6
,解密压缩包获得 flag:flag1{52e0acce-1e87-c966-43a4-59995df10b10},和两个流量包 数字签名系统调试数据包.pcapng、数字签名前置系统调试数据包.pcapng,和两份源代码 login.go、download.go。
第二关
根据靶场上的网络拓扑,我们能进入数字签名前置系统
可以看到需要输入用户名,证书,以及对应的私钥值。
根据上一关,我们已经拥有了这里的部分源码
login.go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195 |
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.CommonNameif username != subjectName {//fmt.Println(err)c.JSON(http.StatusOK, gin.H{"msg": "登录失败,无效证书[代码:1008]", "code": 0})return}serialNumber := cert.SerialNumberif serialNumber.Cmp(big.NewInt(0)) == 0 {//fmt.Println(err)c.JSON(http.StatusOK, gin.H{"msg": "登录失败,无效证书[代码:1009]", "code": 0})return}issuerName := cert.Issuer.CommonNameif issuerName != conf.IssuerName {//fmt.Println(err)c.JSON(http.StatusOK, gin.H{"msg": "登录失败,无效证书[代码:1010]", "code": 0})return}haveExt := falsefor _, 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 stringfor {_, 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.CurvepublicKey.X = pubKey.XpublicKey.Y = pubKey.YsignatureByte, 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.CommonNamegenerateToken(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
123456789101112131415161718192021222324 |
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 []stringfor _, 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上。
具体流程为:
- 检查是否 post 了随机数 randNumStr,如果有则跳到第 16 步,否则继续下面的判断。
- 检查是否输入了用户名
- 检查是否上传了文件
- 检查上传的文件是否为证书类型
- 检查该证书文件能否打开
- 检查该证书文件能否读取
- 检查该证书文件能否以pem格式解析
- 检查该证书是否为 gmx509 格式(应该是,对go语言不是特别熟)
- 检查该证书是否在有效期内
- 检查 username 是否等于证书里的 CommonName
- 检查该证书的 serialNumber 是否为 0
- 检查该证书的 CommonName 是否为服务端配置文件中设定的 IssuerName
- 检查该证书是否有 “1.2.3.4” 类型的拓展,以及值是否和服务端配置文件中设置的相等。
- 检查该 username 是否为系统的注册用户
- 如果上面的检查都通过了则随机生成一个随机数返回给用户,并以该随机数和证书作为键值对存入字典 Cache 中。
- 读取用户传入的签名 signature
- 以随机数 randNumStr 作为键在 Cache 中获取对应证书
- 再次检查该证书文件能否以pem格式解析
- 再次检查该证书是否为 gmx509 格式
- 从证书中获取对应的 sm2 参数
- 十六进制解码用户的 signature,并获取对应的 r,s 值
- 使用公钥验证用户的签名
在上面任何一项检查不通过都会报错返回,并给出相应的错误代码(这实际上是不安全的,理论上应该只单纯的给出错误的返回,让用户无法判断错误点在哪,不过在这个场景中并不太能利用),全部通过则能登录成功。
然后在 数字签名前置系统调试数据包.pcapng 中我们看见了 admin1 的一个登录过程
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869 |
POST /api/certLogin HTTP/1.1Host: 192.168.11.153Connection: keep-aliveContent-Length: 934Accept: 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.82Content-Type: multipart/form-data; boundary=----WebKitFormBoundarysSYBwFLJZmU0edVMOrigin: http://192.168.11.153Referer: http://192.168.11.153/Accept-Encoding: gzip, deflateAccept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6------WebKitFormBoundarysSYBwFLJZmU0edVMContent-Disposition: form-data; name="file"; filename="loginSM2.crt"Content-Type: application/x-x509-ca-cert-----BEGIN CERTIFICATE-----MIIBpjCCAUugAwIBAgIRAIQyrttkXywULI3KhGNxYFYwCgYIKoEcz1UBg3UwPjEOMAwGA1UEChMFQkNTWVMxDjAMBgNVBAMTBUJDU1lTMQ8wDQYDVQQqEwZHb3BoZXIxCzAJBgNVBAYTAk5MMB4XDTIzMDcxNzAzMjMyNVoXDTI0MDcxNzAzMjMyNVowNTEVMBMGA1UEChMMRXhhbXBsZSBJbmMuMQswCQYDVQQLEwJJVDEPMA0GA1UEAxMGYWRtaW4xMFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAEdTKwJfeYUBsH3rw8KlRiabJXz3KxNcH2MuYl7ol27RLS5/nVvvlrY2iw2Ylni+CS+htLoScXpEBsuMzkPjG3VKMzMDEwDgYDVR0PAQH/BAQDAgKkMAwGA1UdEwEB/wQCMAAwEQYDKgMEBApxd2VydHl1aW9wMAoGCCqBHM9VAYN1A0kAMEYCIQCP9wit9wKLNhDB7qzK50PuMldu0WhEFRukZDXegNcpjQIhAK+fiviPZu53F7cAGck8VijLOJFfN0q7xJIqJ4T+nujr-----END CERTIFICATE-----------WebKitFormBoundarysSYBwFLJZmU0edVMContent-Disposition: form-data; name="username"admin1------WebKitFormBoundarysSYBwFLJZmU0edVM--HTTP/1.1 200 OKServer: nginx/1.24.0Date: Tue, 18 Jul 2023 01:33:13 GMTContent-Type: application/json; charset=utf-8Content-Length: 54Connection: keep-alive{"code":1,"msg":"..................","randNum":415979}POST /api/certLogin HTTP/1.1Host: 192.168.11.153Connection: keep-aliveContent-Length: 368Accept: 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.82Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryywsxrJ41AnxgA0zrOrigin: http://192.168.11.153Referer: http://192.168.11.153/Accept-Encoding: gzip, deflateAccept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6------WebKitFormBoundaryywsxrJ41AnxgA0zrContent-Disposition: form-data; name="signature"c4f6d124ebcf0969ae0d86f234680ef7730f62f83d5fa257f6734d80537d63eff7004f1339d2d13368f61ff8327c9e77d2c6a48e85c73a9d739811aeda5341ac------WebKitFormBoundaryywsxrJ41AnxgA0zrContent-Disposition: form-data; name="randNum"415979------WebKitFormBoundaryywsxrJ41AnxgA0zr--HTTP/1.1 200 OKServer: nginx/1.24.0Date: Tue, 18 Jul 2023 01:33:13 GMTContent-Type: application/json; charset=utf-8Content-Length: 213Connection: keep-alive{"code":1,"msg":"...............","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiYWRtaW4xIiwiZXhwIjoxNjg5NzMwMzkzLCJpc3MiOiJxZnpoZSIsIm5iZiI6MTY4OTY0Mzk5Mn0.XQf7xBf5bUswduZrX_GHvlpXFOH8G69NdB47lVlhBMs"} |
我们可以看到 admin1 的证书,随机数 415979 以及对应的签名 c4f6d124ebcf0969ae0d86f234680ef7730f62f83d5fa257f6734d80537d63eff7004f1339d2d13368f61ff8327c9e77d2c6a48e85c73a9d739811aeda5341ac
在这里我们卡了很久,
一开始的想法,首先 SM2 是安全的,解 ECDLP 是不可能的了,不行;
这里只有一组签名数据,临时密钥重用攻击,不行;
用户的签名在网页前端进行,审计了一下签名的js代码,临时密钥的生成没有问题,不行;
最后我们注意到随机数的生成和 Cache 机制
1234567 |
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,那么由于我们也拥有对应的签名,我们就可以实现一次重入攻击,理论可行!
123456789 |
import requestsfor _ 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 直接打开看到
现在回想起来,这就是一个很明显的提示了,明明不受信任的证书,在该前置系统上却能上传成功,说明检查的就不是很完整。随后看到详细信息,找到公钥
是 04 75 … b7 54
我们再将上述 base64 编码解码再转为十六进制编码,得到
于是我们本地生成一个私钥,然后将对应公钥替换进去就可以了。(后面再十六进制解码,换行换一下)
这里我生成的私钥为 $2^{256}-1$ ,对应的证书文件为
1234567891011 |
-----BEGIN CERTIFICATE-----MIIBpjCCAUugAwIBAgIRAIQyrttkXywULI3KhGNxYFYwCgYIKoEcz1UBg3UwPjEOMAwGA1UEChMFQkNTWVMxDjAMBgNVBAMTBUJDU1lTMQ8wDQYDVQQqEwZHb3BoZXIxCzAJBgNVBAYTAk5MMB4XDTIzMDcxNzAzMjMyNVoXDTI0MDcxNzAzMjMyNVowNTEVMBMGA1UEChMMRXhhbXBsZSBJbmMuMQswCQYDVQQLEwJJVDEPMA0GA1UEAxMGYWRtaW4xMFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAEsyF9iEvBdea6azYOsObUOW6upyXD1m6Hv6W+tsDTRWulGZRFxUtWYCqmACXhkHv9JrMOhn22xYoDQmOuSi4nwqMzMDEwDgYDVR0PAQH/BAQDAgKkMAwGA1UdEwEB/wQCMAAwEQYDKgMEBApxd2VydHl1aW9wMAoGCCqBHM9VAYN1A0kAMEYCIQCP9wit9wKLNhDB7qzK50PuMldu0WhEFRukZDXegNcpjQIhAK+fiviPZu53F7cAGck8VijLOJFfN0q7xJIqJ4T+nujr-----END CERTIFICATE----- |
随后上传证书,私钥为
b64encode(long_to_bytes(2**256-1)) -> //////////////////////////////////////////8=
就可以登录成功,然后再次下载到一个加密的压缩包,和一个密文文件
12 |
<!!!!!!!!!!!!解密!解开我,你将获得全部信息!!!!!!!!!!!!!!!!!!>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字节随机数写死了,
1234567891011121314151617181920212223242526272829303132333435363738394041 |
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,也可以邮件至 [email protected] - source:Van1sh的小屋
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论