首页
论坛
专栏
课程
1

[调试逆向] [原创]inctf(ultimateGo)

奈沙夜影 2018-10-11 18:11 548

Go语言的逆向感觉目前没啥方便的工具,只能硬怼汇编,缺少符号的情况下还是有点麻烦的..
等一个师傅们的指教,这个题目我是纯黑盒调试出来的orz

整体思路和切入点

看标题大概就能猜出来是Go语言
IDA加载进去看,还是没符号的Go语言逆向……

 

之前做到有符号的是从main.main函数入手,现在连符号都没有,只好从字符串突破了

 

运行发现有输入提示“Enter pass:"和错误提示"Wrong"

 

Shift+F12的字符串检索中没有出现
这里要知道与C语言用'\0'表示字符串结束不同,Go语言会将所有字符串连接在一起,通过起始指针和字符串长度来表示整个串

 

而IDA无论从之后再说的传参和返回值,还是字符串检索功能都针对的是C语言,对于不按套路的Go语言局限很多
字符串检索失败就是一点

 

这种超长字符串虽然会被检索到,但是搜索功能和显示功能都仅能表示前边的部分,对于index稍大,例如达到上百的时候甚至上千的时候就无能为力了

 

因此只能使用菜单栏中的Search-Text或者Sequence of bytes来搜索
Text会根据交叉引用来查找,在稍等片刻以后应该就能找到

 

而Sequence of bytes则是根据字节流,将"Enter pass:"转成hex形式的字节"45 6e 74 65 72 20 70 61 73 73"进行搜素,秒出。不过双击过去会跳到整个字符串变量的开头,如果还需要具体值的话还得首先undefine这个串,然后重新双击找到0x534aa5,再Ctrl+A生成字符串即可查看交叉引用

 

总之最后会找到
00000000004EBE73 lea rcx, hint_input
这一行,而往下几句的0x4EBEA0处就有一个call

 

动态调试即可确认它就是print函数,然后通过这个交叉引用还能找到一个关键函数doServerStuff,之后再说

 

接下来的东西都我都是依靠汇编级的动态调试来猜
一来由于Go语言传参和返回值都是mov 到栈中,不再有栈帧这个概念,虽然可以节省资源,但是IDA并不认识233
并且最关键的一点事,IDA目前并不支持寄存器传返回值,尤其是多个返回值
所以函数分析基本没用,不幸中的万幸是Go语言目前每个函数的传参和提取返回值都很有规律,非常利于动态调试

 

大多数函数都是这样

