Bab 8: Aristektur AVR

AVR dipilih sebagai contoh Harvard Architecture (tepatnya modified Harvard Architecture). AVR dipakai di Arduino yang saat ini sudah sangat populer. Arduino bisa didapatkan dari berbagai situs seperti Aliexpress dan DX dengan harga murah sekali.

Ketika tulisan ini dibuat, di Aliexpress harga Arduino UNO 3,62 USD sudah termasuk kabel dan jika Anda sudah punya kabel printer, Arduino bisa dibeli 3,20 USD saja. Arduino lainnya yang cukup mudah dipakai adalah Arduino nano yang bisa didapat sekitar 2 USD saja (versi Atmega168P 1.92 USD, versi Atmega32P 2.14 USD). Versi Arduino pro mini lebih murah lagi, tapi Anda perlu membeli USB programmer dan USB to serial.

Dokumentasi

Dokumentasi utama adalah AVR Instruction set manual. Tiap device AVR mendukung instruksi yang tidak sama persis, tablenya bisa dilihat di Wikipedia.

Setiap chip memiliki fitur yang berbeda, misalnya ada yang mendukung USB, ada yang mendukung CAN bus, dsb. Tentunya Anda perlu juga datasheet device yang akan direverse. Dari datasheet ini Anda akan bisa menemukan panduan bagaimana mengakses tiap fitur.

Contoh, untuk Arduino Nano, datasheetnya adalah Atmega328

Bootloader

AVR bisa memiliki bootloader, baik yang sederhana dan opensource maupun yang kompleks dan closed source. Bootloader biasa yang terbuka akan mengijinkan pembacaan dan penulisan memori program mana saja. Bootloader bisa menerima atau menolak perintah apa saja dari host, jadi untuk device yang secure kemungkinan Anda tidak akan bisa membaca isi kode program maupun bootloadernya.

Dalam challenge RHME, mereka mengirimkan device dengan bootloader khusus yang sudah diprogram. Boot loader ini menerima HEX yang sudah dienkripsi, dan bootloader akan mendekrip dan menuliskan isinya ke Flash. Jadi semua file HEX mereka tidak akan bisa di reverse engineer di desktop karena terenrkrip. Di beberapa challenge mereka memberikan tantangan mudah supaya RE bisa dilakukan di desktop (ada versi terenkrip untuk di-write ke device dan versi tidak terenkrip untuk RE). Sebagai catatan: bootloader yang khusus ini juga akan menolak jika kita berusaha membaca data apapun (termasuk juga EPROM).

Tools

Compiler

Seperti telah dibahas sebelumnya, compiler sangat berguna karena kita bisa mencari tahu output dari sebuah kode dalam C.

Compiler yang ada:

  • GCC-AVR (dipakai juga oleh Atmel Studio)
  • IAR

Tools yang saya pakai adalah GCC. Kompilasi sederhana bisa dilakukan seperti ini:

avr-gcc -O3 test-avr1.c -mmcu=atmega328 -o test-avr1.elf

Tentunya silakan diganti dengan MCU yang yang Anda pakai. Jika ingin mendapatkan versi assembly dari program kecil yang Anda buat, gunakan -s, misalnya:

avr-gcc -s test-avr1.c -mmcu=atmega328

Atau bisa juga Anda hasilkan file elf-nya, lalu dump menggunakan avr-objdump

avr-objdump -d test-avr1.elf

Untuk menghasilkan file .hex yang bisa diflash

avr-objcopy -j .text -j .data -O ihex test-avr1.elf test-avr1.hex

Untuk konversi dari hex ke bin

avr-objcopy -I ihex -O binary avr.hex avr.bin

Disassembler

Beberapa disassembler untuk AVR:

  • IDA Pro, tapi harus hati-hati, ELF handling untuk AVR kadang ngawur
  • Radare
  • avr-objdump

Saya sendiri tidak memakai IDA pro, jika ingin memakai ini, coba compile sendiri program Anda lalu coba baca listingnya, dengan ini Anda akan bisa membandingkan dengan kode sendiri yang Anda compile.

