Bad Mouse (SECCON CTF 2019 Domestic Finals) Writeup

本稿はISer Advent Calendar 2019 の 20 日目として書かれた記事です。直近の土日に SECCON CTF という大会があったので、そこで出題されていた Bad Mouse という問題の Writeup を残しておこうと思います。

SECCON CTF 2019 国内決勝

12/21 11:00 - 12/22 16:00 の 2 日間開催された SECCON CTF 2019 国内決勝に、dodododo というチームとして、xrekkusu さん、nolze さん、akiym さんと出場しました。チームメンバーが強かったのと、Web 問題が一切なかったのとで、僕は地蔵をしていただけでした1

この SECCON CTF は King of the Hill という形式の大会で、アタックポイントとディフェンスポイントの合計が高いチームの勝利となります。アタックポイントとは 1 度でも攻撃に成功すれば得られるポイントのことで、ディフェンスポイントとは攻撃成功の証を攻撃対象のサーバ中に残し続けることにより、一定時間おきに得られるポイントのことです。直感的にはいくつかあるサーバに対して攻撃を試みることで点がもらえる、またそのサーバを「占領」することによっても点がもらえる、という感じです。SECCON CTF では攻撃対象のサーバを「塔」と呼んでおり(確か)、今回の問題セットは 5 つの塔と 6 つの Jeopardy 問題により構成されていました。

このような競技の形式上、ディフェンスポイントを継続して取り続けられるかどうかが非常に重要になってきます。そのため dodododo は 1 日目にディフェンスポイントのとれる問題に注力し、1 日目の夜 (問題サーバへのアクセスが出来ない時間帯) に Jeopardy 問題を含むお持ち帰りできる系問題をやり、2 日目はまたディフェンスポイントの取れる問題に時間を割く、という流れで競技時間を使いました。

僕個人としては、1 日目はスネークゲームの問題でなんとも言えない戦績を挙げ、夜は Bad Mouse なる問題と mimura という問題をやり、2 日目はスネークゲームや pwn をするなどしていました2。そして本稿は Bad Mouse なる問題の Writeup です。

Bad Mouse (Reversing)

1 日目の競技開始時に、次のような問題文が公開されるのと同時に、Digispark のボードが各チームに配布されました。

Hack the USB HID device.

No claim no return. Firmware dump list is as follows:
:10000000A5C1C9C1F5C1C7C194C5C5C1C4C1C3C1DA
:10001000C2C1C1C1C0C1BFC1BEC1BDC1BCC13D3EE5
:100020003F4061426344415569486B4A4B4C4D4ED9
:1000300051504F61535C5556575859606B656D6709
(...以下省略...)

なるほど、マイコン (Digispark) に書かれたプログラムを解析すればよいみたいです。

ところでファームウェアダンプとして渡されているものは明らかに Intel HEX 形式の文字列ですね。そこでひとまずこの文字列をバイナリファイルに変換してしまいましょう。

# dump.hex は上記の Intel HEX 形式のファイル
# gobjcopy は適当に合わせる
gobjcopy -I ihex dump.hex -O binary dump.bin

表層解析

私はそんなに Reversing が得意ではありません。そこでまずは簡単な表層解析でもして、フラグのありかへのあたりをつけ、静的解析をサボる方法でも探してみることにしましょう。

“Bad Mouse” という問題名から察するに、この USB デバイスはマウスとして振る舞い、起動すると何らかのマウス操作を通してフラグを表示するのでしょう。そこで適当なペイントソフトウェアを起動し、そこにカーソルを置いた上で、解析対象を適当なマシンに差し込んで動かしてみることにします。

すると以下の動画に示すように、f が始め描画されて、その後カーソルの動きが遅くなります。

フラグが描画されていく様子 (パッチなし)

この結果からは次のような推測ができます。

  • 描画される文字列は flag is ... のような文字列だろう。
    • ファームウェアダンプ中には簡単な画像ライクなファイルが埋まっているのかもしれない。
  • 全ての文字を描画するためには相当な時間を要するだろう。