.text:00000000004EBF1E mov     [rsp+198h+var_198], rax
.text:00000000004EBF22 mov     [rsp+198h+var_190], rcx
.text:00000000004EBF27 mov     [rsp+198h+var_188], rdx
.text:00000000004EBF2C call    sub_4EC0D0
.text:00000000004EBF31 mov     rax, [rsp+198h+var_180]
.text:00000000004EBF36 mov     rcx, [rsp+198h+var_178]
.text:00000000004EBF3B mov     rdx, [rsp+198h+var_170]
.text:00000000004EBF40 mov     rbx, [rsp+198h+va

上面三个参数分别通过rax, rcx, rdx送入栈中
调用完函数后再用rax,rcx,rdx,rbx从栈中取出返回值
有时参数过多或者寄存器有他用时会复用寄存器,但大差不离
动态调试的时候即可在函数前断下依次查看参数,调用完之后待取出返回值再依次查看返回值,从而黑箱猜功能

 

黑箱调试的思路就是遇到call先F8,如果能直接根据输入和输出猜出功能,或者似乎无事发生,就不用跟进去了。如果产生了不知道怎么来的结果,那么首先调整输入,看是否该结果由输入产生,如果是则跟进去,不是则忽视。

主线程

于是继刚才的print之后继续往后走,后一个call是scanf接收输入

.text:00000000004EBEDA call    scanf           ; high->low, error, len, pointer
.text:00000000004EBEDF mov     rax, [rsp+198h+var_180]
.text:00000000004EBEE4 mov     rcx, [rsp+198h+var_178]
.text:00000000004EBEE9 mov     rdx, [rsp+198h+var_188]
.text:00000000004EBEEE test    rcx, rcx

返回值依次为error, len, buff_pointer,保存在rcx, rax, rdx中

 

正常接收的情况下error是0,直接跳到下一个代码块
在sub_4EBF2C的前后可以观察到input被逆序了,并且先后取了两段长度分别为9和4的字符串保存,前面的值则直接被丢弃

Enter pass :1234abcdefghijklmn

 

 

继续往下跟,无事发生
然后跟进sub_429A10中的sub_4562F0,通过jmp rbx跳到sub_4EC2A0中

 

这个函数我之前字符串分析的时候通过Wrong找到了大量调用,所以估计这里才是核心check函数

 

继续往下跟
在第一个call sub_4EC2FE中报错,于是跟进去查看

这里将长度为4的那个串送入了atoi,而这里我用于test的是字符所以报错了

 

于是重新组织输入为"4321abcdefghi",再来一次
这次成功通过sub_4EC2FE,继续往下跟
有一个call将后半段字符和前半段字符用:连接了起来
接着在0x4EC383的地方call进去阻塞了

 

跟进去看可以发现字符串"listen",并且在0x4EC36F处送入eax的字符串参数为"tcp"
那么基本上可以猜测出是开启了tcp端口进行监听

 

端口号是前面那4个字符毫无疑问了,但是后半段9个字符是什么呢?
127.0.0.1也不够呀

 

去搜索Go语言TCP编程的时候发现了一个示例

package main

import (
    "fmt"
    "net"
)

func main() {
    fmt.Println("Starting the server ...")
    // 创建 listener
    listener, err := net.Listen("tcp", "localhost:50000")
    if err != nil {
        fmt.Println("Error listening", err.Error())
        return //终止程序
    }
    // 监听并接受来自客户端的连接
    for {
        conn, err := listener.Accept()
        if err != nil {
            fmt.Println("Error accepting", err.Error())
            return // 终止程序
        }
        go doServerStuff(conn)
    }
}

func doServerStuff(conn net.Conn) {
    for {
        buf := make([]byte, 512)
        len, err := conn.Read(buf)
        if err != nil {
            fmt.Println("Error reading", err.Error())
            return //终止程序
        }
        fmt.Printf("Received data: %v", string(buf[:len]))
    }
}

常用的本地地址"localhost"正好是9个字符

 

于是构造逆序输入"4321tsohlacol"再来,这里就成功过了listen了

 

再往后就是一个死循环,这里很明显是监听会话,有新连接进入就开启一个新线程进行处理,同时主线程继续监听
然后注意doServerStuff应该是作为一个参数送入子线程的CreateThread的,因此不应该是直接的call,而是一个指针

 

上下搜索了一下可疑的lea,果然就在这里

00000000004EC3E1 lea     rax, off_53B6F0

这个off_53B6F0跟过去是一个函数的地址,也就是所谓的doServerStuff处理连接的函数了

处理函数

在它的头部下断,然后主线程F9让他Run起来
另一边再开一个终端nc localhost 1234连上来,果然断到
在这里再次阻塞

00000000004ED0B3 call    rcx

显然是read函数在等待nc那边发送数据,于是我们再来一个test串
走下去以后发现

.text:00000000004ED147 call    sub_4EC930
.text:00000000004ED14C mov     rax, qword ptr [rsp+1D0h+var_1D0+18h]
.text:00000000004ED151 cmp     rax, 10h
.text:00000000004ED155 jnz     loc_4ED

这里的rax恰好为我们test串的长度,也就是说它要求长度为15个字符(最后一个字符为\x0A即回车)

 

下面一段是一个循环,rdx作为下标,与6比较,也就是说要执行6次
执行的内容通过单步跟踪可以发现是将我们的端口号作为int与一个数组逐个求余,结果保存在另一个数组中

IDC>auto addr;for(addr = 0xc42003fe98;Qword(addr)!=0;addr=addr+8){Message("0x%x, ", Qword(addr));}
0x327, 0x125, 0x436, 0xc91, 0x167, 0x282,

 

通过脚本拿到这六个数(和对应的结果0x1ab, 0x3e, 0x9c, 0x4d2, 0x9d, 0x250),暂时还没用到,先继续往后跟

 

这里是一个比较关键的check

00000000004ED1F9 call    sub_4ECCE0

参数为输入的整个串,要求返回值为1,于是跟进去看

check1

.text:00000000004ECF0C loc_4ECF0C:
.text:00000000004ECF0C mov     rdx, [rsp+60h+arg_0]
.text:00000000004ECF11 mov     [rsp+60h+var_60], rdx
.text:00000000004ECF15 mov     [rsp+60h+var_58], rax
.text:00000000004ECF1A mov     [rsp+60h+var_50], rcx
.text:00000000004ECF1F mov     [rsp+60h+var_48], rax
.text:00000000004ECF24 call    sub_456CE0
.text:00000000004ECF29 movzx   eax, byte ptr [rsp+60h+var_40]
.text:00000000004ECF2E mov     rdx, [rsp+60h+arg_8]
.text:00000000004ECF33 jmp     loc_

这里将端口号和输入串和整数4作为参数加入call,很容易猜出是strcmp
比较4个字符,即要求输入串的前4个字符与端口号相等

check2

.text:00000000004ECD3F mov     rax, [rsp+60h+arg_0]
.text:00000000004ECD44 mov     [rsp+60h+var_60], rax
.text:00000000004ECD48 mov     [rsp+60h+var_58], rdx
.text:00000000004ECD4D add     rcx, 4
.text:00000000004ECD51 mov     [rsp+60h+var_50], rcx
.text:00000000004ECD56 mov     [rsp+60h+var_48], 3
.text:00000000004ECD5F call    sub_4A31B0
.text:00000000004ECD64 mov     rax, [rsp+60h+var_40]
.text:00000000004ECD69 cmp     rax, 4

根据参数rcx指向的lhost, 3,和rax指向的输入串以及返回结果要求为4可以猜出这里是查询子串
那么要求我们的输入串在第5个字符处为lho即可通过

check3

.text:00000000004ECD82 mov     rax, [rsp+60h+arg_0]
.text:00000000004ECD87 add     rax, 7
.text:00000000004ECD8B mov     [rsp+60h+var_10], rax
.text:00000000004ECD90 mov     [rsp+60h+var_60], rax
.text:00000000004ECD94 mov     [rsp+60h+var_58], 5
.text:00000000004ECD9D call    j_atoi
.text:00000000004ECDA2 movzx   eax, byte ptr [rsp+60h+var_50]
.text:00000000004ECDA7 test    al, al
00000000004ECDA9 jz      short loc_4ECDBA

这里的rax指向了输入串的第8个字符,长度为5,观察返回值可以发现是个atoi的调用

check4

.text:00000000004ECDE4 mov     [rsp+60h+var_60], rcx
.text:00000000004ECDE8 call    struc_create
.text:00000000004ECDED mov     rax, [rsp+60h+var_58]
.text:00000000004ECDF2 mov     [rsp+60h+var_30], rax
.text:00000000004ECDF7 mov     [rsp+60h+var_60], 7Bh
.text:00000000004ECDFF call    struc_create
.text:00000000004ECE04 mov     rax, [rsp+60h+var_58]
.text:00000000004ECE09 mov     [rsp+60h+var_38], rax
.text:00000000004ECE0E mov     [rsp+60h+var_60], 81BBh
.text:00000000004ECE16 call    struc_create
.text:00000000004ECE1B mov     rax, [rsp+60h+var_58]
.text:00000000004ECE20 mov     [rsp+60h+var_28], rax
.text:00000000004ECE25 mov     [rsp+60h+var_60], 84h
.text:00000000004ECE2D call    struc_create
.text:00000000004ECE32 mov     rax, [rsp+60h+var_58]
.text:00000000004ECE37 mov     [rsp+60h+var_20], rax
.text:00000000004ECE3C mov     [rsp+60h+var_60], 0A7D5h
.text:00000000004ECE44 call    struc_create
.text:00000000004ECE49 mov     rax, [rsp+60h+var_58]
.text:00000000004ECE4E mov     [rsp+60h+var_18], rax

这一段虽然看起来有点麻烦,但实际上结构高度雷同反而导致很容易理清各个函数的参数和返回值
观察返回值可以发现事实上都是创建了一个结构体

debug014:000000C42000C160 dq 0
debug014:000000C42000C168 dq offset unk_C42001A120
debug014:000000C42000C170 dq 1
debug014:000000C42000C178 dq 5
debug014:000000C42000C180 dq 0
debug014:000000C42000C188 dq offset unk_C42001A150
debug014:000000C42000C190 dq 1
debug014:000000C42000C198 dq 5
debug014:000000C42000C1A0 dq 0
debug014:000000C42000C1A8 dq offset unk_C42001A180
debug014:000000C42000C1B0 dq 1
debug014:000000C42000C1B8 dq 5

 

结构全部为

Dword 0
Dword value_pointer
Dword 1
Dword 5

 

而这里的value_pointer都指向了创建结构体时送入的参数,例如第一个结构体的value为输入串的第八位atoi得到的结果,然后第二个结构体的value为0x7B,以此类推

 

再往下是4个不同的函数调用,但他们的参数都是刚才创建的结构体中的两个,一个是struct(input),另一个从上到下依次引用
而每次返回值也仍然是一个结构体,观察value可以发现有一点点微小的变化,根据两个参数和结果可以直接猜出功能:
依次为add, xor, sub, equal
即(?+0x7B)^0x81BB-0x84==0xa7d5

 

很容易算出这里的?十进制为10599,于是可以构成整个输入串

 

(port)lho10599???

final calc

到了这里发现已经可以输出"The Flag is :"了,直接运行发现后面的是一串乱码
而整个流程下来我们还不能确定的值有两个,port和最后三个字符
测试发现最后三个字符对输出内容没有影响,而port是有关的
于是还要计算一下它期望的port是什么

.text:00000000004ED247 mov     qword ptr [rsp+1D0h+var_1D0], rax
.text:00000000004ED24B mov     qword ptr [rsp+1D0h+var_1D0+8], rcx
.text:00000000004ED250 call    something_change2
.text:00000000004ED255 mov     rax, qword ptr [rsp+1D0h+var_1D

调试发现这个函数将会生成flag的后半部分
而前半部分则位于0x4ED37F处,先循环6次输出字符
字符来自于一个数组,这个数组乍一看好像没啥用,但实际上如果你上下翻一下前后的内存,或者对6这个size有印象,亦或者还记得我们之前还有一个求余计算出的数组没有用到的话---是很容易对应上之前用port转整数后逐个求余的数字的
通过一些可见字符可以发现它们是一一对应的,也就是没有再change

 

到了这里其实已经可以推出port是什么了--满足前六个字符为inctf{的整数

port %A == ord('i')
port %B == ord('n')
...
etc

 

印象中这个是可以通过中国剩余定理来求值的,不过我这种学渣并不记得怎么算也懒得翻书了
大力出奇迹,Z3启动!

from z3 import *
s = Solver()
port = Int("port")
a = [0x327, 0x125, 0x436, 0xc91, 0x167, 0x282]
r = [ord(i) for i in "inctf{"]
s.add(port>0, port<9999)
for i in range(6):
    s.add(port%a[i]==r[i])
if(s.check()==sat):
    print(s.model())

得到port=3333

 

至此通过合法输入即可获得flag了

 

后记

当时比赛的时候没想到那个串和port的关系,去纠结了一阵后半段的计算过程
作为学习探讨也记录一下吧

 

在sub_4EC960中加载了两个串,一个是input,另一个则是"01 23 45 6 7 89 ab cd ef fe dc ba 98 76 54 32 10"
然后在0x4ECA87处根据这两个函数算出了一个值
这个串其实很明显,就是md5的标志
自己算了一下确认无误
然后在0x4ECAB3处进行hex_decode得到32个字符
接着进行循环,逐字符和0x54B820处的Qword数组进行异或,最终结果即为后32位

 

整个运算过程完全是单向扩充flag长度,并不可逆



快讯:看雪智能设备漏洞挖掘公开课招生中!

上传的附件:
本主题帖已收到 1 次赞赏,累计¥2.00
最新回复 (4)
kanxue 2018-10-11 18:14
2
Go语言论坛上讨论的比较少,感谢分享!
junkboy 2018-10-11 19:23
3
感谢分享
zlykcaj 2018-10-11 23:52
4
赞一个
蒼V嵐 2018-10-12 11:52
5
返回