All Articles

Cryptoverse CTF 2023 Writeup

2023 年の 5 月 6 日から開催されていた Cryptoverse CTF 2023 に 0nePadding で参加してきました。

最終順位は 17 位/ 364 チームでした。

image-20230509225600693

非常に楽しい CTF で学びも多かったので、いつも通り Writeup を書きます。

もくじ

Simple Checkin(Rev)

Just a checkin challenge. Nothing special.

データセクションの配列を取得して XOR すると Flag が取れます。

以下の Solver で Flag を取得できました。

for i in range(len(local_58)):
    print(chr(local_68[i]^local_58[i]),end="")

Flag は以下でしたが、今まで見た中で一番長い Flag でした。

cvctf{i_apologize_for_such_a_long_string_in_this_checkin_challenge,but_it_might_be_a_good_time_to_learn_about_automating_this_process?You_might_need_to_do_it_because_here_is_a_painful_hex:32a16b3a7eef8de1263812.Enjoy(or_not)!}

Micro Assembly(Rev)

A special message is computed out of this short piece of assembly. Wrap the message you got in cvctf{}.

以下のアセンブリコードが与えられます。

最終的にこのアセンブリコードで取得できる値が Flag になりました。

main:
   PUSH %BP
   MOV  %SP, %BP
@main_body:
   SUB  %SP, $28, %SP
   MOV  $154, -28(%BP)
   MOV  $16, -24(%BP)
   MOV  $16, -20(%BP)
   MOV  $228, -16(%BP)
   MOV  $66, -12(%BP)
   MOV  $286, -8(%BP)
   MOV  $3, -4(%BP)
@if0:
   DIV  -28(%BP), $2, %0
   CMP  %12, $0
   JNE  @false0
@true0:
   DIV  -28(%BP), $2, %0
   MOV  %0, -28(%BP)
   JMP  @exit0
@false0:
@exit0:
   MUL  -24(%BP), $3, %0
   ADD  %0, $1, %0
   MOV  %0, -24(%BP)
   SHL  -20(%BP), $2, %0
   ADD  %0, $3, %0
   MOV  %0, -20(%BP)
@if1:
   DIV  -16(%BP), $3, %0
   CMP  %12, $1
   JNE  @false1
@true1:
   SUB  -16(%BP), $1, %0
   DIV  %0, $3, %0
   MOV  %0, -16(%BP)
   JMP  @exit1
@false1:
   DIV  -16(%BP), $2, %0
   MOV  %0, -16(%BP)
@exit1:
   SUB  -12(%BP), $2, %0
   MOV  %0, -12(%BP)
@if2:
   DIV  -8(%BP), $3, %0
   CMP  %12, $1
   JNE  @false2
@true2:
   SUB  -8(%BP), $1, %0
   DIV  %0, $3, %0
   MOV  %0, -8(%BP)
   JMP  @exit2
@false2:
   DIV  -8(%BP), $2, %0
   MOV  %0, -8(%BP)
@exit2:
   SHL  $11, -4(%BP), %0
   ADD  %0, $11, %0
   MOV  %0, -4(%BP)
   LEA  -28(%BP), %0
   MOV  %0, %13
   JMP  @main_exit
@main_exit:
   MOV  %BP, %SP
   POP  %BP
   RET 

アセンブリコードを上から順になぞりつつ C 言語で書き直すことで Flag を取得しました。

作成したコードは以下です。

#include <stdio.h>
int main()
{
    int a = 154, b = 16, c = 16, d = 228, e = 66, f = 286, g = 3;
    int result = 0;

    a = a / 2;
    b = b * 3 + 1;

    c <<= 2;
    c += 3;

    d = d / 2;
    e -= 2;

    f = f / 3;
    g = 11 << g;
    g += 11;

    printf("%d %d %d %d %d %d %d\n", a, b, c, d, e, f, g);

    return 0;
}

Mac and Cheese(Rev)

Sorry that I lied, there’s Mac but no Cheese.

x86 用 macOS バイナリの解析問題でした。

Ghidra でデコンパイルを行ったところ、プログラムは全部で 5 回の数値の入力を受け付け、各入力値がFUN_100003e10関数の返す値に一致するかを検証していることがわかりました。

そこで、main 関数からFUN_100003e10関数を呼び出す一連の処理を Python で書き起こしました。

