【x86_64/Linux】アセンブリ言語でプログラミングをはじめる

PythonやJavaなど高級言語でプログラミングをする事が当たり前になった現代ではアセンブリ言語なんて時代遅れだと思われるかも知れません。

アセンブリ言語とは

アセンブリ言語は、コンピュータの命令セットアーキテクチャ(Instruction Set Architecture、ISA)に直接対応する低水準プログラミング言語です。コンピュータのCPUは特定の命令セットを理解し、それに基づいて動作します。これらの命令は、通常、バイナリコード(つまり、1と0のみで構成されるコード)で表現されます。

しかし、バイナリコードは人間にとって理解しにくいので、それをより理解しやすい形に変換するのがアセンブリ言語の役割です。アセンブリ言語は、特定のバイナリ命令を「ヒューマンリーダブル」なテキスト形式で表現します。例えば、「10110000」(8ビットバイナリ)はアセンブリ言語では「MOV」(move)命令に対応します。

アセンブリ言語は一つの「抽象レベル」を提供しますが、それでも非常に低水準な言語です。つまり、それはコンピュータハードウェアに非常に近いということです。その結果、アセンブリ言語は非常に高速で効率的なコードを書くために使うことができますが、それは一方でプログラムの理解や保守が難しくなります。

アセンブリ言語を学ぶメリット

低水準レベルの理解

アセンブリ言語を学ぶことはコンピュータがどのように動作するかを深く理解するのに役立ちます。アセンブリはCPUが実際に解釈する命令を直接表現するため、アセンブリを理解するということはコンピュータが内部で何を行っているのかを理解するということになります。これは他の高級言語では得られない知識です。

パフォーマンス

アセンブリ言語は特定のタスクに対して非常に高速で効率的なコードを書くことが可能です。一部のパフォーマンスクリティカルな領域(例えば、組み込みシステム、ゲーム開発の一部、または特定の高性能コンピューティング)では、アセンブリ言語を使うことで高級言語では達成できない最適化を実現することができます。

デバッギングとリバースエンジニアリング

ソフトウェアをデバッグする際や既存のバイナリを理解するためには、アセンブリ言語の知識が非常に役立ちます。これは特にセキュリティの領域で重要で、マルウェア解析や脆弱性探索などにはアセンブリ言語の理解が必須となります。

AT&T表記とIntel表記

次に、AT&T表記とIntel表記について説明します。これらはアセンブリ言語の構文を表す2つの主要な方式です。

AT&T表記

AT&T表記はUNIXシステムで一般的に使用されます。この表記法では、オペランドの順序が「ソース」から「ディスティネーション」へと定義されます。つまり、命令が何か(例えば、データの移動や加算)を行う場合、それは最初のオペランド(ソース)から二番目のオペランド(ディスティネーション)へと行われます。さらに、AT&T表記法ではレジスタ名が%記号で始まり、即値(直接指定される数値)は$記号で始まります。

Intel表記

Intel表記はWindowsシステムで一般的に使用されます。この表記法では、オペランドの順序が「ディスティネーション」から「ソース」へと定義されます。つまり、命令が何かを行う場合、それは最初のオペランド(ディスティネーション)から二番目のオペランド(ソース)へと行われます。また、Intel表記法ではレジスタ名はそのままで、即値に特別な記号は付けられません。

たとえば、同じ命令をAT&T表記とIntel表記で表すと次のようになります。

  • AT&T表記法: mov %eax, %ebx (「eaxレジスタの内容をebxレジスタに移動する」を意味します)
  • Intel表記法: mov ebx, eax (「eaxレジスタの内容をebxレジスタに移動する」を意味します)

このように、両者の表記法はかなり異なるので、どちらの表記法を使用するかは、プログラムの目的や使用するツールによって変わる場合があります。

AT&T表記とIntel表記のどちらを使うべき?

どちらの表記でも好きな方を使って問題ありませんが、Intel表記の方を選ぶ方が多いように感じます。慣れてくればどちらでも対応できるので、最初はご自身が使う環境やツールに合わせて選択すれば良いでしょう。

アセンブリ言語を始める前の基礎知識

「section」について

アセンブリ言語における「section」はプログラムの異なる部分を区切るための指示子です。それぞれのセクションはプログラム内の特定の種類のデータまたはコードを含むために使われます。

text

このセクションは通常、実行可能なコードを含みます。つまり、プログラムの命令がここに保存されます。この部分は読み取り専用であり、実行可能であることが一般的です。

data

このセクションは初期化された静的変数を含みます。つまり、プログラムの開始時点で既に値が設定されている変数がここに保存されます。この部分は読み取りと書き込みの両方が可能です。

bss

このセクションは初期化されていないデータを含むために使用されます。

rodata

このセクションは読み取り専用データ(定数など)を含むために使用されます。

これらのセクションは、プログラムがメモリにロードされるときにオペレーティングシステムがどの部分をどのように扱うべきかを理解するための重要な情報を提供します。

x86_64のシステムコールについて