Untuk melakukan reversing sederhana:

avr-objdump -d namafile.elf

Untuk reversing file .hex

avr-objdump -j .sec1 -d -m avr5 namafile.hex

Perhatikan bahwa alamat yang dipakai objdump adalah 2 kali dari yang dipakai oleh IDA. Alamat yang dipakai oleh objdump adalah penomoran memori secara absolut, sedangkan IDA memakai nomor instruksi. Jadi jika kita memiliki empat instruksi seperti ini (2 byte di kiri adalah opcodenya)

2f 92       push    r2
3f 92       push    r3
4f 92       push    r4
5f 92       push    r5

Maka alamat di IDA adalah:

0: 2f 92    push    r2
1: 3f 92    push    r3
2: 4f 92    push    r4
3: 5f 92    push    r5

Sedangkan di objdump:

0: 2f 92    push    r2
2: 3f 92    push    r3
4: 4f 92    push    r4
6: 5f 92    push    r5

Perlu diperhatikan bahwa ketika menggunakan instruksi IJMP/ICALL, penomoran IDA adalah yang benar. Jadi jika register Z bernilai 2, maka IJMP akan menuju ke instruksi push r4 dan bukan ke push r3.

Debugger dan Simulator

Device AVR tertentu memiliki konektor JTAG sehingga bisa didebug menggunakan hardware debugger.

Simulavr bisa digunakan untuk mengemulasikan kode, terutama jika tidak menggunakan hardware khusus. Versi simulavr yang saya pakai adalah dari: https://github.com/Traumflug/simulavr (versi ini mendukung input/output serial port)

Tools lain

Flashing bisa dilakukan menggunakan program avrdude.

Tools lain yang berguna adalah: avr-objcopy untuk konversi dari dan ke ELF, BIN dan HEX.

Arsitektur

AVR menggunakan arsitektur harvard dengan memori space untuk kode dan data dipisah. Jadi alamat 0 yang menunjuk ke kode tidak sama dengan alamat 0 yang menunjuk ke data. Instruksi untuk mengakses data yang berupa kode program (LPM) dan instruksi untuk mengakses data di RAM berbeda (LD/ST).

Register

AVR memiliki 32 register, masing-masing berukuran 8 bit, dari R0 sampai R31.

Ada pasangan register khusus yang dipakai oleh beberapa instruksi tertentu

 R31:R30 Z
 R29:R28 Y
 R27:R26 X

Karena register 16 bit hanya bisa mengakses memori 64 KB, maka ada register tambahan untuk device yang memiliki memori program lebih dari 64 KB. Register RAMPX, RAMPY, RAMPZ akan ditambahkan ke X, Y, Z supaya bisa mengakses hingga 16 MB.

Layout Memori

Tigapuluh dua register general purpose (R0 sampai R31) dalam AVR di map ke RAM dari 0x00 sampai 0x1F, artinya akses ke alamat memori itu sama dengan mengakses register. Register stack pointer bisa diakses di memori 0x3E (MSB stack pointer) dan 0x3D (LSB stack pointer). Untuk device yang RAM-nya 256 byte atau kurang hanya LSB yang dipakai. Register status/flag (REG) ada di memori 0x3F. Anda akan banyak melihat 3 angka tersebut di listing assembly AVR.

Alamat RAM 0x20 sampai 0x5F dipetakan ke register I/O, dan alamat 0x60-0xFF dipetakan ke extended I/O. Tiap I/O perlu dibaca khusus karena tidak mudah menjelaskannya. Data di RAM yang dipakai program dimulai dari alamat 0x100. Berikut ini memori map yang saya ambil dari datasheet Atmega328P.

dataram.png

Entry Point

Eksekusi dimulai dari alamat 0, atau lebih tepatnya lagi: ketika program direset, maka handler untuk interrupt "RESET" akan dieksekusi, dan kebetulan alamatnya adalah alamat 0. Umumnya di sini akan ditemui RJMP (relative jump) ke awal program.

