サイト内の現在位置

逆アセンブル解析 ~2つの手法とLLM~

NECセキュリティブログ

2025年9月12日

本記事では、プログラムのバイナリファイルをアセンブリに復元する処理である逆アセンブル解析について、手法の違いや特徴を紹介します。また、大規模言語モデル(LLM)による逆アセンブル解析の検証結果を紹介します。

目次

はじめに

プログラムのバイナリファイルを静的解析するうえで、逆アセンブル解析は基盤的な要素であり、その後の制御フロー解析や逆コンパイル解析などの解析精度に大きな影響を及ぼします。本記事では、はじめに逆アセンブル解析の2つの方式について、サンプルコードを用いて説明します。次に、大規模言語モデル(以降、LLMと記述します)を利用した逆アセンブル解析の検証結果を示し、既存手法との比較や現時点での課題を見ていきます。なお本記事はバイナリファイルの解析技術の基礎を理解されている方向けの内容になっています。初学者の方は書籍等で事前に学習してからお読みいただくことを推奨いたします。

逆アセンブル解析の手法

プログラムのバイナリファイルに含まれるバイト列を命令列にデコードする逆アセンブル解析には2つの手法があります。リニア方式(Linear Sweep、リニアスイープ)と再帰方式(Recursive、トラバーサル)です。バイナリ解析を行うための静的解析ツールで逆アセンブル解析を行うものは、このいずれかの方式あるいはそのハイブリッド方式を採用しています。例えば、objdump new window[1]はリニア方式を採用しており、Ghidra new window[2]やIDA Pro new window[3]では再帰方式が中心です new window[4]
まずは、各手法の概要と、その長所・短所を簡単に説明します。

リニア方式

ファイルの先頭(または指定アドレス)から末尾に向かい、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による逆アセンブル解析

逆コンパイル解析にLLMを適用するなどnew window[5]、LLMをプログラムバイナリの静的解析に用いるケースは増えてきています。
そこで、LLMを利用した逆アセンブルの解析精度はどのくらいか気になり、少しだけ試してみました。対象はARM命令セットのバイナリファイルで、1つの関数(TestFunction)を取り出し、objdump、Ghidra、LLMによる逆アセンブル解析結果を比べてみます。

(1) objdumpによる逆アセンブル
注:objdumpはarm-none-eabi-objdumpnew window[6]を利用しています。

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モデルnew window[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 (条件付き含む) は次のようにエンコードされます(()内はビット数を示す)PDF[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

参考文献

執筆者プロフィール

S田(えすだ)※ハンドルネーム
担当領域:リスクハンティング

ソフトウェアの不正機能検査技術の研究開発業務を経て、現在はペネトレーションテスト、脆弱性診断、ソフトウェア検査などに従事。

執筆者の他の記事を読む

アクセスランキング