首页
社区
课程
招聘
[原创]vshell木马分析
发表于: 2025-7-28 10:26 4037

[原创]vshell木马分析

2025-7-28 10:26
4037

发现转正要发表文章,于是吧自己前几天写的博客文章放到这边,但里面还有很多细节,特别是TEB和PEB通过hash查询函数的方法,原本想着复现一下,但没成功复现过,不过概念部分通过这次分析大致已经了解,有兴趣的大佬可以指点一下
要是有理解错的地方也请指出
个人博客原文地址:9d7K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6U0P5W2)9J5k6h3y4S2L8%4A6Z5k6i4S2^5k6%4N6W2j5W2)9J5k6h3y4F1i4K6u0r3i4K6y4r3K9h3c8Q4x3@1b7I4x3K6V1`.

今天来分析一下大佬的vshll的shellcode加载器,首先我们导出bin,文件本地很小只有1kb,

因为是汇编,所以直接拖入ida查看即可

因为是shellcode,所以代码先保存了寄存器和空间

先说一下,这函数主要是通过函数hash来查找出这个函数的虚拟地址,这里的726774Ch就是kernel32.dll里的LoadLibraryA

我们进去sub_45C函数,想按F5发现F5大法失败了,说是要设置语言但实际不行,可能出bug了,只能硬着头皮看汇编了

关于红区的地方有个问题,有人和我讨论说红区不是在栈顶之下的128字节吗,但这里保存在栈顶之上,但我的理解是这些的:首先用户栈向低地址增长
然后看到栈顶之下的 128 字节这个下指的是栈继续往下方(低地址)增长之前预留的空间,因此地址实际上是 RSP 向上 (+offset)。所以 rsp+8 恰好落在红区
理解为沿着栈继续下探之前预留的 128 字节,而不是物理地址比RSP更低的128字节。
也可能是我理解错了,但红区倒不是重点,这块地方很大概率是编译器优化的代码,重点是sub_45C具体的实现。

从刚刚开始我就一直好奇一个事情,就是我们可以看到这个exe的导入表都是空,那么怎么执行外部函数的呢

主要是我不知道mov rax, qword ptr gs:loc_5C+4是什么查了一下才豁然开朗。

也就是说他这里只要拿到 PEB,不调用任何 WinAPI 就能做到下面的操作:

下面借用一下别人的说明

Windows 每个进程的 PEB(Process Environment Block) 中有一个字段 PEB->Ldr。

Ldr 里有三条双向链表:

InLoadOrderModuleList

InMemoryOrderModuleList ← 本函数用的

InInitializationOrderModuleList

每个节点不是简单结构,而是大号 LDR_DATA_TABLE_ENTRY,里面既有链表指针,也记录 DLL 的各种信息。

重要偏移(Win10 x64 常见布局):

上面借用一下别人的说明

我们继续看代码

接下去的代码
就是不断循环查找链表然后计算hash是否和入参一致(忽然发现F5大法恢复了,瞬间舒服了)

return 0就说明已把所有模块都扫完,都没有这个函数,然后返回说明错误了cmp [r8+30h], r14 → jz loc_54C这边做的比较然后跳到loc_54CEAX0

return v6,也就是循环的下一个模块的基址,v6在循环的时候就被定义了。

了解了sub_45C我们看看很清楚了,他获取了一堆函数,然后吧基指放在了rbp中,有的也直接放在了r13、r15等寄存器里

对照后续调用可以推断出大致映射(吧bash丢给ai得出的对应表,估计是互联网上查询的,直接搬来用了):

最后将调用获取函数

下面调用了很多次的rdi也就是发送送握手数据,来发送设备的识别码[rbp-80h] = 0x20343677 → "w64 4";紧跟空格和 0 组成 w64 64。告诉 C2 这是 Windows-64 位客户端

最后就是加载大马的部分,看到了熟悉的VirtualAlloc函数也就是[rbp-20h],下载的大马通过xor 秘钥是0x99,解密后落内存执行。

到这里整体流程已经被摸清楚了

启动与栈帧准备
入口只做两件事:

动态解析 API

加载必需 DLL
利用刚解析出的 LoadLibraryA 动态加载 user32.dll, ws2_32.dll, msvcrt.dll,为网络通信、字符串处理和内存管理做准备。

一次性解析所有关键 API
socket / connect / send / recv / VirtualAlloc / closesocket / _snprintf … 等十几条 API 地址分别保存到寄存器或栈槽里,后面直接调用。

拼接关键字符串

建立 TCP 连接并上报信息

申请可执行内存
VirtualAlloc(NULL, 0x1C9C380 ≈ 30 MB, MEM_COMMIT, PAGE_EXECUTE_READWRITE) 为后续载荷准备 RWX 缓冲区。

循环下载并异或解密

收尾并执行载荷

退出 Loader
如果第二阶段代码返回,则恢复之前保存的寄存器并 retn;否则进程控制权彻底交给后续载荷。

这是一个很小的冲锋tcp加载器:通过 API 哈希隐藏所有函数名,通过sub_45C函数获取具体的函数地址,联网到硬编码C2,下载XOR加密的下一阶段并在内存中直接执行,为远控恶意模块有点类似反射加载dll。

push r.. × 8    保存所有通用寄存器,防止被后续破坏。
lea rbp,[rsp-0x298]
sub rsp,0x398   在原来栈顶以下再开 0x398 字节的大栈帧。后面所有局部变量/拼字符串都放这里。
push r.. × 8    保存所有通用寄存器,防止被后续破坏。
lea rbp,[rsp-0x298]
sub rsp,0x398   在原来栈顶以下再开 0x398 字节的大栈帧。后面所有局部变量/拼字符串都放这里。
mov rax, rsp    暂存当前栈顶地址到 RAX,方便直接在红区里写数据。
mov [rax+8], rbx
mov [rax+10h], rbp
mov [rax+18h], rsi
mov [rax+20h], rdi  把调用的 4 个寄存器存在栈指针下方的红区(未改变RSP他就bu不保存了)。
看样子是比平时一个个 push 更快。(其实我也不知道这样的设计的意义是什么)
push r14    额外保存 R14(后面要当循环计数器)。
sub rsp, 10h    腾出 0x10 字节的局部变量区,IDA 叫 var_18。
mov rax, rsp    暂存当前栈顶地址到 RAX,方便直接在红区里写数据。
mov [rax+8], rbx
mov [rax+10h], rbp
mov [rax+18h], rsi
mov [rax+20h], rdi  把调用的 4 个寄存器存在栈指针下方的红区(未改变RSP他就bu不保存了)。
看样子是比平时一个个 push 更快。(其实我也不知道这样的设计的意义是什么)
push r14    额外保存 R14(后面要当循环计数器)。
sub rsp, 10h    腾出 0x10 字节的局部变量区,IDA 叫 var_18。
内容 栈指针
存的 rbx/rbp/rsi/rdi ← RSP+0x0
var_18 (16 B) ← RSP-0x10
保存的 r14 ← RSP-0x18
字段 偏移 作用
InMemoryOrderLinks 0x20 当前链表的 LIST_ENTRY
DllBase 0x30 模块基址(HMODULE)
BaseDllName 0x58 UNICODE_STRING,DLL 文件名(不含路径)
; 获取 PEB
mov     rax, gs:[0x60]          ; TEB → PEB  (= qword ptr gs:loc_5C+4
 
; 保存调用参数
mov     ebp, ecx                ; 把目标哈希存进 EBP,后面循环都要用
 
; 计数寄存器清零
xor     r14d, r14d              ; r14d = 0,既代表“false”也当循环计数起点
 
; 走到 Ldr 模块链表
mov     rdx, [rax+0x18]         ; RDX = PEB->Ldr
mov     r8,  [rdx+0x10]         ; R8  = Ldr->InMemoryOrderModuleList.Flink
                                ;        (链表第 1 个 LDR_DATA_TABLE_ENTRY)
; 获取 PEB
mov     rax, gs:[0x60]          ; TEB → PEB  (= qword ptr gs:loc_5C+4
 
; 保存调用参数
mov     ebp, ecx                ; 把目标哈希存进 EBP,后面循环都要用
 
; 计数寄存器清零
xor     r14d, r14d              ; r14d = 0,既代表“false”也当循环计数起点
 
; 走到 Ldr 模块链表
mov     rdx, [rax+0x18]         ; RDX = PEB->Ldr
mov     r8,  [rdx+0x10]         ; R8  = Ldr->InMemoryOrderModuleList.Flink
                                ;        (链表第 1 个 LDR_DATA_TABLE_ENTRY)
; 0x0495  外层循环取模块基址
mov     r9, [r8+30h]        ; R9 ← DllBase        → v6 = v5[6]
 
; 0x0519  函数名哈希命中后
lea     eax, [rbx+rdx]
cmp     eax, ebp
jz      loc_52E             ; 跳到构造返回值
 
; 0x052E  计算最终地址
mov     eax, [r10+24h]      ; AddressOfNameOrdinals
add     rax, r9             ;   ↑ 这里 r9 仍为 DLL 基址
mov     eax, [rcx+rdx*4]    ; Function RVA
add     rax, r9             ; RAX = r9 + RVA  → 返回
; 0x0495  外层循环取模块基址
mov     r9, [r8+30h]        ; R9 ← DllBase        → v6 = v5[6]
 
; 0x0519  函数名哈希命中后
lea     eax, [rbx+rdx]
cmp     eax, ebp
jz      loc_52E             ; 跳到构造返回值
 
; 0x052E  计算最终地址
mov     eax, [r10+24h]      ; AddressOfNameOrdinals
add     rax, r9             ;   ↑ 这里 r9 仍为 DLL 基址

传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

收藏
免费 1
支持
分享
最新回复 (2)
雪    币: 380
活跃值: (70)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
这里更正一下,我发现我吧栈增长方向和栈顶下方的概念弄混了
栈增长方向这样理解没问题,栈顶下方确实是数值更小的地址
那就是说如果是rax-8才是用到红区,rax+8只是单存保存寄存器,所以我一开始有点不理解为什么用红区保存这些非关键寄存器。
也就是说
mov rax, rsp
mov [rax+8], rbx
mov [rax+10h], rbp
mov [rax+18h], rsi
mov [rax+20h], rdi 
只是单存的保存寄存器状态,返回时恢复而已
2025-7-29 16:20
0
雪    币: 531
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3
????????,学习了。
2025-7-31 08:49
0
游客
登录 | 注册 方可回帖
返回