# void FUN_100003e10(void)
# {
#   uint uVar1;
  
#   uVar1 = DAT_100008018 ^ DAT_100008018 << 0xb;
#   DAT_100008018 = DAT_10000801c;
#   DAT_10000801c = DAT_100008020;
#   DAT_100008020 = DAT_100008024;
#   DAT_100008024 = DAT_100008024 ^ DAT_100008024 >> 0x13 ^ uVar1 ^ uVar1 >> 8;
#   return;
# }

local10 = 0
DAT_100008018 = int.from_bytes(b'\xff\xd4\xb5\x20','little')
DAT_10000801c = int.from_bytes(b'\xc7\x8f\x37\x32','little')
DAT_100008020 = int.from_bytes(b'\x67\x87\x5f\xd5','little')
DAT_100008024 = int.from_bytes(b'\xad\xa1\x4a\x10','little')

for i in range(5):

    uVar1 = DAT_100008018 ^ DAT_100008018 << 0xb
    DAT_100008018 = DAT_10000801c
    DAT_10000801c = DAT_100008020
    DAT_100008020 = DAT_100008024
    DAT_100008024 = DAT_100008024 ^ DAT_100008024 >> 0x13 ^ uVar1 ^ uVar1 >> 8

    print(DAT_100008024)

    result = DAT_100008024
    local10 = (result % 0x539) + local10

しかし、処理としては間違っていないように思うのに Flag を取得することができませんでした。

あとで気づきましたが、Python で数値計算を行う場合は uint32 のサイズからはみ出してしまう場合があり、それが原因で最終的な演算結果に差異が生まれていたようでした。

演算を行っている部分を以下のように修正すると Flag を取得することができました。

uVar1 = DAT_100008018 ^ (DAT_100008018 << 0xb)
uVar1 = uVar1 & 0xFFFFFFFF
DAT_100008018 = DAT_10000801c
DAT_10000801c = DAT_100008020
DAT_100008020 = DAT_100008024
DAT_100008024 = DAT_100008024 ^ (DAT_100008024 >> 0x13) ^ uVar1 ^ (uVar1 >> 8)
DAT_100008024 = DAT_100008024 & 0xFFFFFFFF

問題を解いているときは上記の修正に思い至らなかったので、最終的には動的解析で Flag を取得しました。

WSL2 で macOS バイナリを動かすために、darling を使用しました。

インストールは以下の手順で行いました。

# 依存モジュールのインストール(deb ファイルでインストールする場合は不要かも)
sudo apt install cmake clang bison flex libfuse-dev libudev-dev pkg-config libc6-dev-i386 \
gcc-multilib libcairo2-dev libgl1-mesa-dev libglu1-mesa-dev libtiff5-dev \
libfreetype6-dev git git-lfs libelf-dev libxml2-dev libegl1-mesa-dev libfontconfig1-dev \
libbsd-dev libxrandr-dev libxcursor-dev libgif-dev libavutil-dev libpulse-dev \
libavformat-dev libavcodec-dev libswresample-dev libdbus-1-dev libxkbfile-dev \
libssl-dev python2

# deb ファイルのダウンロード
wget https://github.com/darlinghq/darling/releases/download/v0.1.20220704/darling_0.1.20220704.focal_amd64.deb

# インストール
sudo dpkg -i darling_0.1.20220704.focal_amd64.deb

続いて、以下の手順で darling で macOS バイナリを実行することができます。

# シェルの起動
darling shell

# macOS バイナリの実行
./challenge

ここで実行したアプリケーションのプロセスは、ホスト側の WSL2 から確認できます。

そのため、もう一つシェルを起動して以下のコマンドを実行することで、darling で起動した macOS バイナリに gdb をアタッチできます。

ps aux | grep challenge
gdb attach <PID>

これで動的解析を行うことで Flag を取得できました。

Solid Reverse(Rev)

Crypto in reverse??

問題スクリプトとして以下が与えられます。

