整数溢出攻击(一):原理介绍
1、CPU算术计算原理介绍
(1)CPU叫"central process unit",中文叫中央处理器,内部主要的功能模块之一就是算术逻辑单元,专门负责做各种快速的算术计算;由于硬件原理限制(再说细一点就是数字电路原理限制:一般情况下高电平表示1,低电平表示0),CPU只能读取、识别和处理二进制数据;如果要处理其他进制的数,需要人为先转成二进制。拿人们常用的10进制算术计算举例:3+5=8,这种算术题小学一年级的学生都会。但对于CPU来说却没法直接计算的,必须要转成二进制才能计算。3+5就变成了 0011+0101,然后从右向左逐位相加,逢2进1,最后的结果就是1000,转成10进制就是8;所有的算术都需要先转成二进制,再通过CPU的算术逻辑单元计算!
(2)加减乘除这种最基本的四则运算都是互相关联的。比如减法可以转换成加负数;乘法可以转换成多个数相加(或则左移位);除法可以转成多个负数相加(或则右移位),那么加法运算就是CPU内部最基本的计算方法了,这个必须从硬件上实现,其他3中运算都可以转成加法运算;加法器可以由异或门和与门实现,所以CPU内部的算术逻辑单元硬件上就是由大量的异或门和与门组成的!加法器的逻辑原理:
上面举了一个简单的加法原理,这里再介绍一个减法原理,比如9-5,可以看成是9+(-5),cpu在硬件上是可以直接计算加法的,但是-5该怎么表示了?计算机中,负数是以正数的补码形式表示的!比如5的原码是00000101,那么反码就是11111010,补码=反码+1,也就是11111011;现在的问题来了,为啥负数要用补码表示了?为啥不直接用反码表示了?为啥不用其他形式的码表示了?
CPU内部只能识别二进制,也只能进行二进制的加法运算,至于减、乘、除这些运算就需要人来进一步抽象了!回到上面提到的9-5=9+(-5),也就是00001001+ 11111011 = 1 0000 0100,此时产生了溢出,OF=1。 如果只取8bit,那么结果就是0000 0100,刚好是4,就是我们想要的结果!如果负数直接用反码,那么-5=11111010,9+(-5) = 00001001 + 11111010 = 1 0000 0011,取8bit的话结果是3,很明显是错的;所以负数直接用反码这种编码方式运算后的结果,和10进制的运算结果无法吻合,一般人习惯了10进制,以10进制结果为准的话,二进制的结果就是错的,也就是说直接用反码表示负数的方式是错的,必须用补码表示负数,其在二进制运算的结果和10进制运算的结果才相同,符合算术计算的通识!这里再举个例子:
10进制下:5-5=0,这个没人反对吧(幼儿园小朋友都会的算术)? 5=00000101,5的反码是11111010;如果直接用反码表示负数,那么5-5=5+(-5)=00000101+11111010=1111 1111,转成10进制就是256,这很明显不符合10进制的算术逻辑。如果用补码表示-5,那么-5=反码+1=11111010+1=11111011,此时5+(-5)= 00000101 + 11111011 = 1 0000 0000,产生了溢出,低8bit刚好是0,符合10进制的算术规则!
本质上,用补码表示负数,是借助了二进制运算溢出的原理,让二进制加法的结果和10进制减法的结果保持一致!
(3)所以说CPU最核心的算术功能原理一点都不复杂,还很简单,就是依靠速度快才完成了很多人脑完不成的任务,所以CPU算术计算功能也可以认为是:速度超快的二傻子(硬件上只会做二进制加法,其他啥也不会了,需要人为基于二进制加法重新定义其他运算)!
(4)CPU硬件上是由无数晶体管构成的,这些晶体管通电表示1,断点表示0; 内存的存储芯片也是由无数的电容构成的,充电表示1,放电表示0;这种0、1都是人为抽象出来的,底层的硬件只管充放电、通电或断点,至于通电/充电后表示什么、断电/放电后又表示什么,都是人为自己设定的!举个栗子:
内存有一个8bit的存储单元,每bit的状态为:0110 0001,其中1表示电容有电,0表示没电; 硬件方面内存能做的就是读写这些电容,让其状态在0和1之间互相转换,至于这些0、1有啥业务意义,cpu和内存这些硬件是不知道的,需要人为赋予意义,比如:
- 如果认为这是10进制的无符号数,那么就是97;
- 如果认为这是10进制的有符号数,由于最高位是0,那么还是正数97;
- 如果认为是16进制的无符号数,那么就是0x61;
- 如果认为是ascii码,那么就是字符“a”;
二进制数是0110 0001,代表什么业务意义完全需要人为定义的!这一点非常重要,后续所有的和数相关的溢出都是根据这个原理来操作的!
2、正式做测试前,先明确各种数据类型的长度和人为抽象的数值范围:
![](https://img2020.cnblogs.com/blog/2052730/202103/2052730-20210311174259006-1039243442.png)
数据类型之间的本质区别就是长度不同,人为解读的方式不同;比如char是1byte,int是4byte,后者的长度是前者的4倍; char默认是有符号的,取值范围-128~128;unsigned char是无符号的,取值范围0~255,这些取值都是人为设定的规则决定的!所以不同类型的数据是可以强行转换的。比如char 类型的字符a,在内存中存储的方式是0110 0001。如果转成10进制的int类型,就是97;不管类型怎么转换,二进制的数值是不变的,唯一不同的是人为怎么去解读和使用!其实C语言中有一种数据叫union,里面可以有很多种不同的基本类型,但是整个变量只取最长的那种类型,比如:
union data{ int n; char ch; short sh; };
这里有3个变量,最长的是int,有4字节,那么data的长度也是4字节,选取不同变量本质上是读取不同长度的数据;比如data d; d.n就读4字节;d.sh就读2字节;d.ch只读1字节!这就是union的本质!
3、上面铺垫了这么多,现在开始实际测试;
(1)先来个简单点的:
#include <iostream> int main() { unsigned short int var1 = 1, var2 = 65537; if(var1== var2) { printf("溢出"); } return 0; }
这段代码定义了两个数,然后判断这两个数是否相等。如果相等就答应溢出,否则直接退出。代码非常简单,一般人看了第一反应就是不相等,然后程序直接退出,实际效果是这样的么? 我们来看看结果:
在控制台居然打印了“溢出”,说明var1==var2是成立的,是不是大跌眼镜啊?
继续分析:既然cpu只能识别和计算二进制的数,那么我们先把1和65537转成二进制看看了!看到了吧,这里的数据类型是unsigned short,只有2字节,超过2字节的部门丢掉;如果只取2字节的长度,1和65537的二进制表示确实是一样的,所以if条件是成立的!
(2)继续:先定义一个4字节的整型,再强制付给2字节的short和1字节的char:
- 由于后面两个长度不够,只能从低位取对应长度的数据;
- %d表示以有符号整型打印,所以s和c高位不够4字节的用ff填充补齐;
- s+c的结果其实是0x1FFFFDC74,但是最高位的1并未打印出来;因为%x只打印unsinged int类型,最多打印4字节!
- sizeof(s+c)*8为啥是32,不是24了? s是2字节,c是char,不是应该只有1字节么? 这就涉及到内存对齐的概念了!32位下,内存是4字节对齐的,这么做可以让cpu快速读写数据。char只有1字节,但还是会占用2字节,和short拼凑成4字节;cpu只需要读内存1次就能取出s和c两个变量!
(3)继续:这里先定义一个有符号的4字节整数i,既然是有符号,那么最高位就是符号位,能用来表示数值的只剩后面的31位了。所以4字节有符号的最大数是2^31-1=2,147,483,647(这里减1是去掉0); 用二进制表示就是0111 1111 1111 1111 1111 1111 1111 1111; 这个数加1后变成了1000 0000 0000 0000 0000 0000 0000 0000,这是二进制的表示方式,转成10进制后是多少了?
这里用计算器看看:和C程序打印出来的一样,只不过没负号。这就奇怪了,最高位是符号位,1表示负数容易理解。剩下31位都是0,为啥转成10进制就变成了-2147483648?
现在用的这个数太大了,为了便于理解,这里以8bit的长度举例: 00000000到01111111,表示0到+127。10000001到11111111,表示-1到-127。大家可以注意到,10000000我们没有用到。因为如果我们把它看成-0,那么会和00000000发生重复。于是人为将10000000定义为-128(即在最终进位后符号位不产生进位)。所以结果是 -2147483648 完全是人为定义的,和cpu没关系!
(4)上面是有符号的例子,4字节最大数值+1变成负数,最小负数-1变成最大正数!因为涉及到符号位,有些细节不好理解,下面拿无符号数做个测试,会容易很多!4字节无符号数最大值是2^32=4,294,967,295;32bit全是1;二进制加1后产生进位,CF=1,第33bit是1,1~32bit全是0了,但打印时%u只取4字节,并且按照无符号数打印,所以结果就是0了!
反过来,减1后,第33bit被借位后变成0,1~32bit都变成1,打印时%u取低4字节,结果就是4,294,967,295;
4、上面举了那么多实例,看起来可能有点乱,总结概括起来就这么几点:
- 有符号数最高位溢出,0和1之间互相变,导致正数和负数之间来回互相变化;
- 无符号数因为数据类型长度的限制产生了截断,在最大和0之间互相来回变化;
应用场景距离:
(1)绕开长度检测;下面这个例子:我们在做字符串的copy、cat前一般都会先检查字符串长度,超长的会采取措施;但这里的例子成功绕开了长度检测,如下: len是int类型,人为赋值2147483648,普通人一看这个数,第一反应比80大,if条件肯定成立,结果了?大跌眼镜,if条件不成立,长度检查被成功绕过!
去年windows下爆出了一个漏洞:https://www.cnblogs.com/theseventhson/p/14004712.html 就是对外部接收到的字符串没有正确地处理导致的!
上面用符号数表示数据长度,输入负数后导致检测失效;同理,用unsigned int类型存储字符串长度同样能想办法攻击,如下:
size_t len; // int len; char* buf; char* fd; len = 4294967296; buf = (char *)malloc(len + 5); memcpy(fd, buf, len);
开发人员的本意是让数据发送方提供字符串的长度,根据对方提供的长度再+5字节分配缓冲区的长度,然后向缓冲区写数据;这里的len是unsigned int类型,如果输入4,294,967,296,再+5变成了4,294,967,301;但这里是unsigned int类型,只取4字节,那么导致buf长度仅仅是4字节(产生了回绕),下面的memcopy极有可能导致缓冲区溢出攻击;
总结:
1、cpu只能识别和处理二进制数;硬件方面,算术运算只实现了二进制的加法,其他3种运算都是人为想办法转换编码和绕行实现的;
2、cpu一点都不聪明,只不过速度非常快罢了,才能完成人脑短时间内完不成的计算!
3、整数溢出常见的利用场景:
- 缓冲区溢出,让程序跳转到我们指定的地方运行
- 金融机构超出余额转账,案例:https://blog.noneage.com/%E4%BB%A5%E5%A4%AA%E5%9D%8A%E6%99%BA%E8%83%BD%E5%90%88%E7%BA%A6%E6%BC%8F%E6%B4%9E%E5%AE%9E%E6%88%98%E8%AF%A6%E8%A7%A3%EF%BC%9A%E6%95%B4%E6%95%B0%E6%BA%A2%E5%87%BA%E6%94%BB%E5%87%BB/
- 让对方分配超大内存作为缓存空间,导致死机
- 客户端程序发送数据改数值:比如网游改攻击力、魔法值;电商改金额、数量等;
4、本质上:整数溢出就是“物极必反”,通过赋一些临界值,让有符号数的正负数之间、无符号数在最大和0之间互相转换;
5、本文总结如下:
参考:
1、https://www.cnblogs.com/flashsun/p/9621986.html 从0开始自制CPU
2、https://bbs.pediy.com/thread-254269.htm 经典整数溢出漏洞演示
3、https://www.bookstack.cn/read/CTF-All-In-One/doc-3.1.2_integer_overflow.md 整数溢出
4、https://cloud.tencent.com/developer/article/1176463 printf详解
5、https://zhuanlan.zhihu.com/p/71025065 C++整型上下限INT_MAX INT_MIN及其运算
6、https://www.cxybl.com/2020/jisuanjijichu_1106/3508.html 波音787 每248天重新启动一次Dreamliner,以避免整数溢出