Tergantung devicenya, alamat-alamat berikutnya adalah alamat interrupt handler (watchdog, timer, dsb). Jika tidak dipakai biasanya ini akan mengarah ke alamat yang akan "hang" (jump ke diri sendiri) atau "reset" (akan jump ke alamat 0).

Disassembly of section .text:

00000000 <__vectors>:
   0:   0c 94 34 00     jmp     0x68    ; 0x68 <__ctors_end>
   4:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
   8:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
   c:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  10:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  14:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  18:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  1c:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  20:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  24:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  28:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  2c:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  30:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  34:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  38:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  3c:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  40:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  44:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  48:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  4c:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  50:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  54:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  58:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  5c:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  60:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>
  64:   0c 94 3e 00     jmp     0x7c    ; 0x7c <__bad_interrupt>

Di dalam reset, hal yang pertama dilakukan biasanya adalah mengeset r1 menjadi 0, membersihkan flag, mensetup stack ke akhir memori.

    eor     r1, r1          ; clear R1
    out     0x3f, r1        ; clear SREG
    ldi     r28, 0xFF       ; 0x8FF (device ini memiliki memori 2048 byte)
                            ; 256 byte pertama dimap ke register/IO 
                            ; 0x8ff-256 = 2047 (alamat dimulai dari 0)
    ldi     r29, 0x08       ; 
    out     0x3e, r29       ; Set stack pointer
    out     0x3d, r28       ; 

Kecuali diakses dengan instruksi khusus (LPM/ELPM/SPM), data di dalam flash/program memory tidak bisa diakses, jadi di awal biasanya ada instruksi untuk mengcopy data yang diiinisilisasi (dan string) ke RAM. Data yang diinisialisasi contohnya adalah variabel global dengan nilai awal di C, misalnya:

 int a = 1;
 char b[] = "123";

Dalam contoh berikut ini, saya membuat program dengan data "hello world". Data yang dicopy hanya 12 byte (0xc) yaitu string "hello world\0", di program alamatnya 0x200 dan ketika diload ke RAM pindah ke alamat 0x100. Jadi ketika reversing, jangan mencoba mencari alamat 0x200 untuk mencari bagian mana yang mengakses string "hello world", karena tidak akan ketemu (sudah pindah ke alamat 0x100).

    <__do_copy_data>:
    ldi     r17, 0x01       ; 1 COUNTER

    ldi     r26, 0x00       ; 
    ldi     r27, 0x01       ; Alamat RAM 0x100 (ke register X)

    ldi     r30, 0x00       ; 
    ldi     r31, 0x02       ; Alamat ROM/Flash 0x200 (ke register Z)
    rjmp    .+4             ; 0x84 <__do_copy_data+0x10>
    lpm     r0, Z+          ; Load dari ROM (dan increment Z)
    st      X+, r0          ; tulis ke RAM (dan increment X)
    cpi     r26, 0x0C       ; Compare counter dengan 0xC
    cpc     r27, r17
    brne    .-10            ; 0x80 <__do_copy_data+0xc>

Status Register (SREG)

Ini merupakan flag register, artinya register ini akan diupdate sebagai efek samping instruksi lain. Misalnya jika hasil pengurangan adalah nol, maka bit Z (zero) akan diset. Di AVR bit-bit ini juga bisa diubah langsung dengan instruksi khusus.

Bit yang ada:

  • C (carry)
  • Z (zero)
  • N (negative)
  • V (overflow)
  • S (signed)
  • H (half carry)
  • T (transfer)
  • I (interrupt)

Instruksi

Sebelum membaca ini, sebaiknya baca dulu bab 5 yang membahas assembly secara umum. Segala macam instruksi jump dan branch tidak akan saya bahas karena strukturnya sama dengan assembly pada umumnya. Instruksi aritmatika dasar juga seperti instruksi dalam kebanyakan prosessor lain.

