64ビットネイティブコード生成モジュールを作ってみよう
なんか、この連載では、おなじみになってきましたね。今回も負けじと64ビットバイナリを生成するプログラムを組んでみます。『x86系CPUのネイティブコードを解析する』で作成したソースコード(ExeCreator)をベースに、コーディングを行います(ここでは新しいプロジェクト名を「ExeCreator64」としています)。
ただし、一つだけ注意点があります。それは64ビットバイナリが動作する環境がないと、実際に生成されたEXEファイルの動作確認が取れないこと(当たり前ですが)。裏を返せば、64ビット版Windowsをお持ちの方には、ぜひとも試してもらいたいサンプルプログラムです。
EXEのPEヘッダ生成部分の変更点
64ビット版のEXEファイルは、内部のコードセクション(.text)の内容が、AMD64対応のネイティブコードになっただけではありません。当然ながら、PEヘッダにおけるポインタ周りのデータは、32ビットから64ビットへ拡張がなされています。
ネイティブコードに関する話題に入る前に、これらPEヘッダに関する部分の生成コードが、以前のExeCreatorのソースコードとどのような部分で違うのかを紹介していきます。
まず、IMAGE_NT_HEADERS
構造体がIMAGE_NT_HEADERS64
という名前に変化します。IMAGE_NT_HEADERS64
構造体の中にはIMAGE_OPTIONAL_HEADER64
構造体(オプショナルヘッダを保有)が含まれており、イメージベースの指定やスタック領域、ヒープ領域に関するメンバが64ビット化されています。メンバ変数が64ビット化されているだけですので、書き込む値の内容には変化がない場合がほとんどです。
マシン識別IDをIMAGE_FILE_MACHINE_AMD64
に変化させている部分に注目しましょう。
IMAGE_NT_HEADERS64 ImagePeHdr;
ImagePeHdr.Signature=IMAGE_NT_SIGNATURE;
//マシンタイプ
ImagePeHdr.FileHeader.Machine= IMAGE_FILE_MACHINE_AMD64;
オプショナルヘッダが64ビット化されたことで、当然のように構造体のサイズにも違いが生じます。また、その直下で指定するEXE特性やマジックナンバーも下記のコードのように変化しています。
EXE特性に関しては、32ビットコードであることを示すIMAGE_FILE_32BIT_MACHINE
フラグが省かれると同時に、2GB以上のメモリ空間を利用可能にするためのIMAGE_FILE_LARGE_ADDRESS_AWARE
フラグが追加されます。
ImagePeHdr.FileHeader.SizeOfOptionalHeader= IMAGE_SIZEOF_NT_OPTIONAL64_HEADER; ImagePeHdr.FileHeader.Characteristics= IMAGE_FILE_EXECUTABLE_IMAGE| IMAGE_FILE_LARGE_ADDRESS_AWARE; ImagePeHdr.OptionalHeader.Magic= IMAGE_NT_OPTIONAL_HDR64_MAGIC;
この他、IMAGE_OPTIONAL_HEADER64
構造体で注意すべき点と言えばBaseOfCode
メンバが省かれ、そこを埋めるかのようにImageBase
メンバが64ビット化されている部分が挙げられます。PEヘッダのフォーマットは昔からのものなので、できれば崩したくないという意図が見え隠れしているような気がします(筆者の考えすぎでしょうか)。
以上のように、PEヘッダ周りはサイズに気をつければ特段問題ありません。とはいっても、当然ながらミスはつきもの。うまくいかない場合は、「dumpbin.exe」を用いてPEヘッダが正常に書き込まれているのかをチェックしてみるのも一つの手です。ファイルの書き込み部分で、データ自体は64ビット化されていても、書き込みバッファサイズが32ビットのままだったといった事態も考えられます。デバッグ不可能なPEヘッダ周りに関して、「dumpbin.exe」は最強の威力を発揮するものだということを覚えておきましょう(筆者は何度も助けられています)。
インポートセクション生成部分の変更点
インポートセクション(.idata)に存在するルックアップテーブルおよびインポートテーブルが64ビット化されている点に注意が必要です。
機械語コーディングの規則
x86からAMD64への変化において、機械語フォーマット自体の規則は高い互換性が保たれていますが、そのデータの内容に多少なりとも変更点があるようです。筆者が確認しただけで、下記のような違いが生じています。このような細かい部分を知っておくだけでもデバッグの回数が減るので、ぜひともおさえておきたい内容です。
call命令の32ビット値
call
命令に引き渡す32ビット値(インポートアドレステーブル内のデータを示す)が「RVAからのオフセット」から「実行中アドレスからのオフセット」に変更されています。
push/popは事実上使われない!?
VC++を利用してAMD64対応のネイティブコードを生成すると確認できるのですが、x86の環境では多用されているpush
とpop
の出現率が著しく低下します。筆者が確認した限りでは、一回も出てきませんでした。ではpush
/pop
はどのような命令に置き換わっているのでしょうか。
x86では、
push rax pop rax
とやるところをAMD64では、
mov qword ptr[rsp+offset],rax mov rax,qword ptr[rsp+offset]
とするのが正しいようです。このmov
命令の前後で
sub rsp,frame_size ;モジュールとしての何らかの処理 add rsp,frame_size
という具合にスタックフレームを確保します。
結局のところ、モジュールの開始部分で必要なだけrsp
を減算、終了部分でrsp
の値を元に戻すという方法でスタックフレームの確保を行っています。スタックの内容を読み書きしたいときは、rsp
からのオフセット値という形でmov
命令を呼び出すというのがAMD64の作法のようです。
こうすることにより、rbp
(ベースポインタ)の出番が無くなる(=緊急用の汎用レジスタが増える)ことや、rsp
の加減算を行わなくて良いため、必要なクロック数が減るなどのメリットが生じているのだと予測します(あくまでも筆者の予測ですので、本当のところは分かりません)。
パラメータ引渡しにレジスタを利用
x86で言うfast_call
と同様の方式ですが、AMD64では第1パラメータ~第4パラメータをrcx
、rdx
、r8
、r9
を介して引き渡すようになります。パラメータを引き渡すだけでメモリアクセスが生じることがなくなるので、速度的には大きなメリットになることと思います。第5パラメータ以降の値は従来どおり、スタックフレームに積まれます。
128ビット境界に気をつける
しばしば、DLL関数内でmovdqa
などの128ビット命令が呼び出される場合があります。このmovdqa
命令、rsp
からのオフセットをオペランドに指定することがあるのですが、そのときに指定したアドレスが128ビット境界でなければ強制終了してしまうという非常に困った自体に陥ります(さすがにどのDLLでこのような128ビット命令が呼ばれるのかは知る由もありません)。そこでモジュールの開始時と終了時にrsp
に対して加減算を行う際に、16で割ると8余る数をrsp
の値としておく必要があります。なぜ8で割り切れる値かと言うと、DLL関数をcallしたときに、ret用の処理の戻り先アドレスとして、rsp
が8バイト加減算されるためです。
ちなみに、これから出てくるサンプルではモジュールの開始時にsub rsp,88
という命令を実行しており、88バイト分のスタックフレームが利用可能になっています。これも88÷16=5(余り8)という法則にのっとっています。この部分を80などに変更してプログラムを実行してみると、上記の内容を直感的に理解できるかもしれません。
AMD64対応のネイティブコード生成
さて、気持ちを新たにしてAMD64アーキテクチャに対応するネイティブコードを生成すべく、「opcode.cpp」に手を加えていきます。やり方はx86のときと同じ。今回はREXプリフィックスに重点を置き、64ビット命令の有効化を行います。
まずは拡張されたレジスタの識別番号の定義から。REG_EAX
がREG_RAX
などという表記に変更している点にも注意しておきましょう。
//レジスタを示す定数 #define REG_NON -1 #define REG_RAX 0x00 //reg:000 #define REG_RCX 0x01 //reg:001 #define REG_RDX 0x02 //reg:010 #define REG_RBX 0x03 //reg:011 #define REG_RSP 0x04 //reg:100 #define REG_RBP 0x05 //reg:101 #define REG_RSI 0x06 //reg:110 #define REG_RDI 0x07 //reg:111 #define REG_R8 0x08 //reg:000(REXプリフィックス) #define REG_R9 0x09 //reg:001(REXプリフィックス) #define REG_R10 0x0A //reg:010(REXプリフィックス) #define REG_R11 0x0B //reg:011(REXプリフィックス) #define REG_R12 0x0C //reg:100(REXプリフィックス) #define REG_R13 0x0D //reg:101(REXプリフィックス) #define REG_R14 0x0E //reg:110(REXプリフィックス) #define REG_R15 0x0F //reg:111(REXプリフィックス)
下記のソースコードはREXプリフィックスを生成するための関数です。オペランドサイズ(op_size
)、各種指定レジスタが拡張レジスタ(r8
~r15
)であるかどうかなどでAMD64の拡張アーキテクチャが有効になる命令であるのかが判別され、適切なREXプリフィックスが生成される仕組みになります。もちろん、オペランドを識別して拡張する必要がなければ(x86でも通用する命令であれば)、この関数がREXプリフィックスを生成することはありません。
void set_rex(int op_size, int reg, int index_reg, int base_reg){ char RexByte; if(reg==REG_NON&&index_reg==REG_NON){ ///////////////////////////////////// // レジスタをr/mのみに指定するとき ///////////////////////////////////// if((base_reg&0x08)==0){ if(op_size==sizeof(char)&&(base_reg&0x04)){ // r/m に spl,bpl,sil,dilを指定するとき RexByte=0x40; } else RexByte=0; } else RexByte=(char)0x41; } else{ ///////////////// // 通常 ///////////////// if((reg&0x08)==0){ //reg … rax~rdi if((index_reg&0x08)==0){ if((base_reg&0x08)==0) RexByte=0; else RexByte=(char)0x41; } else{ if((base_reg&0x08)==0) RexByte=(char)0x42; else RexByte=(char)0x43; } } else{ //reg … r8~r15 if((index_reg&0x08)==0){ if((base_reg&0x08)==0) RexByte=(char)0x44; else RexByte=(char)0x45; } else{ if((base_reg&0x08)==0) RexByte=(char)0x46; else RexByte=(char)0x47; } } } if(op_size==sizeof(_int64)){ //64ビットオペランド RexByte|=0x48; } if(RexByte) NativeBuffer[iNativeLength++]=RexByte; }
次に、以前のソースコードにset_rex
関数の呼び出し手順を加えて、64ビット拡張された機械語を生成できるようにしてみましょう。
void __op_format(int op_size, char op_prefix, char opcode, int reg, int const_value){ //命令プリフィックス if(op_prefix) NativeBuffer[iNativeLength++]=op_prefix; //rexプリフィックス if(op_size!=-1) set_rex(op_size,REG_NON,REG_NON,reg); //オペコード、レジスタ NativeBuffer[iNativeLength++]=(char)(opcode|REGISTER_OPERAND(reg)); //即値 *((long *)(NativeBuffer+iNativeLength))=const_value; iNativeLength+=sizeof(long); } void __op_format(int op_size, char op_prefix, char opcode1, char opcode2, int reg, int base_reg, int offset, char mod){ //命令プリフィックス if(op_prefix) NativeBuffer[iNativeLength++]=op_prefix; //rexプリフィックス set_rex(op_size,reg,0,base_reg); //オペコード if(opcode1) NativeBuffer[iNativeLength++]=opcode1; if(opcode2) NativeBuffer[iNativeLength++]=opcode2; //ModR/M, SIB, disp set_mod_rm_sib_disp(mod,reg,SCALE_NON,INDEX_NON,base_reg,offset); } void __op_format(int op_size, char op_prefix, char opcode1, char opcode2, int reg, int base_reg, int disp, char mod, int const_value){ //命令プリフィックス if(op_prefix) NativeBuffer[iNativeLength++]=op_prefix; //rexプリフィックス set_rex(op_size,reg,0,base_reg); //オペコード if(opcode1) NativeBuffer[iNativeLength++]=opcode1; if(opcode2) NativeBuffer[iNativeLength++]=opcode2; //ModR/M, SIB, disp set_mod_rm_sib_disp(mod,reg,SCALE_NON,INDEX_NON,base_reg,disp); //即値 *((long *)(NativeBuffer+iNativeLength))=const_value; iNativeLength+=sizeof(long); }
この調子で、__op_format
関数の呼び出し部分も変更し、今回利用する一通りの命令生成モジュールを定義していきます。オペランドが64ビットであることを示す第1パラメータに気を配れば、特段注意すべき点はありません。
void op_mov_RV(int reg, int iData){ //mov reg,value __op_format(sizeof(_int64),0,(char)0xC7,0,0,reg,0,MOD_REG,iData); } void op_mov_RR(int reg1, int reg2){ //mov reg,reg2 __op_format(sizeof(_int64),0,(char)0x8B,0,reg1,reg2,0,MOD_REG); } void op_add_RR(int reg1, int reg2){ //add reg1,reg2 __op_format(sizeof(_int64),0,OPCODE_ADD_RR,0,reg1,reg2,0,MOD_REG); } void op_add_RV(int reg, int iData){ //add reg,iData int opcode_in_mod_rm=(char)0x00; __op_format(sizeof(_int64),0,OPCODE_ADD_RV,0, opcode_in_mod_rm,reg,0,MOD_REG,iData); } void op_sub_RV(int reg, int iData){ //sub reg,iData if(reg==REG_RAX){ //raxのみ特殊 __op_format(sizeof(_int64),0,(char)0x2D,0,iData); } else{ int opcode_in_mod_rm=(char)0x05; __op_format(sizeof(_int64),0,(char)0x81,0, opcode_in_mod_rm,reg,0,MOD_REG,iData); } } void op_push_R(int reg){ //push reg //オペコード、レジスタ __op_format(0,OPCODE_PUSH_R,reg); } void op_push_V(int iData){ //push 32ビット即値 __op_format(-1,0,OPCODE_PUSH_V,0,iData); } void op_call_M(long addr){ //命令プリフィックス int op_prefix=(char)0xFF; __op_format(-1,op_prefix,OPCODE_CALL_M,0,addr); } void op_ret(void){ //オペコード __op_format(OPCODE_RET_0); }
最後に、仕上げのネイティブコード生成のための処理を記述します(インラインアセンブラを書いているような錯覚が生じますが、気にしないでください)。
void create_native_buffer(void){ //ネイティブコードを生成 //sub rsp,88 //※スタックフレームの確保(wsprintfが_cdecl呼び出しのため、 // 予めパラメータ用の領域が必要) op_sub_RV(REG_RSP,88); //mov rax,10 op_mov_RV(REG_RAX,10); //mov rcx,20 op_mov_RV(REG_RCX,20); //add rax,rcx op_add_RR(REG_RAX,REG_RCX); //mov r8,rax op_mov_RR(REG_R8,REG_RAX); //mov rdx,dword ptr["10+20=%d"] op_mov_RV(REG_RDX,0x00403000); //mov rcx,dword ptr["0123456789abcdef"] op_mov_RV(REG_RCX,0x00403019); //call dword ptr[wsprintf in import_table] op_call_M(0x1043); //mov r9,0 op_mov_RV(REG_R9,0); //mov r8,dword ptr["64opcode test"] op_mov_RV(REG_R8,0x0040300A); //mov rdx,dword ptr["0123456789abcdef"] op_mov_RV(REG_RDX,0x00403019); //mov rcx,0 op_mov_RV(REG_RCX,0); //call dword ptr[MessageBoxA in import_table] op_call_M(0x1019); //add rsp,88 //※スタックフレームの解放 op_add_RV(REG_RSP,88); //ret 0 op_ret(); }
実行してみよう
ExeCreator64プロジェクトをコンパイルし、出来上がった「ExeCreator64.exe」を実行してみましょう。
x86のときと同様、生成された「C:\test.exe」を実行し、下記のメッセージが表示されれば成功です。「test.exe」は必ず64ビット版Windows上で動かしましょう。
64ビット環境が無くて「test.exe」を動作させることができないが、それでもAMD64対応のアプリケーションに興味があるという有望者は、生成された「test.exe」に対してdumpbinしてみましょう。ヘッダ部分だけでも、きちんと64ビット化されたことが確認できるはずです。
Dump of file c:\test.exe PE signature found File Type: DLL FILE HEADER VALUES 8664 machine (x64) 3 number of sections 449FE329 time date stamp Mon Jun 26 22:37:45 2006 0 file pointer to symbol table 0 number of symbols F0 size of optional header 2022 characteristics Executable Application can handle large (>2GB) addresses DLL OPTIONAL HEADER VALUES 20B magic # (PE32+) 4.00 linker version 3F size of code 2A size of initialized data 0 size of uninitialized data 1000 entry point (0000000000401000) 1000 base of code 400000 image base (0000000000400000 to 0000000000403FFF) ...
まとめ
AMD64の世界を十分に堪能していただけたでしょうか。筆者の想定では、今回が一番ディープな内容だと察しています。広まりつつあるとはいうものの、ソフトウェアが追いついていない現状を抱えるAMD64、今回紹介しきれなかったテクニック、まだまだ発見されていない技術が64ビットにはあるものと信じています。
次回はAMD64にも通じる部分である、SSE2について掘り下げていきます。