2021 天翼杯 bbgo Writeup

robots

  • 关键算法: 位运算
  • 出处: 2021 天翼杯
  • 工具: IDAPython
  • 考点: Golang 恢复符号, 文件解密
  • 解题技巧: 爆破

 

分析

恢复符号

  • 这题恶心在 IDA 7.6 没法恢复符号,估计是出题人魔改了 Golang 编译器的源码或是 patch 了文件
  • (我更倾向于魔改了源码,因为 Golang 初始化 runtime 的时候会对自身的 moduledata 等进行校验,patch 不太容易做到)
  • 所以只能根据 Golang 恢复符号的原理手动进行恢复了,推荐这篇 Blog
  • 还原本题符号的脚本如下
      import idc
      import ida_bytes
      functable_addr = 0x4EFA20
      funcstruct_addr = 0x4F4BE8
      funcname_addr = 0x4BC920
      ea = functable_addr
      while ea < funcstruct_addr:
        f_addr = idc.get_qword(ea)
        fs_addr = idc.get_qword(ea+8)+functable_addr
        print(hex(f_addr), hex(fs_addr))
        name_addr = idc.get_wide_dword(fs_addr+8)+funcname_addr
        idc.create_strlit(name_addr, idc.BADADDR)
        size = ida_bytes.get_max_strlit_length(name_addr, idc.AF_ASCII)
        name = idc.get_bytes(name_addr, size).decode()
        idc.set_name(f_addr,  name, idc.SN_NOCHECK)
        ea += 16
    
  • 然后发现绝大多数函数都能恢复了,但是 main 函数还是恢复不了,不过交叉引用一下 os_ReadFile() 就能定位到了

 

