今回は、先日開催されていた Cyber Apocalypse CTF 2025 の Singlestep という Rev 問をテーマに、Unicorn と Capstone を使用した自己復元型バイナリの難読化解除手法についてまとめていきます。
参考:Cyber Apocalypse CTF 2025 Writeup - かえるのひみつきち
Unicorn による実行コードのエミュレートはかなり前に 1 度使ったことがありますが、それ以降あまり触れてませんでした。
参考:x86_64 アーキテクチャのシェルコードを Unicorn でエミュレートする
今回の問題にトライした際は gdb-python を使用してかなり力技で復号された実行コードのアセンブリを抽出していたのですが、ループ処理を考慮できずに抽出した実行コードが膨大になってしまったり、抽出したコードをデコンパイルできなかったりといった問題がありました。
しかし、今回参考にした Unicorn と Capstone を使用した手法を使用することで、よりスマートにバイナリの難読化解除を行うこと、また難読化解除したコードをデコンパイル可能な形で抽出することができるようになります。
もくじ
問題の概要
Singlestep
Malakar has locked away a sacred artifact away in his dungeon. He has enchanted the locking mechanism to be self-protecting. Can you embark on a mission to free the artifact back to the people’s hands?
問題バイナリとして与えられた ELF を実行すると、以下のように何らかの入力が求められます。
どうやら、この入力に正しい値を入れることで Flag を取得できるようです。
問題バイナリをデコンパイルすると、main 関数は単に 0x43e0 の関数を呼び出すだけのものであることがわかります。
この関数は以下のような実装になっており、見るからに 0x43ec のコードを XOR 演算によって別のものに置き換えた上で実行を継続しようとしていることがわかります。
実際にこの時の処理を gdb で解析してみると、0x43e1 の XOR 演算が実行されることで、0x43ec のコードが関数のプロローグと思われる命令に置き換えられたことがわかります。
さらに、上記のコードを見ると、0x43f1 の XOR 操作で 0x43ec のコードを元に戻す操作を行っており、実行が完了した後の復号されたコードがメモリ内に残存しないように処理されていることがわかります。
以降の処理も追っていくと、基本的には pushfq -> xor から xor -> popfq までのコードブロック単位で難読化された本来の実行コードの復元と実行、そして再難読化を繰り返していることが見て取れます。
コンテスト中は、gdb-python を使用してこのブロック内の難読化解除された実行コードのアセンブリを無理やり抜き出す方法で解析を行いましたが、今回は Unicorn と Capstone を使用してもっとスマートに難読化を解除していきます。
Unicorn と Capstone でプログラムをフックして実行コードをダンプする
以下のコードは、Unicorn でエミュレートした実行コードをフックし、Capstone で逆アセンブルした結果を出力します。
(問題バイナリが自己書き換えの機能を持っているせいか、実行コードを変更する XOR 行のフック動作が実際のプログラムの動作とは異なるような気がしますが一旦無視します)
from unicorn import *
from unicorn.x86_const import *
from capstone import *
# ============================
# Globals
# ============================
# Initialize Unicorn & Capstone
mu = Uc(UC_ARCH_X86, UC_MODE_64) # mu は仮想 CPU として初期化
md = Cs(CS_ARCH_X86, CS_MODE_64) # md は x64 逆アセンブラとして初期化
# Initial values
with open('singlestep', 'rb') as f:
code = f.read()
call_addrs = [0x43E0]
current_call_addr = 0
previous_call_addr = 0
# Hook function
def hook_code(uc, address, size, user_data):
global current_call_addr
global previous_call_addr
previous_call_addr = current_call_addr
instruction_bytes = uc.mem_read(address, size)
for i,instruction in enumerate(md.disasm(instruction_bytes, address)):
complete_instruction = f"{i} 0x{instruction.address:x}:\t{instruction.mnemonic}\t{instruction.op_str}"
print(complete_instruction)
if instruction.mnemonic == "call":
addr = int(instruction.op_str,16)
if addr < 0x1260:
print("PLT function called.")
# uc.emu_stop()
return
elif instruction.mnemonic == "ret":
# uc.emu_stop()
return
# ============================
# 仮想メモリ、RSP/RBP の初期化
stack_addr = 0x900000
stack_size = 0x100000
mu.mem_map(stack_addr, stack_size)
mu.reg_write(UC_X86_REG_RSP, stack_addr + stack_size - 8 - 0x200)
mu.reg_write(UC_X86_REG_RBP, stack_addr + stack_size - 8)
# Read original binary
with open("singlestep", "rb") as f:
code = f.read()
# 0x50000 バイトの仮想メモリを確保して実行コードを展開
code_addr = 0x0
mu.mem_map(code_addr,0x50000)
mu.mem_write(code_addr,code)
# Unicorn にフックを追加
# UC_HOOK_CODE 命令の実行直前にフックを呼び出す
# hook_code(mu, address, size, user_data)
mu.hook_add(UC_HOOK_CODE, hook_code)
# Run program
while len(call_addrs) > 0:
try:
current_call_addr = call_addrs.pop()
mu.emu_start(current_call_addr,0x900D) # emu_start(begin,end)
except Exception as e:
print("Error: %s" % e)
print("at : %s" % hex(mu.reg_read(UC_X86_REG_RIP)))
break
エミュレータの初期化
以下はエミュレータの初期化を行っている部分のコードの抜粋です。
それぞれ x64 をターゲットとして、Uc(UC_ARCH_X86, UC_MODE_64)
と Cs(CS_ARCH_X86, CS_MODE_64)
で Unicorn と Capstone の初期化を行っています。
そして、プログラムのエミュレートのためにスタック領域の確保と RSP/RBP レジスタの初期化を行い、最後に実行ファイルのデータをそのままメモリにマッピングしています。
本来 ELF の動作をエミュレートするためには、恐らく ELF ローダーの動作を再現してライブラリのロードやセクションのアラインメントを構築する必要があると思いますが、今回は XOR による実行コードの復元部分の処理をエミュレートするだけですので、ELF ファイルをそのままメモリにマッピングしても問題ありません。
# Initialize Unicorn & Capstone
mu = Uc(UC_ARCH_X86, UC_MODE_64) # mu は仮想 CPU として初期化
md = Cs(CS_ARCH_X86, CS_MODE_64) # md は x64 逆アセンブラとして初期化
# 仮想メモリ、RSP/RBP の初期化
stack_addr = 0x900000
stack_size = 0x100000
mu.mem_map(stack_addr, stack_size)
mu.reg_write(UC_X86_REG_RSP, stack_addr + stack_size - 8 - 0x200)
mu.reg_write(UC_X86_REG_RBP, stack_addr + stack_size - 8)
# Read original binary
with open("singlestep", "rb") as f:
code = f.read()
# 0x50000 バイトの仮想メモリを確保して実行コードを展開
code_addr = 0x0
mu.mem_map(code_addr,0x50000)
mu.mem_write(code_addr,code)
参考:Programming with C & Python languages – Unicorn – The Ultimate CPU emulator
Unicorn による実行コードのフック
以下のコードでは、すべての命令の実行をフックする UC_HOOK_CODE
を使用して、Unicorn によるコード実行時のフックを構成しています。
Unicorn によるフックはこれ以外にも、特定のメモリアクセスなど様々な操作に対して登録することができます。
参考:alexander-hanel/unicorn-engine-notes: Notes on using the Python bindings for the Unicorn Engine
# Unicorn にフックを追加
# UC_HOOK_CODE 命令の実行直前にフックを呼び出す
# hook_code(mu, address, size, user_data)
mu.hook_add(UC_HOOK_CODE, hook_code)
# Run program
while len(call_addrs) > 0:
try:
current_call_addr = call_addrs.pop()
mu.emu_start(current_call_addr,0x900D) # emu_start(begin,end)
except Exception as e:
print("Error: %s" % e)
print("at : %s" % hex(mu.reg_read(UC_X86_REG_RIP)))
break
Capstone で実行コードの逆アセンブルを行う
命令の実行時に呼び出されるフック関数では、Capstone を使用して実行コードの逆アセンブルを行っています。
Unicorn でフックした関数が呼び出される際には、引数として命令のアドレスとそのサイズが与えられます。
ここで、uc.mem_read(address, size)
にて取得したバイトデータを、Capstone により逆アセンブルした結果を含むイテレータを返す md.disasm(instruction_bytes, address)
に与えることで逆アセンブル結果を確認します。
参考:Disassemble in iterartion style – Capstone – The Ultimate Disassembler
# Hook function
def hook_code(uc, address, size, user_data):
global current_call_addr
global previous_call_addr
previous_call_addr = current_call_addr
instruction_bytes = uc.mem_read(address, size)
for i,instruction in enumerate(md.disasm(instruction_bytes, address)):
complete_instruction = f"{i} 0x{instruction.address:x}:\t{instruction.mnemonic}\t{instruction.op_str}"
print(complete_instruction)
if instruction.mnemonic == "call":
addr = int(instruction.op_str,16)
if addr < 0x1260:
print("PLT function called.")
# uc.emu_stop()
return
elif instruction.mnemonic == "ret":
# uc.emu_stop()
return
これを実行することで、このようにフックされた実行コードの逆アセンブル結果をダンプすることができます。
問題バイナリの難読化解除を行う
問題バイナリの難読化解除を行う際には、大きく以下の 2 つの操作を行う必要がありそうです。
- 問題バイナリの実行コードの内、難読化解除された本来の命令以外のアドレスをすべて nop に置き換える
- 難読化部分の実行コードを難読化解除された命令に置き換える
今回のバイナリで上記の操作を行うためには、以下のような課題がありそうです。
- 難読化解除された実際の命令とそれ以外の命令を明確に区別する条件を定義する必要がある
- ライブラリ関数のなどの呼び出しを無視してエミュレートを継続する必要がある
- 入力値の検証や条件分岐など、難読化解除された実際のコードを実行する必要はない
上記を踏まえ、以下の Solver を作成しました。
このコードを実行することで、難読化解除された実行コードを含む deobfuscated バイナリを作成できます。
from unicorn import *
from unicorn.x86_const import *
from capstone import *
import copy
# ============================
# Globals
# ============================
# Initialize Unicorn & Capstone
mu = Uc(UC_ARCH_X86, UC_MODE_64) # mu は仮想 CPU として初期化
md = Cs(CS_ARCH_X86, CS_MODE_64) # md は x64 逆アセンブラとして初期化
# Initial values
with open('singlestep', 'rb') as f:
code = f.read()
code = bytearray(code)
deobfuscated_code = copy.deepcopy(code)
call_addrs = [0x43E0]
deobfuscated_addrs = []
popfq_flag = False
current_call_addr = 0
previous_call_addr = 0
# Hook function
def hook_code(uc, address, size, user_data):
global current_call_addr
global previous_call_addr
global popfq_flag
global deobfuscated_code
global deobfuscated_addrs
previous_call_addr = current_call_addr
instruction_bytes = uc.mem_read(address, size)
for i,instruction in enumerate(md.disasm(instruction_bytes, address)):
complete_instruction = f"{i} 0x{instruction.address:x}:\t{instruction.mnemonic}\t{instruction.op_str}"
if instruction.mnemonic == "popfq":
popfq_flag = True
deobfuscated_code[address:address+size] = b"\x90" * size
elif instruction.mnemonic == "pushfq":
popfq_flag = False
deobfuscated_code[address:address+size] = b"\x90" * size
elif instruction.mnemonic == "ret":
popfq_flag = False
print("ret.")
# print(complete_instruction)
uc.emu_stop()
return
else:
if popfq_flag and address not in deobfuscated_addrs:
popfq_flag = False
# print(complete_instruction)
deobfuscated_code[address:address+size] = instruction_bytes
deobfuscated_addrs.append(address)
# Skip de-deobfuscated instruction
uc.reg_write(UC_X86_REG_RIP, address + size)
if instruction.mnemonic == "call":
addr = int(instruction.op_str,16)
if addr < 0x1260:
print("PLT function called.")
return
else:
call_addrs.append(addr)
else:
deobfuscated_code[address:address+size] = b"\x90" * size
return
# ============================
# 仮想メモリ、RSP/RBP の初期化
stack_addr = 0x900000
stack_size = 0x100000
mu.mem_map(stack_addr, stack_size)
mu.reg_write(UC_X86_REG_RSP, stack_addr + stack_size - 8 - 0x200)
mu.reg_write(UC_X86_REG_RBP, stack_addr + stack_size - 8)
# Read original binary
with open("singlestep", "rb") as f:
code = f.read()
# 0x50000 バイトの仮想メモリを確保して実行コードを展開
code_addr = 0x0
mu.mem_map(code_addr,0x50000)
mu.mem_write(code_addr,code)
# Unicorn にフックを追加
# UC_HOOK_CODE 命令の実行直前にフックを呼び出す
# hook_code(mu, address, size, user_data)
mu.hook_add(UC_HOOK_CODE, hook_code)
# Run program
while len(call_addrs) > 0:
try:
current_call_addr = call_addrs.pop()
if current_call_addr not in deobfuscated_addrs:
mu.emu_start(current_call_addr,0x900D) # emu_start(begin,end)
deobfuscated_addrs.append(current_call_addr)
except Exception as e:
print("Error: %s" % e)
print("at : %s" % hex(mu.reg_read(UC_X86_REG_RIP)))
break
with open('deobfuscated', 'wb') as f:
f.write(deobfuscated_code)
以下に、各ポイントの概要をまとめます。
難読化解除された実際の命令とそれ以外の命令を明確に区別する条件を定義する
すでに確認している通り、基本的には pushfq -> xor から xor -> popfq までのコードブロック単位で難読化された本来の実行コードの復元と実行、そして再難読化を繰り返していることがわかっています。
そのため、実行トレース時に pushfq -> xor -> popfq
された直後の命令で、かつ pushfq
以外のものをキャプチャできればよさそうです。
ライブラリ関数のなどの呼び出しを無視してエミュレートを継続する必要がある
今回は Unicorn により小さな実行コード単位でエミュレートを行っているため、call 命令に当たった場合には、その関数の呼び出しはトレースせず実行をスキップした上で別途 mu.emu_start によるトレースを行う形にしています。
# Skip de-deobfuscated instruction
uc.reg_write(UC_X86_REG_RIP, address + size)
if instruction.mnemonic == "call":
addr = int(instruction.op_str,16)
if addr < 0x1260:
print("PLT function called.")
return
else:
call_addrs.append(addr)
入力値の検証や条件分岐など、難読化解除された実際のコードを実行する必要はない
難読化解除されたコードについても、実際の処理を行う必要はないため、RIP を改ざんすることでエミュレートをスキップさせています。
# Skip de-deobfuscated instruction
uc.reg_write(UC_X86_REG_RIP, address + size)
Flag 取得
上記のコードで復元した deobfuscated を実行してみると、元のプログラムと同じように動作してくれることがわかります。
この復元後のバイナリを解析してみると、不要部分が nop に置き換えられ、難読化解除された実行コードのみが存在していることがわかります。
また、デコンパイラでも難読化解除された実行コードの解析を行うことができています。
コードを解析してみると、まず始めに 4*4 のメモリ領域を初期化し、以下のような 2 次元配列を作成しているようです。
var_278 = [[ 88, -17, 19, -57],
[ 45, -9, 10, -29],
[-56, 11, -12, 36],
[-40, 8, -9, 26]]
また、入力が 19 文字で、かつ AAA-BBBB-CCCC-DDDD
のようなハイフン区切りの文字列であるかどうかを検証しています。
最後に、始めに初期化した 4x4 の配列と入力値から生成した配列を掛け合わせた結果が単位行列になるかを検証しています。
初期化した配列の逆行列は以下のようになります。
そのため、以下のスクリプトで逆行列から ASCII 文字列を復元することで、Flag 取得に必要なパスワードが BFCF-EJJL-CKKL-BLJQ
であることを特定できます。
import numpy as np
secret = ""
array = [[1, 5, 2, 5],
[4, 8, 7, 8],
[2, 8, 6, 5],
[1, 8, 3, 7]]
for i in range(4):
for j in range(4):
n = array[i][j]
secret += chr(n + i*j + ord("A"))
secret += "-"
print(secret[:-1])
まとめ
コンテスト中は gdb で無理やり難読化解除を行っていましたが、Unicorn と Capstone を使用する方法について大変参考になりました。
ただ、Unicorn も Capstone もナレッジがあまり多くなく使いこなせるようになるまでは逆に手間取りそうだなという印象です。
同じく実行時フックを行うだけであれば、Frida などを使う方法も試した方がいいのかなと思いました。