2024/11/23 14:00 (JST) ~ 2024/11/24 14:00 (JST)に開催されたSECCON CTF 13 QualsのWriteupです。
チームDouble Lariatで参加し、rev問のpackedとF is for Flagを解きました。
[rev] packed (93 pt / 119 solved)
問題名の通り、UPXでパックされたバイナリa.outが与えられます。実行してみると、入力した文字列がフラグと一致しているかを判定するシンプルなフラグチェッカーであることが確認できました。
$ ./packed
FLAG: SECCON{dummy}
Wrong...
upx -d a.outでアンパックできますが、アンパック後のバイナリは標準入力から文字列を受け取ってWrongを表示するだけの意味のないものであるため、アンパック前のバイナリを解析しました。
IDAでdisassembleし、エントリポイントから順に処理を追っていくと0x44EE1Dあたりのアドレスでread syscallを呼び出し、標準入力から得た文字列と何らかのバイト列でxorをとっていることが確認できます。
LOAD:000000000044EE0F push rsp LOAD:000000000044EE10 pop rsi LOAD:000000000044EE11 mov edx, 80h LOAD:000000000044EE16 sub rsi, rdx LOAD:000000000044EE19 xor edi, edi LOAD:000000000044EE1B xor eax, eax LOAD:000000000044EE1D syscall ; LINUX - sys_read LOAD:000000000044EE1F cmp eax, 31h ; '1' LOAD:000000000044EE22 jnz loc_44EEC3 LOAD:000000000044EE28 mov ecx, eax LOAD:000000000044EE2A pop rdx LOAD:000000000044EE2B pop rsi LOAD:000000000044EE2C lea rdi, [rsp-90h] LOAD:000000000044EE34 LOAD:000000000044EE34 loc_44EE34: ; CODE XREF: LOAD:000000000044EE3A↓j LOAD:000000000044EE34 lodsb LOAD:000000000044EE35 xor [rdi], al LOAD:000000000044EE37 inc rdi LOAD:000000000044EE3A loopne loc_44EE34 LOAD:000000000044EE3C call sub_44EE72
また、その後に呼び出しているsub_44EE72ではxorをとった後のバイト列が、別のバイト列と一致しているかを確認しています。
LOAD:000000000044EE72 sub_44EE72 proc near ; CODE XREF: LOAD:000000000044EE3C↑p LOAD:000000000044EE72 LOAD:000000000044EE72 var_88 = byte ptr -88h LOAD:000000000044EE72 arg_0 = qword ptr 8 LOAD:000000000044EE72 LOAD:000000000044EE72 mov ecx, 31h ; '1' LOAD:000000000044EE77 pop rsi LOAD:000000000044EE78 lea rdi, [rsp-8+var_88] LOAD:000000000044EE80 xor edx, edx LOAD:000000000044EE82 LOAD:000000000044EE82 loc_44EE82: ; CODE XREF: sub_44EE72+1B↓j LOAD:000000000044EE82 lodsb LOAD:000000000044EE83 cmp [rdi], al LOAD:000000000044EE85 setnz al LOAD:000000000044EE88 or dl, al LOAD:000000000044EE8A inc rdi LOAD:000000000044EE8D loopne loc_44EE82 LOAD:000000000044EE8F test edx, edx LOAD:000000000044EE91 jnz short loc_44EEC3
gdbで動的解析し、それぞれのバイト列を取得してxorをとるとフラグが得られました。
key = [0x49f9830000004ae8, 0x374c8d4857534475, 0x39482feb5b565efd, 0x803cac5e563273ce, 0x7e8006778f3c0a72, 0x013ce82c06740ffe] expected = [0x1c82cd4f43430fbb, 0x682ef87f240c1c25, 0x567848b43a092dcc, 0xdf0fcf6a3a422caa, 0x17e4371fd04e3a14, 0x7c0f8c1c652b3990] def u64(x): buf = [] for _ in range(8): buf.append(x & 0xff) x >>= 8 return bytes(buf) key = b"".join(u64(x) for x in key) expected = b"".join(u64(x) for x in expected) flag = [a ^ b for a, b in zip(key, expected)] print("".join(chr(x) for x in flag))
$ python3 solve.py
SECCON{UPX_s7ub_1s_a_g0od_pl4c3_f0r_h1din6_c0d3}
[rev] F is for Flag (227 pt / 16 solved)
与えられたバイナリFを実行してみると、packedと同様に入力した文字列がフラグと一致しているかを確認するフラグチェッカーであることが分かります。
$ ./F
FLAG: SECCON{dummy}
"Wrong"
はじめにGhidraでdecompileし、main関数の処理を見てみました。
undefined8 main(void) { basic_ostream<> *this; long in_FS_OFFSET; undefined local_8fa; undefined local_8f9; ... local_760 = &local_8ee; local_758 = &local_8f1; local_750 = &local_8f6; local_748 = &local_8ed; local_740 = &local_8f5; std::__cxx11::basic_string<>::basic_string(flag); /* try { // try from 001056ed to 0010570a has its CatchHandler @ 00106ea7 */ std::operator<<((basic_ostream *)std::cout,"FLAG: "); std::operator>>((basic_istream *)std::cin,(basic_string *)flag); local_8e8 = 0; std::variant<>::variant<>(local_5e8,&local_8e8); local_8ec = 0xb7e9a2a4; std::variant<>::variant<>(local_618,&local_8ec); /* try { // try from 00105770 to 00105774 has its CatchHandler @ 00106b23 */ main::{lambda(std::variant<>,std::variant<>)#19}::operator() (local_5b8,&local_8f1,local_618,local_5e8); local_8e4 = 0x1904c652; std::variant<>::variant<>(local_588,&local_8e4); /* try { // try from 001057b7 to 001057bb has its CatchHandler @ 00106afc */ main::{lambda(std::variant<>,std::variant<>)#19}::operator() (local_558,&local_8f1,local_588,local_5b8); local_8e0 = 0xbe8afe4d; std::variant<>::variant<>(local_528,&local_8e0); /* try { // try from 001057fe to 00105802 has its CatchHandler @ 00106ad5 */ main::{lambda(std::variant<>,std::variant<>)#19}::operator() (local_4f8,&local_8f1,local_528,local_558); local_8dc = 0xbd18775a; std::variant<>::variant<>(local_4c8,&local_8dc); /* try { // try from 00105845 to 00105849 has its CatchHandler @ 00106aae */ main::{lambda(std::variant<>,std::variant<>)#19}::operator() (local_498,&local_8f1,local_4c8,local_4f8); local_8d8 = 0x82841cf4; std::variant<>::variant<>(local_468,&local_8d8); /* try { // try from 0010588c to 00105890 has its CatchHandler @ 00106a87 */ main::{lambda(std::variant<>,std::variant<>)#19}::operator() (local_438,&local_8f1,local_468,local_498); local_8d4 = 0xd2c1d5af; std::variant<>::variant<>(local_408,&local_8d4); ... {lambda(std::variant<>,std::function<>,std::function<>)#1}::operator() ((function<> *)local_48,&local_8f2,(long)local_78,(long)local_198,(long)local_168); /* try { // try from 001067ed to 00106803 has its CatchHandler @ 00106dba */ this = (basic_ostream<> *)operator<<(std::cout,local_48); std::basic_ostream<>::operator<<(this,std::endl<>); std::variant<>::~variant(local_48); std::variant<>::~variant(local_78); std::variant<>::~variant(len_valid); std::variant<>::~variant(local_108); std::variant<>::~variant(local_138); std::variant<>::~variant(local_d8); std::function<>::~function((function<> *)local_1f8); std::function<>::~function((function<> *)local_1c8); std::function<>::~function((function<> *)local_198); std::function<>::~function((function<> *)local_168); std::variant<>::~variant(local_648); std::variant<>::~variant(local_678); std::__cxx11::basic_string<>::~basic_string((basic_string<> *)flag); if (local_20 != *(long *)(in_FS_OFFSET + 0x28)) { /* WARNING: Subroutine does not return */ __stack_chk_fail(); } return 0; }
std::variantのインスタンスが大量に生成されていることが特徴的です。また、main::{lambda{std::variant<>, std::variant<>}#<ID>}::operator()という名前の関数が大量に呼び出されていることが確認できます。この関数は、std::variant同士の演算に関する処理だと推測できます。以下にIDに対応する関数の詳細を示します。ここで、vから始まる引数はstd::variant型を表しています。また、簡単のため第一および第二引数は省略しています。
| ID | 引数 | 詳細 |
|---|---|---|
| 1 | v1,v2 | result = v1 + v2 |
| 3 | v1,v2 | result = v1 * v2 |
| 4 | v1(string) | result = len(v1) |
| 6 | v1,v2 | result = v1 ^ v2 |
| 7 | v1,v2 | result = v1 | v2 |
| 8 | v1,v2 | result = v1 & v2 |
| 9 | v1,v2 | result = (v1 << v2) |
| 10 | v1,v2 | result = (v1 >> v2) |
| 11 | v1,v2 | result = (v1 >> (0x20 - v2)) | (v1 << v2) |
| 17 | v1,v2 | result = (v1 == v2) |
| 17 | v1,v2 | result = (v1 != v2) |
| 19 | v1,v2 | result = Cons(v1, v2) |
上記の関数と似た名前の関数にmain::{lambda{std::variant<>, std::function<>, std::function<>}#1}::operator()も存在します。こちらは第三引数にstd::variant、第四および第五引数にstd::functionを受け取ります。そして、第三引数のstd::variantが0なら第四引数の関数を、1なら第五引数の関数を実行します。
この情報を基にmain関数を解析すると、以下のPythonコードと同等の処理を行っていることが確認できました。
Variant = Union[int, str, Cons] @dataclass class Cons: val: Variant nxt: Variant def main(): flag = input("FLAG: ") # 0x570b ~ 0x63c8 arr1 = Cons(0x11793013, Cons(0x88d2ec9b, ...)) arr2 = Cons(0x3, Cons(0xe, ...)) if len(flag) == 0x40: wrong = check(flag, arr2, ...) else: wrong = 1 if wrong: ret = '"Wrong"' else: ret = "Correct" print(ret)
上記コードにおけるcheck関数は、0x6704で生成しているstd::functionに対応しています。このstd::functionのコンストラクタから次の関数を辿っていくと、関数の中身を特定することができました。
_Function_handler<>::_M_invoke__invoke_r<>__invoke_impl<>main::{lambda()#1}::operator()
解析を進めた結果、この関数の3つのfix<>::operator()で入力した文字列の変換およびフラグのチェックを行っていることが判明しました。
{lambda()#1} * __thiscall main::{lambda()#1}::operator()({lambda()#1} *this,undefined8 *param_1)
{
undefined8 *puVar1;
undefined8 uVar2;
undefined8 *puVar3;
long in_FS_OFFSET;
fix<> local_88 [48];
fix<> local_58 [40];
long local_30;
local_30 = *(long *)(in_FS_OFFSET + 0x28);
puVar1 = (undefined8 *)*param_1;
uVar2 = param_1[4];
puVar3 = (undefined8 *)param_1[1];
fix<>::operator()(local_88,(undefined8 *)param_1[2],param_1[3]);
/* try { // try from 0010551b to 0010551f has its CatchHandler @ 00105579 */
fix<>::operator()(local_58,puVar3,local_88);
/* try { // try from 00105534 to 00105538 has its CatchHandler @ 00105564 */
fix<>::operator()((function<> *)this,puVar1,local_58,uVar2);
std::variant<>::~variant((variant<> *)local_58);
std::variant<>::~variant((variant<> *)local_88);
if (local_30 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return this;
}
fix<>::operator()の処理を追っていくと、次のような形式の関数が多く見つかります。
function<> *
main::{lambda(auto:1,std::variant<>,std::variant<>)#2}::operator()
(function<> *param_1,undefined8 *param_2,undefined8 param_3,undefined8 param_4,
long param_5)
{
undefined8 uVar1;
undefined8 uVar2;
long in_FS_OFFSET;
undefined8 local_150;
undefined8 *local_148;
function<> *local_140;
undefined4 local_134;
undefined8 local_130;
undefined8 *local_128;
undefined8 local_120;
undefined8 local_118;
undefined8 local_110;
undefined8 local_108;
long local_100;
undefined8 local_f8;
function<> local_e8 [32];
function<> local_c8 [32];
variant<> local_a8 [48];
variant<> local_78 [48];
variant<> local_48 [40];
long local_20;
local_20 = *(long *)(in_FS_OFFSET + 0x28);
uVar1 = *param_2;
local_128 = &local_150;
local_120 = param_2[2];
local_118 = param_2[3];
local_110 = param_2[4];
local_f8 = param_2[5];
local_150 = param_3;
local_148 = param_2;
local_140 = param_1;
local_108 = param_4;
local_100 = param_5;
_ZNSt8functionIFSt7variantIJjNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEESt10shared_ptrI4C onsEEEvEEC2IZZ4mainENKUlT_SA_SA_E0_clISt17reference_wrapperIK3fixISF_EEEESA_SE_SA_SA_EUlvE0_vEEOSE _
((undefined (*) [16])local_c8,&local_128);
local_130 = param_4;
_ZNSt8functionIFSt7variantIJjNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEESt10shared_ptrI4C onsEEEvEEC2IZZ4mainENKUlT_SA_SA_E0_clISt17reference_wrapperIK3fixISF_EEEESA_SE_SA_SA_EUlvE_vEEOSE_
((undefined (*) [16])local_e8,&local_130);
uVar2 = local_148[1];
local_134 = 8;
std::variant<>::variant<>(local_78,&local_134);
/* try { // try from 0010c3e1 to 0010c3e5 has its CatchHandler @ 0010c4ae */
std::variant<>::variant(local_a8,param_5);
/* try { // try from 0010c3fb to 0010c3ff has its CatchHandler @ 0010c496 */
{lambda(std::variant<>,std::variant<>)#17}::operator()(local_48,uVar2,local_a8,local_78);
/* try { // try from 0010c422 to 0010c426 has its CatchHandler @ 0010c481 */
{lambda(std::variant<>,std::function<>,std::function<>)#1}::operator()
(local_140,uVar1,(long)local_48,(long)local_e8,(long)local_c8);
std::variant<>::~variant(local_48);
std::variant<>::~variant(local_a8);
std::variant<>::~variant(local_78);
std::function<>::~function(local_e8);
std::function<>::~function(local_c8);
if (local_20 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return local_140;
}
この形式の関数は、{lambda(std::variant<>,std::variant<>)#17}などで何らかの条件式を評価し、その条件の成立に応じて関数を実行するようになっています。解析の結果、条件が成立しなかった場合の関数(上のコードではlocal_c8)の中で自身を再帰的に呼び出すようになっており、実質的にwhileループのような処理を行っていることが分かりました。
これを基に3つのfix::operator()の解析を進めると、上から順番に以下の処理を行っていることが確認できました。
- 入力された文字列を4バイトごとに分割し、リストを作成する
- 1で作成したリストに対して様々な演算を行い、暗号化する
- 2で暗号化した結果を暗号化されたフラグと比較する
これらの処理をPythonで書き直すと以下のようになります。
from Crypto.Util.number import bytes_to_long import copy def rot(x, n): return ((x >> (0x20 - n)) | (x << n)) & 0xffffffff def hoge(chunks, idx): temp = rot(chunks[(idx + 3) % 0x10], 0x1d) temp ^= rot(chunks[(idx + 2) % 0x10], 0x11) temp ^= rot(chunks[(idx + 1) % 0x10], 7) temp ^= chunks[idx % 0x10] return temp def split_to_chunks(x): chunks = [] for i in range(0, len(x), 4): chunks.append(x[i:i+4]) return chunks # flag = input("FLAG: ") # vals = Cons(0x3, Cons(0xe, ...)) def check(flag, vals): # fix<>::operator()(local_88,(undefined8 *)param_1[2],param_1[3]); chunks = [bytes_to_long(x) for x in split_to_chunks(flag)] # fix<>::operator()(local_58,puVar3,local_88); for k in range(8): for i in range(0x10): result = 0 for j in range(8): x = 4*j y = (chunks[i] >> x) & 0xf z = vals[y] result |= (z << x) chunks[i] = result * 0x4e6a44b9 chunks[i] &= 0xffffffff prev = copy.deepcopy(chunks) for i in range(0x10): a = 0x10 + i < 0x10 + k b = 0xc + k < 0x10 + i c = a & b d = i < 0x10 + k e = 0xc + k < i f = d & e g = c | f if not g: chunks[i] = hoge(prev, i) & 0xffffffff # fix<>::operator()((function<> *)this,puVar1,local_58,uVar2); exp_chunks = [ 0x11793013, 0x88d2ec9b, 0x2c0cccdd, 0x97aef869, 0x34bc7c60, 0xf86c82d7, 0x927b5bd9, 0xd5689a8c, 0x451f151a, 0x0f389c4a, 0xd2c1d5af, 0x82841cf4, 0xbd18775a, 0xbe8afe4d, 0x1904c652, 0xb7e9a2a4 ] for c, e in zip(chunks, exp_chunks): if c != e: return 1 return 0
この処理の逆の処理を実装し、実行するとフラグが得られました。
from Crypto.Util.number import * def rot(x, n): return ((x >> (0x20 - n)) | (x << n)) & 0xffffffff def hoge(chunks, idx): temp = rot(chunks[(idx + 3) % 0x10], 0x1d) temp ^= rot(chunks[(idx + 2) % 0x10], 0x11) temp ^= rot(chunks[(idx + 1) % 0x10], 7) temp ^= chunks[idx % 0x10] return temp vals = [0x3, 0xe, 0x1, 0xa, 0x4, 0x9, 0x5, 0x6, 0x8, 0xb, 0xf, 0x2, 0xd, 0xc, 0, 0x7] chunks= [ 0x11793013, 0x88d2ec9b, 0x2c0cccdd, 0x97aef869, 0x34bc7c60, 0xf86c82d7, 0x927b5bd9, 0xd5689a8c, 0x451f151a, 0x0f389c4a, 0xd2c1d5af, 0x82841cf4, 0xbd18775a, 0xbe8afe4d, 0x1904c652, 0xb7e9a2a4 ] for k in range(7, -1, -1): first = (13 + k) & 0xf for i in range(0x10): idx = (first - 1 - i) & 0xf if idx in [first, (first+1)&0xf, (first+2)&0xf]: continue chunks[idx] = hoge(chunks, idx) & 0xffffffff for i in range(0x10): chunks[i] = (chunks[i] * pow(0x4e6a44b9, -1, 0xffffffff+1)) % (0xffffffff+1) for i in range(0x10): result = 0 for j in range(8): x = 4*j y = (chunks[i] >> x) & 0xf z = vals.index(y) result |= (z << x) chunks[i] = result & 0xffffffff print(b"".join(bytes.fromhex(f"{x:08x}")[::-1] for x in chunks))
$ python3 solve.py
b'SECCON{fUnCt10n4l_pRoGr4mM1n6_1s_pR4c7iC4lLy_a_pUr3_0bfu5c4T1oN}'
感想ですが、シンボル名が長すぎてgdbの画面が非常に見づらくなり、動作を追うのに苦労しましたが、面白い問題だったと思います。
