注意: この記事はLLMによって英語から翻訳されたものです。正確性については保証いたしかねますので、あらかじめご了承ください。英語の原文はこちら。
アセンブリ言語(ASM)は、最も低レベルなプログラミング言語です。CPUの命令コードに非常に近く、そのため多種多様なアセンブリ言語が存在し、それぞれが特定のコンピュータアーキテクチャ向けに設計されています。ASMは、パフォーマンスが重視されるプログラム、システムのブートコード、プログラムのリバースエンジニアリングなど、さまざまな場面で使用されています。
本記事では、Intel x86アーキテクチャ向けのASM言語であるNASM(「Netwide Assembler」)について学びます。NASMは16ビット、32ビット、64ビットのプログラムを書くために使用でき、Linux向けの最も人気のあるアセンブラの一つとされています(MacやWindowsでも使用可能です)。
この記事では、NASMでコーディングを始めたり、リバースエンジニアリングのためにNASMコードを理解したりするために必要な知識のほとんどを網羅します。コーディング対象のオペレーティングシステムによって異なる部分もありますが、一般的な概念は同じですので、Linuxを使用する予定がない方にも本チュートリアルは役立つでしょう。
ご想像の通り、アセンブリは非常に広範で複雑なトピックであるため、すべてを100%カバーすることはできません。NASMについてさらに詳しく知りたい場合は、こちらのドキュメントを参照してください。
NASMプログラムの構造#
まず、NASMで書かれた実際のコードを見てみましょう。以下のスニペットは「Hello World」コードで、いくつかの主要な概念とNASMコードの構造を紹介するために使用します。一部の概念については、後続のパートでより詳しく説明します。
global _start
section .text
_start:
mov rax, 1 ; Set the function to call (1 is write)
mov rdi, 1 ; Set the first argument of write (fd 1)
mov rsi, msg ; Set the 2nd argument, the text to write
mov rdx, msg.len ; Set the 3rd argument, lengh to write
syscall ; Call write w/ previously defined things
mov rax, 60 ; Set the function (syscall) exit (id 60)
xor rdi, rdi ; Set the exit return to be 0
syscall ; Execute the syscall exit
section .data
msg: db "Hello, world!",10 ; Assign "Hello, world!\n" to the var msg
.len: equ $ - msg ; Assign len(msg) to msg.lenまず、コードがセクションに分かれていることがわかります。この例では2つ(.textと.data)しかありませんが、使用できるセクションは他にもいくつかあります(ターゲットOSによって異なることに注意してください。たとえば、Windowsでは.textの代わりに.codeを使用します)。
.textはASMコードを書くセクションです。rax、rdi、rsi、rdxはレジスタと呼ばれ、mov、syscall、xorは命令です。これらについては次のパートで説明します.dataはプログラム実行中に、初期化済みのグローバルおよびスタティックオブジェクトを静的に割り当てることができます.bssは未初期化のグローバルおよびスタティックオブジェクトのためのスペースを予約できます.rodataは.dataと同じですが、ここで宣言された変数は読み取り専用(つまり定数)になるという違いがあります
次に目立つのは_startとmsgの要素です。これらはラベルと呼ばれます。_startは、CやC++のような高級言語におけるmain関数に相当します。ここからプログラムの実行が開始されます。また、ラベルを使って関数を定義したり、ジャンプ先のポイントとして使用することもできます(Cのgotoに似ています - 詳細は後述します)。ラベルは、この例のmsgのように変数の定義にも使用されます。これについても後ほど詳しく見ていきます。
msgの下にある.lenに気づくでしょう。ピリオドで始まるラベルはローカルラベルと呼ばれ、直前のローカルでないラベルに関連付けられます。この例では、msg.lenとして呼び出されます。
コード自体は十分に明確だと思いますが、;はコメントを入れるために使用されます。;の後に書かれたものはすべて解釈されません。
レジスタ#
レジスタはプロセッサ内部に保持される記憶領域であり、非常に高速です。17個あり、一部には特定の用途が割り当てられています(たとえば、関数に引数を渡すなど)。技術的には、すべてのレジスタを自由に変更できます(ripを除く)が、どのように使用すべきかについての規約があります。
| 64ビット | 32ビット | 16ビット | 8ビット | 説明 |
|---|---|---|---|---|
| rax | eax | ax | al | システムコール番号を提供する 関数の戻り値を提供する Caller-saveレジスタ |
| rcx | ecx | cx | cl | 第4関数パラメータ Caller-saveレジスタ |
| rdx | edx | dx | dl | 第3関数パラメータ Caller-saveレジスタ |
| rbx | ebx | bx | bl | Callee-saveレジスタ |
| rsi | esi | si | sil | 第2関数パラメータ Caller-saveレジスタ 文字列命令のソースポインタ |
| rdi | edi | di | dil | 第1関数パラメータ Caller-saveレジスタ |
| rsp | esp | sp | spl | スタックポインタ(最上位要素) Caller-saveレジスタ |
| rbp | ebp | bp | bpl | スタックベースポインタ Callee-saveレジスタ |
| r8 | r8d | r8w | r8b | 第5関数パラメータ Caller-saveレジスタ |
| r9 | r9d | r9w | r9b | 第6関数パラメータ Caller-saveレジスタ |
| r10 | r10d | r10w | r10b | Caller-saveレジスタ |
| r11 | r11d | r11w | r11b | Caller-saveレジスタ |
| r12 | r12d | r12w | r12b | Callee-saveレジスタ |
| r13 | r13d | r13w | r13b | Callee-saveレジスタ |
| r14 | r14d | r14w | r14b | Callee-saveレジスタ |
| r15 | r15d | r15w | r15b | Callee-saveレジスタ |
| rip | eip | 次に実行される命令(ripはプログラマが直接アクセスすることはできません) |
関数にパラメータを渡すために使用できるレジスタは6つしかないことに気づくでしょう。これらは整数またはポインタのみを渡すことができます。64ビットより大きいパラメータを渡す場合や、6つ以上のパラメータを渡す場合は、スタックにプッシュする必要があり、最初の引数がスタックの最上位に配置されます。
また、「Callee-saved」と「Caller-saved」の2種類のレジスタがあることにも気づくでしょう。これは厳密な規則というよりも規約ですが、その意味は以下の通りです。
- Caller-saved(揮発性)レジスタは汎用目的で、一時的な情報を保持するためのものです。任意のサブルーチンによって書き換えられる可能性があります
- Callee-saved(非揮発性)レジスタは長期間保持する値を格納するためのもので、関数呼び出しをまたいで保持されるべきです。つまり、関数はこれらのレジスタを使用する場合、関数の開始時にスタックにバックアップし、終了時にそこから復元することが求められます
命令#
NASMにおける次の重要な概念は命令です。命令とは、コンピュータに何をすべきかを伝えるためのキーワードです。このパートでは主要な命令を一覧で紹介します。
データの移動#
| 命令 | 効果 |
|---|---|
mov dest, src | srcの値をdestにコピーする |
関数#
| 命令 | 効果 |
|---|---|
syscall | 関数を呼び出す 使用方法の詳細は、最初のパートのコードまたは「ASMコードの実行とより複雑なファイル構造」パートを参照してください。このページに一般的な関数のコードと引数が一覧されています |
int code | 割り込み信号を送信する。syscallの別の方法となり得るWikipediaのLinuxの例を参照。Linux x32の関数呼び出しは sys/syscall.hで定義されています |
call label | 定義されたラベル(つまり関数 - 別のファイルから来る場合もある)を呼び出す |
push item | アイテムをスタックにプッシュする |
pull item | スタックからアイテムをレジスタにプルする |
算術演算#
| 命令 | 効果 |
|---|---|
inc dest | dest = dest + 1 |
dec dest | dest = dest - 1 |
add dest, src | dest = dest + src |
sub dest, src | dest = dest - src |
shr dest, k | dest = dest >> k |
shl dest, k | dest = dest << k |
xor dest, src | dest = dest ^ src |
shl dest, src | dest = dest & src |
shl dest, src | dest = dest | src |
ジャンプと条件分岐#
| 命令 | 効果 |
|---|---|
jmp location | 指定された場所にジャンプする(レジスタまたはラベル) |
test reg, const | レジスタと定数をビット単位で比較する。jzまたはjnzが続く必要がある |
jz label | ビットが0でなかった場合、ラベルにジャンプする |
jnz label | ビットが0だった場合、ラベルにジャンプする |
cmp x, y | xとyを比較する。jn、jne、jg、jge、ji、jilのいずれかが続く必要がある |
je label | xがyと等しい場合、ラベルにジャンプする |
jne label | xがyと異なる場合、ラベルにジャンプする |
jg label | xがyより大きい場合、ラベルにジャンプする |
ji label | xがyより小さい場合、ラベルにジャンプする |
jge label | xがy以上の場合、ラベルにジャンプする |
jil label | xがy以下の場合、ラベルにジャンプする |
その他の例については、非常に便利なこのチートシートをご覧ください。
データ型#
この記事の冒頭の例で、msg: db "Hello, world!,10"というコード行がありました。これは変数msgにHello, world!\nが割り当てられていることを説明しました。ここでのdbは変数の型です。高級言語では数値を格納するためのintや、一文字を格納するためのcharなどがありますが、NASMの型は単にデータがどれだけのスペースを占めるかを示すために使用されます。利用可能な型は以下の表に一覧されています。
| データ型 | サフィックス | データ割り当て | サイズ(ビット) |
|---|---|---|---|
| Byte | db | resb | 8 |
| Word | dw | resw | 16 |
| Double word | dd | resd | 32 |
| Quad word | dq | resq | 64 |
| Ten bytes | dt | rest | 80 |
| Octo Word | do | reso | 128 |
| Y Word | dy | resy | 256 |
| Z Word | dz | resz | 512 |
先ほどの例ではdbサフィックスを使用していますが、これは1文字が8ビットであるため適切です。
以前見なかったものの一つにデータ割り当ての列があります。これは.bssセクションで、どれだけのスペースを確保したいかを定義するために使用されます。例えば:
.bss
buffer: resb 64 ; reserve 64 bytes
wordvar: resw 1 ; reserve a word
realarray: resq 10 ; array of ten realsNASMでは値の書き方が複数あることに注意してください。以下のコードは、使用できるさまざまな方法を示しています。10進数以外の基数で書く場合、値にはサフィックス(例:16進数のh、2進数のbなど)またはプレフィックス(例:16進数の0x、8進数の0oなど)が付きます。詳細はドキュメントの3.4.1節を参照してください。
db 0x55 ; just the byte 0x55
db 0x55,0x56,0x57 ; three bytes in succession
db 'a',0x55 ; character constants are OK
db 'hello',13,10,'$' ; so are string constants
dw 0x1234 ; 0x34 0x12
dw 'a' ; 0x61 0x00 (it is just a number)
dw 'ab' ; 0x61 0x62 (character constant)
dw 'abc' ; 0x61 0x62 0x63 0x00 (string)
dd 0x12345678 ; 0x78 0x56 0x34 0x12
dd 1.234567e20 ; floating-point constant
dq 0x123456789abcdef0 ; eight byte constant
dq 1.234567e20 ; double-precision float
dt 1.234567e20 ; extended-precision float
mov ax,200 ; decimal
mov ax,0200 ; still decimal
mov ax,0200d ; explicitly decimal
mov ax,0d200 ; also decimal
mov ax,0c8h ; hex
mov ax,$0c8 ; hex again: the 0 is required
mov ax,0xc8 ; hex yet again
mov ax,0hc8 ; still hex
mov ax,310q ; octal
mov ax,310o ; octal again
mov ax,0o310 ; octal yet again
mov ax,0q310 ; octal yet again
mov ax,11001000b ; binary
mov ax,1100_1000b ; same binary constant
mov ax,1100_1000y ; same binary constant once more
mov ax,0b1100_1000 ; same binary constant yet again
mov ax,0y1100_1000 ; same binary constant yet againNASMにおけるもう一つの重要な概念は実効アドレスです。これはメモリを参照する命令のオペランドです。構文は角括弧で囲まれた式です。以下のコードスニペットは使用方法のいくつかの例を示しています。
; Accessing a variable
msg ; The msg variable address
byte[msg] ; The value of the first byte of the variable msg
byte[msg + 1] ; The value of the second byte of the msg variable
word[msg] ; The value of the first two bytes of the msg variable
; Various operations
cmp BYTE [rdi], 0h ; Check if the first byte of rdi is 0hこのパートで最後に触れたいのは、最初の例にあった.len: equ $ - msgの部分です。
equはシンボルを定数値として定義するために使用されます。使用する際は、常にラベルに値を割り当てるために使います。定義は絶対的で、後から変更することはできません$は現在の位置のアドレスです。直前にmsgを定義したので、msgの長さは現在のアドレス - msgのアドレスで得られるバイト数の差であることがわかります
ASMコードの実行とより複雑なファイル構造#
この記事の締めくくりとして、ASMで短いプログラムを書きます。プログラムの引数を取得し、それらとそのサイズを表示します。例として、strlen関数をmainとは別のファイルに書きます。
global my_strlen:function ; We declare the label as a global function
section .text
my_strlen: ; This is the function we will call later
xor rax, rax ; We set the return value as 0
while:
cmp BYTE[rdi], 0h ; If this is the end of the string (\0)
je end ; Then we jump to the label end
inc rax ; We increment the return value by one
inc rdi ; We continue to the next char in the string
jmp while ; We jump to the label while (start of the loop)
end:
ret ; We reached the end of the string, return raxここまで読んできた方なら、my_strlen関数の内容に驚くことはないでしょう。以前触れなかったこととして、別のファイルから呼び出せるようにするには、ラベルをグローバル関数として宣言する必要があります。それでは、プログラムのメイン部分を見てみましょう。
global main
; We use the extern keyword to be able to use the functions defined outside of the file
extern printf
extern my_strlen
section .text
main:
mov r10, rdi ; We save argv into r10
mov r11, 0 ; We initialize r11 to 0 and will use it as a loop counter
loop:
; rsi is the address of the first argv
; We use counter * 8 to be able to get the address of argv[r11]
mov r12, qword [rsi + r11 * 8]
; We call our my_strlen function and give argv[r11] as an argument
; It will return the result in rax
mov rdi, r12
call my_strlen
; The prinf call will overwrite some registers, we save them in the stack
push r10
push r11
push r12
push rsi
; We set the printf arguments, and call the function
mov rdi, printf_format
mov rsi, r11
mov rdx, r12
mov rcx, rax
mov rax, 0 ; We need to set this to 0 or the program will segfault
call printf
; Once we called printf, we restore the registers from the stack
pop r10
pop r11
pop r12
pop rsi
; We increase our counter and check that r11 < argc. If so, we jump to the loop label
inc r11
cmp r10, r11
jg loop
; We call exit(0)
mov rax, 60
xor rdi, rdi
syscall
section .data
; The string we will pass to printf as required by the prototype
; Unless write in the first example, we won't give printf the numbers of characters to write, so we need to string to end with '\0'
printf_format: db "The argument number %d ('%s') is %d characters long.",10,0次のようにしてプログラムをコンパイルでき、期待通りの出力が得られることを確認できます。
user@vm1:/tmp/test$ nasm -f elf64 main.S && nasm -f elf64 function.S
user@vm1:/tmp/test$ gcc -o a.out -no-pie main.o function.o
user@vm1:/tmp/test$ ./a.out 1234 123 12345
The argument number 0 ('./a.out') is 7 characters long.
The argument number 1 ('1234') is 4 characters long.
The argument number 2 ('123') is 3 characters long.
The argument number 3 ('12345') is 5 characters long.今回も、ファイルの内容に驚くことはないはずですが、一点だけ例外があります。NASMプログラムは_startから始まるべきだと述べましたが、ここでのプログラムはmainから始まっています。その理由は、リンクにgccを使用しており、gccがmain関数を呼び出す前に_startを自動生成するためです。
_startを使用したい場合は、ldでコンパイルすることもできます。ただし、printfのような外部関数を使用する場合は不便です(詳細はこちらを参照)。最初の例をコンパイルする場合は、ldで簡単にできます。
user@vm1:/tmp/test$ nasm -f elf64 example.S
user@vm1:/tmp/test$ ld -o a.out example.oコンパイルに関して最後に気になるかもしれないのは、なぜGCCで-no-pieを使用しているかということです。理由は、UbuntuではGCCがデフォルトでPosition Independent Executables(PIE)を生成しますが、現在のコードはPIEと互換性がないためです。-no-pie引数はGCCにPIE実行ファイルを生成しないよう指示しますが、コード内でcall printf wrt ..pltとすることで、コードをPosition independentにすることもできます。
参考資料#
- Netwide Assembler (Wikipedia)
- x86 calling conventions (Wikipedia)
- x64 Cheat Sheet (Doeppner - brown.edu)
- Notes on x86-64 programming (filliatr - lri.fr)
- The Netwide Assembler: NASM (NASMドキュメント)
- NASM Intel x86 Assembly Language Cheat Sheet (Bencode.net)
- Intel® 64 and IA-32 Architectures Software Developer’s Manual (Intel)
- NASM Assembly Language Tutorials (asmtutor.com)
- NASM Tutorial (lmu.edu - ray)
- Linux System Call Table for x86_64 (blog.rchapman.org)
- NASM Manual - Local labels (tortall.net)
クレジット#
- カバー写真:Markus Spiske(Unsplash)