Solidity という、「Ethereumで実行されるスマート コントラクトを開発するために設計された、静的に型付けされた中括弧プログラミング言語」のコードらしいです。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract ReverseMe {
    uint goal = 0x57e4e375661c72654c31645f78455d19;

    function magic1(uint x, uint n) public pure returns (uint) {
        // Something magic
        uint m = (1 << n) - 1;
        return x & m;
    }

    function magic2(uint x) public pure returns (uint) {
        // Something else magic
        uint i = 0;
        while ((x >>= 1) > 0) {
            i += 1;
        }
        return i;
    }

    function checkflag(bytes16 flag, bytes16 y) public view returns (bool) {
        return (uint128(flag) ^ uint128(y) == goal);
    }

    modifier checker(bytes16 key) {
        require(bytes8(key) == 0x3492800100670155, "Wrong key!");
        require(uint64(uint128(key)) == uint32(uint128(key)), "Wrong key!");
        require(magic1(uint128(key), 16) == 0x1964, "Wrong key!");
        require(magic2(uint64(uint128(key))) == 16, "Wrong key!");
        _;
    }

    function unlock(bytes16 key, bytes16 flag) public view checker(key) {
        // Main function
        require(checkflag(flag, key), "Flag is wrong!");
    }
}

Ethereum?が正直全く理解できず、実行環境を用意できなかったので、ドキュメントを頼りにコードを静的解析することにしました。

どうやら、checker関数の検証を突破できる 16 バイトの値で goal を XOR したものが Flag になるようです。

solidity のドキュメントを読んでいくと、bytes8関数などでは、上位ビットが取得され、下位が切り捨てられるという特徴的な動作をすることがわかりました。

つまり、require(bytes8(key) == 0x3492800100670155, "Wrong key!");の行から、key の先頭 8 バイトは0x3492800100670155に一致することがわかります。

続いて、require(uint64(uint128(key)) == uint32(uint128(key)), "Wrong key!");から、key の後半 8 バイトの値は、後半 4 バイトの値と一致することがわかります。

つまり、key の 5 バイト目から 8 バイト目までの値は 0 であることが特定できます。

続いて、magic1(uint128(key), 16) == 0x1964は下位 2 バイトの値が 0x1964 と一致することを意味することがわかります。

これは、以下のような処理が 0xFFFF との AND 演算結果を返す処理であるからです。

uint m = (1 << 16) - 1;
return x & m;

最後に、以下のようにmagic2関数を Python で書き直して、上位 4 バイトが 0 で、下位 2 バイトが 0x1964 であり、かつmagic2(uint64(uint128(key))) == 16を満たすような値を特定しました。

def magic2(x: int) -> int:
    i = 0
    while True:
        x = x >> 1
        print(x)
        if x > 0:
            i += 1
        else:
            break
    return i

print(magic2(0x0000000000011964))

最終的に、0x0000000000011964が対応する値であることがわかり、key は0x34928001006701550000000000011964 になることがわかりました。

これで、key と goal を XOR することで Flag を取得できました。

Standard VM(Rev)

Yet Another Virtual Machine.

今回唯一の Hard 問でしたがスーパー Easy でした。(たぶん作問ミスかな?)

問題バイナリはユーザ入力を受け取り、Flag の検証を行う問題のようです。

メイン部分のデコンパイル結果は以下のようになりました。

undefined8 FUN_00101396(void)
{
  int iVar1;
  undefined8 uVar2;
  
  printf("Flag: ");
  __isoc99_scanf(&DAT_0010200b,&DAT_00104110);
  uVar2 = FUN_00101209();
  FUN_00101356(uVar2);
  iVar1 = strcmp("N]N_MVdP}dSOT",&DAT_00104110);
  if (iVar1 == 0) {
    puts("Correct!");
  }
  else {
    puts("Wrong!");
  }
  return 0;
}

入力値の検証を行っている箇所は色々複雑な処理をしていそうでしたが、このデコンパイル結果を見た時点で angr で解けそうなことがわかるので解析はしていません。

以下のスクリプトで Flag を取得できました。

import angr

proj = angr.Project("standard_vm", auto_load_libs=False)
print("EntryPoint", proj.entry)

# initial_state at the entry point of the binary
init_state = proj.factory.entry_state(args = ['standard_vm'])

# create simulation
simgr = proj.factory.simgr(init_state)

simgr.explore(find=(0x401403), avoid=(0x401411))
print(simgr.found[0].posix.dumps(0))

# b'cvctf{MyVMxd}\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'

東方ダンマクカグラ(Rev)

