系统安全及免杀(11) 黑客外传之shellcode传奇(6)-深入shellcode(1)

本文所载网络安全技术仅为技术研究与学习之用,严禁用于非法攻击、侵入他人系统等违法违规行为。所有内容均需遵守国家相关法律法规,滥用所产生的一切法律责任,由行为人自行承担,与本文作者、发布者无关。


没错!系统安全及免杀系列中的shellcode传奇系列中的深入shellcode系列,知识点有些碎,不过会在后面融会贯通的,这个系列完成之后大概会继续写 PE 的前世今生系列

开新的小系列反复套娃的原因主要还是要说的东西太多了,比起分开多集发还是做成一个小系列比较有连贯性

鄙人技术浅陋,经网上四处观看浏览总结得此文,如有不足希望师傅们多包容⊙﹏⊙

下面正文开始

前言

这个系列的来由是我在对于 loader 的手法已经趋于大成之后(我自己认为的,和其他厉害师傅比肯定差远了)我就在到处看一些更进阶的内容,比如对抗 EDR 的栈欺骗,还有一些高强度对抗环境直接击溃 av/edr 我就在想,这些肯定不是 loader 完成的,loader 完成加载 shellcode,把 c2 节点建立起来就好,保持高度的 OPSEC 才是关键。

尤其是注意到 vshell 可以自定义的调用诸如 windump,fscan,mimikatz 之类的内容,我就不由自主的好奇,被我加载到内存执行的 shellcode,到底干了什么?对于 shellcode 的兴趣越来越深,不仅是 c2 平台生成的,我自己是否也可以进行 shellcode 的编写?

汇编,计组的知识在此时显得无比重要

