Skip to content

Latest commit

 

History

History
470 lines (443 loc) · 48.6 KB

week1.md

File metadata and controls

470 lines (443 loc) · 48.6 KB

Week 1

Day1: 学习CTF Wiki栈溢出基础和ROP基础

传送门: CTF Wiki: Linux Pwn

  • Stack Overflow Principle: 通过栈溢出覆盖掉函数栈帧的返回地址, 当函数返回时就会跳入攻击者覆写的地址继续执行代码.

    1. 确认溢出的长度可以到达栈帧返回地址
    2. 确认没有开启Stack Canary
    3. 确认覆写的地址所在的段具有执行权限
    • 编译选项-fno-stack-protector用于关闭Stack Canary
    • 编译时需要加-no-pie确保不会生成位置无关文件
    • 关闭ASLR: echo 0 > /proc/sys/kernel/randomize_va_space
  • Basic ROP: 在栈溢出的基础上, 通过利用文件本身的gadget来控制寄存器和变量来控制程序流程.

    • ret2text: 跳转到程序已有的高危代码处(system("/bin/sh")), 直接触发高危操作.
    • ret2shellcode: 栈溢出的同时布置shellcode(可以理解为预写好的高危功能性汇编代码), 在溢出时跳转到布置好的shellcode处继续执行.
      1. 因为有执行, 所以需要确保shellcode所在位置有可执行权限.
      2. gef的vmmap可以查看内存段的权限.
      3. pwntool获取shellcode: asm(shellcraft.sh())
    • ret2syscall: 没有执行权限时, 可以通过系统调用来实现控制.
      1. 开启NX保护后, 再如何部署高危代码都没法执行. 所以需要转向利用内核的系统调用实现高危操作.
      2. 可以通过/usr/include/asm/unistd_32.h查看当前内核对应的系统调用号. 比如#define __NR_execve 11, 也就是execve的系统调用号为0xb
      3. 使用ROPgadget可用获取寄存器和字符串的gadget.
        • ROPgadget --binary rop --only 'pop|ret' | grep 'ebx' | grep 'ecx'
        • ROPgadget --binary rop --string '/bin/sh'
        • ROPgadget --binary rop --only 'int'
      4. 使用flat来直观地表示ROP链: flat(['A' * 112, pop_eax_ret, 0xb, pop_edx_ecx_ebx_ret, 0, 0, binsh, int_0x80])
        • 形式为: 溢出用的填充数据, gadget1(函数原本的返回地址), value1, gadget2, value2, ... , int 0x80
    • ret2libc:
      • ret2libc1: 跳转到libc的高危代码(system)并模拟函数调用
        1. 注意跳转到libc的函数去执行, 需要模拟函数调用, 因此跟gadget在栈上的部署方式不一样, 正确的形式为PLT地址, 函数返回地址, 函数参数地址...
        2. 获取system()的plt地址方法: objdump -d ret2libc1 | grep system, 也就是地址是写在汇编里的.
      • ret2libc2: 如果缺少函数调用的条件(缺少函数参数字符串/bin/sh)
        1. 利用libc里的gets函数, 并手动输入相应的函数参数字符串即可弥补.
        2. ['a' * 112, gets_plt, pop_ebx, buf2, system_plt, 0xdeadbeef, buf2]需要注意的是pop_ebx作为gets的返回地址, 它还将buf2给弹出栈, 使得程序继续向下执行system函数部分.
      • ret2libc3: 既没有函数参数字符串(/bin/sh)也没有高危libc函数地址(system)
        1. libc之间函数偏移是固定的, 因此可以通过某个已知的libc函数偏移, 来获取任意其他libc函数地址.
        2. libc有延迟绑定机制, 只有执行过的函数它的GOT才是正确的.
        3. libc内自带有/bin/sh字符串.
        4. 可以利用__libc_start_main地址来泄露偏移.
        5. 利用思路就是 => 构造ROP链通过puts泄露__libc_start_main的got地址 => 使用LibcSearcher获取libc的基址从而获取system地址和/bin/sh地址 => 重载程序 => 构造payload控制.

Day2: 学习CTF Wiki中级ROP和格式化字符串漏洞