あなたは、東方Projectの人気キャラクターである博麗霊夢を操作し、弾幕バトルに挑戦することになりました。このゲームは、敵の攻撃弾をかわしながら、リズムに合わせて攻撃するという新しいスタイルのゲームです。クリアするとフラッグが表示されます。フラッグを見つけるには、ゲームをリバースエンジニアリングする必要があります。頑張ってください!

問題文からして日本語な非常に面白い問題でした。

問題バイナリを起動すると、弾幕シューティングのゲーム画面が起動します。

image-20230509214254302

どうやらこれをクリアすると Flag が取得できるそうですが、ゲームデザイン上クリアできない設計になっているようです。

Ghidra でしばらくバイナリを眺めていましたが、関数が多すぎて上手く解析対象を絞り込めませんでした。

そこでアプローチを変えようと思い色々調べたところ、対象の実行ファイルが PyInstaller でビルドされていることに気づきました。

そこで、PyInstaller Extractor を使用してファイルの展開を試みました。

参考:extremecoders-re/pyinstxtractor: PyInstaller Extractor

py.exe pyinstxtractor.py Main.exe

PyInstaller Extractor でバイナリを展開すると、PyGame を使って作成されていることがわかります。

実行コードを取得するため、以下のオンラインデコンパイラに展開された Main.pyc を突っ込みました。

参考:PyC decompile - Toolnb online toolbox

# uncompyle6 version 3.5.0
# Python bytecode 3.7 (3394)
# Decompiled from: Python 2.7.5 (default, Nov 16 2020, 22:23:17) 
# [GCC 4.8.5 20150623 (Red Hat 4.8.5-44)]
# Embedded file name: Main.py
import os
os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = 'hide'
import pygame, math, random, time

class sizes:

    def __init__(self, width, height):
        self.Width = width
        self.Height = height


currentCount = 0
currentAnim = 1
animLimit = False
playerX = 400
playerY = 450
lastBullet = 0
bulletLimit = 5000
totalBullets = 0
toX = 0
toY = 0
bullets = []
bulletsDirection = [
 1]
bulletsY = []
bulletsX = []