もしフラグが画像ライクなファイルとしてファームウェア中に埋まっているのであれば、もしかすると binwalk コマンドあたりで探せば出てくるかもしれません。

$ binwalk -e dump.bin

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------

おっと、何もありません。少なくとも適切なファイルヘッダのついた画像ファイルは埋まっていなさそうです。なんだかある程度は静的解析をしないといけなさそうです。

静的解析

あまり楽ができないと分かってきたところで、マイコン (Digispark) に書かれたプログラムを解析していくことにします。まずは Ghidra に適切な設定を施して、ファームウェアダンプをロードしてあげます。

Ghidra の設定の様子

ロードされたときの様子

競技中は Ghidra のデコンパイラが大嘘を言っている気がしたので、あまりデコンパイラの出力は信じずに解析していました。ただ以下では簡単のため、Ghidra のデコンパイラの出力を用いて説明をしていきます。

推測ベースで読む

まずはエントリーポイントを適当に探します。すると始めは以下のような処理に飛びそうなのが分かります。

void FUN_code_0001a6(void)

{
  undefined *puVar1;
  
  R1 = 0;
  write_volatile_1(SREG,0);
  R17 = '\0';
  X = (undefined *)0x60;
  Z = 0xfbc;
  while( true ) {
    puVar1 = X;
    if ((byte)X == 100 && X._1_1_ == (char)(R17 + ((byte)X < 100))) break;
    R0 = (undefined)(*(uint *)(uint3)(Z >> 1) >> (Z & 1));
    Z = Z + 1;
    X = X + 1;
    *puVar1 = R0;
  }
  R18 = '\0';
  X = (undefined *)0x64;
  while( true ) {
    puVar1 = X;
    if ((byte)X == 0xc1 && X._1_1_ == (char)(R18 + ((byte)X < 0xc1))) break;
    X = X + 1;
    *puVar1 = R1;
  }
  Y = 0x1a6;
  while( true ) {
    if ((byte)Y == 0xa5 && Y._1_1_ == (char)(((byte)Y < 0xa5) + '\x01')) break;
    Y = Y - 1;
    Z = Y;
    DAT_mem_025d = 0x1c6;
    FUN_code_0007c9();
  }
  DAT_mem_025d = 0x1ca;
  FUN_code_000586();
  do {
                    /* WARNING: Do nothing block with infinite loop */
  } while( true );
}

最後の方に無意味な do ... while があるので、最期の FUN_code_000586(); あたりが本質的な処理でしょう。ということで FUN_code_000586 の処理を見ていきます。

void FUN_code_000586(void)

{
  FUN_code_0005ec();
  FUN_code_000498();
  do {
    FUN_code_0004f6();
  } while( true );
}

なんだか初手で 2 つ関数を呼んで、そのあと do ... while ループに入っています。これがマウスとして振る舞うハードウェアであることを勘案すると、おそらく前半の方で初期化処理なんかをして、後半の do ... while ループでマウスの移動のような処理が進むのでしょう。となると本質は do ... while ループの中の関数、すなわち FUN_code_0004f6 です。次はこれを解析してみます。

void FUN_code_0004f6(undefined2 uParm1,byte bParm2,undefined2 uParm3)