传送门: CTF Wiki: Linux Pwn

  • Intermediate ROP:
    • ret2csu:

      • x64寄存器传参的顺序为rdi, rsi, rdx, rcx, r8, r9, 超出数量的参数根据函数调用约定压入栈中(比如从右向左压栈)
      • __libc_csu_init__libc_start_main调用的用于初始化的函数. 参考: Linux X86 程序启动–main函数是如何被执行的?
      • 示例的level5应是ctf-challenges里的hitcon-level5, 而非蒸米提供的level5
      • 使用ROPgadget搜索可用的gadget是可以发现, 程序并没有直接的控制传参用的寄存器, 大多都是控制r12-r15, 这也就是分析__libc_csu_init的关键: 我们需要其中的mov语句, 通过r13-r15控制x64传参用的前三个寄存器.
      • 分析__libc_csu_init的目的是掌握可控制的寄存器, 也就是能控制rbx, rbp, r12, r13=>rdx, r14=>rsi, r15=>edi, 同时可控的r12rbx以及call qword ptr [r12+rbx*8]能控制调用的函数地址(r12为函数地址, rbx直接为0). add rbx, 1; cmp rbx, rbp; jnz 400600则是约束条件rbx+1==rbp, 故而rbx=0则rbp=1. 这样来看这是一段非常优雅的gadget.
      • write (fd, &buf, count)中, linux下fd=0/1/2分别对应stdin/stdout/stderr.
      1. libc延迟绑定机制, 因此需要等待write输出Hello, World后泄露函数地址.
      2. 泄露函数地址后获取libc基址, 然后获取execve地址
      3. 利用csu执行read()向bss段写入execve地址和参数/bin/sh
      4. 利用csu执行execve(/bin/sh)
      Q1: 为什么要先read()execve地址, 而不是直接调用execve函数呢? 因为call qword ptr [r12+rbx*8]指令, 实际上我们通过csu控制的是一个地址, 而该地址指向的内容才是真正函数的调用地址. 而read()写到bss段的是execve的地址, 但csu调用的时候提供的是bss段的地址, 这样才能完成函数调用. 如果直接传execve地址, 那么是无法调用成功的.
      Q2: 为什么可以用写入的/bin/sh地址能成功, 而直接用libc内的/bin/sh地址就不能成功呢? 我一个可能性比较高的推测是, 回顾我们的gadget, 对于x64传参的第一个寄存器rdi, 其实我们的gadget只能控制寄存器rdi的低32位(edi). 而对于bss段地址来说, 它实际上是一个32位的地址(高32位为0), 而libc内的/bin/sh是一个64位的地址(高32位不为0), 所以没有办法传递完整的地址进去. 所以只能通过bss上写入的/bin/sh地址进行传参.
      csu函数实现
      def csu(func_addr, arg3, arg2, arg1, ret_addr):
         rbx = 0
         rbp = 1
         r12 = func_addr
         r13 = arg3
         r14 = arg2
         r15 = arg1
      
         # pop rbx rbp r12 r13 r14 r15 retn
         csu_pop_gadget = 0x000000000040061A
      
         # r13=>rdx r14=>rsi r15=>edi 
         # call func
         # rbx+1 == rbp
         # add rsp, 8
         # csu_pop_gadget
         csu_mov_gadget = 0x0000000000400600
      
         # pop 6 registers and `add rsp, 8`
         stack_balance = b'\x90' * 0x8 * (6+1)
      
         payload = flat([
             b'\x90'*0x80, b'fake_rbp', p64(csu_pop_gadget),
             p64(rbx), p64(rbp), p64(r12), p64(r13), p64(r14), p64(r15),
             p64(csu_mov_gadget), stack_balance, p64(ret_addr)
         ])
      
         io.send(payload)
         sleep(1)
    • BROP: 盲打的方式通过程序是否崩溃来推测信息. 适用于Nginx, MySQL, Apache, OpenSSH等服务器应用, 因此该攻击还有着一定的实际应用价值.

      理论知识主要参考 Blind Return Oriented Programming (BROP) Attack-攻击原理, 示例程序参考 HCTF2016-出题人失踪了(brop)

      • 实现攻击必需的2个条件:
        1. 存在栈溢出漏洞, 且攻击者可以通过输入轻松触发. (没有程序没有源码没有信息, 打也打不崩, 那还玩什么)
        2. 程序崩溃后会重新运行, 并且重新运行的进程地址不会再次随机化. (能稳定复现, 获取稳定地址, 包括Stack Canary也不能随机化)
      • 描述了4种gadget:
        1. stop gadget: 程序跳转到该gadget片段后, 程序并没有崩溃, 而是进入某种hang/loop状态, 能与攻击者保持连接.
        2. (potentially) useful gadget: 找到stop gadget后, 通过一定的内存布局而发现的更多的不会崩溃的gadget. (当然包括新发现的stop gadget)
        3. brop gadget: 一种特殊的useful gadget, 能帮助我们控制x64传参用的寄存器. 典型示例就是__libc_csu_init()尾部的rop链. gadget能通过指令错位(+7/+9)的方式得到单独控制rsirdi寄存器的新gadget.
        4. trap gadget: 就是会让程序崩溃的gadget.
      • 攻击思路:
        1. 通过爆破, 获取程序崩溃时的字符串填充长度.
        2. 通过单字节枚举, 逐字节地泄露出栈上保存的Canary. (当然也可以枚举出栈上保存的寄存器和原本的返回地址.)
        3. 寻找stop gadget: 早期能得到的信息只有程序崩溃和不崩溃, 所以我们需要获得第一个程序不会崩溃的stop gadget.
        4. 寻找useful gadget: 通过合理的布局栈上的内存, 我们可以利用stop gadget来发掘更多的useful gadget, 并且是能确认该useful gadget弹栈数量的.
          • 比如栈上的布局情况为: ...| buffer | gadget | trap x N | stop | trap|... 则表明该gadget有N个pop指令(N=0,1,...).
        5. useful gadget里筛选出真正有帮助的brop gadget. 这里就以__libc_csu_init()的尾部gadget为例, 该gadget能弹栈6次, 通常认为符合这种性质的gadget很少, 所以有一定把握去判断, 并且该gadget可以通过错位得到单独控制rsirdi的gadget, 也可以通过减去0x1a来获取其上的另一个gadget.
        6. 寻找PLT项. PLT在盲打时有这样的特征: 每一项都有3条指令共16个字节长. 偏移0字节处指向fast path, 偏移6字节处指向slow path. 如果盲打时发现有连续的16字节对齐的地址都不会造成程序崩溃, 这些地址加6后也不会崩溃. 那么就推断为PLT地址.
        7. 确定PLT项内的strcmpwrite(也可以是put):
          • 确定strcmp的目的在于: 目前只能通过brop gadget控制传参用的前2个寄存器(rdi和rsi), 第3个寄存器rdx尚且没法用gadget控制. 因此转变思路通过strcmp和控制字符串长度来给rdx赋值, 变相控制第三个传参用的寄存器.
          • 确定write的目的在于: 需要通过write将内存代码都写回给攻击者. 通常是将fd设置为连接的socket描述符. 而write需要3个参数, 这也是为什么借用strcmp控制rdx的原因.
          • 确定strcmp的方法在于控制函数的两个地址: readablebad(0x00)地址. 这样就有4种参数形式, 并且只有两个参数地址都是readable时函数才会正确执行, 其他情况都没有正确执行, 那么就推断这个plt项对应的是strcmp.
          • 确定write的方法在于确定写入的fd, 就只能尽量枚举文件描述符来测试了. 建议用较大的文件描述符数字.
          • 如果是寻找puts的话, 就比较容易确定. 因为我们只需要控制输出0x400000地址的内容, 该地址通常为ELF文件的头部, 内容为\x7fELF. 构造的payload形式为buffer |pop_rdi_ret | 0x400000 | puts_addr | stop.
        8. 有能力控制输出函数后, 攻击者可以输出更多的.text段代码. 也可以去寻找一些其他函数, 比如dup2execve等:
          • socket输出重定向到stdin/stdout.
          • 寻找/bin/sh, 或者利用write写入到某块内存.
          • 执行execve或构造系统调用.
          • 泄露puts在内存的实际地址, 然后确认libc基址, 获取system地址并构造rop链.
  • Format String Vulnerability:
    • 格式化字符串漏洞的本质在于信任了用户的输入, 攻击者通过输入构造好的格式化字符串来泄露栈上的内存数据.
      • %x%p用于泄露栈内存数据.
      • %s用于泄露变量对应地址的内容, 存在\x00截断.
      • %n$x用于泄露输出函数的第n+1个参数. 这里的n是相对于格式化字符串而言的.
    • 可以通过func@plt%N$s将内存中的func实际地址泄露出来. N表示其在栈上相对格式化字符串而言是第N个参数.
    • 确定了偏移后, 使用...[overwrite addr]....%[overwrite offset]$n. %n写入的值可通过增加输出的字符数量进行调整.
    • 覆写的地址没有位置的要求, 只需要找到对应偏移即可.
    • 利用%hhn进行单字节写入, %hn进行双字节写入.