def main():
    global bullets
    global bulletsX
    global bulletsY
    global currentAnim
    global currentCount
    global lastBullet
    global playerX
    global playerY
    global toX
    global toY
    global totalBullets
    pygame.init()
    pygame.mixer.init()
    pygame.mixer.music.load('Song.mp3')
    pygame.mixer.music.play()
    screenSizes = sizes(800, 600)
    screen = pygame.display.set_mode((screenSizes.Width, screenSizes.Height))
    startBullet = pygame.image.load('Bullet.png')
    startBullet = screen.blit(startBullet, (450, 300))
    bullets.append(startBullet)
    bulletsX.append(450)
    bulletsY.append(300)
    pygame.display.set_caption('Touhou Danmaku Kagura')
    pygame.display.set_icon(pygame.image.load('Icon.png'))
    background = pygame.image.load('Background.jpg')
    background = pygame.transform.scale(background, (screenSizes.Width, screenSizes.Height))

    def getPlayerDirection():
        global animLimit
        if toX == 0:
            animLimit = bool(False)
            return 'Idle'
        if toX >= 1:
            animLimit = bool(True)
            return 'Right'
        if toX <= -1:
            animLimit = bool(True)
            return 'Left'

    def getAnimation():
        global currentAnim
        global currentCount
        if currentCount >= 150:
            if currentAnim <= 7:
                currentCount = 0
                currentAnim += 1
            elif animLimit == False:
                currentCount = 0
                currentAnim = 1

    def loadBullet(x, y):
        newBullet = pygame.image.load('Bullet.png')
        newBullet = screen.blit(newBullet, (x, y))

    def bullet(x, y):
        newBullet = pygame.image.load('Bullet.png')
        newBullet = screen.blit(newBullet, (x, y))
        bulletsDirection.append(random.randint(1, 2))
        bullets.append(newBullet)
        bulletsX.append(x)
        bulletsY.append(y)

    def editDirection():
        global playerX
        global playerY
        if toY <= -1:
            if playerY <= 0:
                pass
            else:
                playerY -= 1
        elif toY >= 1:
            if playerY >= screenSizes.Height - 60:
                pass
            else:
                playerY += 1
            if toX <= -1:
                if playerX <= 0:
                    pass
                else:
                    playerX -= 1
            elif toX >= 1:
                if playerX >= screenSizes.Width - 25:
                    pass
                else:
                    playerX += 1

    def isCollision(bulletX, bulletY):
        distance = math.sqrt(math.pow(playerX - bulletX, 2) + math.pow(playerY - bulletY, 2))
        if distance < 12:
            return True
        else:
            return False

    def makePlayer(posX, posY):
        getAnimation()
        editDirection()
        getDir = getPlayerDirection()
        screen.blit(pygame.image.load(f"Reimu{getDir}{currentAnim}.png"), (posX, posY))

    screen.fill((24, 24, 24))
    pygame.display.update()
    isGameRunning = True
    start = time.time()
    while isGameRunning:
        if time.time() - start <= 1800:
            screen.blit(background, (0, 0))
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    isGameRunning = False
                    break
                if event.type == pygame.KEYDOWN:
                    if event.key == pygame.K_LEFT:
                        toX = -1
                        currentAnim = 1
                    elif event.key == pygame.K_RIGHT:
                        toX = 1
                        currentAnim = 1
                    else:
                        if event.type == pygame.KEYDOWN:
                            pass

                if event.key == pygame.K_DOWN:
                    toY = 1
                elif event.key == pygame.K_UP:
                    toY = -1
                else:
                    if event.type == pygame.KEYUP:
                        if event.key == pygame.K_LEFT:
                            pass
                    if toX <= -1:
                        toX = 0
                    elif event.key == pygame.K_RIGHT:
                        toX = toX >= 1 and 0
                    elif event.key == pygame.K_UP:
                        if toY <= -1:
                            toY = 0

                if event.key == pygame.K_DOWN:
                    if toY >= 1:
                        toY = 0

            currentCount += 1
            lastBullet += 1
            for x in range(0, len(bullets)):
                if lastBullet >= 5:
                    lastBullet = 0
                    if totalBullets <= bulletLimit:
                        totalBullets += 1
                        bullet(random.randint(0, 800), 0)
                    try:
                        if bulletsDirection[x] == 1:
                            bulletsX[x] += 0.5
                        elif bulletsDirection[x] == 2:
                            bulletsX[x] -= 0.5
                        else:
                            bulletsY[x] += 1.5
                            loadBullet(bulletsX[x], bulletsY[x])
                            if bulletsY[x] >= 600:
                                totalBullets -= 1
                                bullets.pop(x)
                                bulletsX.pop(x)
                                bulletsY.pop(x)

                    except:
                        pass

                    try:
                        if isCollision(bulletsX[x], bulletsY[x]) == True:
                            totalBullets -= 1
                            isGameRunning = False
                            bullets.pop(x)
                            bulletsX.pop(x)
                            bulletsY.pop(x)
                    except:
                        pass

            makePlayer(playerX, playerY)
            pygame.display.update()

    if isGameRunning:
        dx = 50
        dy = 300
        totalBullets += 1
        bullet(dx, dy)
        for _ in range(42):
            bulletsY[(-1)] += 1
            loadBullet(bulletsX[(-1)], bulletsY[(-1)])

        dx += 62
        bullet(dx, dy)
        for _ in range(42):
            bulletsY[(-1)] += 1
            loadBullet(bulletsX[(-1)], bulletsY[(-1)])

        dx += 62
        bullet(dx, dy)
        for _ in range(30):
            bulletsX[(-1)] -= 1
            bulletsY[(-1)] += 1
            loadBullet(bulletsX[(-1)], bulletsY[(-1)])

        for _ in range(30):
            bulletsX[(-1)] += 1
            bulletsY[(-1)] += 1
            loadBullet(bulletsX[(-1)], bulletsY[(-1)])

        for _ in range(30):
            bulletsX[(-1)] += 1
            bulletsY[(-1)] -= 1
            loadBullet(bulletsX[(-1)], bulletsY[(-1)])

        for _ in range(30):
            bulletsX[(-1)] -= 1
            bulletsY[(-1)] -= 1
            loadBullet(bulletsX[(-1)], bulletsY[(-1)])
            
        {{ 省略 }}
            
        pygame.display.update()


if __name__ == '__main__':
    main()

while isGameRunning:から始まるループをisGameRunningが True のまま突破するとゲームクリアになるようですが、クリア条件は存在していませんでした。

そのため、リバーシング結果からクリア後の処理を特定していきます。

クリア後の処理では以下の 2 パターンの処理を繰り返しているようでした。

