2023 熵密杯

admin 2023年9月11日09:44:43评论212 views字数 26874阅读89分34秒阅读模式
(把之前两篇文章合并了一下)
8.10号和同事组队“金盾检测”一起参加了在河南郑州举办的首届熵密杯线下赛,是之前没有接触过的闯关赛制,有点类似渗透,关卡之间环环相扣,也都和密码学应用紧密相关。不过很遗憾在之前的比赛中对国密的接触并不多,因此有很多知识盲区,导致此次比赛也未能解出全部赛题,所以玩的也不是很尽兴,希望下次还能再来!

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

初始谜题

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

2023 熵密杯

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

2023 熵密杯

既然没有密钥,那么这题肯定是要用现有的信息了。另外注意到,两个消息都是32字节,却让我们给一个64字节的消息,题目已经推着我们把两个消息合并到一起了。
两个消息合并,那么最后的 MAC 值就用 MAC2好了,于是我们就需要处理一下拼接处的问题。原本 MSG2 的第一分组的异或向量是全零,即有   (以上图为例)
如果直接将两个消息拼接计算 MAC 值,那么根据 CBC 的模式, MSG2 的第一分组的异或向量就是 MAC1,即有 ,那么这显然会影响到 ciphertext1,继而改变了最后的 MAC 值。所以我们要”消去“这个影响。改变的方法也很简单,我们只需要改变对应的 ,即我们的明文消息为 ,这样在两个消息拼接处,我们就有 ,于是最后消息的 MAC 值就是原来 MSG2 的 MAC 值:
我们构造的消息:原始 MSG1:e55e3e24a3ae7797808fdca05a16ac15eb5fa2e6185c23a814a35ba32b4637c2

计算

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

在拼上 :2b93d46c2ead104dce4bfec453767719

得到最终的消息:e55e3e24a3ae7797808fdca05a16ac15eb5fa2e6185c23a814a35ba32b4637c2dfcb8754d310d88071924746b0d562382b93d46c2ead104dce4bfec453767719

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

请输入您的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。额外还给出了一个密文
<!!!!!!!!!!!!解密!解开我,你将获得全部信息!!!!!!!!!!!!!!!!!!>
6B562E2D3E7B6C61636078616C666C62
和加密的源代码
#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] =
            {
                    13405,
                    212118,
                    10619,
                    315714
            };

    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
void xorWithKeys(unsigned char* password, unsigned int round) {
    int i;
    for (i = 0; i < 16; i++) {
        password[i] ^= (unsigned char)(0x78 * round & 0xFF);
    }
}
那么解密就是把轮数 round 的值反着用一遍就好
 newa= ""
 for each in a:
  newa += chr(ord(each)^((0x78*round)&0xff))
 a = newa
接着循环移位,加密是每个字节向左循环移位 3 位
void leftShiftBytes(unsigned char* password) {
    for (int i = 0; i < 16; i++) {
        password[i] = password[i] << 3 | password[i] >> 5;
    }
}
那我们右移回去
 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
