神祕程式碼
大家好 我是周杰倫
今天給大家看個有意思的東西!
不僅有意思,還能學到知識。
話題從兩行(準確的說是一行)神奇的程式碼聊起:
#include <stdio.h>
int main[] = { 232,-1065134080,26643,12517440,4278206464,12802064,(int)printf };
這是一段C++程式碼,猜猜看編譯執行後,會輸出什麼?
可能,你會問:這TM連main函式都沒有,能編譯成功?
還真能!
我們們分別在Windows平臺下的Visual Studio和Linux平臺下的 g++ 進行編譯,然後分別執行看看效果:
Windows下:
Linux下:
不僅能編譯成功,還能正常執行,在Windows上輸出了一個MZ,在Linux上輸出了一個ELF。
熟悉PE檔案格式的同學可能知道,MZ是PE檔案開頭的標誌,另外,ELF也是Linux上的可執行檔案開頭的標誌。
也就是說:上面這行程式碼執行後,把所在可執行檔案頭部的字串給列印出來了!
反彙編真相
看到這裡,你可能有兩個問題:
- 為什麼沒有main函式還能通過編譯?
- 為什麼會輸出這麼一串資訊?
對於第一個問題,相信大家應該也猜到了個八九不離十。雖然程式碼中沒有main函式,但是有一個main陣列啊!會不會跟它有關係?
是的沒錯,對於編譯器而言,函式也好,變數也好,最終都處理成了一個個的符號Symbol,而編譯器並沒有區分這個符號是來自一個函式還是一個陣列。所以,我們用一個main陣列,騙過了編譯器。
也就是說:編譯器把main陣列當成了main函式,把main陣列中的資料當成了main函式的函式體指令。
而要回答第二個問題,那就得看下這個main陣列中的這一段奇怪的數字,到底是一段什麼樣的程式碼?
將main陣列中的數值轉換成16進位制看看,按照一個int變數佔4個位元組對齊:
再進一步,使用反彙編引擎看看這段16進位制資料是什麼指令?
接下來,我們們逐條分析這些指令。
call $+5
這是一條非常重要的指令,請記住:call指令是在執行函式呼叫,執行call指令的時候,會將下一條指令的地址壓入執行緒的棧頂,用於函式返回時取出找到回去的路,那下一條是誰?就是下面的pop eax這條指令,所以執行這個call指令時,會把下面那個pop eax指令的地址壓入棧頂。
再者,call後面的目標地址是$+5,也就是這條call指令地址+5個位元組的地方,同樣是下面那條pop eax指令的地址,所以call的目標函式就是緊接著的下面pop eax指令開始的地方。
那這麼費勁執行這個call $+5的意義何在?其實就是為了獲取當前這段程式碼所在的記憶體空間地址,但是又沒有辦法直接讀取指令暫存器EIP的值,所以藉助一個call,把這段程式碼的地址壓入到堆疊中,隨後再取出來就能知道這段程式碼被放置在記憶體中哪個地址在執行了。
這個手法,是黑客編寫shellcode的慣用伎倆。
pop eax
注意,執行到這裡的時候,執行緒的棧頂存放的就是這條指令所在的位置,是上面那條call指令導致的結果。
接著,pop eax,將棧頂存放的這個地址取出來,放到eax暫存器中。現在eax中存放的就是當前指令的記憶體地址了。
add eax, 13h
上面費這麼大勁拿到了這個地址有什麼用呢?別急,看這條指令,給它加了13h,也就是十進位制的19,回頭看看main陣列那個十六進位制位元組表,加了19後,正好是main陣列最後一個元素所在的位置——裡面存放了printf函式的地址。
所以,截止到這裡,前面這三條指令的目的就是為了能拿到printf函式的地址。
push 400000h↵↵拿到printf函式以後,開始呼叫。這裡給printf傳了一個引數:0x00400000,也就是要列印的字串地址。
mov edi, 400000h↵↵這裡同樣是在給printf函式傳參,這裡和上面那條,一個通過堆疊傳參,一個通過暫存器傳引數,是為了同時相容Windows平臺和Linux x64平臺上的函式呼叫約定。
而之所以傳遞的字串地址是0x00400000,是因為剛好,這個數字是兩個平臺上可執行檔案載入的預設基地址。
Windows:
Linux:
(gdb) x /16c 0x00400000
0x400000: 127 '\177' 69 'E' 76 'L' 70 'F' 2 '\002' 1 '\001' 1 '\001' 0 '\000'
0x400008: 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000'
call dword ptr [eax]
還記得前面eax儲存的是main陣列的最後一個格子的地址,這個格子裡面存放的是printf函式的地址。
於是,通過一個指標呼叫call,來呼叫printf,完成列印輸出。
pop eax
函式呼叫完了,得進行堆疊平衡,前面傳參壓棧了,這裡就得彈出來。
retn
注意這個retn指令,retn指令和call指令對應,call用於呼叫函式,將返回地址壓棧,而retn指令則將棧頂的資料彈出來作為返回地址,跳回去執行。
還記得嗎,現在這段程式碼是處於被第一個call指令呼叫的上下文中的,正常情況下,執行retn是不是應該返回到call指令後面?那豈不是又回去pop eax走一遍亂了套了?但注意,現在棧頂的那個返回地址已經提前被pop出來了(第二行那個pop eax),那現在執行retn,取出來的棧頂資料又是什麼呢?
這個資料就是執行緒執行到整個main函式最開始的時候,棧頂保留的呼叫main函式的呼叫者的返回地址。所以這個retn不是返回到第一個call後面,而是返回到了上一級呼叫main函式的的那個地方。
至於具體是誰在呼叫main函式,這就不是這篇文章的重點了,屬於Linux和Windows上各自的C/C++執行時庫CRT函式的範疇。
到這裡,你應該就能明白,這個程式是如何執行起來的,以及,為什麼會有那樣的輸出資訊。
幾個注意事項
- 首先,為了能夠順利通過編譯,在Linux上,需要使用 g++而不是gcc進行編譯,因為對main這個全域性變數初始化時,C語言規定必須是常量,而不能是動態確定的(最後那個printf函式地址就是動態的),同時還得加上-fpermissive 編譯選項。
- 需要關閉模組的隨機載入功能。現代作業系統為了抵抗安全攻擊,可執行檔案的載入基地址都進行了隨機化,防止被猜測,而這段程式碼能夠正常執行的前提是可執行檔案載入基址是0x00400000。不能隨機化,所以需要通過編譯器來關閉。
- 最後,根據前面的分析其實也知道了,其實程式把main陣列中的資料當成了程式碼在執行。在現代作業系統的安全性保護下,預設情況下是拒絕執行資料所在的記憶體頁面的,因為這些記憶體頁面只有讀寫許可權,而沒有可執行許可權,這一安全機制叫DEP/NX。所以為了正常執行,需要把這個關閉。對於g++,新增-z execstack 編譯選項即可。
總結
其實這段程式碼的思路並非我的原創,在國外有一個國際C語言混亂程式碼大賽(IOCCC, The International Obfuscated C Code Contest)。這個比賽的特點就在於寫最騷的程式碼,實現最奇葩的效果,其中就有這樣的獲獎案例。
後來,國內一個大牛也原創了自己的版本,參考連結:
https://blog.csdn.net/masefee...
不過,這個版本僅適用於Windows平臺,我在此基礎之上,又改了現在這個版本,同時支援Windows和Linux平臺。
這段程式碼本身沒有任何意義,不具備實用價值,但透過程式碼去研究程式碼和程式背後執行的底層原理,瞭解CPU如何呼叫函式、傳遞引數,跳轉,操作堆疊,這些才是這篇文章的意義所在。
給大家留個思考題,下面這行程式碼能正常執行起來嗎,執行起來又做了什麼呢?
int main[] = {0xC3};