关于 sast-sqli
sast-sqli 是用于代码库安全评估的 SQL 注入检测技能,面向安全审查人员。它先定位动态构造 SQL 的位置,再分批追踪用户输入并合并结果。
主要功能
- 01定位动态构造 SQL 的位置
- 02分批并行追踪输入来源
- 03合并核查结果报告
- 04要求预先准备架构摘要
适用场景
- 01审查原始 SQL 查询构造
- 02检查用户输入是否进入 SQL
- 03核查动态排序字段的校验
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ utkusen/ sast-skills --skill sast-sqli安装后在 Claude Code 中输入/sast-sqli确认已加载。
详细介绍
SQL 注入(SQLi)检测
你正在对代码库进行有针对性的安全评估,以查找 SQL 注入漏洞。此技能采用包含子代理的三阶段方法:侦察(查找易受攻击的 SQL 构造位置)、分批验证(以每批 3 个的方式并行进行污点分析)和合并(将各批报告汇总到一个文件中)。
前置条件:必须存在 sast/architecture.md。如果不存在,请先运行分析技能。
什么是 SQL 注入
当用户提供的输入通过字符串拼接或插值(而非参数绑定)被纳入 SQL 查询时,就会发生 SQL 注入。这会让攻击者改变查询逻辑、绕过身份验证、提取敏感数据、修改或删除记录,并在某些配置下执行操作系统命令。
核心模式:未经验证且未参数化的用户输入到达 SQL 查询执行调用。
什么属于 SQLi
- 将用户输入直接拼接到 SQL 字符串中:
"SELECT * FROM users WHERE name = '" + username + "'" - 使用字符串格式化来构造查询:
f"SELECT * FROM orders WHERE id = {order_id}" - 根据用户输入动态指定
ORDER BY/GROUP BY/ 表名或列名,且未通过允许列表验证 - 使用 ORM 的原始查询方法并传入未经清理的输入:
User.objects.raw(f"SELECT * WHERE id={id}")、$queryRawUnsafe(input) - 二阶注入:输入存储在数据库中,之后未经重新清理便用于原始查询
什么不属于 SQLi
请勿将以下情况标记为 SQLi:
- IDOR:将
?id=1改为?id=2以访问其他用户的数据——这是不安全的直接对象引用,属于另一类问题 - 批量赋值:从用户输入设置额外的 ORM 模型字段——这是不同的漏洞
- 通过数据库进行 XSS:在数据库中存储
<script>标签,之后未经转义便渲染——这是 XSS,而非 SQLi - NoSQL 注入:向 MongoDB 运算符中注入内容——概念类似,但属于不同的漏洞类别
- 安全的 ORM 查询:使用参数化的 ORM 查询,如
User.objects.filter(id=user_id)或User.find(params[:id])——请勿将其标记为问题
可防止 SQLi 的模式
看到以下模式时,代码通常不存在漏洞:
1. 参数化查询 / 预处理语句(最常见的修复方式)
# Python — cursor.execute with tuple binding
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
# Node.js — mysql2 / pg placeholder binding
db.query("SELECT * FROM users WHERE id = ?", [userId])
pool.query("SELECT * FROM users WHERE id = $1", [userId])
# Java — PreparedStatement
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
ps.setInt(1, userId);
# Go — database/sql placeholder
db.QueryRow("SELECT * FROM users WHERE id = $1", userID)
# PHP — PDO with named params
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id");
$stmt->execute(['id' => $userId]);
# C# — SqlCommand with parameters
cmd.CommandText = "SELECT * FROM users WHERE id = @id";
cmd.Parameters.AddWithValue("@id", userId);
2. ORM 查询构建器(默认安全)
# Django ORM
User.objects.filter(id=user_id)
# ActiveRecord (Rails)
User.find(params[:id])
User.where(name: params[:name])
# Prisma (tagged template literal form of $queryRaw)
await prisma.$queryRaw`SELECT * FROM users WHERE id = ${userId}`
# Laravel Eloquent (non-raw)
User::find($id)
3. 对动态标识符进行允许列表验证
# Dynamic ORDER BY — validate column name against a hardcoded set before interpolating
ALLOWED_COLUMNS = {'name', 'created_at', 'price'}
if sort_col not in ALLOWED_COLUMNS:
raise ValueError("Invalid column")
query = f"SELECT * FROM products ORDER BY {sort_col}" # safe only after allowlist check
易受攻击与安全示例
Python — Django(原始 SQL)
# VULNERABLE: f-string interpolation in raw()
def search_users(request):
username = request.GET.get('username')
users = User.objects.raw(f"SELECT * FROM auth_user WHERE username = '{username}'")
return JsonResponse(list(users.values()), safe=False)
# SECURE: parameterized raw()
def search_users(request):
username = request.GET.get('username')
users = User.objects.raw("SELECT * FROM auth_user WHERE username = %s", [username])
return JsonResponse(list(users.values()), safe=False)
Python — Flask / SQLAlchemy
# VULNERABLE: f-string into text()
@app.route('/search')
def search():
name = request.args.get('name')
result = db.session.execute(text(f"SELECT * FROM products WHERE name = '{name}'"))
return jsonify(result.fetchall())
# SECURE: named bound parameter
@app.route('/search')
def search():
name = request.args.get('name')
result = db.session.execute(
text("SELECT * FROM products WHERE name = :name"), {"name": name}
)
return jsonify(result.fetchall())
Python — sqlite3 / psycopg2
# VULNERABLE
def get_user(username):
cursor.execute("SELECT * FROM users WHERE username = '" + username + "'")
return cursor.fetchone()
# SECURE
def get_user(username):
cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
return cursor.fetchone()
Node.js — mysql2
// VULNERABLE: template literal in query string
app.get('/user', async (req, res) => {
const { id } = req.query;
const [rows] = await db.query(`SELECT * FROM users WHERE id = ${id}`);
res.json(rows);
});
// SECURE: placeholder binding
app.get('/user', async (req, res) => {
const { id } = req.query;
const [rows] = await db.query('SELECT * FROM users WHERE id = ?', [id]);
res.json(rows);
});
Node.js — pg(PostgreSQL)
// VULNERABLE
app.get('/orders', async (req, res) => {
const status = req.query.status;
const result = await pool.query(`SELECT * FROM orders WHERE status = '${status}'`);
res.json(result.rows);
});
// SECURE
app.get('/orders', async (req, res) => {
const status = req.query.status;
const result = await pool.query('SELECT * FROM orders WHERE status = $1', [status]);
res.json(result.rows);
});
Ruby on Rails
# VULNERABLE: string interpolation in where()
def search
@users = User.where("name = '#{params[:name]}'")
end
# VULNERABLE: find_by_sql with interpolation
def find_user
@user = User.find_by_sql("SELECT * FROM users WHERE email = '#{params[:email]}'")
end
# SECURE: parameterized where()
def search
@users = User.where("name = ?", params[:name])
# or using hash form: User.where(name: params[:name])
end
Java — Spring JDBC
// VULNERABLE: string concatenation
public User findUser(String username) {
String sql = "SELECT * FROM users WHERE username = '" + username + "'";
return jdbcTemplate.queryForObject(sql, userRowMapper);
}
// SECURE: parameterized query
public User findUser(String username) {
return jdbcTemplate.queryForObject(
"SELECT * FROM users WHERE username = ?", userRowMapper, username
);
}
Go — database/sql
// VULNERABLE: fmt.Sprintf to build query
func GetUserByName(name string) (*User, error) {
query := fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", name)
row := db.QueryRow(query)
// ...
}
// SECURE: parameterized query
func GetUserByName(name string) (*User, error) {
row := db.QueryRow("SELECT * FROM users WHERE name = $1", name)
// ...
}
PHP — PDO
// VULNERABLE: string concatenation
function getUser($id) {
$stmt = $pdo->query("SELECT * FROM users WHERE id = " . $id);
return $stmt->fetch();
}
// SECURE: prepared statement
function getUser($id) {
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id");
$stmt->execute(['id' => $id]);
return $stmt->fetch();
}
C# — ADO.NET
// VULNERABLE: string concatenation
public User GetUser(string username) {
using var cmd = new SqlCommand(
"SELECT * FROM Users WHERE Username = '" + username + "'", conn);
return ReadUser(cmd.ExecuteReader());
}
// SECURE: parameterized command
public User GetUser(string username) {
using var cmd = new SqlCommand(
"SELECT * FROM Users WHERE Username = @username", conn);
cmd.Parameters.AddWithValue("@username", username);
return ReadUser(cmd.ExecuteReader());
}
动态 ORDER BY / 列名(所有技术栈)
# VULNERABLE: unsanitized user input as column name (parameterization can't help here)
sort_col = request.args.get('sort', 'name')
cursor.execute(f"SELECT * FROM products ORDER BY {sort_col}")
# SECURE: allowlist validation before interpolation
ALLOWED_SORT_COLS = {'name', 'price', 'created_at'}
sort_col = request.args.get('sort', 'name')
if sort_col not in ALLOWED_SORT_COLS:
return abort(400)
cursor.execute(f"SELECT * FROM products ORDER BY {sort_col}")
执行
此技能通过子代理分三个阶段运行。请将 sast/architecture.md 的内容作为上下文传递给所有子代理。
阶段 1:侦察 — 查找易受攻击的 SQL 构造位置
使用以下说明启动一个子代理:
目标:找出代码库中所有以易受攻击的方式构造 SQL 查询的位置——使用字符串拼接、插值或格式化,并涉及任何变量(不论变量来自何处)。将结果写入
sast/sqli-recon.md。背景:你将获得该项目的架构摘要。请利用它了解技术栈、数据库层、ORM 模式和查询执行方法。
搜索目标——易受攻击的查询构造模式:
查找 SQL 查询执行调用,其中的查询字符串参数是动态构建的,而不是包含占位符参数的静态字符串。标记嵌入查询中的任何动态变量——暂时无需追踪变量是否由用户控制;那是第 2 阶段的工作。
- 拼接字符串并传入 SQL 执行调用:
cursor.execute("SELECT ... WHERE id = " + var)$pdo->query("SELECT * FROM users WHERE id = " . $var)jdbcTemplate.query("SELECT * WHERE username = '" + var + "'")- 将 F 字符串/模板字面量用作查询参数:
cursor.execute(f"SELECT * WHERE name = '{var}'")db.query(`SELECT * WHERE id = ${var}`)db.QueryRow(fmt.Sprintf("SELECT * WHERE id = '%s'", var))- 使用字符串格式化函数构建查询:
cursor.execute("SELECT * WHERE id = %s" % var)(注意:这是%格式化,不是参数绑定)cursor.execute("SELECT * WHERE id = {}".format(var))String.format("SELECT * WHERE id = '%s'", var)(Java)sprintf("SELECT * WHERE id = %s", $var)(PHP)- 调用 ORM 原始/不安全方法,并传入动态构建的字符串(而非带绑定参数的静态模板):
- Django:
Model.objects.raw(f"...")、RawSQL(f"...")、extra(where=[f"..."])- ActiveRecord:
where("col = '#{var}'")(字符串参数中使用 Ruby 插值)- Sequelize:
sequelize.query(`...${var}...`)、literal(var)- TypeORM:
createQueryBuilder().where(`col = '${var}'`)、.query("..." + var)- Prisma:
$queryRawUnsafe(...)、$executeRawUnsafe(...)- Entity Framework:
FromSqlRaw("..." + var)、ExecuteSqlRaw("..." + var)- 动态标识符——在查询字符串中,将任何变量用作列名、表名或
ORDER BY/GROUP BY的值(参数化无法保护标识符;只有允许列表验证才能做到):
f"SELECT * FROM {table_var}"`SELECT * FROM ${tableVar}`f"SELECT * ORDER BY {sort_col}"跳过的内容(这些是安全的构造模式——不要标记):
- 不含动态部分的静态查询字符串:
cursor.execute("SELECT * FROM users WHERE id = %s", (val,))- ORM 安全查询构建器方法:
.filter()、.where(col: val)、.findOne()、.findUnique()、使用带标签模板字面量的prisma.$queryRaw- 字符串本身是静态的、值通过单独的参数列表传入的正确参数化原始查询:
execute("SELECT * WHERE id = %s", (val,))、query("SELECT * WHERE id = ?", [val])输出格式——写入
sast/sqli-recon.md:# SQLi Recon:[项目名称] ## 摘要 共发现 [N] 处以易受攻击的方式构造 SQL 查询的位置。 ## 易受攻击的构造位置 ### 1. [描述性名称——例如“get_user 查询中的字符串拼接”] - **文件**:`path/to/file.ext`(第 X-Y 行) - **函数/端点**:[函数名称或路由] - **查询执行方法**:[cursor.execute / db.query / raw / 等] - **构造模式**:[字符串拼接 / F 字符串 / 模板字面量 / % 格式化 / .format() / fmt.Sprintf / ORM 原始查询] - **插值变量**:`var_name`——[简要说明其代表的内容,例如“看起来像排序列”或“来源未知”] - **代码片段**:[易受攻击的查询构造及执行调用]
[每个位置重复上述内容]
阶段 1 之后:检查是否存在候选项,再继续
阶段 1 完成后,读取 sast/sqli-recon.md。如果侦察结果发现 零个易受攻击的构造位置(摘要报告“Found 0”,或者“Vulnerable Construction Sites”部分为空或不存在),则完全跳过阶段 2。改为将以下内容写入 sast/sqli-results.md,然后停止:
# SQLi Analysis Results
No vulnerabilities found.
只有阶段 1 至少发现一个易受攻击的构造位置时,才继续阶段 2。
阶段 2:验证 — 污点分析(分批处理)
阶段 1 完成后,读取 sast/sqli-recon.md,并将构造位置拆分为每批最多 3 个位置。针对每一批并行启动一个子 Agent。每个子 Agent 仅追踪分配给自己的位置中的用户输入,并将结果写入自己的批次文件。
分批流程(由你,即编排者执行,而非子 Agent):
- 读取
sast/sqli-recon.md,统计“Vulnerable Construction Sites”下编号位置章节的数量(### 1.、### 2. 等)。 - 将这些位置划分为每批最多 3 个。例如,8 个位置 → 3 批(1-3、4-6、7-8)。
- 对每一批,从侦察文件中提取这些位置章节的完整文本。
- 并行启动所有批次子 Agent,并且每个子 Agent 只传入分配给它的位置。
- 每个子 Agent 将结果写入
sast/sqli-batch-N.md,其中 N 是从 1 开始的批次编号。 - 从
sast/architecture.md中识别项目的主要语言/框架,并从上文的“Vulnerable vs. Secure Examples”部分中仅选择匹配的示例。例如,如果项目使用带有pg的 Node.js,则包含“Node.js — pg (PostgreSQL)”和相关的 Node 示例。在下文所示的[TECH-STACK EXAMPLES]位置,将这些选定的示例纳入每个子 Agent 的指令中。
向每个批次子 Agent 提供以下指令(替换为对应批次的值):
目标:针对每个分配到的易受攻击的 SQL 构造位置,确定用户提供的值是否会传递到插值变量。我们的目标是发现 SQL 注入漏洞。将结果写入
sast/sqli-batch-[N].md。分配给你的构造位置(来自侦察阶段):
[在此粘贴分配位置的完整文本,并保留原始编号]
背景:你将获得该项目的架构摘要。利用它了解请求入口点、中间件以及数据在应用中的流动方式。
SQLi 参考 — 反向追踪插值变量的来源:
- 直接用户输入 — 变量直接赋值自请求来源,且未经过任何转换:
- HTTP 查询参数:
request.GET.get(...)、req.query.x、params[:x]、$_GET['x']、c.Query("x")- 路径参数:
request.path_params['id']、req.params.id、params[:id]- 请求正文 / 表单字段:
request.POST.get(...)、req.body.x、params[:x]、$_POST['x']- HTTP 标头:
request.headers.get(...)、req.headers['x']- Cookie:
request.COOKIES.get(...)、req.cookies.x- 间接用户输入 — 变量经由转换、函数调用或中间赋值从用户输入派生。追踪完整链路:
- 变量赋值自函数返回值 → 检查该函数参数的来源
- 变量作为函数参数传入 → 检查调用位置
- 变量从在其他位置设置的类属性或共享状态中读取 → 查找设置该值的位置
- 变量根据条件赋值 — 检查所有分支
- 二阶输入 — 变量从数据库读取,但存储值最初来自用户输入:
- 查找该值写入数据库的位置 — 它是否来自用户提供的字段?
- 写入时是否进行了清理或参数化处理?
- 服务端 / 硬编码值 — 变量来自配置、环境变量、硬编码常量或不受用户影响的服务端逻辑 — 此位置不可利用。
缓解措施(即使用户输入可能到达该变量,也要检查):
- 使用前进行允许列表验证(尤其是动态标识符 — 列名 / 表名、
ORDER BY)- 在上下文中确实能约束值的类型转换(例如,在纯数字 SQL 片段中使用
int(val))- 自定义转义(
mysql_real_escape_string、addslashes、自制清理函数)不等同于参数化 — 如果存在污点,仍应归类为“可能存在漏洞”针对本项目技术栈的易受攻击与安全示例:
[TECH-STACK EXAMPLES]
分类:
- 存在漏洞:有明确证据表明用户输入到达插值变量,且没有有效的缓解措施。
- 可能存在漏洞:用户输入可能到达该变量(间接流),或仅采用了较弱的缓解措施(自定义转义)。
- 不存在漏洞:变量仅来自服务端,或已采用有效的参数化 / 允许列表验证。
- 需要人工审查:无法有把握地确定变量来源(不透明的辅助函数、复杂的数据流、外部库)。
输出格式 — 写入
sast/sqli-batch-[N].md:# SQLi 批次 [N] 结果 ## 发现 ### [存在漏洞] 描述性名称 - **文件**:`path/to/file.ext`(第 X-Y 行) - **端点 / 函数**:[路由或函数名称] - **问题**:[例如,“HTTP 查询参数 `username` 直接流入 f-string SELECT 查询”] - **污点追踪**:[从入口点到构造位置的逐步追踪] - **影响**:[攻击者可执行的操作 — 提取记录、绕过身份验证、删除数据等] - **修复建议**:[使用参数化查询、对应的 ORM 写法,或对标识符使用允许列表] - **动态测试**:[sqlmap 命令或手动 curl 负载。展示参数、负载和预期响应信号。 示例:sqlmap -u "https://app.example.com/search?q=test" -p q --dbs]
### [可能存在漏洞] 描述性名称 - **文件**:`path/to/file.ext`(第 X-Y 行) - **端点 / 函数**:[路由或函数名称] - **问题**:[例如,“存在间接数据流,或仅使用了自定义转义”] - **污点追踪**:[尽力追踪;标注不确定的步骤] - **风险说明**:[说明为何仍有风险] - **修复建议**:[改用参数化查询] - **动态测试**:[尝试绕过的负载]
### [不存在漏洞] 描述性名称 - **文件**:`path/to/file.ext`(第 X-Y 行) - **端点 / 函数**:[路由或函数名称] - **原因**:[例如,“服务端常量”或“排序列由允许列表控制”] ### [需要人工审查] 描述性名称 - **文件**:`path/to/file.ext`(第 X-Y 行) - **端点 / 函数**:[路由或函数名称] - **不确定之处**:[无法确定来源的原因] - **建议**:[需要手动追踪的内容]
阶段 3:合并 — 汇总批次结果
在所有阶段 2 批次子 Agent 完成后,读取每个 sast/sqli-batch-*.md 文件,并将它们合并到一个 sast/sqli-results.md 文件中。由你(协调器)直接完成此操作,无需子 Agent。
合并流程:
- 读取所有
sast/sqli-batch-1.md、sast/sqli-batch-2.md……文件。 - 收集每个批次文件中的所有发现并合并为一个列表,同时保留原始分类和所有详细字段。
- 汇总所有批次的数量,填写执行摘要(已分析的构造点 = 侦察阶段发现并分批处理的构造点总数,即所有批次中的构造点数量之和)。
- 按照以下格式将合并后的报告写入
sast/sqli-results.md:
# SQLi Analysis Results: [Project Name]
## Executive Summary
- Construction sites analyzed: [total across all batches]
- 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/sqli-results.md后,删除所有中间批次文件(sast/sqli-batch-*.md)。
重要提醒
- 读取
sast/architecture.md,并将其内容作为上下文传递给所有子 Agent。 - 阶段 2 必须在阶段 1 完成后运行,因为它依赖侦察阶段的输出。
- 阶段 3 必须在所有阶段 2 批次完成后运行,因为它依赖所有批次的输出。
- 每个子 Agent 的批次大小为每个子 Agent 3 个构造点。如果总共只有 1-3 个构造点,则使用一个子 Agent。如果有 10 个,则使用 4 个子 Agent(3+3+3+1)。
- 并行启动所有批次子 Agent,不要按顺序运行。
- 每个批次子 Agent 只接收侦察文件中分配给它的构造点文本,而不是整个侦察文件。这样可以让每个子 Agent 的上下文保持精简、聚焦。
- 阶段 1 仅进行结构分析:标记 SQL 查询字符串中嵌入的任何动态变量,无论其来源如何。阶段 1 不要追踪用户输入,那是阶段 2 的工作。
- 阶段 2 仅进行污点分析:针对分配的每个构造点,追踪插值变量的来源。如果它来自用户可控的来源,该构造点就是真实漏洞。
- 聚焦于原始 SQL 和 ORM 的原始/不安全方法。标准 ORM 查询构造器调用(
.filter()、.where(col: val)、.find())默认安全,不要将其标记出来。 - 如有疑问,应将其分类为“需要人工审查”,而不是“无漏洞”。在安全评估中,漏报比误报更糟。
- 污点可能间接传播:请求参数可能在中间件中被提取、存储在共享对象中、经过多个辅助函数,最终到达查询构造。追踪完整链路。
- 自定义转义(包括
mysql_real_escape_string、addslashes或自行编写的清理函数)不等同于参数化——即使存在转义,也应标记为“可能存在漏洞”。 - 对于动态标识符(列名/表名),参数化无济于事——唯一安全的修复方法是使用允许列表进行验证。任何没有允许列表的动态标识符都应标记出来,无论其是否看起来受用户控制。
- 二阶注入很容易被忽略:用户输入的值可能先存储在数据库中,之后又在代码库的其他位置被读取并不安全地用于原始查询。阶段 2 应将从数据库读取的值视为可能带有污点,并追踪其最初的写入位置。
- 清理中间文件:写入最终的
sast/sqli-results.md后,删除sast/sqli-recon.md和所有sast/sqli-batch-*.md文件。