{
  byte bVar1;
  byte bVar2;
  char cVar3;
  char cVar4;
  undefined uVar5;
  byte bVar6;
  undefined uVar7;
  undefined uVar8;
  undefined uVar9;
  
  uVar9 = Yhi;
  uVar8 = Ylo;
  read_volatile(DDRG);
  uParm1 = CONCAT11(R23R22._1_1_,6);
  W = FUN_code_0007b0();
  bParm2 = W._1_1_;
  bVar1 = read_volatile_1(PORTG);
  cVar3 = read_volatile_1(DAT_mem_0066);
  R23R22._0_1_ = bVar1 + (byte)W;
  R23R22._1_1_ = cVar3 + R1 + CARRY1(bVar1,(byte)W);
  if ((W & 0x80) != 0) {
    R23R22._1_1_ = R23R22._1_1_ + -1;
  }
  bVar1 = (byte)R23R22 + 0x20;
  Z = CONCAT11(R23R22._1_1_ - ((bVar1 < 0xe0) + -1),bVar1);
  bVar2 = -(byte)R23R22 - 0x3f;
  uParm3 = CONCAT11(-1 - (R23R22._1_1_ + (bVar2 < (byte)R23R22)),bVar2);
  Z._0_1_ = (char)(*(uint *)(uint3)(Z >> 1) >> ((uint)bVar1 & 1)) + bVar2 & 0x3f;
  while( true ) {
    Z._1_1_ = 0;
    bParm2 = bParm2 - 1;
    if ((bParm2 & 0x80) == 0x80) break;
    Z._0_1_ = (byte)Z >> 1;
  }
  Ylo = (byte)Z & 1;
  Yhi = '\b';
  do {
    cVar3 = read_volatile_1(DAT_mem_0060);
    if (cVar3 == -0x80) {
      cVar3 = -0x7f;
    }
    write_volatile_1(DAT_mem_0076,Ylo);
    write_volatile_1(DAT_mem_0077,R1);
    write_volatile_1(OCR1CL,cVar3);
    write_volatile_1(OCR1CH,R1);
    FUN_code_0004b2();
    Yhi = Yhi + -1;
  } while (Yhi != '\0');
  cVar4 = read_volatile_1(DDRG);
  cVar3 = read_volatile_1(DAT_mem_0060);
  if (cVar3 == '\x01') {
    cVar3 = cVar4 + '\x01';
    W = CONCAT11(cVar3,cVar4);
    write_volatile_1(DDRG,cVar3);
    if ('\v' < cVar3) {
      write_volatile_1(DAT_mem_0060,0xff);
      write_volatile_1(DDRG,cVar4);
      write_volatile_1(DAT_mem_0076,Ylo);
      write_volatile_1(DAT_mem_0077,4);
      write_volatile_1(OCR1CL,R1);
      write_volatile_1(OCR1CH,R1);
      W = FUN_code_0004b2();
      Ylo = uVar8;
      Yhi = uVar9;
      return;
    }
  }
  else {
    bVar1 = cVar4 - 1;
    W = CONCAT11(bVar1,cVar4);
    write_volatile_1(DDRG,bVar1);
    if ((bVar1 & 0x80) != 0) {
      write_volatile_1(DAT_mem_0060,1);
      write_volatile_1(DDRG,cVar4);
      uVar5 = read_volatile_1(PORTG);
      uVar7 = read_volatile_1(DAT_mem_0066);
      W = CONCAT11(uVar7,uVar5);
      W = W + 2;
      write_volatile_1(DAT_mem_0066,W._1_1_);
      write_volatile_1(PORTG,(byte)W);
      write_volatile_1(DAT_mem_0076,Ylo);
      write_volatile_1(DAT_mem_0077,4);
      write_volatile_1(OCR1CL,R1);
      write_volatile_1(OCR1CH,R1);
      FUN_code_0004b2();
      bVar6 = read_volatile_1(PORTG);
      cVar3 = read_volatile_1(DAT_mem_0066);
      bVar2 = (bVar6 < 0x43) + 2;
      bVar1 = cVar3 - bVar2;
      W = CONCAT11(bVar1,bVar6);
      if (bVar2 <= bVar1) {
        write_volatile_1(DAT_mem_0066,R1);
        write_volatile_1(PORTG,R1);
      }
    }
  }
  Ylo = uVar8;
  Yhi = uVar9;
  return;
}

見るに堪えないデコンパイル結果ですね。しかし少し俯瞰すると、DAT_mem_0076, DAT_mem_0077, OCR1CL, OCR1CH への write_volatile_1 が 3 箇所くらいあることが分かります。またその後に必ず FUN_code_0004b2 が呼ばれていることも分かります。とくにこの 3 箇所は以下のような形で並んでいます。

