
聊到 pwn 入門很多人第一關就栽在整數安全上。原因很簡單這玩意兒表面看是“小學數學”實際上在 C 語言和內存模型的加成下處處都是反直覺的坑。CTFshow 的 Pwn 入門系列應該是不少新手接觸的第一套成體系題單100 分段走到 101-110主題正是整數安全Integer Security。這篇文章我想把這十道題背后的東西徹底拆開從整數在內存里怎么存、漏洞為什么會出現到每類題型的標準解題套路再到實際調試中容易踩的坑一次性講清楚。這套題適合什么人一句話總結已經會寫簡單棧溢出、能看懂基本反匯編但一見到unsigned int、size_t、類型轉換就開始頭暈的新手。刷完 101-110你對“整數”這件事會有完全不一樣的敏感度后面再看堆題、看 glibc 源碼也會輕松不少。1. 先看整體整數安全考什么難點又在哪1.1 “整數安全”不等于“整數溢出”很多人第一次聽到“整數安全”會以為這就是溢出攻擊。其實范圍比溢出大得多。CTF 里常說的整數安全包括了四類問題整數溢出Integer Overflow有符號或無符號整數在加減乘除時超出類型邊界發生回繞。整數截斷Integer Truncation把一個較大類型窄化為較小類型高位被丟棄。有符號與無符號混用Signed/Unsigned Confusion比較或傳參時隱式轉換導致檢查被繞過。除法與取模的邊界問題除零、取模符號、最小值除以 -1 等非典型場景。為什么入門階段要把它們歸成一個專題因為這幾類漏洞本身幾乎不涉及復雜的利用鏈通常只需要你理解“整數在內存里是按補碼存儲、運算會發生回繞”這兩條鐵律就能直接構造出繞過或改寫內存的攻擊。它們很適合作為 Pwn 的基礎能力訓練讓你把注意力放在“數據如何被解釋”上而不是一開始就陷入 ROP、堆布局這些復雜構件。1.2 為什么這個系列放在棧溢出之前或之后都有道理CTFshow 的 Pwn 入門序列里100 分段前面大概講的是簽到、nc 連接、簡單的重定向等熱手內容101-110 扎堆出整數安全說明出題人是故意在這個時間點把“類型敏感度”立起來。我之前在帶人入門的時候也發現一個規律直接教棧溢出很多人都能照著 exp 模板跑通但一旦題目的漏洞點從strcpy變成read的長度由用戶控制八成新手會卡住——因為read的第三個參數是size_t無符號 64 位你輸入 -1它會變成一個天文數字。所以整數安全本質上是“更底層的判斷力訓練”。它要求你不能再照著腳本抄而是真正明白代碼里寫的是int還是unsigned、比較時有沒有隱式轉換、賦值時有沒有截斷這些細節最終會影響內存讀寫范圍。2. 開刷之前工具鏈、環境與讀題姿勢2.1 從 CTFshow 題庫找到專題入口CTFshow 的題庫頁面一般按分類展示Pwn 入門分類下會有若干個小節。找到“整數安全”或直接按題號 101-110 搜索即可。每道題部署成一個獨立的容器頁面會給你一個nc xxx.xxx.xxx.xxx port形式的連接地址。這個設計對新手很友好不用自己搭靶機遠程就是一個真實運行的程序。但要注意CTFshow 很多題目的容器帶時限比如做題 10-30 分鐘會自動關閉所以拿到地址后先把 exp 寫清楚再開容器避免浪費激活次數。2.2 本地工具鏈少一樣都難受遠程容器只能用來最終驗證真正高效的做法是在本地把題目跑一遍動態調試。一個完整的 Pwn 入門環境大致包括Python 3 pwntools寫 exp 的主力連接、發送、接收、地址封裝都靠它。gdb pwndbg 或 peda動態調試必需pwndbg 在查看棧布局和寄存器方面比原版 gdb 直觀太多。IDA 或 Ghidra靜態逆向。入門題用 IDA 看偽代碼最快Ghidra 免費但對新手信息量略大。checksec查看二進制開啟的保護。pwntools 自帶的checksec命令就夠用。裝好之后我建議把checksec和file當成本能動作。拿到一個二進制第一件事就是file pwn101 checksec pwn101file告訴你架構和位數checksec告訴你有沒有棧保護Canary、有沒有 PIE、NX 開沒開。這些信息直接決定你的利用方式。比如這十道題里絕大多數題目為了降低難度可能不會開 PIE甚至不會開 Canary于是你只需要關注棧溢出的偏移和關鍵變量的相對位置。2.3 關鍵讀題姿勢先看“長度”和“類型”面對這類題目我建議有意識地訓練自己“只看兩樣東西”哪個變量是由用戶輸入決定的這個變量是什么類型它被用在什么位置舉例來說如果看到這樣的偽代碼unsigned int size; char buf[64]; read(0, size, 4); read(0, buf, size);那么你已經找到了漏洞size是unsigned int如果我們輸入0xFFFFFFFFread會嘗試往buf寫入 4 GB 數據直接溢出棧。但更多時候題目會加一個檢查if (size 32) exit(0); char buf[32]; read(0, buf, size);這時候就要看size的類型。如果size是int我們輸入負值比如-1檢查size 32不成立但read的參數要轉換成size_t-1 變成0xFFFFFFFFFFFFFFFF照樣炸棧。這就是典型的有符號/無符號轉換繞過。101-110 里這一類變體重復出現了好幾次練到最后你看到“檢查 拷貝”的組合就應該本能地先檢查類型。3. 核心原理拆解整數漏洞的四大模式與出題變體3.1 模式一有符號/無符號混用繞過長度檢查這是整數安全里最常見的一類。C 語言有一條隱式轉換規則當有符號類型和無符號類型同時出現在表達式里時有符號類型會被轉換為無符號類型。這意味著int n -1; unsigned int m 10; if (n m) { // 不會進入 } if ((unsigned int)n m) { // 會進入因為 -1 轉為無符號后等于 4294967295 }在 CTF 里這個“比較陷阱”通常出現在“先檢查、后拷貝”的代碼里。攻擊者輸入一個負數讓檢查失效然后這個負數在傳給read、memcpy、strncpy這類函數時被隱式轉換成size_t或unsigned int變成巨大無符號數從而造成超長寫入。對付這類題你要做的就是在 exp 里手動把 -1 封裝成對應位數的字節。pwntools 提供了很順手的方法# 32 位程序 payload p32(-1) # 等同于 p32(0xFFFFFFFF) # 64 位程序 payload p64(-1) # 等同于 p64(0xFFFFFFFFFFFFFFFF)3.2 模式二整數截斷大數變小、高位丟失整數截斷發生在寬類型向窄類型賦值時。比如unsigned int a 0x100; unsigned char b a; // b 0x00b只保留低 8 位高位全部丟棄。這類漏洞在出題時經常偽裝成“長度可控的拷貝”。典型的偽代碼是這樣unsigned short size; char buf[16]; size get_user_input(); read(0, buf, size);看起來size最大也就 65535寫入 16 字節的buf會導致棧溢出。但很多新手會疑惑這算什么整數漏洞其實真正危險的是中間還藏了一層“轉換”。例如從int轉成unsigned short時輸入負數會產生意外的小數字或大數字。假設size是short類型輸入 -1它的內存內容是0xFFFF如果后續被當成unsigned short傳給memcpy長度是 65535同樣溢出。我在做這一組題目時最大的感受是不要用“常識”判斷變量的值要用“位模式”判斷。C 語言里類型只是一層解釋實際內存里就是那 2 個字節或 4 個字節。你輸入一個數最后傳到危險函數里的是這個數的位模式被解釋成目標類型后的值。3.3 模式三乘法與加法溢出改寫關鍵變量這十道題里也出現了乘法溢出。比如分配或拷貝大小由兩個輸入相乘得到int len get_int(); int count get_int(); char *dst malloc(len * count); read(0, dst, len * count);如果len * count溢出回繞為一個小正數甚至負數但read的第三個參數會被轉換成size_t配合前面說的符號問題可以造成堆溢出或棧溢出。經典場景是malloc(len * count)分配了很小的空間后續寫入卻使用巨大的len * count或直接使用count作為長度。這個模式在高版本的 glibc pwn 里也很常見體會它的意義不只是過題。這類題在 exp 里的計算就要特別小心Python 的整數是任意精度不會自動溢出。你要手動對自己想要產生的溢出結果進行處理。例如想讓兩個 32 位數相乘后在 32 位下等于 0x100 這個很小的值那么先找兩個大整數使它們的乘積在模2^32意義下等于 0x100。具體可以這樣表達import ctypes len_value 0x10001 count_value 0x10001 result (len_value * count_value) 0xFFFFFFFF # 模擬 32 位溢出截斷 print(hex(result)) # 輸出 0x1這種手動截斷在你寫復雜一點的多階段 exp 時幾乎必用。3.4 模式四類型提升與符號擴展的隱形坑還有一個萬金油知識點C 語言里char、short在參與運算前會先提升為int。這個規則叫整數提升Integer Promotion。大多數人只是背過結論但沒意識到它會引發漏洞。例如char a 0xFF; // a 實際為 -1 if (a 255) { // 不會進入 } if ((unsigned char)a 255) { // 會進入 }因為a 255時a先被提升為int值為 -1而 255 是int兩者比較為假。這類問題在 CTF 中往往和“數組下標”一起出現一個負數通過類型提升變成了很大的索引值從而讀到越界數據。101-110 里有一兩道題會在這里設卡需要你仔細看反編譯后偽代碼里變量被強制轉換成了什么類型。我遇到這類題時的經驗是不要相信偽代碼里顯示的類型一定要回到匯編看movzx還是movsx指令。movzx表示無符號擴展movsx是有符號擴展這決定了數值是 0x000000FF 還是 0xFFFFFFFF。4. 題型拆解與通用 exp101-110 的完成姿勢4.1 先給一個通用的連接模板不管十道題具體是什么樣子解題流程大方向是一致的連上遠程程序 → 輸入構造數據 → 觸發漏洞 → 拿到 shell 或讀到 flag。一個貼合這套題的 pwntools 通用模板如下from pwn import * context.arch amd64 context.log_level debug # 遠程容器地址按題目填寫 r remote(host, port) # 本地調試時可以改成這樣 # r process(./pwn101) # 接收提示信息 r.recvuntil(bPlease input something:) # 構造 payload整數溢出點在這里 payload ba * 32 # 覆蓋到關鍵變量之前 payload p32(0xFFFFFFFF) # 關鍵變量賦值為 -1 payload bb * 8 # 繼續覆蓋返回地址前的區域 r.sendline(payload) r.interactive()刷題初期我強烈建議暴力把context.log_level debug打開。這樣發送和接收的數據都會以十六進制打印出來便于你判斷是不是字節序弄反了。4.2 前三題熱身級別的“檢查繞過 直接讀 flag”101-103 的難度梯度其實很溫柔大概率是同一套模板反復練程序先讀入一個長度做一次不嚴格的大小檢查然后read進固定棧緩沖區。有的題會直接在后門函數里 cat flag有的題則需要你覆蓋返回地址到 get_flag 函數。解題步驟可以先這樣走checksec看保護確認棧上有沒有 canary、PIE 是否開啟。用 IDA 或 Ghidra 找危險函數確定檢查變量的類型。靜態算棧偏移或動態用 pwndbg 的cyclic/cyclic -l確定覆蓋返回地址的偏移量。構造 payload關鍵位置填特殊字節讓檢查失效。本地通遠程驗證。這類題的“特殊字節”一般就是p32(-1)或p64(-1)。你不需要真的取一個天文數字填進去用 -1 的位模式就夠了。我第一次刷這個系列時最大的誤區是試圖手工算“溢出后的值”浪費不少時間——實際上你只要把-1的位模式填進去C 語言自然會在比較時按無符號轉成最大值。4.3 中間四題截斷、乘法溢出開始上臺103-107 左右題目開始混合“多個小漏洞點”。比如一個程序既有整數截斷又有棧溢出或者先做一次 malloc 大小計算再用用戶可控的長度 memcpy。刷到這里你需要習慣使用本地調試來看真實的內存內容。舉一個常見的出題方式int size read_int(); char v[16]; if (size 16) { read(0, v, size); }如果size是int你輸入 -1size 16成立但read拿到無符號參數后是 18446744073709551615直接打穿棧。這類題本質上和前面一樣但寫法上會更隱蔽。你要多長個心眼只要一個變量同時被“比較”和“作為長度”立刻警覺。我在這個階段發現的另一個高頻坑是題目里的read可能不是一次讀入你想要的所有數據。比如第一段調用read(0, size, 4)第二段調用read(0, buf, size)。因為都在標準輸入上你要用send分開發送而不是sendline一把梭。用sendline會多塞一個換行符可能導致第二段read的第一字節變成0x0a打亂布局。正確做法是r.send(p32(-1)) # 第一段給 size payload ba * 0x20 p64(win_addr) r.send(payload) # 第二段給棧數據4.4 最后幾題綜合利用與題型內卷108-110 會稍微升級比如把整數漏洞藏在循環、數組索引、甚至格式化字符串的寬度參數里。我的經驗是先不急著上 exp把偽代碼整體讀三遍標注出每一個“用戶可控變量”和“它的類型”。如果看到snprintf(buf, len, fmt, ...)這類函數也別忽略len的來源。格式化字符串的寬度字段如果來自整數溢出同樣可以導致任意棧內存泄漏或寫。到了這一步你實際上已經掌握了刷后續題目最重要的能力讀代碼時先給變量“標類型”。4.5 exp 里最容易翻車的三個地方第一字節序。pwntools 的p64默認是小端。x86/amd64 都是小端機器所以寫入時低位在前。如果你要把一個 64 位地址0x400500發送出去應該是p64(0x400500)它會自動轉成\x00\x05\x40\x00\x00\x00\x00\x00。新手很容易拿著字符串拼地址直接拼成 ASCII導致地址完全錯誤。第二發送長度。sendline和send的差別前面提過如果程序用的是read優先用send如果程序用scanf或者gets則用sendline。第三覆蓋返回地址前的棧內容可能會影響程序運行。如果你多覆蓋了幾個字節程序 pop 返回地址后會連帶著把后面幾個棧數據也 pop 掉運氣不好會段錯誤。所以能用多少就覆蓋多少不要貪多。5. 實操中常踩的坑與調試技巧5.1 最常見的問題速查我在帶人刷這套題時收集了一個高頻問題的清單這里直接整理成表格現象可能原因解決辦法本地打不通直接段錯誤覆蓋了非法棧數據或返回地址不對重新計算偏移確認返回地址真實存在去掉多余覆蓋recvuntil卡死程序輸出和預期不一致或者沒開 debug 日志打開context.log_leveldebug觀察實際字符串整數溢出后輸出了巨大地址Python 不會自動像 C 一樣回繞用 0xFFFFFFFF或 0xFFFFFFFFFFFFFFFF手動截斷開了 PIE 后地址對不上需要泄漏程序基址檢查是否存在可以泄漏內存的題目點或換靜態地址利用發送 -1 時變成字符串“-1”直接調用了sendline(b-1)而不是send(p32(-1))確認該位置期望的是數字還是字節字節就用 p32/p64服務器不回復容器可能已關閉或 payload 導致程序提前崩潰重新部署容器逐步調試到崩潰前一步5.2 調試三板斧gdb 里怎么定位“什么時候溢出的”面對這種“檢查被繞過”的題目新手最大的困惑是明明知道有整數漏洞但不知道該在哪里下斷點。我的習慣是三步走第一步先在調用危險函數處下斷點。比如反編譯看到read(0, buf, size)就在read的調用處斷下。第二步用i r rdi rsi rdx查看三個寄存器rdi通常是文件描述符rsi是緩沖區地址rdx是長度。當你看到rdx是0xffffffffffffffff這種值就說明溢出確認了。第三步單步到read返回后觀察棧上的返回地址是否已經被覆蓋。用 pwndbg 的search或stack命令查看rsp附近的數據。這套流程對 101-110 基本是通殺。它要比你在頭腦里推演十遍都管用因為你能直接看到攻擊者的輸入是如何落到棧上的。5.3 本機通、遠程掛一半是環境問題一半是習慣問題很多新手刷到后期會碰上“本地一把過遠程打不了”的玄學現象。排除容器關閉后最常見的兩個原因一是 glibc 版本不同。遠程容器如果基于 Ubuntu 22.04glibc 版本較高棧偏移、地址布局都可能和本地的 Ubuntu 18.04 不一致。這種情況可以嘗試用題目給的常見 libc 包做本地調試或者改用不需要 libc 偏移的利用方式比如直接 ret2win。二是輸入里帶有換行。遠程的交互邏輯可能和本地有一點差別尤其當程序第一段read只讀了固定長度時多發的換行會讓后續讀取錯位。建議把本地腳本改成和遠程完全一樣的交互習慣不要順手加\n。6. 刷完 101-110 之后一點心得和下一步這十道題做完你獲得的最核心能力不是“會寫某個 exp”而是建立了對 C 語言類型系統的敏感性。以后再看到unsigned、size_t、各種類型的賦值和轉換你會下意識思考“這里會不會被繞過”。這種意識比任何具體漏洞利用技巧都值錢因為堆題里到處都是長度和類型轉換格式字符串里也有寬度控制邏輯漏洞更是在和類型檢查捉迷藏。我個人刷這套題時印象最深的一點是真正的難點反而不是溢出本身而是“意識到這里有個整數轉換”。偽代碼一頁頁翻下來每個變量都老老實實待在自己的位置沒有任何顯眼提示你只能靠經驗和對位模式的感覺去抓它。如果你剛刷完 101-110下一步我建議按這個順序繼續先刷棧溢出的入門擴展題把 ROP 基礎補上接著做格式字符串專題感受任意讀寫再往后就是堆題從 tcache 基礎開始。整數安全作為地基已經打牢后面的路會順暢很多。最后再分享一個小技巧把每道題最后能跑通的 exp 都存下來命名成“題號漏洞模式”比如“104_mul_overflow.py”。過兩周回來翻一翻你會發現自己當時寫的腳本雖然能過題但很多地方可以優化這種對比往往是進步最快的時刻。