梳理主要逻辑

  • 上来就是一个 if 判断,大胆猜测这是在比较 argc ,所以这题逻辑就是通过 argv[] 给出待加密的文件和加密文件名
  • 这时候使用 findcrypt 搜一下,main 函数里面识别出了一个 CRC_table ,推测是上来对要加密的文件生成了一个校验和
  • 之后这里就要进行大量的动调了,基本就是在每次调用函数之前下个断点,函数返回之后下一个断点,用于确定函数调用的传参和返回值(参数一般通过各种寄存器传递,返回值一般就是在 rax, rcx 这些寄存器里面
  • 这么做是因为 Golang 的调用约定实在是混乱,只靠 IDA 的静态分析根本没法正确识别参数和返回值
  • 通过动调,可以判断出 sha1.Write() 传入的参数是栈里面的常量和 CRC 拼接起来的 bytes,然后运行 sha1.Sum() 对其进行 Hash
  • 接着动调。(注意这里无需在每个函数处都下断点,因为那些 runtime 开头的函数都是 Golang 底层的一些函数,不太重要,后面需要重点关注的两个函数就是 sub_4830E0sub_483580
  • 动调到 sub_4830E0 发现传入了刚刚 Hash 操作产生的 0x14 个 bytes,调用后在 r8 寄存器里可以发现新生成了 0xb0 个 bytes,感觉这地方是个密钥生成的函数,重命名为 genKey()
  • 接着动调 sub_483580 ,发现这里头就是传入了 16 bytes 的明文和上面新生成的 bytes,因此感觉这就是加密函数了,重命名为 enc()

 

分析 genKey 函数

  • 函数乍一看非常复杂,所以一定要动调,静态调试谁也看不懂
  • 这部分感觉是值拷贝的过程,动调可以确定是把传入的 digest 数组 copy 了一份
  • 然后调用 runtime.makeslice 初始化了一个数组
  • 这里又是一个循环赋值操作,对比左值右值,推测应该是进行了 v47[n] = v47[n-1] + const 这样的操作,v47 就是刚刚初始化的数组,所以感觉它可能就是 keys
  • 这部分就是主要的密钥生成逻辑了,不好意思,只能硬看了
  • 得到密钥生成的算法如下(包括 hash 部分)
      func genKey(guess1, guess2 byte) []uint32 {
          var length, i, a, b, c, d, lll, index uint32
          sha := sha1.New()
          seed := []byte{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, guess1, guess2}
          sha.Write(seed)
          rawDigest := sha.Sum(make([]byte, 0))
          length = uint32(len(rawDigest))
          digest := make([]uint32, 0x14)
          for i = 0; i < uint32(len(rawDigest)/4); i++ {
              digest[i] = binary.LittleEndian.Uint32(rawDigest[4*i : 4*(i+1)])
          }
          keysLen := 2*length + 4
          keys := make([]uint32, keysLen)
          keys[0] = 0x0BE38A40C
          for i = 1; i < 2*length+4; i++ {
              keys[i] = keys[i-1] + 0x48C20B1A
          }
          lll = uint32(3 * keysLen)
          a = 0
          b = 0
          c = 0
          d = 0
          for index = 1; index <= lll; index++ {
              keys[a] = bits.RotateLeft32(keys[a]+b+c, 3)
              tmp1 := keys[a]
              tmp2 := c + tmp1 + digest[d]
              tmp3 := tmp1 + c
              tmp4 := bits.RotateLeft32(tmp2, int(tmp3))
              digest[d] = tmp4
              c = digest[d]
              tmp5 := (a + 1) % (2*uint32(length) + 4)
              a = tmp5
              d = (d + 1) % 4
              b = tmp1
          }
          return keys
      }
    

 

分析 enc 函数

  • enc 函数相对 genKey 来说要简单一些,如果你能分析出 genKey 的逻辑,那么分析 enc 肯定就没问题了,不断动调即可
  • 得到 enc 函数的逻辑如下
      func enc(crc1, crc2 byte) {
          rawPlain := []byte{0x1e, 0x26, 0x2e, 0x20, 0x2b, 0x2d, 0x2f, 0x1c, 0x29, 0x22, 0x1d, 0x1f, 0x21, 0x28, 0x23, 0x27}
          plain := make([]uint32, len(rawPlain)/4)
          keys := genKey(crc1, crc2)
          cipher := make([]uint32, 4)
          length := 0x14
          for i := 0; i < len(rawPlain)/4; i++ {
              plain[i] = binary.LittleEndian.Uint32(rawPlain[4*i : 4*(i+1)])
          }
          var p, q, m, n uint32
          p = plain[0]
          q = keys[0] + plain[1]
          m = plain[2]
          n = keys[1] + plain[3]
          for index := 1; index <= length; index++ {
              tmp1 := bits.RotateLeft32(q*(2*q+1), 7)
              tmp2 := bits.RotateLeft32(n*(2*n+1), 7)
              tmp3 := bits.RotateLeft32(tmp1^p, int(tmp2))
              c1 := bits.RotateLeft32(tmp2^m, int(tmp1))
              p = q
              q = keys[2*index+1] + c1
              m = n
              n = keys[2*index] + tmp3
          } // b->a, d->c
          cipher[0] = keys[2*length+2] + p
          cipher[1] = q
          cipher[2] = keys[2*length+3] + m
          cipher[3] = n
      }
    
  • 然后就是对其进行逆向操作,这里尝试使用 z3 ,但是由于本题需要爆破,因为效率原因遂放弃,后来意识到因为 rol 是循环左移,不会丢失原来 uint32 中的 bits ,所以可以倒推回去
      func dec(crc1, crc2 byte, realCipher []uint32) []byte {
          keys := genKey(crc1, crc2)
          cipher := realCipher
          length := 0x14
          var a, b, c, d, c1, tmp1, tmp2, tmp3 uint32
          plain := make([]uint32, 4)
          d = cipher[3]
          c = cipher[2] - keys[2*length+3]
          b = cipher[1]
          a = cipher[0] - keys[2*length+2]
          for index := length; index >= 1; index-- {
              tmp3 = d - keys[2*index]
              d = c
              c1 = b - keys[2*index+1]
              b = a
              tmp1 = bits.RotateLeft32(b*(2*b+1), 7)
              tmp2 = bits.RotateLeft32(d*(2*d+1), 7)
              a = bits.RotateLeft32(tmp3, -int(tmp2)) ^ tmp1
              c = bits.RotateLeft32(c1, -int(tmp1)) ^ tmp2
          }
          plain[0] = a
          plain[1] = b - keys[0]
          plain[2] = c
          plain[3] = d - keys[1]
          bytesPlain := []byte{}
          for i := 0; i < 4; i++ {
              tmp := make([]byte, 4)
              binary.LittleEndian.PutUint32(tmp, plain[i])
              bytesPlain = append(bytesPlain, tmp...)
          }
          return bytesPlain
      }
    

 

解题

爆破未知的 CRC

  • 现在所有部分都逆明白了,这时候需要爆破两个 byte 的 CRC
  • 由于被加密的文件是 PNG 图像,所以它的头部是有特征的,这里就看解密的结果是不是存在 PNG 三个字符就行了
  • 写出爆破函数
      func bruteCRC() (uint8, uint8) {
          var a, b int
          var crc1, crc2 uint8
          file, _ := os.ReadFile("flag.png.enc")
          realCipher := make([]uint32, 4)
          for i := 0; i < 4; i++ {
              realCipher[i] = binary.LittleEndian.Uint32(file[4*i : 4*(i+1)])
          }
          wg := sync.WaitGroup{}
          runtime.GOMAXPROCS(runtime.NumCPU())
          for a = 0; a <= 0xff; a++ {
              for b = 0; b < 0xff; b++ {
                  t1 := a
                  t2 := b
                  wg.Add(1)
                  go func() {
                      plain := dec(byte(t1), byte(t2), realCipher)
                      if strings.Contains(string(plain), "PNG") {
                          crc1 = uint8(t1)
                          crc2 = uint8(t2)
                      }
                      wg.Done()
                  }()
              }
          }
          wg.Wait()
          return crc1, crc2
      }
    
  • 爆破得到两个 bytes 是 179, 136

解密文件

  • 解密文件就是很常规了,不再赘述
      func fileDec(crc1, crc2 uint8) {
          file, _ := os.ReadFile("flag.png.enc")
          realCipher := make([]uint32, 4)
          plainBytes := []byte{}
          for i := 0; i < len(file); i += 16 {
              for j := 0; j < 4; j++ {
                  realCipher[j] = binary.LittleEndian.Uint32(file[i+4*j : i+4*(j+1)])
              }
              tmp := dec(crc1, crc2, realCipher)
              plainBytes = append(plainBytes, tmp...)
          }
          os.WriteFile("flag.png", plainBytes, 0777)
      }
    
  • Flag 如图所示

 

总结

  • 文件加密题里面经常有爆破若干字节的套路
  • Golang 的动调真的很重要很重要
(完)