x86_64 における除算、剰余、バブルソート

高水準言語(たとえば C)で書かれたアルゴリズムは、ソースコードを見るととても分かりやすく見えます。しかし、コンパイラはそれらをアセンブリレベルでかなり不可思議なパターンに変換できます。リバースエンジニアリングの技術を高めるには、そうしたパターンを学び、素早く識別できるようにする必要があります。このブログ記事では、今日 CTF 問題を解いているときに遭遇した例をいくつか紹介します。

整数間の符号付き除算

まずはこのコードを見てみましょう。

#include <stdio.h>

int div(int a)
{
    return a / 42;
}

int main()
{
    int a;
    scanf("%d", &a);
    a = div(a);
    printf("%d", a);
    return 0;
}

これをコンパイルして、あなたの素敵な iaito や radare2 を起動し、アセンブリを見てみましょう。main() は気にしなくてよいので、直接 sym.div に進むと、次のようなものが見えます。

36: div (int64_t arg1);
`- args(rdi) vars(1:sp[0xc..0xc])
0x00001149      push     rbp
0x0000114a      mov      rbp,    rsp
0x0000114d      mov      dword [var_4h],  edi ; arg1
0x00001150      mov      eax,    dword [var_4h]
0x00001153      movsxd   rdx,    eax
0x00001156      imul     rdx,    rdx,    0x30c30c31
0x0000115d      shr      rdx,    0x20
0x00001161      sar      edx,    3
0x00001164      sar      eax,    0x1f
0x00001167      sub      edx,    eax
0x00001169      mov      eax,    edx
0x0000116b      pop      rbp
0x0000116c      ret

ここには idiv 命令がありません。代わりに、0x00001156imul 命令があり、その後に shr 命令と sar 命令が続いています。その結果は edx レジスタに入ります。

では、別の数を使ったらどうなるでしょうか。試してみましょう。次をコンパイルしてください。

#include <stdio.h>

int div(int a)
{
    return a / 6;
}

int main()
{
    int a;
    scanf("%d", &a);
    a = div(a);
    printf("%d", a);
    return 0;
}

すると、似たような逆アセンブルが得られます。

33: div (int64_t arg1);
`- args(rdi) vars(1:sp[0xc..0xc])
0x00001149      push     rbp
0x0000114a      mov      rbp,    rsp
0x0000114d      mov      dword [var_4h],  edi ; arg1
0x00001150      mov      eax,    dword [var_4h]
0x00001153      movsxd   rdx,    eax
0x00001156      imul     rdx,    rdx,    0x2aaaaaab
0x0000115d      shr      rdx,    0x20
0x00001161      sar      eax,    0x1f
0x00001164      sub      edx,    eax
0x00001166      mov      eax,    edx
0x00001168      pop      rbp
0x00001169      ret

負の数を使うとどうなるでしょうか。6-7 に置き換えると、次のようになります。

36: div (int64_t arg1);
`- args(rdi) vars(1:sp[0xc..0xc])
0x00001149      push     rbp
0x0000114a      mov      rbp,    rsp
0x0000114d      mov      dword [var_4h],  edi ; arg1
0x00001150      mov      eax,    dword [var_4h]
0x00001153      movsxd   rdx,    eax
0x00001156      imul     rdx,    rdx,    0xffffffff92492493
0x0000115d      shr      rdx,    0x20
0x00001161      add      edx,    eax
0x00001163      sar      edx,    2
0x00001166      sar      eax,    0x1f
0x00001169      sub      eax,    edx
0x0000116b      pop      rbp
0x0000116c      ret

では、定数を使わなかったらどうなるでしょうか。次をコンパイルしてください。

#include <stdio.h>

int div(int a, int b)
{
    return a / b;
}

int main()
{
    int a, b;
    scanf("%d %d", &a, &b);
    a = div(a, b);
    printf("%d", a);
    return 0;
}

すると、ついに少し違うものが得られます。

19: div (int64_t arg1, signed int64_t arg2);
`- args(rdi, rsi) vars(2:sp[0xc..0x10])
0x00001149      push     rbp
0x0000114a      mov      rbp,    rsp
0x0000114d      mov      dword [var_4h],  edi ; arg1
0x00001150      mov      dword [var_8h],  esi ; arg2
0x00001153      mov      eax,    dword [var_4h]
0x00001156      cdq
0x00001157      idiv     dword [var_8h]
0x0000115a      pop      rbp
0x0000115b      ret

