关于 sast-fileupload
sast-fileupload 是用于代码库安全评估的文件上传漏洞检测技能,面向安全审查人员。它分阶段发现上传位置、并行核查绕过方式,并合并结果报告。
主要功能
- 01发现文件接收与存储位置
- 02分批并行核查绕过风险
- 03合并分析结果报告
- 04要求预先准备架构摘要
适用场景
- 01审查应用的文件上传实现
- 02检查扩展名与文件名校验
- 03查找上传文件存储路径风险
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ utkusen/ sast-skills --skill sast-fileupload安装后在 Claude Code 中输入/sast-fileupload确认已加载。
详细介绍
不安全文件上传检测
你正在执行一次有针对性的安全评估,以查找代码库中的不安全文件上传漏洞。此技能采用包含子 Agent 的三阶段方法:发现(找出所有接收并存储上传文件的位置)、分批验证(每批最多包含 3 个上传位置,并行检查绕过手段)以及合并(将各批报告整合到一个结果文件中)。
前提条件:必须存在 sast/architecture.md。如果不存在,请先运行分析技能。
什么是不安全文件上传
如果应用程序接受用户上传的文件,却未正确验证或限制可上传的内容,攻击者就可能上传可执行文件或恶意文件,这种情况称为不安全文件上传。最严重的后果是远程代码执行(RCE):攻击者上传 Web shell(例如 .php 文件),服务器在通过直接 URL 访问该文件时执行它。
核心模式:用户提供的文件未经充分的扩展名验证,就被写入某个存储位置,而且存储的文件可以被访问或执行。
什么属于不安全文件上传
- 不检查扩展名或内容,接受任何文件类型:
file.save(upload_path),且没有任何验证 - 仅验证 Content-Type:检查
Content-Type: image/png,却不验证实际扩展名或文件内容——只需手动设置标头就能轻松绕过 - 扩展名阻止列表不完整:阻止
.php,却未阻止.php3、.php4、.php5、.phtml、.phar、.shtml - 大小写绕过:阻止
.php,却允许.PHP、.Php、.pHp - 双扩展名绕过:
shell.php.jpg——代码提取最后一个.jpg并认为它安全,但服务器(Apache)会将其作为 PHP 文件处理 - 文件名中的路径遍历:通过未经清理的文件名存储
../../webroot/shell.php - 文件名清理不完整:只去除
../,却未处理%2e%2e%2f等编码形式 - 将上传的文件放在可执行 Web 内容的目录中,且未禁用执行
什么不属于不安全文件上传
请勿将以下情况标记为文件上传漏洞:
- 通过 SVG 实现存储型 XSS:上传嵌入
<script>的 SVG 并将其反射回去——这是 XSS,而不是上传文件执行问题 - 通过文件内容实现 SSRF:上传会触发外部请求的 XML 或 SVG——这是 XXE/SSRF,而不是文件上传执行问题
- 通过大文件造成 DoS:缺少文件大小限制——这是另一类可用性问题
- 下载时存在 IDOR:未经授权即可访问其他用户上传的文件——这是 IDOR
- 安全的上传方式:将文件存储在 Web 根目录之外;或通过受控下载端点提供文件,并设置
Content-Disposition: attachment;或将文件存储在不具备公开执行能力的对象存储桶中
可防止不安全文件上传的模式
如果你同时看到以下模式,代码很可能不存在漏洞:
1. 安全扩展名允许列表(最重要)
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
ext = filename.rsplit('.', 1)[-1].lower()
if ext not in ALLOWED_EXTENSIONS:
abort(400)
2. 魔术字节 / 文件内容验证(深度防御)
import magic
mime = magic.from_buffer(file.read(2048), mime=True)
ALLOWED_MIMES = {'image/png', 'image/jpeg', 'image/gif'}
if mime not in ALLOWED_MIMES:
abort(400)
3. 使用可信库清理文件名
from werkzeug.utils import secure_filename
filename = secure_filename(file.filename) # strips path separators and dangerous chars
4. 在 Web 根目录之外存储上传文件
/var/uploads/ ← not served by the web server
/var/www/html/ ← web root (do NOT store uploads here)
5. 通过受控端点提供上传文件,并设置 Content-Disposition
@app.route('/download/<filename>')
def download(filename):
return send_from_directory(UPLOAD_FOLDER, filename,
as_attachment=True) # forces download, prevents execution
6. 将文件重命名为服务器生成的 UUID
import uuid
stored_name = str(uuid.uuid4()) + '.jpg' # extension is server-controlled, not user-controlled
易受攻击与安全示例
Python — Flask
# VULNERABLE: no extension check, file stored in web-accessible directory
@app.route('/upload', methods=['POST'])
def upload():
f = request.files['file']
f.save(os.path.join('static/uploads', f.filename))
return 'uploaded'
# VULNERABLE: content-type only check (trivially bypassed with curl -H)
@app.route('/upload', methods=['POST'])
def upload():
f = request.files['file']
if f.content_type not in ['image/png', 'image/jpeg']:
abort(400)
f.save(os.path.join('static/uploads', f.filename))
return 'uploaded'
# VULNERABLE: blocklist — .phtml/.phar/.php5 not covered
BLOCKED = {'.php', '.sh', '.exe'}
@app.route('/upload', methods=['POST'])
def upload():
f = request.files['file']
ext = os.path.splitext(f.filename)[1].lower()
if ext in BLOCKED:
abort(400)
f.save(os.path.join('static/uploads', f.filename))
return 'uploaded'
# SECURE: allowlist + sanitized filename + outside web root
ALLOWED = {'png', 'jpg', 'jpeg', 'gif'}
UPLOAD_FOLDER = '/var/uploads' # outside web root
@app.route('/upload', methods=['POST'])
def upload():
f = request.files['file']
filename = secure_filename(f.filename)
ext = filename.rsplit('.', 1)[-1].lower()
if ext not in ALLOWED:
abort(400)
f.save(os.path.join(UPLOAD_FOLDER, filename))
return 'uploaded'
Python — Django
# VULNERABLE: no validation on FileField
class DocumentForm(forms.ModelForm):
class Meta:
model = Document
fields = ['upload']
# VULNERABLE: manual save with no extension check
def upload(request):
f = request.FILES['file']
with open(f'media/uploads/{f.name}', 'wb+') as dest:
for chunk in f.chunks():
dest.write(chunk)
# SECURE: custom validator on FileField
def validate_file_extension(value):
ext = os.path.splitext(value.name)[1].lower()
if ext not in ['.png', '.jpg', '.jpeg', '.gif']:
raise ValidationError('Unsupported file extension.')
class DocumentForm(forms.ModelForm):
upload = forms.FileField(validators=[validate_file_extension])
Node.js — Multer(Express)
// VULNERABLE: no file filter, stored in public directory
const upload = multer({ dest: 'public/uploads/' });
app.post('/upload', upload.single('file'), (req, res) => {
res.send('uploaded');
});
// VULNERABLE: MIME type filter only (can be faked)
const upload = multer({
dest: 'uploads/',
fileFilter: (req, file, cb) => {
if (!file.mimetype.startsWith('image/')) return cb(null, false);
cb(null, true);
}
});
// SECURE: allowlist of extensions + storage outside web root
const ALLOWED_EXT = ['.jpg', '.jpeg', '.png', '.gif'];
const storage = multer.diskStorage({
destination: '/var/uploads', // not served by Express
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${uuidv4()}${ext}`);
}
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, ALLOWED_EXT.includes(ext));
}
});
PHP
// VULNERABLE: no extension check, stored in web root
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);
// VULNERABLE: checking only content type header
if ($_FILES['file']['type'] !== 'image/jpeg') {
die('Invalid file type');
}
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);
// VULNERABLE: blocklist missing phtml/phar
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
$blocked = ['php', 'sh', 'py'];
if (in_array($ext, $blocked)) die('Blocked');
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);
// SECURE: allowlist + rename to UUID + outside web root
$allowed = ['jpg', 'jpeg', 'png', 'gif'];
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allowed)) die('Invalid extension');
$stored = '/var/uploads/' . bin2hex(random_bytes(16)) . '.' . $ext;
move_uploaded_file($_FILES['file']['tmp_name'], $stored);
Java — Spring Boot(MultipartFile)
// VULNERABLE: no validation, stored in web-accessible path
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
Path path = Paths.get("src/main/resources/static/uploads/" + file.getOriginalFilename());
Files.write(path, file.getBytes());
return "uploaded";
}
// VULNERABLE: content type header only
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
if (!file.getContentType().startsWith("image/")) throw new BadRequestException();
Files.write(Paths.get("uploads/" + file.getOriginalFilename()), file.getBytes());
return "uploaded";
}
// SECURE: allowlist + UUID rename + path outside web root
private static final Set<String> ALLOWED = Set.of("jpg", "jpeg", "png", "gif");
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
String original = StringUtils.cleanPath(file.getOriginalFilename());
String ext = FilenameUtils.getExtension(original).toLowerCase();
if (!ALLOWED.contains(ext)) throw new BadRequestException("Invalid extension");
String stored = UUID.randomUUID() + "." + ext;
Files.write(Paths.get("/var/uploads/" + stored), file.getBytes());
return "uploaded";
}
Go
// VULNERABLE: no extension check, stored in static directory
func uploadHandler(w http.ResponseWriter, r *http.Request) {
file, header, _ := r.FormFile("file")
defer file.Close()
dst, _ := os.Create("static/uploads/" + header.Filename)
defer dst.Close()
io.Copy(dst, file)
}
// SECURE: allowlist extension + UUID rename + outside web root
var allowed = map[string]bool{"jpg": true, "jpeg": true, "png": true, "gif": true}
func uploadHandler(w http.ResponseWriter, r *http.Request) {
file, header, _ := r.FormFile("file")
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext == "" || !allowed[ext[1:]] {
http.Error(w, "invalid extension", http.StatusBadRequest)
return
}
stored := "/var/uploads/" + uuid.New().String() + ext
dst, _ := os.Create(stored)
defer dst.Close()
io.Copy(dst, file)
}
Ruby on Rails
# VULNERABLE: no content type or extension validation
def upload
file = params[:file]
File.open(Rails.root.join('public', 'uploads', file.original_filename), 'wb') do |f|
f.write(file.read)
end
end
# SECURE: ActiveStorage with content type allowlist (Rails 6+)
has_one_attached :avatar
validates :avatar, content_type: ['image/png', 'image/jpg', 'image/jpeg']
# Note: still validate extension too — content_type is user-supplied in some configurations
# SECURE: CarrierWave with extension and content type allowlist
class AvatarUploader < CarrierWave::Uploader::Base
def extension_allowlist
%w[jpg jpeg png gif]
end
def content_type_allowlist
/image\//
end
end
C# — ASP.NET Core
// VULNERABLE: no extension check, stored in wwwroot
[HttpPost]
public async Task<IActionResult> Upload(IFormFile file) {
var path = Path.Combine("wwwroot/uploads", file.FileName);
using var stream = new FileStream(path, FileMode.Create);
await file.CopyToAsync(stream);
return Ok();
}
// SECURE: allowlist + GUID rename + outside web root
private static readonly HashSet<string> _allowed = new() { ".jpg", ".jpeg", ".png", ".gif" };
[HttpPost]
public async Task<IActionResult> Upload(IFormFile file) {
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!_allowed.Contains(ext)) return BadRequest("Invalid extension");
var stored = Path.Combine("/var/uploads", $"{Guid.NewGuid()}{ext}");
using var stream = new FileStream(stored, FileMode.Create);
await file.CopyToAsync(stream);
return Ok();
}
执行
此技能通过子 Agent 分三个阶段运行。将 sast/architecture.md 的内容作为上下文传递给所有子 Agent。
阶段 1:查找所有文件上传位置
启动一个子代理,并提供以下指示:
目标:找出代码库中所有接收并存储用户上传文件的位置。将结果写入
sast/fileupload-recon.md。背景:你将获得项目架构摘要。利用它了解框架、文件存储模式,以及上传内容是写入本地磁盘、云存储还是 CDN。
搜索内容 — 文件上传处理模式:
查找所有从 HTTP 请求接收文件并写入或存储文件的代码。暂时不要评估是否进行了验证,只需找出所有位置。
- Python / Django:
- 访问
request.FILESInMemoryUploadedFile、TemporaryUploadedFiledefault_storage.save(...)、FileSystemStorage().save(...)- Model
FileField/ImageField表单提交- 对上传数据调用
shutil.copyfileobj(f, dest)或手动执行.write(f.read())- Python / Flask:
request.files.get(...)或request.files[...]- 对
FileStorage对象调用file.save(...)werkzeugFileStorage处理- Node.js:
multer中间件:upload.single(...)、upload.array(...)、upload.fields(...)busboy、formidable、multiparty表单解析express-fileupload:req.files- 对请求流调用
fs.writeFile/fs.createWriteStream/pipe()- PHP:
- 访问
$_FILES- 调用
move_uploaded_file(...)- 调用
copy($_FILES[...]['tmp_name'], ...)- Java / Spring:
- 控制器方法中的
MultipartFile参数:@RequestParam MultipartFileCommonsMultipartFile、StandardMultipartFilePart.write(...)(Servlet API)file.transferTo(...)、Files.write(path, file.getBytes())- Go:
r.FormFile(...)或r.MultipartForm.File- 对来自 multipart 表单的
file调用io.Copy(dst, file)- 使用从
header.Filename派生的文件名调用os.Create(...)- Ruby / Rails:
- 使用
.read、.original_filename、.tempfile处理params[:file]- 使用上传数据调用
File.open(..., 'wb')has_one_attached/has_many_attached(ActiveStorage)- CarrierWave
mount_uploader、Shrineinclude Shrine::Attachment- C# / ASP.NET:
IFormFile参数:file.CopyToAsync(...)、file.OpenReadStream()HttpPostedFileBase.SaveAs(...)Request.Files[...]输出格式 — 写入
sast/fileupload-recon.md:# 文件上传侦查:[项目名称] ## 摘要 找到 [N] 个文件上传位置。 ## 上传位置 ### 1. [描述性名称 — 例如“头像上传端点”] - **文件**:`path/to/file.ext`(第 X-Y 行) - **端点 / 函数**:[路由或函数名称] - **框架 / 方法**:[例如 Flask request.files / multer / move_uploaded_file] - **存储目标**:[路径、变量或存储抽象 — 例如“static/uploads/”或“通过 boto3 写入 S3”或“未知”] - **已观察到的验证**(初步结果,第 2 阶段将深入分析):[列出任何扩展名检查、内容类型检查,或填写“未见相关检查”] - **代码片段**:[接收并保存上传文件的代码]
[对每个位置重复以上内容]
阶段 1 之后:继续前检查候选项
阶段 1 完成后,读取 sast/fileupload-recon.md。如果侦查结果为零个上传位置(摘要报告“找到 0 个”,或“上传位置”部分为空或缺失),则完全跳过阶段 2 和阶段 3。改为将以下内容写入 sast/fileupload-results.md,然后停止:
# File Upload Analysis Results
No file upload sites found.
只有在阶段 1 找到至少一个上传位置时,才继续阶段 2。
阶段 2:检查扩展名绕过漏洞(分批处理)
第 1 阶段完成后,读取 sast/fileupload-recon.md,并将上传站点拆分为每批最多 3 个站点。为每批并行启动一个子代理。每个子代理只分析分配给自己的站点,并将结果写入各自的批次文件。
批次划分流程(由你作为编排者执行,不要交给子代理):
- 读取
sast/fileupload-recon.md,并统计带编号的站点章节(### 1.、### 2. 等)。 - 将它们划分为每批最多 3 个站点。例如,8 个站点 → 3 批(1-3、4-6、7-8)。
- 从侦察文件中提取每批站点章节的完整文本。
- 并行启动所有批次子代理,并且每个子代理只传入分配给它的站点。
- 每个子代理将结果写入
sast/fileupload-batch-N.md,其中 N 是从 1 开始的批次编号。 - 从
sast/architecture.md中确定项目的主要语言/框架,并且只选择上文“易受攻击与安全示例”部分中与之匹配的示例。例如,如果项目使用 Node.js 和 Multer,则只包含“Node.js — Multer (Express)”示例。将选出的示例按下方[TECH-STACK EXAMPLES]所示,加入每个子代理的指令中。
向每个批次子代理提供以下指令(替换为对应批次的值):
目标:针对下方分配的每个文件上传站点,判断攻击者能否通过操纵文件名、扩展名或 Content-Type 标头上传恶意文件(例如 PHP Web shell、JSP shell、Python 脚本)。将结果写入
sast/fileupload-batch-[N].md。分配给你的上传站点(来自侦察阶段):
[在此处粘贴分配给你的站点章节全文,并保留原始编号]
背景:你将获得项目架构摘要。利用它了解框架、存储路径以及上传文件的提供方式。
参考 — 什么是以及不是什么不安全文件上传:
重点关注可执行文件或危险文件类型在缺乏充分控制的情况下进入存储。不要将通过 SVG 实现的存储型 XSS、通过上传 XML 实现的 SSRF、由文件大小限制导致的 DoS,或下载时的 IDOR 标记为文件上传执行问题(其他技能会处理这些问题)。
降低风险的模式 — 如果你发现多项强有力的措施组合(允许列表、清理、存储在 Web 根目录之外、UUID 重命名),除非仍存在可利用的绕过方式,否则该站点很可能是不易受攻击的。
适用于本项目技术栈的易受攻击与安全示例:
[TECH-STACK EXAMPLES]
针对每个上传站点,评估以下绕过向量:
未检查扩展名:没有对文件名或扩展名进行任何形式的验证。任何文件都会被接受。立即标记为易受攻击。
仅验证 Content-Type / MIME 标头:验证只读取请求标头中的
Content-Type或mimetype,但不检查实际文件名扩展名或文件字节。攻击者可以上传shell.php,同时设置Content-Type: image/png。标记为易受攻击。基于阻止列表的验证:明确列出禁止的扩展名。检查阻止列表是否覆盖服务器技术栈中的所有扩展名:
- PHP 服务器:是否也阻止了
.php3、.php4、.php5、.php7、.phtml、.phar、.shtml?如果缺少任何一种,标记为易受攻击。- Java 服务器:是否也阻止了
.jsp、.jspx、.jsw、.jsv、.jspf?- ASP.NET 服务器:是否也阻止了
.asp、.aspx、.ashx、.asmx、.cer、.asa?- Node.js:服务器配置是否可能导致
.js被执行?检查上传目录中的.js文件是否可以被 require/执行。- 任何阻止列表本质上都弱于允许列表——即使看起来完整,也标记为可能易受攻击。
大小写敏感绕过:阻止了
.php,但未阻止.PHP、.Php、.pHp。检查比较操作是否使用.toLowerCase()/.lower()/strtolower()/ 不区分大小写的匹配。双扩展名 / 多重扩展名:
shell.php.jpg— 如果代码通过取最后一个点之后的部分来提取扩展名,那么允许列表应能拦截它。不过,在配置了AddHandler的 Apache 服务器上,可能会使用最左侧匹配到的扩展名来执行。检查扩展名的提取方式:
- 安全:
filename.rsplit('.', 1)[-1]、path.extname(filename)(取最后一个扩展名)- 服务器配置风险:Apache
AddHandler application/x-httpd-php .php— 即使是shell.php.jpg也可能作为 PHP 执行文件名中的路径遍历:如果存储路径使用原始文件名且未进行清理,
../../webroot/shell.php可能将文件放入非预期目录。检查是否使用:
secure_filename()、basename()、path.basename()、Path.GetFileName()或filepath.Base()— 这些函数会移除目录分隔符,属于安全做法- 未清理便直接在路径拼接中使用
file.filename、header.Filename、file.getOriginalFilename()、$_FILES['name']— 标记为易受攻击文件存储在 Web 可执行目录中:即使使用了正确的扩展名允许列表,如果上传文件存放在 Web 服务器提供服务的目录中(例如
static/uploads/、public/uploads/、wwwroot/uploads/),而 Web 服务器配置为执行脚本,那么扩展名验证中的绕过就会造成严重后果。注明存储路径是否可通过 Web 访问。没有基于内容的验证(魔数):服务器信任扩展名,但不验证实际文件内容。名为
shell.jpg、内容却包含 PHP 代码的文件,如果扩展名检查可被绕过且服务器会执行该文件,仍然很危险。将缺少魔数检查注明为促成风险的弱点。分类:
- 易受攻击:完全没有验证,或存在明显可绕过的检查(仅检查 Content-Type、阻止列表缺少常见扩展名、未使用
.lower()、文件名存在路径遍历)。- 可能易受攻击:阻止列表看似完整,但本质上弱于允许列表;或者允许列表存在潜在边缘情况(例如未考虑大写扩展名)。
- 不易受攻击:对安全扩展名使用严格的允许列表(不区分大小写),同时清理文件名和/或使用服务器生成的 UUID 重命名,并将文件存储在 Web 根目录之外或置于受控下载端点之后。
- 需要人工审查:验证逻辑位于无法完整查看的共享辅助函数或中间件中;或者存储路径是动态的,无法确定。
输出格式 — 写入
sast/fileupload-batch-[N].md:# 文件上传批次 [N] 结果 ## 发现 ### [易受攻击] 描述性名称 - **文件**:`path/to/file.ext`(第 X-Y 行) - **端点 / 函数**:[路由或函数名称] - **问题**:[例如,“未验证扩展名 — 接受任何文件类型”或“仅使用 Content-Type 标头作为检查依据”] - **绕过向量**:[具体方法 — 例如,“直接上传 shell.php”或“上传 .php 文件时设置 Content-Type: image/png”或“使用阻止列表未涵盖的 .phtml 扩展名”] - **存储路径**:[文件存放位置 — 是否可通过 Web 访问] - **影响**:[例如,“攻击者上传 PHP Web shell,并通过访问 /uploads/shell.php 实现 RCE”] - **修复建议**:[具体修复措施 — 改用允许列表、添加 `.lower()`、使用 secure_filename、将存储位置移到 Web 根目录之外] - **动态测试**:[展示绕过方式的 curl 命令或 HTTP 请求。 示例:curl -X POST https://app.example.com/upload
-F "[email protected];type=image/png"
然后访问:https://app.example.com/static/uploads/shell.php?cmd=id]### [可能易受攻击] 描述性名称 - **文件**:`path/to/file.ext`(第 X-Y 行) - **端点 / 函数**:[路由或函数名称] - **问题**:[例如,“基于阻止列表的扩展名检查 — 本质上不完整”] - **绕过向量**:[可能的绕过方式 — 例如,“如果服务器使用 Apache/PHP,可尝试 .phtml、.phar、.php5”] - **存储路径**:[文件存放位置] - **风险说明**:[仍然存在风险的原因] - **修复建议**:[将阻止列表替换为允许列表] - **动态测试**:[尝试绕过的载荷]
### [不易受攻击] 描述性名称 - **文件**:`path/to/file.ext`(第 X-Y 行) - **端点 / 函数**:[路由或函数名称] - **原因**:[例如,“仅允许 png/jpg/gif 的严格允许列表,并使用 .lower()、UUID 重命名,且存储位置在 Web 根目录之外”] ### [需要人工审查] 描述性名称 - **文件**:`path/to/file.ext`(第 X-Y 行) - **端点 / 函数**:[路由或函数名称] - **不确定之处**:[无法确定验证逻辑或存储路径的原因] - **建议**:[需要人工追踪的内容]
阶段 3:合并 — 汇总批次结果
等待所有阶段 2 批次子 Agent 完成后,读取每个 sast/fileupload-batch-*.md 文件,并将它们合并为单个 sast/fileupload-results.md。由你(协调者)直接完成此操作,无需子 Agent。
合并步骤:
- 读取所有
sast/fileupload-batch-1.md、sast/fileupload-batch-2.md等文件。 - 收集每个批次文件中的所有发现,并将它们合并为一个列表,保留原始分类和所有详细字段。
- 汇总所有批次的数量,用于执行摘要(分析的网站总数应与侦察阶段的数量一致;各分类的数量应跨批次求和)。
- 使用以下格式将合并后的报告写入
sast/fileupload-results.md:
# File Upload Analysis Results: [Project Name]
## Executive Summary
- Upload sites analyzed: [total from recon]
- Vulnerable: [N]
- Likely Vulnerable: [N]
- Not Vulnerable: [N]
- Needs Manual Review: [N]
## Findings
[All findings from all batches, grouped by classification:
VULNERABLE first, then LIKELY VULNERABLE, then NEEDS MANUAL REVIEW, then NOT VULNERABLE.
Preserve every field from the batch results exactly as written.]
- 写入
sast/fileupload-results.md后,删除所有中间批次文件(sast/fileupload-batch-*.md)。
重要提醒
- 读取
sast/architecture.md,并将其内容作为上下文传递给所有子 Agent。 - 阶段 2 必须在阶段 1 完成后运行,因为它依赖侦察输出。
- 阶段 3 必须在所有阶段 2 批次完成后运行,因为它依赖所有批次输出。
- 每个子 Agent 处理 3 个上传点。如果总共只有 1 到 3 个上传点,则使用单个子 Agent。如果有 10 个,则使用 4 个子 Agent(3+3+3+1)。
- 并行启动所有批次子 Agent,不要按顺序运行。
- 每个批次子 Agent 只接收分配给它的网站在侦察文件中的文本,而不是整个侦察文件。这样可以让每个子 Agent 的上下文更小、更聚焦。
- 阶段 1 仅用于发现:找出所有接收并存储用户提供文件的位置。阶段 1 不要深入分析验证逻辑,只需记录可见信息。这是阶段 2 的工作。
- 阶段 2 仅用于绕过分析:针对每个分配的上传点,检查验证逻辑,并判断能否通过扩展名操纵、大小写变化、内容类型伪造或路径遍历绕过验证。
- 阶段 3 仅用于合并:将批次文件合并到
sast/fileupload-results.md并删除中间文件;阶段 3 不要重新分析代码。 - 允许列表始终比阻止列表更可靠。任何基于阻止列表的方法至少应标记为 可能存在漏洞,因为阻止列表几乎总是不完整。
- Content-Type(HTTP 标头中的 MIME 类型)完全由攻击者控制,绝不能将其视为安全控制措施。
- 大小写敏感性很重要:如果缺少
.toLowerCase(),那么.PHP就能绕过对.php的检查。务必检查。 - 文件名中的路径遍历是独立于扩展名绕过的另一种攻击途径,两者都要检查。
- 即使扩展名检查正确,如果文件存储在可由 Web 执行的目录中,其防护能力也会减弱。每项发现都要记录存储位置。
- 魔数检查(读取文件的实际字节)属于纵深防御,但不能替代扩展名允许列表:在有效图像文件末尾附加 PHP 代码仍可能造成危险。
- 拿不准时,应归类为“需要人工审核”,而不是“未发现漏洞”。在安全评估中,漏报比误报更糟糕。
- 清理中间文件:写入最终的
sast/fileupload-results.md后,删除sast/fileupload-recon.md和所有sast/fileupload-batch-*.md文件。