リバースエンジニアリングへの道
出田 守です。
最近、情報セキュリティに興味を持ち、『リバースエンジニアリング-Pythonによるバイナリ解析技法』という本(以降、「教科書」と呼びます)を読みました。
「こんな世界があるのか!かっこいい!」と感動し、私も触れてみたいということでド素人からリバースエンジニアリングができるまでを書いていきたいと思います。
ちなみに、教科書ではPython言語が使用されているので私もPython言語を使用しています。
ここを見ていただいた諸先輩方からの意見をお待ちしております。
軌跡
環境
OS: Windows10 64bit Home (日本語)
CPU: Intel® Core™ i3-6006U CPU @ 2.00GHz × 1
メモリ: 2048MB
Python: 3.6.5
私の環境は、普段Ubuntu16.04を使っていますが、ここではWindows10 64bitを仮想マシン上で立ち上げております。
ちなみに教科書では、Windowsの32bitで紹介されています。
独習逆アセンブラへの道 - その1 「逆アセンブラとは」
前回まで『Intel Software Developer's Manual』の1部をさっくりまとめてました。今回以降も『Intel Software Developer's Manual(以下IDMと呼びます)』を参考にしつつ、逆アセンブラについて学び、実装していきます。
目標は基本的なCPU命令セットをアセンブル出来るようなアセンブラを作成することです。まず最初はMOV命令がアセンブル出来ることを目指します。
なお、ところどころの画像についてはIDMから引用しています。
[!] ここでの検証環境はUbuntu16.04を使用して行っております。バイナリファイルもELFフォーマットを使ってます。
逆アセンブラとは
機械語で書かれたプログラムを、アセンブリ言語に変換するプログラムのことです。アセンブラはアセンブリ言語で書かれたプログラムを、コンピュータが認識する機械語に変換するプログラムで、その逆が言葉の通り逆アセンブラなわけですね!
機械語で書かれたプログラムのままでは読めない(読める方もいる)ので、人間にもわかりやすいアセンブリ言語に変換しようということですね。
リバースエンジニアリングをする上でも理解しておかなければいけない技術(と思います笑)です。デバッガについて学んでいる際、ブレークポイントを学びました。では実際ブレークポイントを仕掛ける場所をどのように探すのかというと、プログラムをアセンブリ言語に変換し、ブレークポイントを仕掛ける場所を探します。しつこいようですが、プログラムをアセンブリ言語に変換するために必要なのが逆アセンブラということですね。っていことに気付いたため逆アセンブラについて学んでいきます。笑
CPUの働き
CPUはメモリに配置された機械語プログラムをフェッチ、デコード、実行します。フェッチはメインメモリから機械語プログラムを読み込みます。デコードは読み込んだ機械語プログラムを決められた形式に従って解釈します。実行は解釈した機械語を実行します。
今回は逆アセンブラを学ぶ上で、重要になってきそうなデコードに焦点を当てていきます。
デコード
私初心者ですから、「フェッチされた機械語はどうせオペコードとオペランドのペアなだけやろ?」と余裕を持っていました。
ここで、再びIDMの登場です。IDMの2部のChapter2「INSTRUCTION FORMAT」が載っています。これを参照するとIntel 64とIA-32アーキテクチャのprotectedモード、real-addressモード、virtual-8086モードでは図2-1のようなフォーマットになっています。さらにIA-32eでは図2-3のようなフォーマットになっています。
全然予想と違いましたね。笑
Intel64とIA-32では、まず1部でもたびたび登場した命令プレフィクスが、次にオペコード、ModR/M、SIB、ディスプレイスメント、即値という順番の形式です。
IA-32eでは、まずレガシー命令プレフィクス、REXプレフィクス、オペコード、ModR/M、SIB、ディスプレースメント、即値という順番の形式です。プレフィクス以外では違いはないようです。
まずは、Intel64とIA-32での形式のそれぞれの役割を学び、次にIA-32eも同様に学びます。
Intel64とIA-32における命令形式とその役割
命令プレフィクス
命令プレフィクスは4つのグループの中から任意に指定することができます。詳細は、Section 2.1.1を参照してください。
- Group 1
- Lock and repeatプレフィクス
- BNDプレフィクス
- Group 2
- Segment overrideプレフィクス
- Branch hints
- Group 3
- Operand-size overrideプレフィクス
- Group 4
- Address-size overrideプレフィクス
特定の状況下で、命令に特別な操作をさせたいときに指定するようです。
オペコード
命令にはそれぞれ番号が振られており、それがオペコードです。オペコードは1Byteまたは2Byte、3Byteの長さを持ちます。オペコードには時々ModR/Mがエンコードされるようです。ちなみに、オペコードフィールドの中にさらにフィールドを持つことが出来るようです。詳細はSection 2.1.2を参照してください。
ModR/MとSIB
多くの命令でアドレッシング形式指定Byte(ModR/M)を持ちます。ModR/Mには3つのフィールド情報があります。
- modフィールド
r/mフィールドと組み合わせて、8つのレジスタと24のアドレス指定という値を生成します。 - reg/opcodeフィールド
レジスタ番号または3bit以上のオペコード情報を指定。オペコードによりこのフィールドの目的が決定されます。 - r/mフィールド
オペランドとしてレジスタを指定、またはアドレッシングをエンコードするためにmodフィールドを組み合わせます。
SIBはModR/Mに加えてさらにアドレス指定が必要なとき指定されます。32bitアドレス指定のbase+index形式あるいはscale+index形式で、SIBが必要です。SIBは3つのフィールドを持ちます。
- scaleフィールド
倍率を指定します。 - indexフィールド
indexレジスタのレジスタ番号を指定します。 - baseフィールド
baseレジスタのレジスタ番号を指定します。
値とModR/M, SIBの対応するアドレス指定形式は表2-1から2-3を参照してください。ModR/Mの16bitアドレス指定形式は表2-1に、32bitアドレス指定形式は表2-2に示します。表2-3はSIBの32bitアドレス指定形式を示しています。ModR/Mのreg/opcodeが拡張オペコードを表す場合の有効なエンコードはAppendix Bを参照してください。
表2-1と2-2では、実効アドレス列には、ModR/MのModとR/Mフィールドを使用して、命令の第一オペランドに割り当てることができる32の実効アドレスが示されています。最初の24の実効アドレスはメモリ指定方法を、最後の8つ(Modが11B)は汎用レジスタ、MMXレジスタ、XMMレジスタの指定方法を示しています。
例えば、Mod=11BでR/M=000Bの場合、EAXレジスタまたはAXレジスタ、ALレジスタ、MM0レジスタ、XMMレジスタを指します。どのレジスタが使用されるかは、オペコードのByteサイズとオペランドサイズ属性で決まります。
上から7行目(REG=)は第二オペランドの位置を与えるために、3bitのreg/opcodeフィールドを示しています。第二オペランドは汎用レジスタ、MMXレジスタ、XMMレジスタにしなければいけません。1-5行目には、表の値に対応するレジスタが示されています。繰り返しますが、どのレジスタが使用されるかは、オペコードのByteサイズとオペランドサイズ属性で決まります。
命令が第二オペランドを必要としない場合、reg/opcodeフィールドは拡張オペコードとして使用されるかもしれません。拡張オペコードは表の6行目に示されています。6行目の値は整数形式で示されていることに注意してください。
ディスプレイスメントと即値
一部のアドレス指定形式では、ModR/M(またはSIB)の直後にディスプレイスメントが含まれます。ディスプレイスメントは1, 2, 4Byteを指定できます。
即値オペランドは常に任意のディスプレイスメントに従います。即値は1,2,4Byteを指定できます。
例1
例えば以下の命令の場合、
MOV eax, 1
MOV r32, imm32が適用されます。
ここでimm8は即値(immediate)のByteを指します。imm16ならWord, imm32ならDword, imm64ならQwordです。
r8はByteの汎用レジスタの1つを指します。あるいはREX.Rと64bitモードを使用しているとき、R8L-R15LのByteレジスタを指定可能です。これも、r16ならWord、r32ならDword、r64ならQwordになります。
またよく出てくるr/m8はByteの汎用レジスタの内容またはメモリのByteのいずれかという意味です。r/m16ならWord、r/m32ならDword、r/m64ならQwordということですね!
この場合、オペコード+レジスタ番号と即値で解釈されるようです。
オペコードはB8Hで、eaxで0番なので、B8となるはず。
実際アセンブリ言語でテストプログラムを作成し、objdumpなどで逆アセンブルしてみると、b8 01となっているのを確認できました。
例2
では、次の命令の場合、
MOV ebx, eax
MOV r/m32, r32が適用されます。
同じようにobjdumpで確認してみるとオペコードは89Hで、ModR/MがC3Hでした。先の表を確認すると、C3Hは第一オペランドがEBXで第二オペランドがEAXとなることが分かります。おーすごい!
C3Hで各フィールドがどうなっているかというと、
C3H=11000011Bとなります。
Modフィールドは11、
Regフィールドは000、
R/Mフィールドは011
ということですね!ちょっとずつわかってきましたね!
次にIA-32eモードでの各フィールドを見ていきます。
IA-32eモードにおける命令形式とその役割
REXプレフィクス
REXプレフィクスは64bitモードで使用される命令プレフィクスです。次の場合に使います。
- GPRsとSSEレジスタを指定するとき
- 64bitオペランドサイズを指定するとき
- 拡張制御レジスタを指定するとき
64bitモードの全ての命令でREXプレフィクスが必要ということではありません。REXプレフィクスはオペコードまたはエスケープオペコード(0FH)の直前におかなければいけません。
[再掲]
REXプレフィクスはオペコードマップの1つの行にまたがり、エントリ40H-4FHを占める16のオペコードセットです。これらのオペコードはIA-32操作モードと互換モードで有効な命令(INCまたはDEC)を表します。64bitモードでは、同じオペコードが命令プレフィクスREXを表し、個々の命令として扱われません。これは恐らく、IA-32や互換モードにおけるINC/DEC命令は40+rwまたは40+rdとして解釈できます。一方64bitモードではREXプレフィクスが40H-47Hを占めるため、先の40+rwと40+rdが利用できないということでしょうか。
INC/DEC命令の単一Byteのオペコード形式は64bitモードでは利用できません。INC/DEC機能は、同じ命令(オペコードFF/0およびFF/1)のModR/M形式を使用して引き続き使用できます。
REXプレフィクス形式については表2-4を参照してください。図2-4から図2-7はREXプレフィクスフィールドを使用した場合の例です。一部のREXプレフィクスフィールドの組み合わせは無効です。そのような場合は、プレフィクスは単に無視されます。
また、追加情報として以下があります。
- REX.Wを設定するとオペランドのサイズを決定できますが、オペランドの幅のみを決定するわけではありません。66Hサイズプレフィクスと同様に64bitのオペランドサイズのオーバーライドはバイト固有の操作には影響しません。
- 非Byte操作の場合:66Hプレフィクスがプレフィクス(REX.W = 1)と共に使用される場合、66Hは無視されます。
- 66HオーバーライドがREXとREX.W=0と使用される場合、オペランドサイズは16bitです。
- REX.RはGPRまたはSSE制御、デバッグレジスタをエンコードするとき、ModR/Mのregフィールドを変更します。ModR/Mが他のレジスタを指定したり、拡張オペコードを定義するとき、REX.Rは無視されます。
- REX.X bitはSIBインデクスフィールドを変更します。
- REX.BはModR/Mのr/mフィールドのベースまたはGPRアクセスに使用されるオペコードregフィールドのいずれかを変更します。
IA-32アーキテクチャでは、Byteレジスタ(AH, AL, BH, BL, CH, CL, DH, DL)はレジスタ0-7としてModR/M regフィールドまたはr/mフィールド、オペコードregフィールドでエンコードされました。REXプレフィクスは、Byte操作にGPRの最下位Byteを使用可能にするByteレジスタ用の追加アドレス機能を提供します。
ModR/MとSIBの特定の組み合わせは、レジスタエンコードに特別な意味を持たせます。一部の組み合わせでは、REXプレフィクスで拡張されたフィールドはデコードされません。表2-5に、各ケースの動作を示します。
ダイレクトメモリオフセット形式のMOVs
64bitモードでは、ダイレクトメモリオフセット形式のMOV命令が拡張され、64ビットの即値絶対アドレスが指定されます。このアドレスはmoffsetと呼ばれます。この64bitのメモリオフセットを指定するためのプレフィクスは必要ありません。これらのMOV命令の場合、メモリオフセットのサイズはアドレスサイズのデフォルト値(64bitモードでは64bit)に従います。表2-6を参照してください。
RIP相対アドレス指定
新しいアドレス指定形式として、RIP相対アドレス(相対命令ポインタ)指定が64bitモードに実装されました。実効アドレスは次の命令の64bit RIPにディスプレイスメントを加えることにより形成されます。
IA-32あるいは互換モードでは、相対アドレス指定は制御転送命令でのみ利用可能です。64bitモードでは、ModR/Mを使った命令のアドレス指定はRIP相対アドレス指定を使うことが出来ます。RIP相対アドレス指定を使用しない場合、全てのModR/Mはメモリをゼロに対応させます。
詳しくはSection 2.2.1.6を参照してください。
逆アセンブラ作成 - MOV命令を逆アセンブルできるまで
ここまでをふまえて、まずはMOV命令を解釈できる逆アセンブラを作成します。
アセンブリ言語環境としてNASMを使用しています。
テストプログラム1
逆アセンブルされるプログラムを以下に示します。
BITS 64
section .text
global _start
_start:
mov eax, 1
非常に簡単です。笑
アセンブルする方法を以下に示します。
$ nasm -f elf64 -o sample/obj/sample_ch15_1.o sample/src/sample_ch15_1.asm
$ ld -s -o sample/bin/sample_ch15_1 sample/obj/sample_ch15_1.o
ではまずは、EAXに即値をコピーする単一ByteのMOV命令を逆アセンブルするプログラムを作成します。
逆アセンブラ作成
とりあえず上記のテストプログラムのための逆アセンブラを作成してみます。
なので、1命令しか受け付けません。笑
徐々に汎用的にしていく感じで・・・。
以下はメインプログラムです。
import os
import argparse
import instructions
from registers import Registers64
from memory import Memory
def disasm(entry, file_path):
result = []
# import pdb;pdb.set_trace()
# check argument
if entry<0:
raise
if not os.path.isfile(file_path):
raise
# file open as binary
memory = Memory()
memory.read_dump(file_path)
reg = Registers64()
reg.RIP = entry
if reg.RIP>=0x400000:
reg.RIP -= 0x400000
instruction = instructions.get_instructions(memory.dump[reg.RIP])
result.append(instruction(memory, reg))
for info in result:
print(info)
reg.show()
return True
# parse argument
parser = argparse.ArgumentParser()
parser.add_argument("-e", "--entry", type=str, required=True, help="application entry point")
parser.add_argument("-f", "--file", type=str, required=True, help="file path")
args = parser.parse_args()
if not disasm(int(args.entry, 16), args.file):
raise
else:
print("Dissassenbler quit...")
私の環境では、デフォルトでエントリポイントが0x400080でした。
それで、バイナリファイルを読み込むと実際には0x80のところにエントリポイントがあったので、0x400000減算しています。どのようにしたほうが正解なんですかね?
メモリ関連を以下のファイルに定義します。
read関数はintelがリトルエンディアンを採用しているので、そのような読み込みをしています(なってるはず・・・)。
class Memory:
def __init__(self):
self.dump = None
def read_dump(self, file_path):
fp = open(file_path, "rb")
self.dump = fp.read()
fp.close()
return self
def read(self, reg, size):
value = 0
for i, v in enumerate(self.dump[reg.RIP:reg.RIP+size]):
value |= v<<i*size*2
return value
以下のファイルに命令群を列挙しようかと考えています。
from constants import *
def get_instructions(opcode):
if 0xb8<=opcode<=0xb8+0x8:
return mov_r_imm
def mov_r_imm(memory, reg):
opcode = memory.dump[reg.RIP]
reg_n = opcode-0xb8
reg.RIP += 1
imm = memory.read(reg, DWORD)
reg.RIP += 1
if not reg.set_register(reg_n, imm):
return False
return "{:08x} {} {}".format(reg.RIP-2, reg.get_register_name(reg_n), imm)
レジスタは以下のファイルに定義します。
レジスタ名がRxxと64bit向けになってしまってます。get_registerやset_registerも16bit, 32bitなどサイズを気にしていないので、そこら辺は修正しないといけないのかなと思います。
あと、オペランドのサイズで使用するレジスタが決まる(例えばRAXなのかEAXなのか)とありましたが、オペランドサイズをどのように見るのでしょうか。恐らくRAXかEAXかはREXプレフィクスがあるかどうかで分かるのかもしれません。ただRAXと16bitレジスタはどのように見分けるのでしょうか。あれ、64bitモードでは16bitレジスタってないんでしたっけ?(ただいま迷走中)
(追記)
64bitモードで64bitレジスタと32bitレジスタ、16bitレジスタの見分け方が分かりました。
NASMでRCXの場合はREXプレフィクスが付いてました。値は48H(01001000B)で、つまりREX.Wが付いていたということですね!
次に、16bitレジスタを使用してみると、66Hが付いてました。よく見かけるこのプレフィクスは恐らく16bitレジスタを使ってますよーということでしょうか。
それ以外は、デフォルトで32bitレジスタが使用されるようです。
間違っていたら、コメントでも何でもいただけると嬉しいです。
class Registers64:
def __init__(self):
self.RIP = 0x0
self.RAX = 0x0
self.RCX = 0x0
self.RDX = 0x0
self.RBX = 0x0
self.RSP = 0x0
self.RBP = 0x0
self.RSI = 0x0
self.RDI = 0x0
def get_register_name(self, n, r=False):
if n==0:
return "rax" if r else "eax"
elif n==1:
return "rcx" if r else "ecx"
elif n==2:
return "rdx" if r else "edx"
elif n==3:
return "rbx" if r else "ebx"
elif n==4:
return "rsp" if r else "esp"
elif n==5:
return "rbp" if r else "ebp"
elif n==6:
return "rsi" if r else "esi"
elif n==7:
return "rdi" if r else "edi"
else:
return False
def get_register(self, n):
if n==0:
return self.RAX
elif n==1:
return self.RCX
elif n==2:
return self.RDX
elif n==3:
return self.RBX
elif n==4:
return self.RSP
elif n==5:
return self.RBP
elif n==6:
return self.RSI
elif n==7:
return self.RDI
else:
return False
def set_register(self, n, value):
ret = True
if n==0:
self.RAX = value
elif n==1:
self.RCX = value
elif n==2:
self.RDX = value
elif n==3:
self.RBX = value
elif n==4:
self.RSP = value
elif n==5:
self.RBP = value
elif n==6:
self.RSI = value
elif n==7:
self.RDI = value
else:
ret = False
return ret
def show(self):
print("RIP: 0x{:016x}".format(self.RIP))
print("RAX: 0x{:016x}".format(self.RAX))
print("RCX: 0x{:016x}".format(self.RCX))
print("RDX: 0x{:016x}".format(self.RDX))
print("RBX: 0x{:016x}".format(self.RBX))
print("RSP: 0x{:016x}".format(self.RSP))
print("RBP: 0x{:016x}".format(self.RBP))
print("RSI: 0x{:016x}".format(self.RSI))
print("RDI: 0x{:016x}".format(self.RDI))
定数を以下に定義します。
BYTE = 1
WORD = 2
DWORD = 4
QWORD = 8
実行するとちゃんとEAXに1がコピーされているのが確認できます。
とりあえず、雰囲気はこんな感じじゃないかと思います。笑
続きは次回にします。引き続きMOV命令を実装していきます。次はModR/Mの実装に挑戦します。