システムコールとは、ユーザモードからカーネルモードへのインターフェースを提供する一連のインターフェースです。一般的にコンピュータのオペレーティングシステム(OS)が提供する機能へのアクセスポイントのことを指します。これにはファイル操作、ネットワークアクセス、プロセス管理などが含まれます。

x86_64アーキテクチャでは、システムコールは syscall 命令を使用して呼び出されます。これは32ビットのx86アーキテクチャで使われていた int 0x80 命令とは異なります。syscall 命令は、システムコールの実行をより効率的に行うために導入されました。

システムコールを行う際には、以下のレジスタが使用されます:

  • rax: システムコールの番号。これによりカーネルがどのシステムコールを実行すべきかを知ることができます。
  • rdi, rsi, rdx, r10, r8, r9: システムコールの引数。最大6つの引数を渡すことができます。これらのレジスタが引数を格納します。
  • システムコールが完了すると、結果は rax レジスタに格納されます。

例えば、write システムコールを呼び出すためには、以下の手順を踏むことになります:

  1. rax レジスタに 1 をロードします。これが write システムコールの番号です。
  2. rdi レジスタにファイルディスクリプタをロードします。例えば、1 は標準出力(つまりコンソール)を表します。
  3. rsi レジスタに書き込むデータのアドレスをロードします。
  4. rdx レジスタに書き込むデータの長さをロードします。
  5. syscall 命令を実行します。これにより、指定されたシステムコールがカーネルモードで実行されます。

これは一例であり、他のシステムコールでも同様の手順を踏むことになります。ただし、システムコールの番号や引数の内容は呼び出すシステムコールにより異なる点に注意が必要です。

システムコールの引数を調べる方法

Linuxでシステムコールをどのようにして呼び出すのか調べたい場合はmanコマンドを使う事ができます。

たとえばwriteシステムコールであれば man 2 write コマンドで調べる事ができます。man 2 の「2」はシステムコールを調べるという意味ですので、システムコールを調べる際は必ず man 2 XXX とします。

NAME
       write - write to a file descriptor

SYNOPSIS
       #include <unistd.h>

       ssize_t write(int fd, const void *buf, size_t count);

システムコール番号を調べる方法

システムコール番号はC言語のヘッダーファイルを見れば分かります。

$ find /usr/include/ -type f | grep syscall
/usr/include/syscall.h
/usr/include/x86_64-linux-gnu/sys/syscall.h
/usr/include/x86_64-linux-gnu/asm/vsyscall.h
/usr/include/x86_64-linux-gnu/bits/syscall.h
$

上記でリストアップされたヘッダーファイルを追いかけていけば見つけられます。わたしが使っているUbuntu Linux 22.04 LTSでは次のヘッダーファイルにシステムコール番号が定義されていました。32ビットと64ビットで別れているので注意してください。

  • 32bit: /usr/include/x86_64-linux-gnu/asm/unistd_32.h
  • 64bit: /usr/include/x86_64-linux-gnu/asm/unistd_64.h

ヘッダーファイル内では次のように定義されています:

#define __NR_read 0
#define __NR_write 1
#define __NR_open 2
#define __NR_close 3

アセンブリ言語でHello, World!を表示させるプログラムを書く

AT&T表記とIntel表記両方で書いて実行しています。

AT&T表記

AT&T表記では「#」以降をコメントとして扱います。

.section .data
hello:
    .string "Hello, World!\n"

.section .text
.globl _start
_start:
    # writeを実行
    movq $1, %rax            # システムコール番号 (sys_write)
    movq $1, %rdi            # ファイル記述子 (stdout)
    leaq hello(%rip), %rsi   # メッセージのアドレス
    movq $14, %rdx           # メッセージの長さ
    syscall                  # システムコールを実行

    # exitを実行
    movq $60, %rax           # システムコール番号 (sys_exit)
    xorq %rdi, %rdi          # 終了ステータスを0にする
    syscall                  # システムコールを実行

上記のコードを「hello_world-x86_64-att.s」として保存しました。asコマンドでアセンブルします。

as hello_world-x86_64-att.s -o hello_world-x86_64-att.o

これで「hello_world-x86_64-att.o」という名前のオブジェクトファイルが生成されます。次にldコマンド(リンカ)を使って実行ファイルを生成します。

ld hello_world-x86_64-att.o -o hello_world-x86_64-att

これでhello_world-x86_64-attという実行ファイルが生成されたので実行してみます。

$ ./hello_world-x86_64-att
Hello, World!
$

Intel表記

Intel表記では「;」以降をコメントとして扱います。

section .data
hello db 'Hello, World!', 0Ah  ; 文字列と改行

section .text
global _start
_start:
    ; writeを実行
    mov rax, 1              ; システムコール番号 (sys_write)
    mov rdi, 1              ; ファイル記述子 (stdout)
    mov rsi, hello          ; メッセージのアドレス
    mov rdx, 14             ; メッセージの長さ
    syscall                 ; システムコールを実行

    ; exitを実行
    mov rax, 60             ; システムコール番号 (sys_exit)
    xor rdi, rdi            ; 終了ステータスを0にする
    syscall                 ; システムコールを実行

