Bab 2: Membaca Source Code

Daftar Isi Utama

Sebagian proses reversing akan dilakukan terhadap binary code, tapi sebagian akan dilakukan pada source code. Source code ini bisa berupa source code asli maupun hasil proses dekompilasi. Hal pertama yang perlu dipelajari sebelum reversing adalah: mengerti bagaimana membuat program, atau minimal membaca kode program.

Proses reverse engineering binary code kira-kira bisa dilakukan seperti ini:

  • Dengan decompiler kita mendapatkan source code dalam bahasa tingkat tinggi (high level language), dan kita baca source tersebut
  • Dengan disassembler kita mendapatkan disassembly dan kita baca disassemblynya

Saya akan memberikan beberapa contoh source code yang seharusnya bisa dipahami. Jika Anda belum bisa memahami program di level source code, kemungkinan besar Anda belum siap untuk melakukan pembacaan source code di level assembly. Untuk Anda yang masih belum bisa mengikuti, sebaiknya mempelajari C dan pemrograman secara umum.

Memahami dan mengubah flow program

Salah satu penggunaan reverse engineering adalah untuk mengakali software agar tidak memeriksa kondisi tertentu. Contoh kasus yang paling umum adalah: patching software supaya menjadi versi full version (bypass software protection). Tapi ada banyak contoh kasus lain di mana proses pengubahan flow program ini bisa berguna:

  • Memaksa suatu software agar jalan di OS baru (misalnya dengan bypass pemeriksaan versi)
  • Memperbaiki bug tertentu (misalnya dengan menskip aksi tertentu yang menyebabkan software crash)
  • Optimasi software tertentu (misalnya aplikasi yang selalu melakukan koneksi ke internet bisa dibypass pemeriksaannya)

Pada kasus yang sederhana, kita hanya perlu mengetahui di mana proses pemeriksaan kondisi dilakukan, dan apa yang harus diubah agar jalannya program sesuai keinginan kita.

#!/usr/bin/python

from serial_checker import is_serial_valid
from secret import print_secret

s = raw_input('Serial: ')

if is_serial_valid(s):
   print "The secret is "
   print_secret()
else:   
   print "Invalid serial"

Ketika berurusan dengan source code seperti ini, kita bisa dengan mudah langsung memanggil print_secret(). Dalam assembly pengubahan semacam itu mungkin dilakuan, tapi biasanya lebih rumit, hal yang mudah adalah mengubah kondisi misalnya False menjadi True atau dalam kasus mengubah is_serial_valid(s) menjadi True, atau menambahkan not is_serial_valid().

Memahami algoritma dasar

Dalam kebanyakan kasus reverse engineering, kita tidak memiliki dokumentasi dan bahkan tidak memiliki nama fungsi. Seorang reverser perlu bisa memahami berbagai algoritma sederhana. Contohnya seperti ini:

void process1(int *a, int *b)
{
    int c;
    c = *a;
    *a = *b;
    *b = c;
}

void process2(int a, int *b)
{
    int c,d;
    for(c=0; c<a; c++) {
        for(d=c; d<a; d++) {
           if (b[c] > b[d]) 
               process1(&b[c], &b[d]);
        }
    }

}

Jika Anda tidak langsung tahu bahwa process1 adalah swap alias menukar dua variabel, maka Anda perlu belajar lagi dasar pemrograman. Fungsi process2 adalah algoritma bubble sort, salah satu algoritma sorting paling dasar. Algoritma yang lebih rumit seperti merge sort, quick sort kadang ditemui (tapi sering kali orang memakai algoritma standar dalam sebuah library).

Selain algoritma dasar yang diajarkan di kelas pemrograman (seperti sorting, searching, insert list, delete list, dsb), ada beberapa algoritma umum yang perlu diketahui misalnya:

  • Encoding base64 (dan mungkin varian lain seperti base32, base48)
  • Algoritma hashing
  • Algoritma enkripsi dan secure hashing