do {
    (write_volate_1 たち)
    FUN_code_0004b2();
} whle(hoge);

if (foo) {
    (write_volate_1 たち)
    FUN_code_0004b2();
} else {
    (write_volate_1 たち)
    FUN_code_0004b2();
}

ここで先の表層解析で確認したマウスの動きが 「ゆっくり下がる→右にちょっとずれる→ゆっくり上がる→…」 というものだったことを思い出しましょう。このような挙動は次のように書き下すことが出来ます。

int Y;
while (...) {
    for (...){    
        (マウスを縦方向に Y だけ動かす);
        次の移動まで少しだけ間を開ける;
    }

    if (下に動いてたら){
        (右に動かす);
        Y を負にセットする ( 上に動くようにする)
        次の移動まで少しだけ間を開ける;
    } else {
        (右に動かす);
        Y を正にセットする ( 下に動くようにする)
        次の移動まで少しだけ間を開ける;
    }
}

おっと、なんだかこの形の処理は、今見ている関数 FUN_code_0004f6 (とそれを呼び出している FUN_code_000586do ... while) と非常に似ていますね。となるとこの DAT_mem_0076, DAT_mem_0077, OCR1CL, OCR1CH への write_volatile_1 がマウスの変位等を調整していそうな感じがします3。なおこれらの 4 つのシンボルは連続した領域を指しています。

となると FUN_code_0004b2 の処理はマウス移動操作間の時間を制御しているはずです。そこでこの関数も解析してみることにします。

void FUN_code_0004b2(uint uParm1,undefined2 uParm2,undefined2 uParm3,undefined2 uParm4,
                    undefined2 uParm5,undefined2 uParm6)

{
  byte bVar1;
  byte bVar2;
  byte bVar3;
  char cVar4;
  undefined2 uVar5;
  undefined2 uVar6;
  undefined2 uVar7;
  undefined2 uVar8;
  undefined2 uVar9;
  undefined2 uVar10;
  
  cVar4 = read_volatile_1(DAT_mem_0067);
  write_volatile_1(DAT_mem_0067,cVar4 + 1U);
  uParm1 = uParm1 & 0xff00 | (uint)(byte)(cVar4 + 1U) & 0xff01;
  FUN_code_00063f();
  uVar10 = uParm3;
  uVar9 = uParm4;
  uVar8 = uParm5;
  uVar7 = uParm6;
  uVar6 = R7R6;
  uVar5 = R5R4;
  bVar1 = read_volatile_1(PORTG);
  bVar2 = read_volatile_1(DAT_mem_0066);
  if ((byte)(R1 + (bVar1 < 0xd)) <= bVar2) {
    Z = _EE_RDY & 0xff;
    if ((char)Z == '=') {
      if (bVar2 < (byte)(R1 + (bVar1 < 100))) {
        uParm2 = 100;
      }
      else {
        uParm2 = 500;
        if ((byte)(R1 + (bVar1 < 0x96)) <= bVar2) {
          uParm2 = 1000;
          if ((byte)(R1 + (bVar1 < 200)) <= bVar2) {
            uParm2 = 5000;
            if ((byte)(R1 + (bVar1 < 0xfa)) <= bVar2) {
              uParm2 = 10000;
              bVar1 = (bVar1 < 0x2c) + 1;
              if (bVar1 <= (byte)(bVar2 - bVar1)) {
                uParm2 = 20000;
              }
            }
          }
        }
      }
      goto LAB_code_00041a;
    }
  }
  uParm2 = 10;
LAB_code_00041a:
  uParm1 = 0;
  uParm4 = uParm2;
  uParm3 = 0;
  W = FUN_code_0005e0();
  uParm6 = uParm1;
  uParm5 = W;
  while( true ) {
    if ((char)(R15R14._1_1_ +
              (R1 < (byte)((char)R15R14 + (R1 < (byte)(R13R12._1_1_ + (R1 < (byte)R13R12)))))) <=
        (char)R1) break;
    W = FUN_code_0005e0();
    R5R4 = uParm1;
    R7R6 = W;
    R5R4._0_1_ = (byte)uParm1;
    R13R12._0_1_ = (byte)R13R12 - (byte)R5R4;
    R5R4._1_1_ = (char)(uParm1 >> 8);
    bVar2 = R5R4._1_1_ + ((byte)R13R12 < (byte)R5R4);
    bVar1 = (char)((uint)uParm4 >> 8) - bVar2;
    R7R6._0_1_ = (char)W;
    bVar3 = (char)R7R6 + (bVar1 < bVar2);
    bVar2 = (char)R15R14 - bVar3;
    R7R6._1_1_ = (char)((uint)W >> 8);
    uParm4 = CONCAT11(bVar1 + R9R8._1_1_ + CARRY1((byte)R13R12,(byte)R9R8),(byte)R13R12 + (byte)R9R8
                     );
    uParm3 = CONCAT11(((char)((uint)uParm3 >> 8) - (R7R6._1_1_ + (bVar2 < bVar3))) + R11R10._1_1_ +
                      CARRY1(bVar2,(byte)R11R10),bVar2 + (byte)R11R10 + CARRY1(bVar1,R9R8._1_1_));
    W = FUN_code_0003c0();
    uParm5 = R7R6;
    uParm6 = R5R4;
  }
  R5R4 = uVar5;
  R7R6 = uVar6;
  uParm6 = uVar7;
  uParm5 = uVar8;
  uParm4 = uVar9;
  uParm3 = uVar10;
  return;
}