但是去找个教材,找个课一集一集的看,看不了一点,看了不到五分钟就力竭了(

还是从实际需要解决的问题自顶向下的去拆解,看看具体需要什么,不需要的知识装进脑子里面也是浪费身体能量

作为小系列中的小系列中的第一集,一如既往的可能不能进入正题,先做一些新知识点的铺垫喵

知识点轰炸

寄存器

寄存器是 CPU 内部的高速存储单元,是计算机体系结构中最核心的组件之一

每个 CPU(2026年了也很少有非多核的 CPU 了,所以准确来说是每个 CPU 核心)都有一套在物理电路上固定安家了的寄存器套装

在x64汇编中,有两种类型的寄存器

易失性(Volatile)寄存器

RAX、RCX、RDX、R8、R9、R10、R11

正如其名,会根据函数调用等情况不断的改变值

非易失性(Non-Volatile)寄存器

RBX、RBP、RDI、RSI、R12、R13、R14、R15、RSP

在函数调用后不会改变值,并且可以可靠地用于存储你的代码中需要的数值


另外,根据 “ x64 Microsoft __fastcall ” 也就是 64 位的 Windows 的调用约定:

一个函数可以有多个参数,但前四个参数必须依次存放在存器 RCXRDXR8R9 之中,如果还有第五个以及更多,再放入栈中存放

与 32 位的调用约定不一样,因为 64 位架构的情况下,寄存器更多,而寄存器的速度是最快的,一次寄存器操作,只需要一个时钟周期,效率拉满这一块

ps:一条汇编指令,比如:

mov ebx, eax

花的时间其实是 4 到 5 个周期,因为一个汇编指令不是单独的“读”或者“写”操作(单独的这俩之一的操作确实是只要一个周期),而是:

取指(IF):1 周期

译码(ID):1 周期

执行(EX):1 周期

访存(MEM):可能 0/1 周期

写回(WB):1 周期

//唉,又想到当时学校学计组的痛苦时光了,当时属实是把我恶心坏了,天天做实验插那个破线,实验器材的那个板子有一股像是饭馊了一样的怪味(

示例,当调用 ExitProcess 函数,并传入 0 值时(一个 Windows API,接收一个无符号整数类型的退出码作为唯一参数,没有返回值):

; void ExitProcess(UINT uExitCode);
mov r15, rax ;rax已经提前存好了 ExitProcess 函数的地址
mov rcx, 0   ;将 0 值放入 rcx 寄存器
call r15     ;执行,相当于 c 语言执行 ExitProcess(0);

如上,必须将参数 0 放入调用约定所规定的函数的第一个参数所必须放入的 rcx 寄存器

栈在数据结构的语境中,是一个抽象的遵循先进后出的数据结构,但在此处有更为具体的含义

“栈”是一块预先分配在进程虚拟内存中的连续内存区域(属于进程的 “栈空间”,区别于堆、数据段、代码段),与数据结构中一样,都是先进后出,由 rsp 寄存器(x64)指向栈的栈顶

同样也是先进后出,类比枪上的弹夹,先压入进去的第一颗子弹在最后才会被打出去

栈中一个数据 8 字节(其实这句话不太严谨,只不过就算不足 8 字节也会补 0 ,比如 int 类型)

栈的增长方向

栈是 “从高到低” 增长的,在内存里面进程首先分配一个栈底,然后开始装入一个个内容的时候,栈原本的老资历数据(假设没有被取走)不动,让新来的小资历数据放在老资历的低地址一侧,栈顶(rsp)也就往低地址处偏移了


在 x64 下,栈内的数据主要是这些:

  • 保存函数调用的返回地址(call 指令自动压入当前指令的下一条地址)
  • 传递函数参数(x64 快速调用约定中,前 4 个参数用寄存器,但超过 4 个或某些场景仍用栈传递)
  • 保存函数内的局部变量
  • 保存调用者的寄存器上下文(比如调用函数前保存 rbx / rbp 等非易失寄存器)

call 指令与返回地址

展开说一下刚刚说到的“函数调用的返回地址” 究竟是什么?

函数调用的这个过程里,CPU 干了什么?

CPU 是按顺序一条接一条执行指令的(比如执行完指令 A→指令 B→指令 C),当遇到 call func (也就是调用执行 func 函数)时,CPU 需要:

  1. 记住 “执行完 func 后该回到哪里继续执行”(这个 “位置” 就是返回地址
  2. 跳转到 func 函数的第一条指令执行
  3. func 执行完后,按 “返回地址” 回到原来的位置继续执行

返回地址的内容也就是执行完这个函数之后,接下来该跳到哪个地址那里去接着干活

也就是说,执行一条 call 汇编指令,在背后完成了:

  1. 把 “返回地址” 压入栈
  2. 跳转到被调用函数(func)的入口地址执行

相当于出门之前写一个办完事得回家吃饭的备忘录(

栈帧

类似于数据结构当中所说的栈中那一个个的格子,栈是个柜子的话,栈帧就是抽屉,是栈内存中专门分配给某个函数的一块独立区域,是这个函数执行时候的专属小空间

创建栈帧的经典手法:

push rbp
mov rbp, rsp

拆开说一下 rbp 和 rsp

rbp:栈基址寄存器,存放着当前栈帧的基地址(所以叫栈帧基址寄存器可能更好?)

rsp:刚刚提到过了,栈顶指针

push rbp 把原本的栈基址压入栈,这一步就是实现保存好主调函数的栈帧基址,以便执行完被调函数之后返回,因为这个 rbp 的值下一条指令就会被覆盖,先 push 到栈顶存起来,此时栈顶是主调函数栈帧的 rbp 内值也就是在栈顶存了个栈帧基址地址值,也就是此时此刻的 [rsp]

这里举个例子稍微强调一下,汇编中写 rbp,代表的是 rbp 这个寄存器内部存放的值,而 [rbp] 指的是 rbp 内部地址值指向的数据,也就是 rbp 地址往高处 8 个字节内的内容

mov rbp,rsp 把 rsp ,也就是此时的栈顶指针的值,赋值给 rbp ,也就是让 rbp 指向此时此刻的栈顶,栈顶的数据内容是旧的 rbp,完成了创建栈帧

下面给出一个主调函数调用被调函数的创建栈帧示例:

; 调用者函数(主调函数)
caller:
    ; 执行call指令前,rsp指向栈顶(假设已16字节对齐)
    call callee       ; 1. 压入返回地址(caller的下一条指令地址)到栈;2. 跳转到callee

; 被调用函数(被调函数)
callee:
    ; 第一步:保存上一个栈帧的基址(旧rbp)
    push rbp          ; 把调用者的rbp值压入栈,此时因为压入一个新数据,会有 rsp -=8
    ; 第二步:建立当前函数的栈帧基址
    mov rbp, rsp      ; 让rbp = 当前rsp,此时rbp就成为当前栈帧的“锚点”(基址)
                      ; 从此以后,函数内访问局部变量/参数都以rbp为基准(比如[rbp-8]是第一个局部变量)
    
    ; 第三步:为局部变量分配栈空间(可选)
    sub rsp, 32       ; 相当于分配32字节给局部变量,也就是 rsp -=32,此时栈帧范围是 [rbp, rsp] 区间
    
    ; --------------------------
    ; 函数的核心逻辑(示例:给局部变量赋值)
    mov qword [rbp-8], 10   ; 第一个局部变量(rbp-8)赋值10
    mov qword [rbp-16], 20  ; 第二个局部变量(rbp-16)赋值20
    ; --------------------------
    
    ; 第四步:函数返回前清理栈帧
    mov rsp, rbp      ; 恢复rsp到栈帧基址(释放局部变量的栈空间)
    pop rbp           ; 弹出之前保存的旧rbp,恢复调用者的栈帧基址,rsp +=8
    ret               ; 弹出返回地址,跳回caller继续执行

可以看到最后清理栈帧,因为被调函数结束了(示例只是分配了两个局部变量就没干别的事情),需要回到主调函数

这时候,之前的 rbp 就起作用了,虽然 rbp 是新栈帧的基址,但是 [rbp] 是旧栈帧的旧 rbp,在创建栈帧的第一步 push 来的

因此:

    mov rsp, rbp      ; 恢复rsp到栈帧基址(释放局部变量的栈空间)
    pop rbp           ; 弹出之前保存的旧rbp,恢复调用者的栈帧基址,rsp +=8
    ret               ; 弹出返回地址,跳回caller继续执行

栈对齐

刚刚说了那么多栈的东西,现在来说一下栈对齐这个要求

x64 下,栈以 16 字节的“边界”运行,在进行函数调用之前,栈需要根据这一原则进行对齐

就像是 Minecraft 中 “区块” 的概念(因为都是16所以联想到了区块),如果不对齐,cpu 就容易出现问题

具体是什么问题以及为什么这么要求,不想深究,因为我感觉没什么用,就当作是一条铁律就好了

这时候会看上一小节给出的主调函数调用被调函数创建栈帧的示例:

caller 函数在 call callee 的时候,正如上文说过的,call 会压入返回地址,产生 rsp -= 8(不对齐了)

不过在 callee 的开头:

push rbp

又 push 了一个,再 rsp -= 8,这下对齐了

从数学的角度上来讲,也就是说要保持 RSP必须能被16整除

误区

为什么开一个误区小标题,因为我当时在学的时候也迷惑了

不是说 “函数调用前保证 16 字节对齐” 吗?如果靠 callee 里的 push rbp 来补齐,这不已经开始调用了吗(

//其实是个语文的问题,这句规则的话语的信息熵太大了,应该是翻译的锅

“调用前” 指的是 call 指令之前,在 call 之后产生了不对齐不属于主调函数的管辖范围

没错,所以可以准确的重新描述一下栈对齐的规则是:

在 call 来调用函数之前保持栈对齐

影子空间

先说定义:

Windows x64 调用约定强制要求、由调用者在栈上预先分配的 32 字节(4×8)栈区域,专门用于存放前 4 个寄存器参数(RCX、RDX、R8、R9)的内存备份,也就是在这集开始之时所说的被调函数前四个参数必须存放在里面的四个寄存器

如果不这么做会发生什么?

一般来说本应存有返回值的 rax 寄存器会变成全 0 值,这可太糟糕了(

在线工具引入

shellcode 翻译成汇编指令

https://defuse.ca/online-x86-assembler.htm

好用就完事了,输入 shellcode,不管是 0x00,0x00,0x00 还是 f1b2668c 这种格式,输入进去就能转换成汇编指令

动态调用Windows API

先上示例:

HMODULE hKernel32 = LoadLibrary("kernel32.dll");
FARPROC pVirtualAlloc = GetProcAddress(hKernel32, "VirtualAlloc");

//终于回到熟悉的 c++ 了

先 LoadLibrary 再 GetProcAddress 就能避免直接使用 API 在自己 exe 的导入表(突然想起来原本打算更新的 PE 系列的下一集就是讲这个,鸽到现在还没写hh)里面出现敏感的 API

没什么好解释的了,这一集打字给我打累了都,就到这了

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