AVR hanya memiliki 124 instruksi. Ini sangat sedikit dibanding Intel, dan meskipun angka 124 ini terlihat banyak, tapi sebenarnya banyak yang redundan jika dilihat dari sisi reverser, misalnya instruksi ADD untuk penjumlahan 2 register, sedangkan ADI untuk register dengan immediate (dengan angka langsung). Yang sejenis ini ada beberapa: SUB/SUBI, AND/ANDI, OR/ORI). Kemudian ada lebih dari 20 instruksi branch yang intinya adalah melihat status register, plus 17 instruksi yang sekedar mengeset bit dalam SREG bisa dibaca sekilas saja.

Untuk device seperti Arduino UNO, Nano yang memiliki flash memory kurang dari 64 KB, kita bisa mengabaikan instruksi yang memakai prefix E (EICALL, EIJMP, ELPM). Biasanya dalam reversing instruksi SLEEP, BREAK dan WDR juga tidak perlu diperhatikan.

Ada berbagai cara mengelompokkan instruksi, menurut saya instruksi AVR bisa dikelompokkan seperti ini:

  • instruksi aritmatika (ADC, ADD, ADIW, ASR, DEC, FMUL, FMULS, FMULSU, INC, MUL, MULS, NEG, SBC, SBCI, SBIW, SUB, SUBI)
  • instruksi bit/logic (AND, ANDI, CBR, CLR, COM, EOR, LSL, LSR, OR, ORI, ROL, ROR, SBR, SER, SWAP)
  • conditional logic (SBRC, SBRS)
  • compare (CP, CPC, CPI, CPSE, TST)
  • I/O (CBI, IN, OUT, SBI)
  • conditional I/O (SBIC, SBIS)
  • flag (BSET, BST, CLC, CLH, CLI, CLN, CLT, CLV, CLZ, SEC, SEH, SEI, SEN, SES, SET, SEV, SEZ)
  • branching (BRBC, BRBS, BRCC, BRCS, BREQ, BRGE, BRHC, BRHS, BRID, BRIE, BRLO, BRLT, BRMI, BRNE, BRPL, BRSH, BRTC, BRTS, BRVC, BRVS)
  • debugging (BREAK)
  • call (CALL, EICALL, ICALL, RCALL, RET, RETI)
  • jump (JMP, EIJMP, IJMP, RJMP)
  • akses register (LDI, MOV, MOVW)
  • akses ke memori program (ELPM, LPM, SPM)
  • akses ke RAM (ST, STD, LD, LDD, LAT, LAS, STS, XCH)
  • akses ke stack (PUSH, POP)
  • enkripsi (DES)
  • lain-lain (NOP, SLEEP, WDR)

Karena ukuran register hanya 8 bit, maka berbagai operasi aritmatika pada angka 16 bit akan terdiri dari beberapa instruksi. Instruksi DIV juga tidak ada jadi jika kode program kita mengandung operasi mod (%) maka compiler akan menghasilkan fungsi built in __divmodhi4 dan __udivmodhi4 (dan di dalamnya ada loop).

Contoh sederhana lain adalah seperti ini:

int h_m_s_to_seconds(int h, int m, int s)
{
        return h*3600 + m*60 + s;
}

Dalam assembly yang dioptimasi:

00000080 <h_m_s_to_seconds>:
  80:   20 e1           ldi     r18, 0x10       ; 16
  82:   3e e0           ldi     r19, 0x0E       ; 14
  84:   fc 01           movw    r30, r24
  86:   e2 9f           mul     r30, r18
  88:   c0 01           movw    r24, r0
  8a:   e3 9f           mul     r30, r19
  8c:   90 0d           add     r25, r0
  8e:   f2 9f           mul     r31, r18
  90:   90 0d           add     r25, r0
  92:   11 24           eor     r1, r1
  94:   ec e3           ldi     r30, 0x3C       ; 60
  96:   e6 9f           mul     r30, r22
  98:   90 01           movw    r18, r0
  9a:   e7 9f           mul     r30, r23
  9c:   30 0d           add     r19, r0
  9e:   11 24           eor     r1, r1
  a0:   82 0f           add     r24, r18
  a2:   93 1f           adc     r25, r19
  a4:   84 0f           add     r24, r20
  a6:   95 1f           adc     r25, r21
  a8:   08 95           ret

