メインコンテンツへスキップ
  1. 記事/

NASMアセンブリ入門

Ixonae
著者
Ixonae
目次

注意: この記事は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コードを書くセクションです。raxrdirsirdxはレジスタと呼ばれ、movsyscallxorは命令です。これらについては次のパートで説明します
  • .dataはプログラム実行中に、初期化済みのグローバルおよびスタティックオブジェクトを静的に割り当てることができます
  • .bssは未初期化のグローバルおよびスタティックオブジェクトのためのスペースを予約できます
  • .rodata.dataと同じですが、ここで宣言された変数は読み取り専用(つまり定数)になるという違いがあります

次に目立つのは_startmsgの要素です。これらはラベルと呼ばれます。_startは、CやC++のような高級言語におけるmain関数に相当します。ここからプログラムの実行が開始されます。また、ラベルを使って関数を定義したり、ジャンプ先のポイントとして使用することもできます(Cのgotoに似ています - 詳細は後述します)。ラベルは、この例のmsgのように変数の定義にも使用されます。これについても後ほど詳しく見ていきます。

msgの下にある.lenに気づくでしょう。ピリオドで始まるラベルはローカルラベルと呼ばれ、直前のローカルでないラベルに関連付けられます。この例では、msg.lenとして呼び出されます。

コード自体は十分に明確だと思いますが、;はコメントを入れるために使用されます。;の後に書かれたものはすべて解釈されません。

レジスタ
#

レジスタはプロセッサ内部に保持される記憶領域であり、非常に高速です。17個あり、一部には特定の用途が割り当てられています(たとえば、関数に引数を渡すなど)。技術的には、すべてのレジスタを自由に変更できます(ripを除く)が、どのように使用すべきかについての規約があります。

64ビット32ビット16ビット8ビット説明
raxeaxaxalシステムコール番号を提供する
関数の戻り値を提供する
Caller-saveレジスタ
rcxecxcxcl第4関数パラメータ
Caller-saveレジスタ
rdxedxdxdl第3関数パラメータ
Caller-saveレジスタ
rbxebxbxblCallee-saveレジスタ
rsiesisisil第2関数パラメータ
Caller-saveレジスタ
文字列命令のソースポインタ
rdiedididil第1関数パラメータ
Caller-saveレジスタ
rspespspsplスタックポインタ(最上位要素)
Caller-saveレジスタ
rbpebpbpbplスタックベースポインタ
Callee-saveレジスタ
r8r8dr8wr8b第5関数パラメータ
Caller-saveレジスタ
r9r9dr9wr9b第6関数パラメータ
Caller-saveレジスタ
r10r10dr10wr10bCaller-saveレジスタ
r11r11dr11wr11bCaller-saveレジスタ
r12r12dr12wr12bCallee-saveレジスタ
r13r13dr13wr13bCallee-saveレジスタ
r14r14dr14wr14bCallee-saveレジスタ
r15r15dr15wr15bCallee-saveレジスタ
ripeip次に実行される命令(ripはプログラマが直接アクセスすることはできません)

関数にパラメータを渡すために使用できるレジスタは6つしかないことに気づくでしょう。これらは整数またはポインタのみを渡すことができます。64ビットより大きいパラメータを渡す場合や、6つ以上のパラメータを渡す場合は、スタックにプッシュする必要があり、最初の引数がスタックの最上位に配置されます。

また、「Callee-saved」と「Caller-saved」の2種類のレジスタがあることにも気づくでしょう。これは厳密な規則というよりも規約ですが、その意味は以下の通りです。

  • Caller-saved(揮発性)レジスタは汎用目的で、一時的な情報を保持するためのものです。任意のサブルーチンによって書き換えられる可能性があります
  • Callee-saved(非揮発性)レジスタは長期間保持する値を格納するためのもので、関数呼び出しをまたいで保持されるべきです。つまり、関数はこれらのレジスタを使用する場合、関数の開始時にスタックにバックアップし、終了時にそこから復元することが求められます

命令
#

NASMにおける次の重要な概念は命令です。命令とは、コンピュータに何をすべきかを伝えるためのキーワードです。このパートでは主要な命令を一覧で紹介します。

データの移動
#

命令効果
mov dest, srcsrcの値をdestにコピーする

関数
#

命令効果
syscall関数を呼び出す
使用方法の詳細は、最初のパートのコードまたは「ASMコードの実行とより複雑なファイル構造」パートを参照してください。このページに一般的な関数のコードと引数が一覧されています
int code割り込み信号を送信する。syscallの別の方法となり得る
WikipediaのLinuxの例を参照。Linux x32の関数呼び出しはsys/syscall.hで定義されています
call label定義されたラベル(つまり関数 - 別のファイルから来る場合もある)を呼び出す
push itemアイテムをスタックにプッシュする
pull itemスタックからアイテムをレジスタにプルする

算術演算
#

命令効果
inc destdest = dest + 1
dec destdest = dest - 1
add dest, srcdest = dest + src
sub dest, srcdest = dest - src
shr dest, kdest = dest >> k
shl dest, kdest = dest << k
xor dest, srcdest = dest ^ src
shl dest, srcdest = dest & src
shl dest, srcdest = dest | src

ジャンプと条件分岐
#

命令効果
jmp location指定された場所にジャンプする(レジスタまたはラベル)
test reg, constレジスタと定数をビット単位で比較する。jzまたはjnzが続く必要がある
jz labelビットが0でなかった場合、ラベルにジャンプする
jnz labelビットが0だった場合、ラベルにジャンプする
cmp x, yxとyを比較する。jnjnejgjgejijilのいずれかが続く必要がある
je labelxがyと等しい場合、ラベルにジャンプする
jne labelxがyと異なる場合、ラベルにジャンプする
jg labelxがyより大きい場合、ラベルにジャンプする
ji labelxがyより小さい場合、ラベルにジャンプする
jge labelxがy以上の場合、ラベルにジャンプする
jil labelxがy以下の場合、ラベルにジャンプする

その他の例については、非常に便利なこのチートシートをご覧ください。

データ型
#

この記事の冒頭の例で、msg: db "Hello, world!,10"というコード行がありました。これは変数msgHello, world!\nが割り当てられていることを説明しました。ここでのdbは変数の型です。高級言語では数値を格納するためのintや、一文字を格納するためのcharなどがありますが、NASMの型は単にデータがどれだけのスペースを占めるかを示すために使用されます。利用可能な型は以下の表に一覧されています。

データ型サフィックスデータ割り当てサイズ(ビット)
Bytedbresb8
Worddwresw16
Double wordddresd32
Quad worddqresq64
Ten bytesdtrest80
Octo Worddoreso128
Y Worddyresy256
Z Worddzresz512

先ほどの例ではdbサフィックスを使用していますが、これは1文字が8ビットであるため適切です。

以前見なかったものの一つにデータ割り当ての列があります。これは.bssセクションで、どれだけのスペースを確保したいかを定義するために使用されます。例えば:

.bss
buffer:         resb    64              ; reserve 64 bytes
wordvar:        resw    1               ; reserve a word
realarray:      resq    10              ; array of ten reals

NASMでは値の書き方が複数あることに注意してください。以下のコードは、使用できるさまざまな方法を示しています。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 again

NASMにおけるもう一つの重要な概念は実効アドレスです。これはメモリを参照する命令のオペランドです。構文は角括弧で囲まれた式です。以下のコードスニペットは使用方法のいくつかの例を示しています。

; 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にすることもできます。


参考資料
#

クレジット
#