Day3: 回顾软件安全保护技术和学习ARM汇编基础

  • 软件保护技术:
    • 反调试:
      • 利用WinAPI检测调试状态: IsDebuggerPresent.
      • 内存数据检查: 比如通过PEB的字段(BeingDebug), 堆上的标志信息(Heap flags)来检测调试.
      • 调试驱动检测: 基于一些使用了驱动的调试器的行为特征, 比如调试器会在启动后创建相应的驱动链接符号, 来确定是否存在调试器.
      • 进程窗口检测: 比如枚举当前所有进程名/窗口名来检查是否存在已知调试器.
      • 特征码检测: 枚举当前所有正在运行的进程, 匹配特定调试器的内存代码数据来判断是否有调试器.
      • 时间差检测: 通过调试和非调试模式下程序运行的时间差异来判断是否存在调试.
      • 断点检测/异常检测: 断点检测在于判断内存代码是否被修改为int3, int 2d等软中断指令, 异常检测在于故意触发异常,如果调试器接管了异常则认定为存在调试器.
      • 功能破坏: 基于大部分程序通常都不会使用系统提供的调试功能这一假设, 保证程序正常运行的前提下, 破坏系统提供的调试相关功能. 比如在创建线程时指定ThreadHideFromDebugger属性可以隐藏线程引发的异常, 接收不到异常调试器就无法正常工作.
      • 双进程保护: 基于一个进程只能同时被一个调试器调试的前提, 以调试方式启动被保护的程序, 通过占用调试行为的方式来阻止攻击者去调试分析受保护程序.
    • 反虚拟机:
      • BIOS信息检测: 虚拟机软件厂商的BIOS通常具有明显的品牌特征.
      • 字符串特征检测: 虚拟机产品明显的字符串特征.
      • 后门端口检测: 比如VMWARE的后门I/O端口0x5658("VX")读取数据得到VMXh
    • 数据校验:
      • 文件校验: 实现计算好程序文件的校验值, 然后运行时再校验比对判断文件本身是否被修改.
      • 内存校验: 通常程序运行时, .text/.rsrc等区段是不会修改的, 通过运行时计算内存数据的校验值来判断内存数据是否被修改.
    • 导入表加密: 保护导入表能阻止攻击者去获取对应的符号信息, 增大分析难度.
      1. 可以简单地劫持导入表函数调用处来隐藏调试器/反汇编器提供的符号信息.
      2. 也可以预先将导入表函数地址加密存储到某个位置, 然后将导入表RVA指向解密代码, 解密代码运行后得到真实的函数地址, 并跳转过去执行.
      3. 另一种方式就是, 将导入表函数的入口代码进行加密或虚拟化, 在运行时解密.
      4. IAT模拟: 自己实现一些程序可能调用的外部函数, 然后替换导入表内的原始函数.
    • 模块拷贝移位: 用于对抗代码Hook的技术, 方法是复制移位模块, 然后映射模块内的数据到内存以及重定位, 替换原模块函数调用地址.
    • 资源加密:
      1. 在程序运行时将资源解压/解密, 然后修正PE文件头的资源指向.
      2. Hook各种与资源相关的函数, 然后在调用函数时动态解密资源.
    • 代码加密: 代码加密的目的是将原始代码转换为等价的, 极其复杂的, 更多的代码.
      • 代码膨胀/变形: 将1条或多条指令转变为等价的其他指令, 更多是用于膨胀.
      • 垃圾代码(花指令): 目的也是膨胀, 但是方式就是插入无用的或者干扰(误导)调试器反汇编算法的代码.
      • 代码乱序(平坦化): 通过跳转指令打乱指令的正常顺序, 增大分析难度.
      • 多分支: 也是花指令的一种, 只是这里的花指令着重在分支跳转指令上, 这些分支跳转大部分是根本不会执行的deadcode, 但是会让攻击者在分析时难以确定代码的具体执行流程.
      • call链: 通过call指令来打乱执行流程.
    • 代码虚拟化: 设计一套虚拟机和对应的opcode来在保证语义的前提下, 模拟原本的指令. 虚拟机本质也是程序代码, 运行虚拟机本身也会影响当前的上下文, 因此虚拟机设计时需要保存/恢复上下文, 解决虚拟机和原始代码在上下文的使用冲突. 通常有以下两种方案:
      • 堆机: 开辟新的栈空间来运行虚拟机代码, 代码执行完后恢复原始的栈空间地址即可.
      • 栈机: 不开辟新空间, 在原有栈空间分出一部分专门给虚拟机使用, 并避免原始指令影响到虚拟机专用的栈空间.
    • 脚本引擎: 将程序的部分功能分交给脚本引擎解释执行.
    • 网络加密: 将程序的部分代码放到服务器执行, 服务器只返回代码的执行结果.
    • 硬件加密: 类似网络加密, 只是将关键数据/代码转移到了硬件介质里.
    • 代码签名: 利用签名严重算法, 对程序文件数据进行签名, 将对这些签名的校验作为能否运行该软件的判断条件.
  • ARM汇编基础
    • Introduction to ARM Assembly
      • ARM为RISC指令, 相比CISC具有精简的指令和更多的通用寄存器.
      • ARM只能使用操作寄存器的指令, 并且使用Load/Store模型访问内存(也就是只有Load/Store指令能访问内存).
      • 指令精简可以带来更快的运行速度, 但同时在可用指令有限的情况下难以高效地编写软件.
      • ARM有两种模式ARM模式Thumb模式. Thumb模式下的指令长度既可以是2字节也可以是4字节.
      • ARMv3前使用小端, 之后支持双端并且可以切换字节序.
    • Data Types Registers
      • s后缀表示signed, b表示byte长度为8, h表示halfword长度为16. ARM的word32位长.
      • 大小端的切换由CPSR寄存器的第9E来指示.
      • 寄存器数量取决于ARM的版本. 通常有30个32位寄存器, 前16个寄存器用户模式下可用, 其他寄存器只有特权模式下可用.
        • R0-R6为通用寄存器, 其中R0对应EAX
        • R7用于保存系统调用号
        • R8-R10也是通用寄存器
        • R11(FP)类似于EBP, 也就是栈基寄存器
        • R12(IP)Intra Procedural Call内部过程调用寄存器.(x86没有接触过呢)
        • R13(SP)类似于ESP, 也就是栈顶寄存器
        • R14(LR):即Link Register, 链接寄存器
        • R15(PC): 程序计数器, 类似于EIP.
        • CPSR: 当前程序状态寄存器, 类似于EFLAGS.
      • ARM上的函数调用约定: 前四个参数存储在寄存器R0-R3中.
      • 链接寄存器R14(LR): 据解释, LR实际上是函数调用时用于保存函数的返回地址, 意义在于快速进入和返回叶函数.
      • 程序计数器R15(PC): ARM模式下指令长度为4, Thumb模式下长度为2. PC会根据所处模式来递增相应的指令长度. 执行分支指令时, 会将分支跳转的目的地址保存到PC. 但程序执行过程中, PC存储的总是当前执行指令的后2条指令(ARM模式就+8, Thumb模式就+4).