Perhatikan konstanta 3600 (0xe10) diload menggunakan dua instruksi (ldi r18, 0x10 dan ldi r19, 0x0e). Secara umum ini agak mempersulit RE karena kita jika kita cari 0xe10 tidak akan ketemu. Ini berlaku juga untuk konstanta seperti lokasi string, sehingga biasanya disassembler tidak akan otomatis menemukan reference ke string.

Calling convention

GCC untuk AVR memiliki calling convention seperti ini:

R0 scratch register (boleh dipakai sembarang)
R1 selalu 0

Ketika memanggil fungsi, R18-R27, R30, R31 bisa diubah oleh fungsi yang kita panggil, jadi pemanggil harus kita simpan jika ingin nilainya tidak berubah. Register R2–R17, R28, R29 harus disimpan oleh fungsi, jadi pemanggil bisa yakin bahwa nilainya tidak akan berubah.

Parameter selalu dipass semuanya di register, atau semuanya di memori. Calling conventionnya agak rumit:

  • Register R8 sampai R25 dipakai untuk parameter dan return value
  • Ukuran parameter dibulatkan ke bilangan genap berikut (jadi untuk 1 char akan dianggap memakai 2 register)
  • Jika ada return value, alokasi registernya dianggap seperti parameter pertama
  • Alokasi register mulai dari R26 minus ukuran register

Contoh:

int func(char a, long b)

Nilai kembalian adalah int (2 byte): 26 - 2 = 24, jadi ini akan disimpan R24 dan R25

Ukuran a 1 byte dibulatkan jadi 2, jadi ini akan ditempatkan di R24, dan R25 akan dibiarkan tidak terpakai.

Ukuran b adalah 4 byte, jadi akan memakai R23, R22, R21, R20

Serial I/O

Tidak seperti komputer yang outputnya ke layar monitor, I/O sebuah microcontroller bisa ke mana saja, misalnya ke LED, speaker, LCD (dengan berbagai interface yang mungkin), serial port. Dalam berbagai challenge biasanya serial port digunakan. Untuk berbagai jenis I/O hal pertama yang bisa Anda coba adalah: mencari contohnya dalam C, lalu compile dengan GCC, lalu lakukan disassembly.

Contohnya, Anda bisa melihat posting blog ini mengenai bagaimana melakukan input/output serial port. Bisa dilihat bahwa yang penting adalah: ketika input kita membaca dari UDR0, dan output dengan menulis ke UDR0. Ini kemudian bisa dicocokkan dengan MCU yang kita pakai, contohnya di Atmega 328 ini ada di alamat 0xC6 (silakan search sendiri string "UDR0" di dalam datasheet Atmega328).

Dengan mencari instruksi yang mengakses 0xC6 kita akan menemukan subrutin yang menangani input serial

    ldi     r24, 0xC6       ; R25:R24 -> 00C6
    ldi     r25, 0x00       ; 
    movw    r30, r24        ; copy R25:R24 -> R30:R29
    ld      r24, Z          ; baca dari serial ke r24

Dan contoh output serial

    ldi     r24, 0xC6       ; R25:R24 -> 00C6
    ldi     r25, 0x00       ; 
    movw    r30, r24        ; -> copy R25:R24 -> R30:R29
    st      Z, r18          ; outputkan isi r18 ke serial

Biasanya fungsi yang memanggil serial output per karakter adalah fungsi puts atau printf, dan fungsi yang memanggil serial input per karakter adalah gets atau semacamnya. Dari situ Anda bisa menelusuri balik untuk menemukan string apa yang dicetak.

Membaca ROM dengan printf

Ketika melakukan eksploitasi printf, ada satu format string yang berguna yaitu %S (S besar, bukan kecil) yang akan membaca dari ROM. Dengan ini kita bisa melakukan ROM dumping untuk mempelajari kode program jika tidak punya binarynya. Dokumentasi lengkap format string yang didukung AVR LIBC bisa dibaca di sini.

S Similar to the s format, except the pointer is expected to point to a program-memory (ROM) string instead of a RAM string.

Copyright © 2009-2018 Yohanes Nugroho