ここでは前半の if の並びにより uParm2 がセットされており、後半の while ループではその値と FUN_code_0005e0 の値が鍵となっています。そして FUN_code_0005e0 についても解析してやると、(ここでは具体的に示しませんが) この関数は時間系のものであることが分かります。したがって後半の while ループはおそらく uParm2 の値を使って生成された適当な時間の間処理を止める、という処理に見えます。となると FUN_code_0004b2 前半の uParm2 の生成処理が「フラグの描画が進むにつれてカーソル移動が遅くなる」という挙動を実現していそうです。

比較ベースで解析する

ここまでの解析を通して、だいぶプログラムの構成が見えてきました。もうマウスカーソルの変位制御をしていそうな箇所も、その変位制御間の時間 — 文字描画は後半に行くにつれて遅くなっていくのでした — を制御している箇所も、ある程度推測がついています。

しかしここまでの解析では勘に頼ってしまっています。特にこのデバイスがどうやって USB デバイスとして振る舞っているのか、どの write_volatile_1 がマウスのどのような制御に使われているのかは、全く分かっていません。

そこでここからは、以下の Digispark をマウスとして利用するときに使われるライブラリを読みながら、また実際に自分でも同じようなプログラムを書きながら、より精緻な解析をしてみることにします。

例えば上述のリポジトリの DigiMouse.h を見てみると、ここにはマウスカーソルの移動を実現するための関数が幾つか置いてあります。moveXmoveYscrollleftClick のような関数がそれに当たります。これらの関数が実際行っていることは last_built_report なる変数 (1 バイト * 4 の配列) への代入です。

ところで先程カーソル処理をしているのだろう、と推測した 4 つの write_volatile_1 の並びは、メモリ中の連続した領域 (4 バイト) への書き込みを行っていたのでした。したがって先程の 4 つの write_volatile_1 の並びは、まさに last_built_report[0:3] への書き込みであり、これがマウスカーソルの移動 + クリック状態の変更 + スクロールの制御を行っているとみて間違いなさそうです。

このようにして、実際にライブラリのソースコードと見比べたり、実際に自分で作ってみたシンボル情報付きのバイナリと見比べたりしながら解析を進めることで、ここまでの推測が妥当であることを確認していきました。もっとも始めから推測に自信を持っていたわけではなく、解析にかけた時間の殆どは、このような既存のコードとの照らし合わせ作業です。