Day4: 学习ARM汇编基础和CTF Wiki的花式ROP

传送门: azeria-labs / ROP Tricks

  • ARM Assembly
    • ARM Instruction Set
      • ARM模式亦或是Thumb模式跟所处的特权等级无关.
      • 开发ARM Shellcode时需要尽量避免NULL空字节出现, 因此常用Thumb指令
      • ARM版本之间会有略微差别, 需要根据对应版本查询官方文档
      • Thumb有三个版本:
        1. Thumb-1: 16比特长, 用于ARMv6及早期版本
        2. Thumb-2: 16/32比特长, 扩展了Thumb-1, 支持更多的指令. 适用于ARMv6T2ARMv7.
        3. ThumbEE: 包括一些对动态生成代码的变化.
      • ARM和Thumb指令的区别:
        1. 条件执行: ARM所有指令都可以条件执行, Thumb只能通过IT指令允许部分指令有条件地执行.
        2. 32位表示: 32位的Thumb指令会多一个.w的后缀
        3. 桶式移位器(ARM独有特性): 能用于精简指令.
      • 要切换处理器执行状态, 需要满足以下两者条件其一:
        1. 使用分支指令BXBLX并将目标寄存器的最低有效位设置为1(通过+1实现)
        2. 状态寄存器置位T
      • ARM汇编指令格式MNEMONIC{S}{condition} {Rd}, Operand1, Operand2. 注意Operand2的使用稍有灵活, 并且有些指令中Operand1是隐含的.
    • Memory Instructions: Loading and Storing Data
      • [pc, #12]表示pc相对寻址. 不过要注意, ARM里的pc指的是当前指令的下2条指令位置, 也就是ARM模式下+8, Thumb模式下+4
      • 地址模式: offset / pre-indexed / post-indexed
        • 立即数作为偏移的情况:
          • str r2, [r1, #2]: 地址模式: offset. 直接将r2寄存器中的值存到r1+2所表示的地址处. r1没有变化
          • str r2, [r1, #4]!: 地址模式: pre-indexed(!是一个标识的特征). 类似offset寻址模式, 寻址r1+4, 寻址存储完执行r1=r1+4
          • ldr r3, [r1], #4: 地址模式: post-indexed. 寻址r1, 寻址完执行r1=r1+4
        • 寄存器作为偏移的情况: 类似立即数作偏移的情况, 很好理解.
        • 移位寄存器作为偏移的情况: 类似立即数作偏移的情况, 不过移位的优先级是最高的, 比如str r2, [r1, r2, LSL#2]就是将r2内的值保存到r1+r2<<2的地址处.
      • ARM使用立即数: ARM使用立即数的方式很不灵活, 格式为v = n ror 2*r 其中n in [0-255], r in [0-30]. 对于不能合规的立即数, 考虑拆分成两个更小的数加起来, 或者使用LDR指令比如LDR r1, =511
    • Load and Store Multiple
      • 多次加载/存储可以使用LDMSTM指令
      • LDMLDR的方向是相反的, 同样STMSTR方向也相反
      • 扩展-IA (increase after), -IB (increase before), -DA (decrease after), -DB (decrease before)
      • PUSHPOP和x86汇编基本一致.
      • PUSH等价于STMDB sp! reglist
      • POP等价于LDMIA sp! reglist
    • Conditional Execution and Branching
      • 分支条件在标志寄存器中会相应地置位, 这点跟x86一致, 区别主要在标志寄存器各个位的含义略有不同. ARM的分支通过在指令后加相应的条件码来实现.
        Condition Code Meaning (for cmp or subs) Status of Flags
        CS or HS Unsigned Higher or Same (or Carry Set) C==1
        CC or LO Unsigned Lower (or Carry Clear) C==0
        MI Negative (or Minus) N==1
        PL Positive (or Plus) N==0
        AL Always executed -
        NV Never executed -
        VS Signed Overflow V==1
        VC No signed Overflow V==0
        HI Unsigned Higher (C==1) && (Z==0)
        LS Unsigned Lower or same (C==0)
      • ITIF-Then-(Else)的缩写.
      • IT指令格式: IT{x{y{z}}} cond, 也就是最多可以有条件地执行4条指令
        • cond指定IT块中第1条指令的条件
        • x指定第2条指令的条件, y指定第3条, z指定第4
      • IT块里T的条件要跟I保持一致, E的条件要跟IT相反. (这也很好理解, 就是ARM划分分支的一种形式)
      • 条件码的反义就不硬背了, 直接看ITE就可以判断IT块里的情况.
      • branch指令跟x86的类似, 只是助记符不一致, 理解还是很好理解的.
        • B: 单纯跳转分支
        • BL: 将PC+4保存到LR然后跳转分支
        • BX/BLX: 相比多了一个Exchange, 也就是切换指令集(ARM <-> Thumb)
        • BX/BLX通常会使用类似add r2, pc, #1; bx r2的方法先取pc然后+1的方法使最低有效位置为1(0转ARM,1转Thumb), 然后用BX/BLX切换指令集. (这里不用担心内存块对齐4的问题, CPU会自动屏蔽没有对齐的那个bit1/0).
    • Stack and Functions
      • 栈的部分不必多说
      • 函数部分熟悉Prologue, BodyEpilogue
        • prologue: push {r11, lr}; add r11, sp, #0; sub sp, sp, #16
        • body: mov r0, #1; mov r1, #2; bl max
        • epilogue: sub sp, r11, #0; pop {r11, pc}
  • ROP Tricks
    • stack pivoting
      • 直接劫持栈指针指向攻击者的内存, 可以以较少的指令达成攻击, 对于开启PIE保护的程序也可以适用.
      • 利用的gadget为pop rsp/esp, 也可以通过libc_csu_init的gadget经过错位获得.
      • 有办法控制到esp后, 还需要想办法将esp的值指向写入的shellcode部分. 可以加\x90垫.
    • Stack smash
      • Canary检查到溢出后, 程序会执行__stack_chk_fail函数打印argv[0]指针. 而攻击思路就是借栈溢出覆盖argv[0]实现信息泄露.
      • 攻击需要确定溢出到argv[0]所需要的字节数, 以及需要溢出的地址.

Day5: 学习CTF Wiki整数溢出和堆管理机制

在此前需要了解glibc的堆内存管理器的机制. 主要参考 glibc内存管理ptmalloc源代码分析. Seebug有一个堆资料的归档也可以省下找资料的功夫.

  • 整数溢出:
    • 上界溢出: 上界溢出能使得数值变得极小, 有符号整数正极大=>0, 无符号整数正极大=>负极小
    • 下界溢出: 跟上界溢出相反, 有符号整数0=>正极大, 无符号整数从负极小=>正极大
    • 错误的类型转换没有严格限制数值范围是造成整数溢出的两个常见原因.
  • 堆溢出基础:
    • malloc(size_t n):
      • 返回指针, 指向新分配的至少为n字节的内存块地址.
      • 如果n=0, 返回系统允许的最小块. 通常32位下是16字节, 64位下是24或32字节.
      • size_t通常是无符号整数, 因此n<0会造成整数溢出变成非常大的值, 而malloc通常也会因为分配不了这么大的内存而失败.
    • free(void* p):
      • 释放由p指向的内存块.
      • p=Null时, free不会进行任何操作
      • pdouble free后造成漏洞.
      • 当释放很大的内存块时, 会将该内存还给系统
    • 系统调用(s)brk / mmap: mallocfree都是通过系统调用来分配释放内存.
      • (s)brk: 可以通过增加brk的大小来向操作系统申请内存. 比如curr_brk = sbrk(0); brk(curr_brk+4096);就可以在curr_brk的基础上新增加0x1000的堆内存空间.
      • 查看堆内存可以根据进程的pid号去cat /proc/[pid]/maps查看.
      • mmap: mmap相比brk的操作粒度更细一些, 有几个可以控制的参数. 类似mmap(NULL, (size_t)132*1024, PROT_READ|PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0)
      • dlmalloc所有的线程都共享一个堆, 因此不支持多线程, 如果两个线程同时申请内存, 就只能一个线程进入临界区, 另一个线程等待.
      • 操作系统倾向于第一次直接分配一个大内存给程序, 避免多次分配内存(切换内核态和用户态)开销. 同时释放的内存也不会立即回收, 而是交由glibc继续管理.
  • ptmalloc源代码分析:
    • 基础知识
      • x86平台Linux进程内存布局:
        • 32位Linux会将ELF载入到0x8048000(128M)
        • .bss段与stack之间的空间分为两部分: heapmmap region
        • stackmmap region都是反向生长(高地址=>低地址), heap是正向低地址=>高地址
      • 操作系统内存分配的相关函数:
        • 内存的延迟分配: 只有在真正访问一个地址的时候才建立这个地址的物理映射. Linux内核在用户申请内存时分配的是一个线性区(虚拟内存), 只有当用户使用这块内存的时候内核才会分配具体的物理页面给用户. 而物理页面的释放也是通过释放线性区, 找到其对应的物理页面, 将其全部释放.
        • Heap相关函数:
          • int brk(void *addr); brk()是一个非常简单的系统调用, 仅仅只是改变mm_struct结构的成员变量brk的值
          • void *sbrk(intptr_t increment); 注意increment=0时, sbrk()返回的是进程当前brk值, increment>0时扩展brk, increment<0时收缩brk.
        • Mmap相关函数:
          • void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); 将一个文件或其他对象映射进内存
            • prot是内存保护标志: 有PROT_EXEC, PROT_READ, PROT_WRITE, PROT_NONE.
            • flags: 指定映射对象的类型, 映射选项和映射页是否可以共享. (不太懂什么含义先忽略)
    • 概述:
      • 内存管理方法:
        1. C风格的内存管理: 实现mallocfree函数, 通过调用brk()mmap()来管理内存. 但是需要程序员手动管理内存, 繁琐复杂困难.
        2. 池式内存管理: 为程序的每个特定阶段分配特定的内存. 优点是简单快速易于实现, 缺点是只适用于操作分阶段的程序, 兼容性差难以维护.
        3. 引用计数: 通过标记引用次数来判断数据结构是否存活.
        4. 垃圾回收: 垃圾回收会在可用内存减少到一定程度时才会启动, 首先以程序所知的"可用数据"(栈数据,全局变量,寄存器)出发, 去追踪相应存活的数据. 没有找到的其他数据就被标记为垃圾进行销毁.

Day6: ptmalloc2内存管理机制(分配和释放)

  • ptmalloc2内存管理概述
    • 内存管理的设计假设
      1. 长生命周期大内存分配使用mmap, 特别大的内存总是使用mmap, 短生命周期的内存分配用brk
      2. 尽量缓存临时使用的空闲小内存块, 大内存长生命周期内存释放时则直接返还系统
      3. 空闲小内存只会在mallocfree期间进行合并, free时空闲内存块可能放回pool而非返还系统
      4. 收缩堆的条件: 当前free的chunk大小 + 前后能合并的chunk大小 > 64KB并且堆顶的大小达到阈值
      5. 需要长期存储的程序不适合用ptmalloc管理内存
      6. 多个线程可以从同一个arena中分配内存.
    • 内存管理数据结构
      • main_arenanon_main_arena
        • Doug Lea实现的内存分配器只有一个主分配区(main_arena), 因此每次分配内存为避免竞争都会加锁, 而这样会带来很大开销.
        • ptmalloc增加了多个非主分配区(non_main_arena), main_arenanon_main_arena形成环形链表进行管理. 每一个arena利用互斥锁, 使线程对于该arena的访问互斥.
        • main_arena能访问进程的heapmmap映射区域, 而non_main_arena只能访问mmap映射区域.
        • 线程申请内存: 先查找线程私有变量看是否已经存在一个arena, 如果有就对该arena加锁然后分配内存, 如果没有, 就去循环链表找没加锁的arena. 如果arena都加锁了, 那么malloc就会开辟新的arena, 将该arena加入循环链表, 用该arena分配内存.
      • chunk的组织
        • 使用中chunk结构: ptmalloc-busy-chunk.png
          • chunk指针指向chunk的起始位置, 而mem指针才是真正返回给用户的指针.
          • P: 表示前一个chunk是否在使用中.
            • P=0表示前一个chunk空闲, 这时chunk的第一个域prev_size才生效. prev_size用于找到前一个chunk的起始地址.
            • P=1表示前一个chunk正在使用中, prev_size无效, 无法依据prev_size找到前一个块的位置(不会对前一个chunk进行任何操作)
          • M: 表示chunk从内存区域分配获得. M=1表示从mmap映射区域分配, M=0表示从heap区域分配.
          • A: 表示该chunk所属arena. A=1表示non_main_arena, A=0表示main_arena.
        • 空闲的chunk结构: ptmalloc-free-chunk.png
          • 空闲状态时没有M标志.
          • fd指向后一个空闲的chunk, bk指向前一个空闲的chunk. fdbk组合成双向链表.
          • large bin中的空闲chunk, 还有额外两个指针fd_nextsizebk_nextsize. 用于加快在large bin查找最近匹配的空闲chunk.
          • 不同的chunk链表使用binsfastbins来组织.
        • chunk中的空间复用:
          • chunk之间复用一些无意义的域空间, 以尽量减小chunk所占空间.
          • 一个chunk正在使用时, 它后一个chunk的prev_size肯定是无效的, 就可以把这个空间省下来. inuse_chunk_size = (用户请求大小+8-4)对齐8
      • 空闲chunk容器
        • Bins
          • 用户free的内存交由ptmalloc管理, 当用户下一次请求内存, ptmalloc就会从空闲内存里挑一块给用户, 减少了系统调用, 也就降低了开销.
          • ptmalloc相似大小的chunk用双向链表链接起来, 这样的链表称为bin
          • ptmalloc一共维护了128个bin并组成数组(array), 也就是对应了128个size.
          • 假设数组索引从1开始, array[1] = unsorted bin, array[2:64] = small bins, array[65:128] = large bins
          • small bins:
            • 两个相邻的small bin中的chunk大小相差8bytes
            • small bin里的chunk按头进尾出进行排列, 新释放的chunk存入链表的头部, 新申请的chunk从链表尾部取出.
          • large bins:
            • 每一个bin分别包含给定范围内的chunk, chunk按大小排列, 相同大小的chunk按头进尾出排列.
            • ptmalloc会分配符合要求的最小chunk
          • 当空闲chunk链接到bin中, ptmalloc会把该chunk的P标志设为0(注意: 这个标志实际上处在下一个chunk), 同时ptmalloc会检查它前后的chunk是否也是空闲的. 如果空闲, 就合并成大的chunk, 然后把合并后的chunk放到unsorted bin里去.
          • 并非所有的chunk被释放后都放到bin中. ptmalloc为了效率会把一些小的chunk先放到fast bins里.
        • Fast Bins
          • 小内存的分配总是频繁的, fast bins就是为此而引入
          • size < max_fast(64B)的chunk释放后放入fast bins内.
          • fast bins内的chunk不会改变P标志位, 这样也就无法将其合并.
          • 当需要小于mas_fast的chunk时, ptmalloc会首先在fast bins内找相应的空闲块, 找不到才会去bins里找.
          • 在某个特定时间点, ptmalloc会遍历fast bins, 将相邻的空闲chunk进行合并, 将合并后的chunk加入unsorted bin中, 然后再将unsorted bin中的chunk加入bins
        • Unsorted Bin
          • unsorted bin可以看做是bins的一个缓冲区.
          • malloc时会优先查找fast bins, 然后找unsorted bin, 然后找bins.
          • unsoretd bin找不到合适的chunk, malloc会将unsorted bin的chunk加入到bins, 然后从bins继续查找和分配.
        • Top chunk
          • top chunkmain_arenanon_main_arena存在不一致的地方, 具体原因在于main_arena是唯一能映射进程heap区域的地方.
          • top chunk会在fast binsbins都无法满足分配需求的时候使用, 如果top chunk也无法满足, 那么就系统调用一块新的, 然后和top chunk合并.
        • mmaped chunk: 当申请的chunk足够大, top chunk也无法满足时, ptmalloc会使用mmap将页映射到进程空间, 这样的chunk在释放时则直接解除映射将内存返还系统.
        • Last remainder: 当需要分配一个small chunk但在small bins找不到合适的, 而last remainder的大小可以满足, 那么就切割last remainder成两个chunk, 一个大小合适的chunk返回给用户, 另一个chunk成为新的last remainder
      • sbrkmmap
        • ptmalloc在最开始时, 如果请求的空间小于mmap分配阈值, main_arena就使用sbrk()来分配内存作为heap. non_main_arena则使用mmap映射空间作为sub-heap.
        • 之后就根据用户的分配释放来管理内存, 再遇上分配空间不足的情况, main_arena继续使用sbrk来增加heap大小(申请的大小得小于mmap分配阈值), non_main_arena则还是使用mmap映射新的sub-heap.
    • 内存分配概述
      1. 分配算法概述:
        • size < 64B: 用pool算法
        • size in 64B...512B: 在最佳匹配算法分配和pool算法分配取合适的
        • size >= 512B: 最佳匹配算法分配
        • size >= mmap分配阈值(128KB): 如果没有动态调整过mmap分配阈值, 就按大于默认的128KB就直接调用mmap. 否则大于调整过的mmap阈值才调用mmap分配
      2. ptmalloc内存分配的具体步骤:
        1. 获取arena的锁: 查看线程私有实例是否存在一个arena => 搜索arena的循环链表找没有加锁的arena => 所有arena都加锁了, ptmalloc开辟新的arena, 将该arena加入循环链表和线程的私有实例并加锁, 然后进行内存分配.
          • 开辟出来的新arena一定为non_main_arena, 因为main_arena是从父进程继承而来
          • 开辟新arena需要调用mmap创建一个sub-heap, 并设置好top chunk
        2. 根据用户请求大小计算实际需要分配的chunk大小
        3. 判断申请的chunk大小是否满足 size <= max_fast, 满足则使用fastbins分配, 否则继续.
        4. 判断大小是否在small bins范围内. 是则用small bins分配, 否则继续.
        5. 到此说明需要分配的是大内存. ptmalloc首先遍历fastbins的chunk, 将相邻chunk合并存入unsorted bin. 然后在unsorted bin中找合适的chunk切割返回给用户, 否则继续
        6. large bins里找一块最小满足的chunk. 找不到则继续
        7. 使用top chunk分配, 如果top chunk也不满足所需chunk的大小, 则继续
        8. 使用sbrkmmap来增大top chunk的大小以满足分配, 或者直接使用mmap来分配内存(这需要满足mmap分配阈值).
    • 内存回收概述
      1. 首先获取arena的锁, 保证线程安全
      2. 判断传入指针是否为0, 为0直接return
      3. 判断释放的hcunk是否为mmaped chunk, 是则调用munmap释放. 如果开启了mmap分配阈值的动态调整, 且当前回收chunk的大小大于mmap分配阈值, 则将mmap分配阈值设置为该chunk大小, mmap收缩阈值设为mmap分配阈值的2倍, 释放完成. 否则进行下一步
      4. 判断chunk的大小和位置, 若chunk_size <= max_fast且该chunk不与top chunk相邻, 则将该chunk放入fastbins中(不修改该chunk的P标志, 也不与相邻chunk进行合并), 否则进行下一步
      5. 判断前一个chunk是否处在使用中, 如果前一个chunk也是空闲状态, 则一起合并
      6. 判断后一个chunk是否为top chunk, 如果不是, 则判断后一个chunk是否空闲状态, 空闲则合并, 将合并后的chunk放到unsorted bin中. 如果是后一个chunk是top chunk, 那么无论它有多大都一律和top chunk合并, 更新top chunk的大小等信息. 都同样继续以下步骤
      7. 判断合并后的chunk大小是否大于FASTBIN_CONSOLIDATION_THRESHOLD, 如果是, 则触发fastbins的合并操作, 合并后的chunk放入unsorted bin
      8. 判断top chunk的大小是否大于mmap收缩阈值, 大于的话, 对于main_arena会试图归还topchunk的一部分(最初分配的128KB不会返还)给操作系统. 对于non_main_arena会进行sub-heap收缩, 将top chunk的一部分返还给操作系统. 如果top chunk为整个sub-heap, 会把整个sub-heap返回给系统. 至此释放结束, free()函数退出.
        • 收缩堆的条件是当前free的chunk大小加上前后能合并的chunk的大小大于64K, 并且top chunk的大小要达到mmap收缩阈值, 才可能收缩堆.

Day7: 软件破解技术

  • 静态分析:
    • 基本信息分析: 从三个方面判断程序是否加密
      1. PE程序的区段信息: 正常的PE程序比较规则, 大多是.text/.data/.rsrc/.reloc, 而加密后的区段常有明显特征
      2. PE导入表信息: 加密后的导入表往往只有少数的几个dll.
      3. PE程序入口点: 标准编译器编译出来的入口点代码比较规范.
    • 代码静态分析: 结合工具进行静态分析, 比如IDA, .NET程序使用ildasm IL/.NET Reflector
  • 软件调试:
    • 一般调试原理:
      • windows内置有调试API和相应的调试事件.
      • 异常处理流程: 软硬件异常->通过IDT被系统捕获->系统分类异常->交由调试器处理->通过KiUserExceptionDispatcher函数交由进程内SHE处理->再次交由调试器处理->触发系统软件异常流程
        • 任何异常, 尤其是软件异常, 都需要内核过滤, 并在保护层和内核层来回切换, 速度相当慢
        • 调试器处理异常的优先级在保护层中是最高的 , 内核无法处理的异常都会优先传递给调试器来处理
      • 调试器一般软断点都是通过人为触发INT3异常来实现
      • 硬件断点通过CPU的DR系列寄存器实现. 因为寄存器数据有限, 因此最多只能同时下4个硬件断点.
      • 硬件断点的好处在于无需修改调试指令, 并且执行速度很快.
      • 内存断点是通过修改内存页的属性触发访问异常实现
    • 伪调试技术:
      • 在程序进程内注入代码, 接管KiUserExceptionDispatcher函数入口, 从而在改程序处理任何异常前得到对异常的优先处理. 然后代替系统将异常处理的信息转交给外部调试器.
    • 远程调试: cdb -server tcp:port=123 -noio c:\windows\notepad.exe然后用windbg连接远程调试会话.
    • 虚拟机调试: 连接虚拟机提供的调试接口进行调试
  • Hook:
    • 代码Hook: 使用流程控制指令(比如jmppush/ret组合指令)来实现对程序流程的控制
    • 模块Hook: 这里的模块可以理解为DLL, GetModuleHandleA函数能给定模块名后获得模块对应的基址, 进程每次载入模块, 系统都会维护一张模块的列表, 列表中保存了模块的许多信息其中就包括基址. 而这个列表所在的地址保存在PEB的LoaderData+C位置, 而模块链表的结构中的hashTableList就是GetModuleHandleA所查找的表.
  • 代码注入:
    1. 暂停的方式启动进程, 这样能保证程序的入口代码尚未被执行
    2. 注入需要在目标进程中执行的额外代码
    3. 设置线程上下文的方式修改主模块入口到额外代码入口. windows下以暂停方式启动一个进程后, 系统会把主模块入口放在线程上下文的eax成员中, 修改该成员即可修改主模块入口地址.
    4. 恢复目标进程并执行
  • 补丁:
    • 冷补丁: 直接修改程序中所包含的数据来修改程序执行流程或结果.
    • 热补丁: 在程序运行过程中直接修改程序所在进程的内存空间数据
    • SMC: 直接修改压缩或加密后数据, 使这些数据被解压或者解密后最终呈现我们所涉及的数据.
    • 虚拟化补丁: 通过硬件或者软件虚拟将代码运行时执行和读写的代码页分离, 然后通过修改执行页中的数据达到修改程序运行流程的目的.
  • 模块重定位
    • 在Windows进程中, 除了NTDLL模块地址无法直接修改, 其他模块都可以重定位
    • 具体步骤
      1. 通过篡改ZwOpenSection函数, 使系统放弃以共享内存段的方式加载一个模块
      2. 通过篡改ZwMapViewOfSection函数, 使系统加载模块到指定的基址
      3. 处理特殊模块kernel32