# パターン 1
dx += 62
bullet(dx, dy)
for _ in range(42):
    bulletsY[(-1)] += 1
    loadBullet(bulletsX[(-1)], bulletsY[(-1)])

# パターン 2
dx += 62
bullet(dx, dy)
for _ in range(30):
    bulletsX[(-1)] -= 1
    bulletsY[(-1)] += 1
    loadBullet(bulletsX[(-1)], bulletsY[(-1)])

for _ in range(30):
    bulletsX[(-1)] += 1
    bulletsY[(-1)] += 1
    loadBullet(bulletsX[(-1)], bulletsY[(-1)])

for _ in range(30):
    bulletsX[(-1)] += 1
    bulletsY[(-1)] -= 1
    loadBullet(bulletsX[(-1)], bulletsY[(-1)])

for _ in range(30):
    bulletsX[(-1)] -= 1
    bulletsY[(-1)] -= 1
    loadBullet(bulletsX[(-1)], bulletsY[(-1)])

この処理では、以下のように 1 と 0 の文字列を画面に出力していました。

image-20230509220457983

そのため、前述のコードの「パターン 1」を 1 に、「パターン 2」を 0 に置き換えることで以下のバイト列を得ることができました。

11000111110110110001111101001100110111101110100100110011100100110011011110101011110001100111111101

このバイト列を Cyberchef に突っ込んで Flag を取得できました。

image-20230509220645018

The Cyber Heist(Forensic)

A group of hackers has stolen a sensitive piece of data, and it’s up to you to recover it. We only found this USB sniffer capture that was taken during the cyber attack. Can you uncover the message from the hackers left to us?

Note: All alphabetical characters in the flag are lower-case.

問題バイナリの pcapng ファイルを WireShark で展開すると、USBPcap で取得した一連の通信であることがわかります。

image-20230509222312992

ちょうど前回参加した WaniCTF でも似た問題が出たので同じ手法で解けるかと思ったのですが、 Leftcver Capture Dataを含むパケットが存在しておらず、Flag 取得に至りませんでした。

参考:Wani CTF 2023 Writeup - かえるのひみつきち

キーボードの通信じゃないのかと思ったので、どのような機器が繋がっているのかを調べることにしました。

以下のようにしてベンダ ID とプロダクト ID から各機器を調べました。

tshark -r ./challenge.pcap -T fields -e usb.idProduct -e usb.idVendor | grep -v '^\s*$'

# https://www.usb.org/sites/default/files/vendor_ids042523.pdf でベンダ ID を調べる
0x2813  0x2109 VIA Labs, Inc
0x0203  0x04d9 Holtek Semiconductor, Inc.
0x0037  0x1532 Razer (Asia‐Pacific) Pte Ltd.
0x006d  0x256c GRAPHICS TECHNOLOGY (HK) CO., LIMITED

その結果、Holtek Semiconductor, Incが Keyaboard などを作成しているメーカーであるという点が気になりました。

そこで、WireShark でこの機器の通信に絞って監査してみたところ、HID Data の値がLeftcver Capture Dataの値と非常によく似た値であることがわかりました。

image-20230509225119158

そのため、tshark を使ってこのデバイスのHID Dataのみを抽出し、keystrokes.txtに情報を抽出し、Flag を取得できました。

# HID Data を取得
tshark -r challenge.pcap -Y 'usbhid.data && usb.addr == "1.2.1"' -T fields -e usbhid.data | sed 's/../:&/g2' > keystrokes.txt

# keystrokes.txt からキー入力を解析
python3 solver.py ./keystrokes.txt

solver.py は以下を使用しました。

#!/usr/bin/python
# -*- coding: utf-8 -*-

import sys