パッチを当てる

さて、解析はある程度できてきました。もし完璧に解析が出来ているという自信があれば、先程のマウスの制御処理を再現するスクリプトを手元で書いてみてもよいでしょう。しかしそのような作業はしばしば退屈で、かつバグらせると面倒です。そこで今回は適当にプログラムにパッチをあて、Digispark のボードに書き込み、走らせてみることにします。

例えば先程確認したディレイの制御(FUN_code_0004b2)で重要な役割を果たしていた uParm2 は、以下のようにして生成されていたのでした。

(... 省略 ...)
  if ((byte)(R1 + (bVar1 < 0xd)) <= bVar2) {
    Z = _EE_RDY & 0xff;
    if ((char)Z == '=') {
      if (bVar2 < (byte)(R1 + (bVar1 < 100))) {
        uParm2 = 100;
      }
      else {
        uParm2 = 500;
        if ((byte)(R1 + (bVar1 < 0x96)) <= bVar2) {
          uParm2 = 1000;
          if ((byte)(R1 + (bVar1 < 200)) <= bVar2) {
            uParm2 = 5000;
            if ((byte)(R1 + (bVar1 < 0xfa)) <= bVar2) {
              uParm2 = 10000;
              bVar1 = (bVar1 < 0x2c) + 1;
              if (bVar1 <= (byte)(bVar2 - bVar1)) {
                uParm2 = 20000;
              }
            }
          }
        }
      }
      goto LAB_code_00041a;
    }
  }
  uParm2 = 10;
(... 省略 ...)

なんだか適当にこの辺の処理を破壊してやれば良さそうです。しかし僕はあまりこの辺の処理をじっくり読んでいなかったので、ここに並んでいる if たちの条件式が何を意味しているのかはあまり理解していません。そこでこの辺の即値を全部 10 にしてみます。

(... 省略 ...)
  if ((byte)(R1 + (bVar1 < 0xd)) <= bVar2) {
    Z = _EE_RDY & 0xff;
    if ((char)Z == '=') {
      if (bVar2 < (byte)(R1 + (bVar1 < 100))) {
        uParm2 = 10;
      }
      else {
        uParm2 = 50;
        if ((byte)(R1 + (bVar1 < 0x96)) <= bVar2) {
          uParm2 = 10;
          if ((byte)(R1 + (bVar1 < 200)) <= bVar2) {
            uParm2 = 10;
            if ((byte)(R1 + (bVar1 < 0xfa)) <= bVar2) {
              uParm2 = 10;
              bVar1 = (bVar1 < 0x2c) + 1;
              if (bVar1 <= (byte)(bVar2 - bVar1)) {
                uParm2 = 10;
              }
            }
          }
        }
      }
      goto LAB_code_00041a;
    }
  }
  uParm2 = 10;
(... 省略 ...)

このような変更を Ghidra 上で加えた後に、File > Export Program から Intel HEX 形式のファイルを生成し、micronuscleus を利用して、このファイルを Digispark に書き込んであげます。

$ micronucleus --run (生成した hex ファイル)

動かす

改めてペイントソフトウェア上にカーソルを置き、フラグを描画させてみます。以下にその様子を示します。

フラグが描画されていく様子 (パッチ後)

暫く待つと無事フラグが出力されました。

フラグ

やったね。

終わりに

CPU 実験のお陰でこういう面倒な問題に抵抗がなくなった気がします。理情のおかげですね。

  1. 正直問題セットに関して思うところはたくさんあるんですが、ここでは一切そのことに言及しないことにします。 

  2. なお mimura (STM32F1x のハードウェアが与えられる問題) は ST-Link でファームウェアダンプだけやりました(あとはチームメンバーがシュッと解いてくれました)。 

  3. もっともここまでの解析結果だけでは、これらの 4 つのシンボル全てがマウスの制御に使われていると確信できませんが。 

Written on December 23, 2019