- 关键算法: 位运算
- 出处: 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_4830E0
和sub_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 的动调真的很重要很重要