上記のコードを「hello_world-x86_64-intel.s」として保存しました。実行するには、最初にnasmを使ってアセンブルします。nasmがインストールされていない場合は、aptコマンドでインストールしておきます。

sudo apt -y install nasm

nasmでアセンブルするには -f elf64 オプションでファイル形式を指定する必要があります。今回は64ビットELFフォーマットを指定します。

nasm -f elf64 hello_world-x86_64-intel.s

これで「hello_world-x86_64-intel.o」という名前のオブジェクトファイルが生成されます。次にldコマンド(リンカ)を使って実行ファイルを生成します。

ld hello_world-x86_64-intel.o -o hello_world-x86_64-intel

これでhello_world-x86_64-intelという実行ファイルが生成されたので実行してみます。

$ ./hello_world-x86_64-intel
Hello, World!
$

アセンブリ言語から生成した実行ファイルを逆アセンブルしてみる

逆アセンブルするためにLinuxコマンドのobjdumpを使います。-Dオプションを使うとすべてのセクションを逆アセンブルできます(-dオプションを使うとtextセクションのみ逆アセンブルする)。

Intel表記で逆アセンブルしたい場合は-M intelオプションを付けてください。

$ objdump -D hello_world-x86_64-att

hello_world-x86_64-att:     file format elf64-x86-64


Disassembly of section .text:

0000000000401000 <_start>:
  401000:       48 c7 c0 01 00 00 00    mov    $0x1,%rax
  401007:       48 c7 c7 01 00 00 00    mov    $0x1,%rdi
  40100e:       48 8d 35 eb 0f 00 00    lea    0xfeb(%rip),%rsi        # 402000 
  401015:       48 c7 c2 0e 00 00 00    mov    $0xe,%rdx
  40101c:       0f 05                   syscall
  40101e:       48 c7 c0 3c 00 00 00    mov    $0x3c,%rax
  401025:       48 31 ff                xor    %rdi,%rdi
  401028:       0f 05                   syscall

Disassembly of section .data:

0000000000402000 :
  402000:       48                      rex.W
  402001:       65 6c                   gs insb (%dx),%es:(%rdi)
  402003:       6c                      insb   (%dx),%es:(%rdi)
  402004:       6f                      outsl  %ds:(%rsi),(%dx)
  402005:       2c 20                   sub    $0x20,%al
  402007:       57                      push   %rdi
  402008:       6f                      outsl  %ds:(%rsi),(%dx)
  402009:       72 6c                   jb     402077 <_end+0x67>
  40200b:       64 21 0a                and    %ecx,%fs:(%rdx)
        ...
$

この逆アセンブル結果を見ると分かるようにほぼアセンブリ言語で書いたものと変わりません。以下はAT&T表記で書いたHello, World!です。

.section .data
hello:
    .string "Hello, World!\n"

.section .text
.globl _start
_start:
    # writeを実行
    movq $1, %rax            # システムコール番号 (sys_write)
    movq $1, %rdi            # ファイル記述子 (stdout)
    leaq hello(%rip), %rsi   # メッセージのアドレス
    movq $14, %rdx           # メッセージの長さ
    syscall                  # システムコールを実行

    # exitを実行
    movq $60, %rax           # システムコール番号 (sys_exit)
    xorq %rdi, %rdi          # 終了ステータスを0にする
    syscall                  # システムコールを実行

textセクションを見るとhelloの箇所が0xfebに変わっただけで、他は一致しています。次にdataセクションに目を向けると何やら意味不明なアセンブリ言語が表示されています。

0000000000402000 :
  402000:       48                      rex.W
  402001:       65 6c                   gs insb (%dx),%es:(%rdi)
  402003:       6c                      insb   (%dx),%es:(%rdi)
  402004:       6f                      outsl  %ds:(%rsi),(%dx)
  402005:       2c 20                   sub    $0x20,%al
  402007:       57                      push   %rdi
  402008:       6f                      outsl  %ds:(%rsi),(%dx)
  402009:       72 6c                   jb     402077 <_end+0x67>
  40200b:       64 21 0a                and    %ecx,%fs:(%rdx)

この箇所はもうお気づきかと思いますがアセンブリ言語ではありません。Hello, World!\nという文字列です(末尾の「\n」は改行文字)。文字列をマシンコードとして逆アセンブルしてしまったので意味不明の逆アセンブル結果が表示されていた、という事です。

$ echo 'Hello, World!' | xxd
00000000: 4865 6c6c 6f2c 2057 6f72 6c64 210a       Hello, World!.
$

まとめ

アセンブリ言語をメインにプログラミングする機会はほぼないと思いますが、バイナリをハックする場合は必ず必要な知識になるのでハッカーとしてレベルアップしたいという方はアセンブリ言語を学ぶ事をおすすめします。

WEBアプリを専門にハックする方でもアセンブリ言語の知識を持っていても損をする事はないと思います。

この記事は役に立ちましたか?

もし参考になりましたら、下記のボタンで教えてください。

関連記事