#More symbols in https://www.fileformat.info/search/google.htm?q=capslock+symbol&domains=www.fileformat.info&sitesearch=www.fileformat.info&client=pub-6975096118196151&forid=1&channel=1657057343&ie=UTF-8&oe=UTF-8&cof=GALT%3A%23008000%3BGL%3A1%3BDIV%3A%23336699%3BVLC%3A663399%3BAH%3Acenter%3BBGC%3AFFFFFF%3BLBGC%3A336699%3BALC%3A0000FF%3BLC%3A0000FF%3BT%3A000000%3BGFNT%3A0000FF%3BGIMP%3A0000FF%3BFORID%3A11&hl=en
KEY_CODES = {
    0x04:['a', 'A'],
    0x05:['b', 'B'],
    0x06:['c', 'C'],
    0x07:['d', 'D'],
    0x08:['e', 'E'],
    0x09:['f', 'F'],
    0x0A:['g', 'G'],
    0x0B:['h', 'H'],
    0x0C:['i', 'I'],
    0x0D:['j', 'J'],
    0x0E:['k', 'K'],
    0x0F:['l', 'L'],
    0x10:['m', 'M'],
    0x11:['n', 'N'],
    0x12:['o', 'O'],
    0x13:['p', 'P'],
    0x14:['q', 'Q'],
    0x15:['r', 'R'],
    0x16:['s', 'S'],
    0x17:['t', 'T'],
    0x18:['u', 'U'],
    0x19:['v', 'V'],
    0x1A:['w', 'W'],
    0x1B:['x', 'X'],
    0x1C:['y', 'Y'],
    0x1D:['z', 'Z'],
    0x1E:['1', '!'],
    0x1F:['2', '@'],
    0x20:['3', '#'],
    0x21:['4', '$'],
    0x22:['5', '%'],
    0x23:['6', '^'],
    0x24:['7', '&'],
    0x25:['8', '*'],
    0x26:['9', '('],
    0x27:['0', ')'],
    0x28:['\n','\n'],
    0x29:['␛','␛'],
    0x2a:['⌫', '⌫'],
    0x2b:['\t','\t'],
    0x2C:[' ', ' '],
    0x2D:['-', '_'],
    0x2E:['=', '+'],
    0x2F:['[', '{'],
    0x30:[']', '}'],
    0x32:['#','~'],
    0x33:[';', ':'],
    0x34:['\'', '"'],
    0x36:[',', '<'],
    0x37:['.', '>'],
    0x38:['/', '?'],
    0x39:['⇪','⇪'],
    0x4f:[u'→',u'→'],
    0x50:[u'←',u'←'],
    0x51:[u'↓',u'↓'],
    0x52:[u'↑',u'↑']
}


#tshark -r ./usb.pcap -Y 'usb.capdata && usb.data_len == 8' -T fields -e usb.capdata | sed 's/../:&/g2' > keyboards.txt
def read_use(file):
    with open(file, 'r') as f:
        datas = f.readlines()
    
    datas = [d.strip() for d in datas if d] 
    cursor_x = 0
    cursor_y = 0
    lines = []
    output = ''
    skip_next = False
    lines.append("")
    
    for data in datas:
        shift = int(data.split(':')[0], 16) # 0x2 is left shift 0x20 is right shift
        key = int(data.split(':')[2], 16)

        if skip_next:
            skip_next = False
            continue
        
        if key == 0 or int(data.split(':')[3], 16) > 0:
            continue
        
        #If you don't like output get a more verbose output here (maybe you need to map new rekeys or remap some of them)
        if not key in KEY_CODES:
            #print("Not found: "+str(key))
            continue
        
        if shift != 0:
            shift=1
            skip_next = True

        if KEY_CODES[key][shift] == u'↑':
            lines[cursor_y] += output
            output = ''
            cursor_y -= 1
        
        elif KEY_CODES[key][shift] == u'↓':
            lines[cursor_y] += output
            output = ''
            cursor_y += 1

        elif KEY_CODES[key][shift] == u'→':
            cursor_x += 1

        elif KEY_CODES[key][shift] == u'←':
            cursor_x -= 1

        elif KEY_CODES[key][shift] == '\n':
            lines.append("")
            lines[cursor_y] += output
            cursor_x = 0
            cursor_y += 1
            output = ''

        elif KEY_CODES[key][shift] == '[BACKSPACE]':
            output = output[:cursor_x-1] + output[cursor_x:]
            cursor_x -= 1
        
        else:
            output = output[:cursor_x] + KEY_CODES[key][shift] + output[cursor_x:]
            cursor_x += 1
    
    if lines == [""]:
        lines[0] = output
    
    if output != '' and output not in lines:
        lines[cursor_y] += output
    
    return '\n'.join(lines)

if __name__ == '__main__':
    if len(sys.argv) < 2:
        print('Missing file to read...')
        exit(-1)
    sys.stdout.write(read_use(sys.argv[1]))

まとめ