Divisionen, Moduli und Bubble Sort in x86_64

Algorithmen, die in Hochsprachen (z. B. C) geschrieben sind, wirken im Quellcode sehr offensichtlich. Der Compiler kann sie jedoch auf Assemblerebene in recht mysteriöse Muster umwandeln. Um unsere Fähigkeiten in der Reverse Engineering zu verbessern, müssen wir diese Muster kennenlernen, damit wir sie schnell erkennen können. Dieser Blogbeitrag listet einige Beispiele auf, die darauf beruhen, was mir heute beim Lösen einer CTF-Aufgabe begegnet ist.

Vorzeichenbehaftete Divisionen zwischen ganzen Zahlen

Nehmen wir diesen Code als Beispiel:

#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;
}

Kompilieren Sie ihn, starten Sie Ihr glänzendes iaito oder radare2, und sehen Sie sich die Assemblersprache an. Wir müssen uns nicht um main() kümmern, also können wir direkt zu sym.div gehen; dort sehen wir etwas in etwa wie Folgendes:

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

Wir sehen hier keine idiv-Instruktion. Stattdessen sehen wir an 0x00001156 eine imul-Instruktion, gefolgt von einer shr-Instruktion und einer sar-Instruktion. Danach befindet sich das Ergebnis im Register edx.

Aber was passiert, wenn wir eine andere Zahl verwenden? Versuchen wir es. Kompilieren Sie dies:

#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;
}

Und wir erhalten eine ähnliche Disassemblierung:

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

Was passiert, wenn wir eine negative Zahl verwenden? Ersetzen Sie 6 durch -7, und wir erhalten etwas Ähnliches:

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

Und was passiert, wenn wir keine Konstante verwenden? Kompilieren Sie dies:

#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;
}

Danach erhalten wir endlich etwas anderes:

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

Eine x86_64-CPU unterstützt Division durchaus, aber sie ist teuer. Wenn der Divisor also bereits als Konstante gegeben ist, führt der Compiler diese Optimierung für uns zur Compile-Zeit durch. Er berechnet aus dem Divisor einen magischen Multiplikator und wandelt die Division in eine Kombination aus Multiplikation und Bitverschiebungen um. Es gibt eine Arbeit, die diesen Algorithmus genauer erklärt.

Wenn die Konstante jedoch erst zur Laufzeit bekannt ist, hilft diese Optimierung nicht, also verwendet der Compiler direkt die Divisionsanweisung.

Modulo-Operationen

Kompilieren wir nun dies:

#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;
}

Für mymod() erhalten wir diese Disassemblierung:

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

Die Instruktionen von 0x00001150 bis 0x00001161 implementieren eine Division mithilfe des oben erklärten magischen Multiplikators. Danach erhält es eine Kopie des Dividenden in ecx und verschiebt ihn arithmetisch um 31 Bits nach rechts. Wenn der Dividende nicht negativ ist, wird ecx 0 sein; andernfalls ist er -1.

Dies dient dazu, einen Bias aus dem Dividenden zu berechnen, denn die Division schneidet immer zur Null hin ab. Wir verwenden den Quotienten, um den Modulo zu berechnen: Für positive Zahlen gilt Modulo = Dividende - Divisor * Quotient; für negative Zahlen gilt Modulo = Dividende - Divisor * (Quotient + 1).

Bubble Sort

Kompilieren wir nun dies:

#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;
}

Diesmal betrachten wir den von iaito erzeugten Graphen von sym.bubble:

der Graph von sym.bubble

Wir können sehen, dass bei 0x00001144 ein Zähler gesetzt wird. Im Block 0x00001209 gibt es einen Vergleich. Wenn der Zähler den Schwellenwert noch nicht erreicht hat, geht die Ausführung weiter. Bei 0x00001150 wird ein weiterer Zähler gesetzt. Im Block 0x0000115c findet der Vergleich zwischen zwei benachbarten Zahlen statt. Wenn die erste größer ist, wird im Block 0x0000118c ein Austausch vorgenommen.

Fazit

Dies sind nur einige Beispiele für die Muster, die wir beim Reverse Engineering sehen können. Je mehr Reverse-Engineering-Arbeit wir machen, desto mehr Erfahrung gewinnen wir, und desto schneller werden wir in der Lage sein, weitere Muster in der Disassemblierung zu erkennen.

Erstellt mit Hugo
Theme Stack gestaltet von Jimmy