x86_64 CPU は 確かに 除算をサポートしていますが、コストは高いです。したがって、除数がすでに定数として与えられている場合、コンパイラはコンパイル時にその最適化を行ってくれます。除数から magic multiplier(魔法の乗数)を計算し、除算を乗算とビットシフトの組み合わせに変換します。このアルゴリズムについては、さらに詳しく説明している論文があります。

ただし、その定数が実行時にしか分からない場合、この最適化は役に立たないため、直接除算命令が使われます。

剰余演算

では、これをコンパイルしてみましょう。

#include <stdio.h>

int mymod(int a)
{
    return a % 42;
}

int main()
{
    int a;
    scanf("%d", &a);
    a = mymod(a);
    printf("%d", a);
    return 0;
}

mymod() の逆アセンブルは次のようになります。

45: mymod (int64_t arg1);
`- args(rdi) vars(1:sp[0xc..0xc])
0x00001149      push     rbp
0x0000114a      mov      rbp,    rsp
0x0000114d      mov      dword [var_4h],  edi ; arg1
0x00001150      mov      eax,    dword [var_4h]
0x00001153      movsxd   rdx,    eax
0x00001156      imul     rdx,    rdx,    0x30c30c31
0x0000115d      shr      rdx,    0x20
0x00001161      sar      edx,    3
0x00001164      mov      ecx,    eax
0x00001166      sar      ecx,    0x1f
0x00001169      sub      edx,    ecx
0x0000116b      imul     ecx,    edx,    0x2a
0x0000116e      sub      eax,    ecx
0x00001170      mov      edx,    eax
0x00001172      mov      eax,    edx
0x00001174      pop      rbp
0x00001175      ret

0x00001150 から 0x00001161 までの命令は、上で説明した magic multiplier を使った除算です。その後、被除数のコピーが ecx に入り、それを 31 ビット算術右シフトします。被除数が非負なら ecx は 0、そうでなければ -1 になります。

これは、被除数から bias を計算するためです。除算は常にゼロ方向へ切り捨てられるからです。商を使って剰余を計算します。正の数では、剰余 = 被除数 - 除数 * 商 です。負の数では、剰余 = 被除数 - 除数 * (商 + 1) です。

バブルソート

最後に、これをコンパイルしてみましょう。

#include <stdio.h>

void bubble(int *arr, int n)
{
    for (int i1 = 0; i1 < n - 1; i1++)
    {
        for (int i2 = 0; i1 + i2 < n - 1; i2++)
        {
            if (arr[i1] > arr[i2])
            {
                int tmp = arr[i1];
                arr[i1] = arr[i2];
                arr[i2] = tmp;
            }
        }
    }
}

int main()
{
    int a[42];
    for (int i1 = 0; i1 < 42; i1++)
        a[i1] = 42 - i1;
    bubble(a, 42);
    for (int i1 = 0; i1 < 42; i1++)
        printf("%d", a[i1]);
    return 0;
}

今回は、iaito が生成した sym.bubble のグラフを見てみましょう。

sym.bubble のグラフ

0x00001144 でカウンタが設定されているのが分かります。0x00001209 のブロックには比較があります。カウンタがしきい値にまだ達していなければ、処理は続きます。0x00001150 では別のカウンタが設定されます。0x0000115c のブロックでは、隣り合う 2 つの数の比較が行われます。前者の方が大きければ、0x0000118c のブロック内で交換が行われます。

結論

これは、リバースエンジニアリング中に見かける可能性のあるパターンのほんのいくつかの例にすぎません。リバースエンジニアリングの作業を重ねるほど経験が増え、逆アセンブルの中からより多くのパターンを素早く識別できるようになります。

Hugo で構築されています。
テーマ StackJimmy によって設計されています。