然后位置置换
void swapPositions(unsigned char* password) {
    int i;
    unsigned char temp[16];
    int positions[16] =
            {
                    13405,
                    212118,
                    10619,
                    315714
            };

    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 位。于是
 table = [13405,212118,10619,315714]
 newa = ""
 for i in range(16):
  newa += a[table[i]]
 a = newa
最后是比特反转,这个我们反逆回来就好了。整合一下
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 = [13405,212118,10619,315714]

 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

第二关

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

2023 熵密杯


可以看到需要输入用户名,证书,以及对应的私钥值。
根据上一关,我们已经拥有了这里的部分源码

login.go

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, "%sn", &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

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 的一个登录过程
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代码,临时密钥的生成没有问题,不行;

2023 熵密杯


2023 熵密杯


最后我们注意到随机数的生成和 Cache 机制
   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,那么由于我们也拥有对应的签名,我们就可以实现一次重入攻击,理论可行!
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 = "------WebKitFormBoundaryuaSs8kkIgmx9BXiZrnContent-Disposition: form-data; name="file"; filename="2.cer"rnContent-Type: application/x-x509-ca-certrnrn-----BEGIN CERTIFICATE-----rnMIIBpjCCAUugAwIBAgIRAIQyrttkXywULI3KhGNxYFYwCgYIKoEcz1UBg3UwPjEOrnMAwGA1UEChMFQkNTWVMxDjAMBgNVBAMTBUJDU1lTMQ8wDQYDVQQqEwZHb3BoZXIxrnCzAJBgNVBAYTAk5MMB4XDTIzMDcxNzAzMjMyNVoXDTI0MDcxNzAzMjMyNVowNTEVrnMBMGA1UEChMMRXhhbXBsZSBJbmMuMQswCQYDVQQLEwJJVDEPMA0GA1UEAxMGYWRtrnaW4xMFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAEdTKwJfeYUBsH3rw8KlRiabJXrnz3KxNcH2MuYl7ol27RLS5/nVvvlrY2iw2Ylni+CS+htLoScXpEBsuMzkPjG3VKMzrnMDEwDgYDVR0PAQH/BAQDAgKkMAwGA1UdEwEB/wQCMAAwEQYDKgMEBApxd2VydHl1rnaW9wMAoGCCqBHM9VAYN1A0kAMEYCIQCP9wit9wKLNhDB7qzK50PuMldu0WhEFRukrnZDXegNcpjQIhAK+fiviPZu53F7cAGck8VijLOJFfN0q7xJIqJ4T+nujrrn-----END CERTIFICATE-----rn------WebKitFormBoundaryuaSs8kkIgmx9BXiZrnContent-Disposition: form-data; name="username"rnrnadmin1rn------WebKitFormBoundaryuaSs8kkIgmx9BXiZ--rn"
 res = requests.post(burp0_url, headers=burp0_headers, data=burp0_data)
 if '415979' in res.text():
  print("[+] SUCCESS")
可惜,在赛场上运气不好,一直没有成功。而且成功后也面临一个问题,由于直接网页端访问需要我们输入私钥文件,因此我们后面也只能一直用脚本交互,会十分的不方便。于是我们再一次卡住了。
直到两点半放出提示:直接替换证书的公钥。
我们看到前面的检查,确实没有检查包括指纹、使用者密钥标识符、授权密钥标识符等上游信任链的问题。但是又一个问题出现了,我不熟悉 pem 文件的格式,所以并不知道怎么替换公钥。
于是使用笨方法,首先保存证书的 base64 编码,改后缀为 .cer,我们可以用 windows 直接打开看到

2023 熵密杯

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

2023 熵密杯


是 04 75 ... b7 54
我们再将上述 base64 编码解码再转为十六进制编码,得到

2023 熵密杯


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

这里我生成的私钥为 ,对应的证书文件为

-----BEGIN CERTIFICATE-----
MIIBpjCCAUugAwIBAgIRAIQyrttkXywULI3KhGNxYFYwCgYIKoEcz1UBg3UwPjEO
MAwGA1UEChMFQkNTWVMxDjAMBgNVBAMTBUJDU1lTMQ8wDQYDVQQqEwZHb3BoZXIx
CzAJBgNVBAYTAk5MMB4XDTIzMDcxNzAzMjMyNVoXDTI0MDcxNzAzMjMyNVowNTEV
MBMGA1UEChMMRXhhbXBsZSBJbmMuMQswCQYDVQQLEwJJVDEPMA0GA1UEAxMGYWRt
aW4xMFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAEsyF9iEvBdea6azYOsObUOW6u
pyXD1m6Hv6W+tsDTRWulGZRFxUtWYCqmACXhkHv9JrMOhn22xYoDQmOuSi4nwqMz
MDEwDgYDVR0PAQH/BAQDAgKkMAwGA1UdEwEB/wQCMAAwEQYDKgMEBApxd2VydHl1
aW9wMAoGCCqBHM9VAYN1A0kAMEYCIQCP9wit9wKLNhDB7qzK50PuMldu0WhEFRuk
ZDXegNcpjQIhAK+fiviPZu53F7cAGck8VijLOJFfN0q7xJIqJ4T+nujr
-----END CERTIFICATE-----

2023 熵密杯


随后上传证书,私钥为

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

就可以登录成功,然后再次下载到一个加密的压缩包,和一个密文文件
<!!!!!!!!!!!!解密!解开我,你将获得全部信息!!!!!!!!!!!!!!!!!!>
2D303C3023614C226E436D20482C7D43
这里没给额外的加密代码,猜想应该还是用的前面的加密方式,所以我们用前面的解密脚本再次解密,得到压缩包 eufi*@(%$DKK884+ ,打开压缩包,获得 flag,flag2{7b84588a-0a54-5639-a828-9062e8a7f6c2}

比赛中没有做出来的后续关卡

在通过第二关后,得到了一份火狐浏览器的代理工具和对应的操作指南,另外还有数字签名系统的签名和验签的 C 源码。然后登录数字签名前置系统后会有一个进入数字签名系统的链接,点进去是跨 B 段的,所以无法访问,要用代理。不过代理没有给用户名和密码,我们需要根据前面还没有用到的 数字签名系统调试数据包.pcapng 来进行分析。根据题目的提示,调试包中有两次握手,均采用 ECDH 来协商密钥,其中服务端的公钥不变,客户端的公钥变了。另外公告提示说服务端使用的私钥可以在前面 Gitea 中的 openssl 源码中获取(0ops在得知该消息后立刻拿到了一血,估计前面是卡在这了)。然后我们需要根据这个私钥计算会话的预主密钥,构造好文件导入 wireshark 配置,随后解密会话,就能拿到用户名和密码,进入数字签名系统,并获得 flag3。
我们查看了一下得到的签名和验签的 C 代码,里面给出了msg1和msg2,以及msg1的签名,猜测我们要计算出 msg2 的签名。根据公告,我看到了 Gitea 中的改动,出题人对 openssl 项目中的 crypto/rand/drbg_lib.c 文件中一个生成随机数的函数进行了修改,将原本生成32字节随机数写死了,
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(000, 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] = {0x670xc60x690x730x510xff0x4a0xec0x290xcd0xba0xab0xf20xfb0xe30x460x7c0xc20x540xf80x1b0xe80xe70x8d0x760x5a0x2e0x630x330x9f0xc90x9a};

    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 的签名,通过系统的验证。
事实上,服务端在生成私钥时调用了该函数!

第三关

我们看到数字签名系统调试数据包中服务端使用的公钥(No.66)

2023 熵密杯

随后进行本地测试,验证上面的随机数是否为服务端私钥
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey,X25519PublicKey
from cryptography.hazmat.primitives.kdf.hkdf import HKDF

rand0 = [0x670xc60x690x730x510xff0x4a0xec0x290xcd0xba0xab0xf20xfb0xe30x460x7c0xc20x540xf80x1b0xe80xe70x8d0x760x5a0x2e0x630x330x9f0xc90x9a]


sk = "".join(hex(i)[2:].rjust(2,'0'for i in rand0)
print(sk)

privatekey=X25519PrivateKey.from_private_bytes(bytes.fromhex(sk))
print((privatekey.public_key()._raw_public_bytes().hex()))

2023 熵密杯

注意到和流量包中的公钥是相等的,于是我们就可以用服务端的私钥和客户端的公钥计算预主密钥,然后导入  wireshark 进行会话解密。
整个流量包中有两次会话的协商,我们先在第一个 Client Key Exchange 中抓取客户端的第一个公钥(No.69)

2023 熵密杯

然后计算它们的协商密钥

from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey,X25519PublicKey
from cryptography.hazmat.primitives.kdf.hkdf import HKDF

rand0 = [0x670xc60x690x730x510xff0x4a0xec0x290xcd0xba0xab0xf20xfb0xe30x460x7c0xc20x540xf80x1b0xe80xe70x8d0x760x5a0x2e0x630x330x9f0xc90x9a]


sk = "".join(hex(i)[2:].rjust(2,'0'for i in rand0)
# print(sk)

privatekey=X25519PrivateKey.from_private_bytes(bytes.fromhex(sk))
# print((privatekey.public_key()._raw_public_bytes().hex()))


publickey=X25519PublicKey.from_public_bytes(bytes.fromhex('a0022027e0390ead7d82e1e74ae2d2f045fbf72896b9846d7f28bfa184280e3e'))

result=privatekey.exchange(publickey)
print(result.hex())
得到 7ff739dbe782d963e54e3242d83b3a01a6535aed3579f6a514a664b363915903
另外找到 Client Hello 里的随机数(No.64)

2023 熵密杯

预主密钥的格式为 PMS_CLIENT_RANDOM[空格]Random[空格]sharekey
于是第一个预主密钥为
PMS_CLIENT_RANDOM 9d8f92cc2ac8f33293da5169d49c82794c660fc937bd0c1b05f5e062e491da85 7ff739dbe782d963e54e3242d83b3a01a6535aed3579f6a514a664b363915903
同理我们在 No.3334 可以找到另一个 Random,在 No.3341 可以找到另一个客户端的公钥
最终预主密钥文件为
PMS_CLIENT_RANDOM 9d8f92cc2ac8f33293da5169d49c82794c660fc937bd0c1b05f5e062e491da85 7ff739dbe782d963e54e3242d83b3a01a6535aed3579f6a514a664b363915903
PMS_CLIENT_RANDOM b5dbfb40bc4c2b1a46bbc594fc89a56c17fe7db891beb7c111691516bd3117d1 4c8c1680018a8dd48749d642b6a6df5cc2104cb98842b82b0d748430108b8f61
随后【编辑】->【首选项】->【TLS】

2023 熵密杯

导入后我们即可看到解密后的流量。
追踪一下 HTTP 流即可看到签名系统的 用户名密码 以及 flag3

2023 熵密杯

另外代理 socks 代理的用户名和密码可以在 No.19 的数据包中找到

2023 熵密杯

伪造签名

进入数字签名系统后,

2023 熵密杯

我们需要计算新消息的签名。
首先 SM2 签名理论上是不会有什么问题的,并且前面一题的考点已经是私钥泄露了,那么这里应该是没法直接获取私钥的。在签名中,与私钥同等重要的,就是临时密钥了。在上一篇文章中我们猜测这里可能是临时密钥重用。不过那需要至少已知两条签名我们才能恢复私钥,所以这个思路应该可以否定了。不过,我们在第二关还获取到了一份数字签名系统签名验签源码:sign-verify.c,那么切入点显然会在这了。
在其中的 Sign 函数中,我们注意到
 //Generate Random Number
 unsigned char randomScalar[32];
 unsigned int i_time=0;
 time_parse(message, &i_time);
 if(derive_from_time(i_time,randomScalar,32))
  goto err;
 BN_bin2bn(randomScalar, 32, k);
看到  time_parse 和 derive_from_time 函数
int time_parse(char *str_time, unsigned int *i_time){
 struct tm s_time;

 /* strptime(str_time,"%Y年%m月%d日%H:%M:%S",&s_time);
 s_time.tm_isdst = -1;
 *i_time = mktime(&s_time);  */



 int year, month, day, hour, minute,second;
 sscanf(str_time,"%d-%d-%d %d:%d:%d", &year, &month, &day, &hour, &minute, &second);
 s_time.tm_year= year-1900;
 s_time.tm_mon= month-1;
 s_time.tm_mday= day;
 s_time.tm_hour= hour;
 s_time.tm_min= minute;
 s_time.tm_sec= second;
 s_time.tm_isdst= -1;

 *i_time = mktime(&s_time);
 return 0;
}

int derive_from_time(unsigned int seed, unsigned char *randomScalar, int length) {
    if (randomScalar == NULL || length <= 0) {
        return 1// Invalid input
    }

    unsigned int currentSeed = seed;
    int generatedLength = 0;


    while (generatedLength < length) {
        unsigned char shaOutput[SHA256_DIGEST_LENGTH];
        SHA256((const unsigned char *)&currentSeed, sizeof(currentSeed), shaOutput);
        int remainingLength = length - generatedLength;
        int copyLength = remainingLength < SHA256_DIGEST_LENGTH ? remainingLength : SHA256_DIGEST_LENGTH;
        memcpy(randomScalar + generatedLength, shaOutput, copyLength);
        generatedLength += copyLength;
        currentSeed++;
    }
    return 0// Success
}
乱七八糟的,但是总而言之,随机数 k 和消息中的时间相关。
那么思路就很显然了:我们可以计算签名 msg1 时使用的临时密钥 k,有了 k 也就能恢复签名用的私钥 sk,从而也就能给 msg2 签名了。
由于 c 的大数计算可麻烦,这里还是先用它的代码把临时密钥 k 打印出来先
编译指令:gcc tmpk.c -L. -l crypto -l ssl -o tmpk (把 tmpk.c 放在 openssl 目录下)
 //Generate Random Number
 unsigned char randomScalar[32];
 unsigned int i_time=0;
 time_parse(message, &i_time);
 if(derive_from_time(i_time,randomScalar,32))
  goto err;
 BN_bin2bn(randomScalar, 32, k);
 BN_print_fp(stdout, k); 
 printf("n"); 
得到 D2D569D2A7250B2B27DF909C9AFC1FD9E0A555AEC4BFB5D80CD71F70ADACF414
已知临时密钥 ,根据签名值我们可以获取 ,而计算私钥 sk 的公式为
注意到这里有一个坑点,签名里的 r 和 s 用 FlipEndian 处理过,字节序变化了,所以我们在计算的时候也要相应处理
from Crypto.Util.number import *
r = 0x37AF670C4742BD0C8D7CF68FCEBFE61885AA630695D50A15DF279CD64327466F
r = bytes_to_long(long_to_bytes(r)[::-1])
s = 0x6701CFB5F356887B9441323FDC08FBA900E1050109FD95F024DC9C178CEBE7A4
s = bytes_to_long(long_to_bytes(s)[::-1])
n = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123
k = 0xD2D569D2A7250B2B27DF909C9AFC1FD9E0A555AEC4BFB5D80CD71F70ADACF414
print((k-s)*inverse(s+r,n)%n)
得到私钥 104515905597970870556286963199400550747760654012576876144731059595513283165045
验证一下

2023 熵密杯

和公钥一致!
所以我们可以构造私钥文件 pri_pub/priSM2.key ( hex(bytes_to_long(long_to_bytes(sk::-1]))
753bffd7cd2353cbe72702159162f8da8f7118d8b4944fe74ddbf7e2fee711e7
然后把main函数修改一下
int main()
{
 unsigned char pub[64];
 unsigned char pri[64];
 unsigned char message1[128] = "2023-8-10 09:11:13, A transfers 50000.00 to B.";
 unsigned char message2[128] = "2023-8-10 11:31:01, B transfers 50000.00 to A.";
 unsigned char digest[32];
 unsigned char sig1[64];
 unsigned char sig2[64];
 int ret;

 printf("msg1:t%sn",message2);
 ret = Sign_Prifile(message2, sig1);
 user_printf_hex("sig1:t",sig1,64);
 ret = Verify_Pubfile(message2, sig1);
 printf("verify:t%dn",ret);

 return 0;
}
运行得到 msg2 的签名

2023 熵密杯

2023 熵密杯

完结!撒花!

(PS:做到现在,仍然不知道 AAA 是怎么在没拿到 flag3 的情况下进入签名系统,完成签名计算的,疑惑。难道说他们找到了签名系统的洞可以注册用户?)

原文始发于微信公众号(Van1sh):2023 熵密杯

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年9月11日09:44:43
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   2023 熵密杯http://cn-sec.com/archives/2024046.html

发表评论

匿名网友 填写信息