Saya menyebutkan dua jenis hashing: hashing untuk dipakai dalam struktur data (hash table) dan secure hashing (seperti MD5, SHA1). Salah satu contoh adalah hashCode di bahasa pemrograman Java (berikut ini adalah implementasi untuk kelas java.lang.String)

    @Override public int hashCode() {
        int hash = hashCode;
        if (hash == 0) {
            if (count == 0) {
                return 0;
            }
            for (int i = 0; i < count; ++i) {
                hash = 31 * hash + charAt(i);
            }
            hashCode = hash;
        }
        return hash;

Khusus untuk algoritma enkripsi dan secure hash, ada trik yang sangat membantu: sebagian algoritma standar memiliki konstanta tertentu. Konstanta ini bisa berupa sebuah bilangan (contohnya Algoritma enkripsi TEA memiliki konstanta 0x9e3779b9) atau array bilangan tertentu (contohnya algoritma DES memiliki S-Box).

Perlu dicatat bahwa tidak semua algoritma enkripsi memiliki konstanta, kita perlu mengenali bentuk algoritma standar ini atau setidaknya bagian dari algoritmanya supaya bisa segera mengenali di tengah berbagai fungsi yang rumit.

Memahami algoritma khusus

Sebagian besar aplikasi memiliki algoritma khusus, dan algoritma itu menjadi target utama RE. Contoh:

Untuk memahami algoritma ini, ada dua pendekatan: statis dan dinamis (dan tentunya gabungan keduanya). Pendekatan statis membaca dengan teliti setiap baris kodenya dan berusaha memahaminya. Pendekatan dinamis menjalankan programnya dan melihat nilai variabel di setiap langkah.

Contoh kasus: proteksi password

Saya akan memberikan contoh kasus proteksi password, dari yang sederhana sampai yang rumit. Dalam sebuah CTF varian dari berbagai pendekatan ini merupakan hal yang paling sering muncul, biasanya password adalah flag, tapi bisa juga passwordnya digunakan untuk mendekrip sebuah string flag.

Dalam dunia nyata, biasanya kita tidak mencari password, tapi encryption key. Contohnya adalah key dalam kontroversi AACS.

Perbandingan sederhana

Bentuk proteksi paling sederhana adalah: password dituliskan apa adanya dan ada di source codenya. Di sini bisa dilihat bahwa password yang diminta adalah 123456.

#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[])
{
    char password[] = "123456";
    
    if (argc<2) {
        printf("Usage: protect1 <password>\n");
        return 0;
    }
    if (strcmp(argv[1], password)==0) {
        printf("correct password\n");
    } else {
        printf("incorrect password\n");
    }
}

Menyembunyikan konstanta

Konstanta 123456 jelas terlihat sebelumnya, ini bisa disembunyikan dengan berbagai cara. Dalam kasus ini kita bisa saja mengurangi 1.

#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[])
{
    int i;
    char password[] = "012345";
    
    if (argc<2) {
        printf("Usage: protect2 <password>\n");
        return 0;
    }

    for (i=0; i < strlen(password); i++) {
        password[i] += 1;
    }
    
    if (strcmp(argv[1], password)==0) {
        printf("correct password\n");
    } else {
        printf("incorrect password\n");
    }
}

Atau menggunakan XOR

#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[])
{
    int i;
    char password[] = "RQPWVU";
    
    if (argc<2) {
        printf("Usage: protect3 <password>\n");
        return 0;
    }

    for (i=0; i < strlen(password); i++) {
        password[i] ^= 99;
    }
    
    if (strcmp(argv[1], password)==0) {
        printf("correct password\n");
    } else {
        printf("incorrect password\n");
    }
}

Dengan cara ini: seseorang tidak bisa langsung tahu passwordnya tanpa membaca source codenya. Ini masih gampang sekali diselesaikan dengan menambahkan:

puts(password);

Sebelum strcmp dilakukan. Dalam kasus RE yang sesungguhnya, kita tentunya tidak memiliki source code, tapi proses print masih bisa dilakukan menggunakan debugger (atau teknik LD_PRELOAD).

Penyembunyian Atau menggunakan varian lain, misalnya: base64 (dan bahkan tabel karakternya bisa kita modifikasi), bisa juga menggunakan enkripsi standar -- seperti DES/AES atau RSA.

Perbandingan per karakter

Selain menggunakan strcmp untuk perbandingan teks, kita juga bisa membandingkan huruf demi huruf.

#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[])
{
    int i;
    char password[] = "RQPWVU";
    
    if (argc<2) {
        printf("Usage: protect4 <password>\n");
        return 0;
    }

    if (strlen(password)!=strlen(argv[1])) {
        printf("incorrect password\n");
        return 0;
    }

    for (i=0; i < strlen(password); i++) {
        if (argv[1][i]!=(password[i] ^ 99)) {
            printf("incorrect password\n");
            return 0;
        }
    }
    
    printf("correct password\n");

}

Tentunya selain xor, fungsi apapun bisa dipanggil

#include <stdio.h>
#include <string.h>

static int myfunc(int x)
{
    return x*2 + 1;
}

int main(int argc, char *argv[])
{
    int i;
    int  password[] = {99, 101, 103, 105, 107, 109};
    
    if (argc<2) {
        printf("Usage: protect5 <password>\n");
        return 0;
    }

    if ((sizeof(password)/sizeof(int))!=strlen(argv[1])) {
        printf("incorrect password\n");
        return 0;
    }

    for (i=0; i < strlen(password); i++) {
        if (argv[1][i]!=myfunc(password[i])) {
            printf("incorrect password\n");
            return 0;
        }
    }
    
    printf("correct password\n");

}

Jika kita memiliki source code, mengubah source ini sangat mudah. Tanpa source code, kita masih bisa mendapatkan passwordnya

Perbandingan dengan waktu konstan

Teknik perbandingan satu langkah demi satu langkah bisa dengan mudah dibruteforce dengan bantuan tools seperti Intel PIN. Teorinya mudah: jika karakter pertama sudah benar, maka look akan terus ke karakter kedua, artinya akan ada lebih banyak instruksi yang dilakukan jika ada huruf yang benar. Ada cara perbandingan lain yang memakai waktu yang konstan. Salah satu caranya adalah: kita bisa menjumlahkan selisih antara input dan yang diharapkan. Jika input dan yang diharapkan sama, maka nilainya adalah 0. Tentunya tidak harus operasi penjumlahan yang dilakukan, bisa saja pengurangan atau operasi lain (tapi tidak bisa perkalian karena jika satu saja benar, semua akan 0).

#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[])
{
    int i;
    char password[] = "RQPWVU";
    
    if (argc<2) {
        printf("Usage: protect4 <password>\n");
        return 0;
    }

    if (strlen(password)!=strlen(argv[1])) {
        printf("incorrect password\n");
        return 0;
    }

    int total = 0;
    for (i=0; i < strlen(password); i++) {
        total += argv[1][i]-(password[i] ^ 99);
    }

    if (total!=0) {
        printf("incorrect password\n");
    } else {    
        printf("correct password\n");
    }

}

Perbandingan per blok

Selain membandingkan per karakter, kita juga bisa membandingkan tiap N huruf, misalnya tiap 2 atau 3 huruf. Tentunya ini juga bisa digabung dengan teknik perbandingan waktu konstan.

#include <stdio.h>
#include <string.h>

static int myfunc(int x, int y)
{
    return x*1024 + y*128;
}

int main(int argc, char *argv[])
{
    int i;
    int  password[] = {56576, 58880, 61184};
    const char *userpass;
    
    if (argc<2) {
        printf("Usage: protect6 <password>\n");
        return 0;
    }

    userpass = argv[1];

    if ((sizeof(password)/sizeof(int))*2!=strlen(userpass)) {
        printf("incorrect password\n");
        return 0;
    }

    for (i=0; i < strlen(userpass)/2; i+=2) {
        if (myfunc(userpass[i], userpass[i+1])!=password[i/2]) {
            printf("incorrect password\n");
            return 0;
        }
    }
    
    printf("correct password\n");

}

Perbandingan hash

Proteksi ini sangat aman, kita tidak bisa mendapatkan passwordnya tanpa brute force (dengan asumsi bahwa algoritma yang dipakai adalah secure hash). Secara umum hal seperti ini tidak akan ditemui di CTF kecuali jika kita bisa melakukan brute force atau menggunakan MD5 brute force online.

#include <stdio.h>
#include <string.h>
#include <openssl/md5.h>

int main(int argc, char *argv[])
{
    unsigned char digest[MD5_DIGEST_LENGTH];
    char md5hex[33];
    
    if (argc<2) {
    printf("Usage: protect8 <password>\n");
    return 0;
    }
   
    MD5((unsigned char*)argv[1], strlen(argv[1]), (unsigned char*)&digest);

    for(int i = 0; i < 16; i++)
         sprintf(&md5hex[i*2], "%02x", (unsigned int)digest[i]);

    if (strcmp(md5hex, "e10adc3949ba59abbe56e057f20f883e")==0) {
    printf("correct password\n");
    } else {
    printf("incorrect password\n");
    }

    return 0;
}

Aneka perbandingan lain

Tergantung kreativitas programmer, perbandingan bisa dilakukan dengan berbagai cara. Contohnya:

  • Perbandingan dengan menggunakan banyak thread
  • Perbandingan dengan banyak proses
  • Menggunakan berbagai trik sistem operasi (misalnya signal, message queue, dsb)
  • Menggunakan berbagai trik bahasa (misalnya menggunakan channel di bahasa Go)

Contoh kasus: serial key

Dalam berbagai software, proteksi yang dipakai adalah dalam bentuk serial key (atau serial number jika semuanya adalah angka). Biasanya user diminta memasukkan "user" dan "serial key". Berbeda dengan password, bisa ada banyak serial key yang valid untuk sebuah "user". Selain terhubung ke user, serial key juga bisa terhubung dengan identitas hardware (MAC Address, IMEI, dsb).

Berikut ini beberapa teknik memeriksa serial key. Ada beberapa cara pengecekan:

  • Ketika "user" dimasukkan, program menghasilkan serial key secara internal, lalu "serial" yang dimasukkan user dibandingkan dengan yang dihasilkan secara internal. Dalam proses perbandingan trik-trik di bagian sebelumnya juga bisa dipakai.
  • Input "user" dan "serial" digabung, lalu hasil gabungan tersebut dicek apakah memenuhi syarat tertentu
  • Serial key bisa berupa string atau angka dalam bentuk terenkripsi dan berisi informasi tentang user

Menghasilkan serial key

Dalam cara ini: program menghasilkan serial key secara internal, lalu serial itu dicek dengan yang dimasukkan user. Dengan cara ini hanya 1 serial key yang valid.

Contoh pertama ini sangat sederhana: jika panjang namanya ganjil, serial numbernya harus angka ganjil. Perhatikan bahwa saya menggunakan trik sederhana untuk melakukan pembandingan ini, ada banyak sekali trik yang bisa dipakai untuk melakukan satu hal.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static int is_serial_valid(const char *user, const char *serial)
{    
    return (strlen(user)&1) == (atoi(serial)&1);
}

int main(int argc, char *argv[])
{
    char *user;
    char *serial;
    if (argc<3) {
        printf("Usage: serial1 <user> <serial>\n");
        return 0;
    }
    user = argv[1];
    serial = argv[2];
    
    if (is_serial_valid(user, serial)) {
        printf("correct serial\n");
    } else {
        printf("incorrect serial\n");
    }
}

Berikutnya juga relatif mudah: serial key hanyalah semua huruf dijumlahkan

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static int is_serial_valid(const char *user, const char *serial)
{
    int total;
    int i;
    for (i=0; i < strlen(user); i++) {
    total += user[i];
    }
    return (total == atoi(serial));
}

int main(int argc, char *argv[])
{
    char *user;
    char *serial;
    if (argc<3) {
        printf("Usage: serial1 <user> <serial>\n");
        return 0;
    }
    user = argv[1];
    serial = argv[2];
    
    if (is_serial_valid(user, serial)) {
        printf("correct serial\n");
    } else {
        printf("incorrect serial\n");
    }
}

Contoh user/serial yang valid adalah yohanes/33526.

Hashing hanyalah sebuah fungsi, jadi fungsi sederhana sebelumnya bisa diganti dengan hash atau fungsi apapun.

Mengecek serial key memenuhi syarat tertentu

Dalam contoh ini, string "user" dan "serial" digabung (concatenate) kemudian dijumlahkan. Total dari penjumlahan harus memenuhi angka tertentu (contoh serial yang valid: yohanes/123456).

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static int is_serial_valid(const char *user, const char *serial)
{    
    int i, total;
    for (i=0; i < strlen(user); i++) {
        total += user[i];
    }
    for (i=0; i < strlen(serial); i++) {
        total += serial[i];
    }
    return (((total + 17)*100057)%1871)==812;
}

int main(int argc, char *argv[])
{
    char *user;
    char *serial;
    if (argc<3) {
        printf("Usage: serial3 <user> <serial>\n");
        return 0;
    }
    user = argv[1];
    serial = argv[2];
    
    if (is_serial_valid(user, serial)) {
        printf("correct serial\n");
    } else {
        printf("incorrect serial\n");
    }
}

Tentunya ada banyak sekali cara untuk mengecek serial number, beberapa contohnya:

  • Penggabungan string bisa dilakukan dengan berbagai cara, bukan sekedar menyambung string, bisa dibuat selang seling, bisa dihash, dsb
  • Rumus penjumlahan bisa diganti dengan metode lain, misalnya penjumlahan dan perkalian
  • Pengecekan akhir bisa dilakukan dengan berbagai cara (tidak hanya dengan ==)

Serial key dengan crypto signing

Membuat serial key dengan symmetric key sama saja dengan fungsi biasa, tapi menggunakan asymmetric (private/public key) memiliki sifat unik: kita bisa menciptakan signature dengan private key dan memverifikasi signature dengan public key. Dalam kasus RSA Jika modulus tidak bisa difaktorisasi, maka kita tidak bisa mendapatkan private key, dan tidak bisa membuat serial number baru

Untuk contoh ini saya menggunakan Python supaya lebih singkat karena library RSA bisa lebih mudah diakses.

openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:512
openssl rsa -pubout -in private_key.pem -out public_key.pem

Jumlah bit yang kecil (512) akan menghasilkan serial key yang pendek tapi ini akan kurang aman. Supaya aman sebaiknya menggunakan jumlah bit yang besar, dan pengguna aplikasi bisa memasukkannya di sebuah text box yang besar (atau via file).

import Crypto
from Crypto.PublicKey import RSA
from Crypto.Hash import SHA256
import sys

if len(sys.argv)<2:
    print "usage keygen <user>"
    exit(0)

key="""-----BEGIN PRIVATE KEY-----
MIIBUwIBADANBgkqhkiG9w0BAQEFAASCAT0wggE5AgEAAkEAv8O0OIyJ2Q0lK6eW
xxF5L8Tf8hr+AcH2zht3IoHwTPhoVWzQbqlbyB0HUfs+J22lGw9IIdWLZ+YavUip
YwDrFQIDAQABAkAxcEMGUTU4wCrVFl/I8rhLmHYj9NGHonn+qRYNz3IkZXM1+UHD
tpp4ShdMTjtTH7gX5pzFijhfjgtVDF2Bm0ABAiEA/aYtCpKQzH4GtODW8SZYshgY
/UFMsS3osVFTfTN6zH0CIQDBirJSgv/cJ5gGd1xtd/HexmApPQjiUiKUhsALARaU
eQIgWM/tp20IPEHIUV8Eg61ckwczAMHze3pKpoOGSylSTvUCIFROmoce0V2RUcPf
Ur/Ms+ua9mCAWdJcfPu+BwHEI5XhAiBuEc2STMNl++TFbIJ9313oCKydMItJ7FkM
LOxyEjaAhA==
-----END PRIVATE KEY-----
"""

privkey = RSA.importKey(key)

text = sys.argv[1]

signature = privkey.sign(SHA256.new(text).digest(), '')

print signature

print "%s_%x" % (text.encode("hex"), signature[0])

Dan program untuk mengeceknya:

import Crypto
from Crypto.PublicKey import RSA
from Crypto.Hash import SHA256
import sys


if len(sys.argv)<3:
    print "usage keycheck <user> <serial>"
    exit(0)

key="""-----BEGIN PUBLIC KEY-----
MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAL/DtDiMidkNJSunlscReS/E3/Ia/gHB
9s4bdyKB8Ez4aFVs0G6pW8gdB1H7PidtpRsPSCHVi2fmGr1IqWMA6xUCAwEAAQ==
-----END PUBLIC KEY-----
"""

public_key = RSA.importKey(key)

user = sys.argv[1]
serial = sys.argv[2]

(realtext,sig) = serial.split("_")

sig = (int(sig, 16), )

realtext = realtext.decode("hex")

if realtext!=user:
    print "Invalid serial"
    exit(0)

hash = SHA256.new(user).digest()

if public_key.verify(hash, sig):
    print "Correct key"
else:
    print "Invalid key"

Jika Anda menemui program yang tidak ada key generatornya, kemungkinan metode sejenis ini yang dipakai. Biasanya dalam kasus seperti ini, cracker akan melakukan patching (baik dengan patching key baru, atau patching kodenya). Kelemahan dengan cara patching adalah: di versi berikutnya patch ini perlu diupdate (key bisa disimpan di tempat lain, algoritma bisa diubah sedikit).

Copyright © 2009-2018 Yohanes Nugroho