Japan
サイト内の現在位置
逆アセンブル解析 ~2つの手法とLLM~
NECセキュリティブログ2025年9月12日
本記事では、プログラムのバイナリファイルをアセンブリに復元する処理である逆アセンブル解析について、手法の違いや特徴を紹介します。また、大規模言語モデル(LLM)による逆アセンブル解析の検証結果を紹介します。
目次
はじめに
プログラムのバイナリファイルを静的解析するうえで、逆アセンブル解析は基盤的な要素であり、その後の制御フロー解析や逆コンパイル解析などの解析精度に大きな影響を及ぼします。本記事では、はじめに逆アセンブル解析の2つの方式について、サンプルコードを用いて説明します。次に、大規模言語モデル(以降、LLMと記述します)を利用した逆アセンブル解析の検証結果を示し、既存手法との比較や現時点での課題を見ていきます。なお本記事はバイナリファイルの解析技術の基礎を理解されている方向けの内容になっています。初学者の方は書籍等で事前に学習してからお読みいただくことを推奨いたします。
逆アセンブル解析の手法
リニア方式
ファイルの先頭(または指定アドレス)から末尾に向かい、1命令ずつアドレス順にデコードしていきます。分岐命令や関数呼び出し命令に遭遇しても、その飛び先へは移動せず、次のアドレスをデコードしていきます。
再帰方式
エントリポイントなどからスタートし、プログラムの制御フローを解析しながら分岐先をたどり、実際に到達し得るアドレスだけを逆アセンブルしていきます。分岐命令や関数呼び出し命令を検出すると、その飛び先をキュー/スタックに積み、未解析なら解析を続行します。
各方式の長所と短所
リニア方式は全バイト列を漏れなくデコード可能です。ただし、コードとデータが混在していると、データを誤って命令としてデコードしてしまう場合や、x86/x64のような命令長が可変であるバイナリファイルに対して命令のデコードを誤る場合があります。
再帰方式は、実行経路上に存在する「本当の」命令列のみ解析するため、コードとデータの誤判定が少なく、またx86/x64のような命令長が可変であるバイナリファイルに対しても比較的精度良く命令のデコードが可能という特徴があります。一方で、レジスタを引数とするジャンプ命令を例とした間接ジャンプ命令など、ジャンプ先を静的に確定できない分岐があると探索が途切れ、関数の中に未解析領域が残ってしまう欠点があります。
具体例による方式の比較
各方式の概略について説明しました。ここでは、いくつかの簡単なアセンブリコードのサンプルを見ながら、2つの方式を比較してみましょう。
まずはsample1.asmを使って比較してみます。
; sample1.asm
section .text
global _start
_start:
jmp skip_data
fake_bytes: ; 命令バイトではなくデータバイト
db 0xE8, 0x00, 0x00, 0x00, 0x00 ; CALLのバイト列に見える
db 0x90 ; NOP に見える
skip_data:
mov eax, 1
mov ebx, 42
int 0x80
オブジェクトファイルの形式にアセンブルします。
$ nasm -f elf32 sample1.asm -o sample1.o
それでは、このオブジェクトファイルをそれぞれの方式で逆アセンブルしてみます。今回、リニア方式はobjdumpを、再帰方式はGhidraを利用します。
注:今回使用したのはGhidra 11.2.1となります。
(1) objdumpによる逆アセンブル
$ objdump -d sample1.o
-----
00000000 <_start>:
0: eb 06 jmp 8 <skip_data>
00000002 <fake_bytes>:
2: e8 00 00 00 00 call 7 <fake_bytes+0x5>
7: 90 nop
00000008 <skip_data>:
8: b8 01 00 00 00 mov $0x1,%eax
d: bb 2a 00 00 00 mov $0x2a,%ebx
12: cd 80 int $0x80
fake_bytesのデータバイトも命令(CALL命令やNOP命令)としてデコードしてしまうのがわかります。結果として、CALL命令のような本来は存在しない処理が発生します。
(2) Ghidraによる逆アセンブル
_start
00010000 eb 06 JMP skip_data
fake_bytes
00010002 e8 ?? E8h
00010003 00 ?? 00h
00010004 00 ?? 00h
00010005 00 ?? 00h
00010006 00 ?? 00h
00010007 90 ?? 90h
skip_data
00010008 b8 01 00 00 00 MOV EAX,0x1
0001000d bb 2a 00 00 00 MOV EBX,0x2a
00010012 cd 80 INT 0x80
こちらは最初のJMP命令をデコードしたのち、飛び先のskip_dataに移り命令をデコードしています。そのためfake_bytesのバイトはデコードせず、誤った命令が生成されないことがわかります。なお、この時点でfake_bytesは未解析領域として残りますが、解析者がデータバイトだと判断すれば、手動でGhidraにデータバイトとして認識させることができます。
次にsample2.asmを使って比較してみます。
;sample2.asm
section .text
global _start
_start:
mov eax, table ; ジャンプテーブルのアドレスをeaxに格納
jmp [eax + ecx*4] ; ecx の値で飛び先を決める (間接ジャンプ)
table: ;ジャンプ先テーブル
dd case1
dd case2
dd case3
case1:
mov edx, 1
jmp done
case2:
mov edx, 2
jmp done
case3:
mov edx, 3
jmp done
done:
mov eax, 60
xor edi, edi
syscall
sample1.asmと同様に、こちらもオブジェクトファイルにアセンブルした後、それぞれの方式で逆アセンブルして比べてみます。
(1) objdumpによる逆アセンブル
$ objdump -d sample2.o
-----
Disassembly of section .text:
00000000 <_start>:
0: b8 08 00 00 00 mov $0x8,%eax
5: ff 24 88 jmp *(%eax,%ecx,4)
00000008 <table>:
8: 14 00 adc $0x0,%al
a: 00 00 add %al,(%eax)
c: 1b 00 sbb (%eax),%eax
e: 00 00 add %al,(%eax)
10: 22 00 and (%eax),%al
...
00000014 <case1>:
14: ba 01 00 00 00 mov $0x1,%edx
19: eb 0e jmp 29 <done>
0000001b <case2>:
1b: ba 02 00 00 00 mov $0x2,%edx
20: eb 07 jmp 29 <done>
00000022 <case3>:
22: ba 03 00 00 00 mov $0x3,%edx
27: eb 00 jmp 29 <done>
00000029 <done>:
29: b8 3c 00 00 00 mov $0x3c,%eax
2e: 31 ff xor %edi,%edi
30: 0f 05 syscall
ジャンプテーブル(table)は本来データバイトによるテーブルにもかかわらず、命令バイト(ADD命令やAND命令)としてデコードしてしまいますが、続くcase1からdoneまで問題なくデコードできています。
(2) Ghidraによる逆アセンブル
_start
00010000 b8 08 00 01 00 MOV EAX,table
00010005 ff 24 88 JMP dword ptr [EAX + ECX*0x4]
table
00010008 14 00 01 00 addr case1
0001000c 1b 00 01 00 addr case2
00010010 22 00 01 00 addr case3
case1
00010014 ba ?? BAh
00010015 01 ?? 01h
...
case2
0001001b ba ?? BAh
0001001c 02 ?? 02h
...
case3
00010022 ba ?? BAh
00010023 03 ?? 03h
...
done
00010029 b8 ?? B8h
0001002a 3c ?? 3Ch
...
00010031 05 ?? 05h
こちらはジャンプテーブルをきちんとデータ(アドレス値)によるテーブルであると認識できています。しかし、0x10005のJMP命令の飛び先を静的解析で決定できないため、case1からdoneまで逆アセンブル解析が到達せず、未解析領域になることがわかります(図中の「??」が未解析領域であることを示しています)。
LLMによる逆アセンブル解析
00029564 <TestFunction>:
29564: e1a0c00d mov ip, sp
29568: e92dd8f0 push {r4, r5, r6, r7, fp, ip, lr, pc}
2956c: e24cb004 sub fp, ip, #4
(省略)
295b4: ebffbff6 bl 19594 <func_1>
295b8: e2507000 subs r7, r0, #0
295bc: 1a000009 bne 295e8 <TestFunction+0x84>
(省略)
29610: e51b3048 ldr r3, [fp, #-72]
29614: e1a0cc22 lsr ip, r2, #24
29618: e1a00c23 lsr r0, r3, #24
(省略)
29650: e50b3048 str r3, [fp, #-72]
29654: 0affffd9 beq 295c0 <TestFunction+0x5c>
(省略)
29678: e2433001 sub r3, r3, #1
2967c: e3530005 cmp r3, #5
29680: 908ff103 addls pc, pc, r3, lsl #2
29684: ea00002c b 2973c <TestFunction+0x1d8>
29688: ea000026 b 29728 <TestFunction+0x1c4>
2968c: ea00001b b 29700 <TestFunction+0x19c>
29690: ea000029 b 2973c <TestFunction+0x1d8>
29694: ea000011 b 296e0 <TestFunction+0x17c>
29698: ea00000c b 296d0 <TestFunction+0x16c>
2969c: eaffffff b 296a0 <TestFunction+0x13c>
296a0: e3500000 cmp r0, #0
296a4: 01a04000 moveq r4, r0
296a8: 0a000002 beq 296b8 <TestFunction+0x154>
296ac: e5900004 ldr r0, [r0, #4]
296b0: ebffff72 bl 29480 <func_9>
296b4: e3a04000 mov r4, #0
296b8: e3570000 cmp r7, #0
(省略)
296cc: eaffffbc b 295c4 <TestFunction+0x60>
296d0: e3500000 cmp r0, #0
296d4: 0a00001a beq 29744 <TestFunction+0x1e0>
(省略)
29738: eafffff4 b 29710 <TestFunction+0x1ac>
2973c: e59f401c ldr r4, [pc, #28]
29740: eaffffdc b 296b8 <TestFunction+0x154>
29744: e3a03002 mov r3, #2
29748: e1a00005 mov r0, r5
2974c: e50b3020 str r3, [fp, #-32]
29750: ebffff32 bl 29420 <func_13>
29754: e1a04000 mov r4, r0
29758: eaffffd6 b 296b8 <TestFunction+0x154>
2975c: 6101004a tstvs r1, sl, asr #32
29760: 61010048 tstvs r1, r8, asr #32
29764:
ARM命令セットは命令長が固定サイズ(1命令4バイト)のため、リニア方式によるデコード精度は高いことが期待できます。この例でも、ほとんどの命令デコードに誤りはなさそうです。ただ、ARMバイナリではデータバイトがコード領域に混在していることがあり、先述の通りobjdumpではこれを認識できず、命令として誤ってデコードします。この例では末尾の0x2975cから0x29763まではデータバイトと推測されますが、命令(TSTVS)としてデコードしています。
(2) Ghidraによる逆アセンブル
続いてGhidraの再帰方式による逆アセンブル解析結果です。
undefined TestFunction()
00029564 0d c0 a0 e1 cpy r12,sp
00029568 f0 d8 2d e9 stmdb sp!,{r4,r5,r6,r7,r11,r12,lr,pc}
0002956c 04 b0 4c e2 sub r11,r12,#0x4
(省略)
000295b4 f6 bf ff eb bl func_1
000295b8 00 70 50 e2 subs r7,r0,#0x0
000295bc 09 00 00 1a bne LAB_000295e8
(省略)
00029610 48 30 1b e5 ldr r3=>local_4c,[r11,#-0x48]
00029614 22 cc a0 e1 mov r12,r2, lsr #0x18
00029618 23 0c a0 e1 mov r0,r3, lsr #0x18
(省略)
00029650 48 30 0b e5 str r3,[r11,#local_4c]
00029654 d9 ff ff 0a beq LAB_000295c0
(省略)
00029678 01 30 43 e2 sub r3,r3,#0x1
0002967c 05 00 53 e3 cmp r3,#0x5
00029680 03 f1 8f 90 addls pc,pc,r3, lsl #0x2
00029684 2c 00 00 ea b LAB_0002973c
00029688 26 ?? 26h; 0x00029688-0x000296b7まで未解析
00029689 00 ?? 00h
...
000296b6 a0 ?? A0h
000296b7 e3 ?? E3h
LAB_000296b8
000296b8 00 00 57 e3 cmp r7,#0x0
000296bc c0 ff ff 1a bne LAB_000295c4
(省略)
000296c8 1c be ff eb bl func_10
000296cc bc ff ff ea b LAB_000295c4
000296d0 00 ?? 00h ;0x000296d0-0x0002973bまで未解析
000296d1 00 ?? 00h
...
0002973a ff ?? FFh
0002973b ea ?? EAh
LAB_0002973c
0002973c 1c 40 9f e5 ldr r4,[DAT_00029760]
00029740 dc ff ff ea b LAB_000296b8
00029744 02 ?? 02h; 0x00029744-0x0002975bまで未解析
00029745 30 ?? 30h
...
0002975a ff ?? FFh
0002975b ea ?? EAh
DAT_0002975c
0002975c 4a 00 01 61 undefined4 6101004Ah
DAT_00029760
00029760 48 00 01 61 undefined4 61010048h
全体を眺めると、0x29564のCPY/MOV命令(/の後ろはobjdumpによるデコード結果)や、0x29614のMOV/LSR命令など、一部の命令はobjdumpと異なって見えますが、ARM命令セットでは同じ機械語を複数のニーモニックで表すエイリアスがあり、命令としては同じものです。また、objdumpでは認識できなかった末尾0x2975cから0x29763のデータバイトも問題なく認識しています。ただ、0x29680のADDLS命令に遭遇すると、次の0x29684のB命令までデコードして、そのあとは未解析領域となっています。このADDLS命令では、PCの値がレジスタR3の値により動的に決定するため、静的解析ではPCの値を決定することができず、結果としてジャンプ先がわかりません。次のB命令をデコードした後、B命令のジャンプ先である0x2973cに解析が移り、そこから命令のデコードを再開しています。最終的に、ジャンプ先として特定することができなかった0x29688から0x296b7や、0x296d0から0x2973bのコード領域は未解析のまま残っています。
(3) LLMによる逆アセンブル
今回使用したのは、OpenAI-o1モデル[7]です。また、LLMへの入力情報として、解析対象のバイト列のほかに、このバイト列がARM 32bitの命令セットであることも伝えています。これらの前提をもとに、LLMによる逆アセンブル解析の結果を見てみましょう。
00029564 <TestFunction>:
29564: e1a0c00d mov ip, sp
29568: e92dd8f0 push {r4, r5, r6, r7, fp, ip, lr, pc}
2956c: e24cb004 sub fp, ip, #4
(省略)
295b4: ebffbff6 bl 00000000 <sub_function?>
295b8: e2507000 subs r7, r0, #0
295bc: 1a000009 bne 295e8 <TestFunction+0x84>
(省略)
29610: e51b3048 ldr r3, [fp, #-72]
29614: e1a0cc22 mov ip, r2, lsl #24
29618: e1a00c23 mov r0, r3, lsl #24
(省略)
29650: e50b3048 str r3, [fp, #-72]
29654: 0affffd9 beq 29564 <TestFunction>
(省略)
29678: e2433001 sub r3, r3, #1
2967c: e3530005 cmp r3, #5
29680: 908ff103 addls pc, pc, r3
29684: ea00002c b 2973c <TestFunction+0x1d8>
29688: ea000026 b 29744 <TestFunction+0x1e0>
2968c: ea00001b b 29700 <TestFunction+0x19c>
29690: ea000029 b 29748 <TestFunction+0x1e4>
29694: ea000011 b 296dc <TestFunction+0x78>
29698: ea00000c b 296c0 <TestFunction+0x5c>
2969c: eaffffff b 296a0 <TestFunction+0x3c>
296a0: e3500000 cmp r0, #0
296a4: 01a04000 moveq r4, r0
296a8: 0a000002 beq 296b8 <TestFunction+0x54>
296ac: e5900004 ldr r0, [r0, #4]
296b0: ebffff72 bl 00000000 <sub_function?>
296b4: e3a04000 mov r4, #0
296b8: e3570000 cmp r7, #0
(省略)
296cc: eaffffbc b 29540
296d0: e3500000 cmp r0, #0
296d4: 0a00001a beq 29744 <TestFunction+0x1e0>
(省略)
29738: eafffff4 b 2971c <TestFunction+0xb8>
2973c: e59f401c ldr r4, [pc, #28]
29740: eaffffdc b 296c0 <TestFunction+0x5c>
29744: e3a03002 mov r3, #2
29748: e1a00005 mov r0, r5
2974c: e50b3020 str r3, [fp, #-32]
29750: ebffff32 bl 00000000 <sub_function?>
29754: e1a04000 mov r4, r0
29758: eaffffd6 b 296b0 <TestFunction+0x4c>
2975c: 6101004a .word 0x6101004a ; likely data
29760: 61010048 .word 0x61010048 ; likely data
全体としてはよくデコードできている印象です。BL命令による関数呼び出し先は意図的にゼロ表記にしている可能性がありますが、0x29684から0x2969cまでのジャンプテーブルや、末尾0x2975cから0x29763までのデータバイトを問題なく認識しています。
LLMによる解析では、上から順にバイト列を読み込み、学習済みの情報をもとに該当する命令を推測して出力していると思われます。バイト列を先頭から順に漏れなく処理するという点ではリニア方式に似ています。しかし、objdumpなどの静的解析ツールが命令セットのバイト列と命令の(厳密な)対応関係をもとに変換する一方で、LLMはあくまで推測して出力するため、ばらつきがあることに気づきます。例えば、分岐命令によるジャンプの飛び先アドレスが、0x295bcのBNE命令のようにobjdumpの結果と一致している、つまり正しい場合もあれば、0x29654のBEQ命令のように異なっている、つまり誤っている場合もあります。objdumpでは、分岐命令によるジャンプの飛び先アドレスはARM命令セットの仕様に従って計算されます(Appendix を参照)が、LLMではそのような厳密な計算をしていないようです。同じB命令でも、0x29684では飛び先アドレスが正しいですが、0x29688では誤っています。そのうえ、誤り具合であるアドレスのずれは、0x29688では28バイト、0x29690では12バイトとなっています。したがって、特定の命令だけ計算方法を誤っている、というわけではありません。
あくまで今回使用したLLMの、現時点でのモデルと設定においては、という注意書きが必要ですが、ジャンプ命令や関数呼び出し命令について、遷移先の解析情報は信用しないほうが良いでしょう。
まとめ
今回は、逆アセンブル解析の手法が大きく2種類に分かれることと、各手法の特徴や長所・短所について説明しました。
リニア方式では、命令長が固定サイズの命令セットには精度が高く、制御フロー解析が困難な場合であっても一通りの命令デコードを実行する一方で、命令長が可変サイズである命令セットの命令やデータバイトなどを誤認識してしまう欠点があります。
再帰方式では、制御フロー解析を実行しながら命令のデコードを進めていくので、命令長が可変サイズである命令セットの命令やデータバイトなどを比較的精度良く解析できる一方で、制御フロー解析が困難な間接ジャンプ命令などに遭遇すると命令のデコードが進まなくなるなどの欠点があります。
したがって、この2つの手法の特徴を把握しつつ、うまく併用していくことが、実際の逆アセンブル解析を精度良く実行するために重要です。
最後に、LLMではどの程度逆アセンブル解析ができるのかを試してみました。結果としては、x86/x64やARMに対して良く学習していると考えられ、全体として命令のデコードはよくできている印象でした。しかし、ジャンプ命令や関数呼び出し命令の飛び先・呼び出し先アドレスは正確な計算をしていないため、やはり上記2つの既存手法を併用していきたいですね。このブログでの紹介や検証結果が皆様のお役に立つことを祈っております。
Appendix
- ARM branch命令のアドレス計算
ARM の B/BL (条件付き含む) は次のようにエンコードされます(()内はビット数を示す)[8]。
フィールド: cond(4) | 101(3) | L(1) | imm24(24)
condは条件、101は分岐命令のコード、Lはリンク、つまりBL命令かを示します。また、分岐先のアドレスは下記のように計算されます。
分岐先 = (PC + 8) + SignExtend(imm24 << 2, 26bit)
例えば、 下記のBEQ命令を見てみます。
29654: 0affffd9 beq 295c0
左から下記のように分解します。
0000(cond), 101(code), 0(L), ffffd9(imm24)
したがって、この命令はリンクではない分岐命令で、condが0のBEQ命令を示します。
また、分岐先のアドレスは下記の通りです。
分岐先 = (0x29654 + 8) + SignExtend(0xffffd9 << 2, 26bit) = 0x295c0
参考文献
- [1]Ubuntu Manpage: objdump - オブジェクトファイルの情報を表示する
https://manpages.ubuntu.com/manpages/jammy/ja/man1/objdump.1.html
- [2]GitHub - NationalSecurityAgency/ghidra: Ghidra is a software reverse engineering (SRE) framework
https://github.com/NationalSecurityAgency/ghidra
- [3]IDA Pro: Powerful Disassembler, Decompiler &Debugger
https://hex-rays.com/ida-pro
- [4]Dennis Andriesse, “Practical Binary Analysis”, no starch press
https://nostarch.com/binaryanalysis
- [5]GitHub - albertan017/LLM4Decompile: Reverse Engineering: Decompiling Binary Code with Large Language Models
https://github.com/albertan017/LLM4Decompile
- [6]arm-none-eabi-objdump(1) — binutils-arm-none-eabi — Debian unstable — Debian Manpages
https://manpages.debian.org/unstable/binutils-arm-none-eabi/arm-none-eabi-objdump.1.en.html
- [7]OpenAI o1 Hub | OpenAI
https://openai.com/ja-JP/o1/
- [8]ARM Architecture Reference Manual (ARM DDI 0100E)
https://www.intel.com/programmable/technical-pdfs/654202.pdf
執筆者プロフィール
S田(えすだ)※ハンドルネーム
担当領域:リスクハンティング
ソフトウェアの不正機能検査技術の研究開発業務を経て、現在はペネトレーションテスト、脆弱性診断、ソフトウェア検査などに従事。

執筆者の他の記事を読む
アクセスランキング