tryhackme Web 框架:代码审计
熟悉代码库
-
阅读顺序
1 自述文件和文档 该应用程序的功能、运行方式以及使用的框架
2 依赖关系清单 库和版本、第三方攻击面
3 配置文件 调试标志、密钥、数据库字符串
4 路线/入口点 所有入侵途径列表,我们的攻击面地图
5 身份验证中间件和装饰器 哪些路由受到保护,哪些不受保护。
6 数据库/模型层 数据存储在哪里以及如何构建查询
7 单个路由处理程序 每个入口点背后的逻辑 -
依赖关系表现
Python: requirements.txt (或 pyproject.toml )
Java: pom.xml
.NET:.csproj
pip-audit 可以作为快速的补充检查工具,它会将清单与漏洞数据库进行比较,但请将其视为辅助检查,而非代码审查的重点。
- 配置文件
未关闭的调试标志( DEBUG = True )
字符串字面量形式编写的密钥、嵌入密码的数据库连接字符串以及被禁用的安全检查
在 Flask 项目中,这些文件通常位于 config.py 文件中
- 路由即攻击面
在 Flask 中,路由使用 @app.route 装饰器进行标记
Django为 urlpatterns 列表中
Spring 使用所谓类似 @GetMapping 的注解
- 身份验证边界
在 Flask 中,这通常意味着在路由上方添加 @login_required 装饰器(或自定义检查)
- 陌生的框架
回到上述的阅读顺序
源到汇分析
- 源或输入点
在 Flask 中,常见的数据源都位于 request 对象中:
request.args # query string parameters
request.form # POST form fields
request.json # JSON request body
request.cookies # cookie values
request.headers # request headers
request.files # uploaded files
- 汇或接收器
主要的接收器包括:
SQL 查询构造( cursor.execute )
Shell 命令执行( os.system , subprocess , shell=True )
模板渲染( render_template_string )
反序列化( pickle.loads , yaml.load )
文件路径构造( open , send_file )
任意执行( eval , exec )
- 数据流
源与汇之间的数据流可能存在化解输入风险的数据清理(Sanitisation)、校验(Validation)或类型强转(Type Coercion),也可能什么都没有。例如,在 ID 进入查询语句前进行 int() 类型强转即可堵住 SQL 注入漏洞;在打开路径前通过正则匹配拒绝 ../ 则能消除路径穿越风险。请认真研读路径本身,切勿凭空假设其存在。
-
双向追踪
-
为什么源头消毒还不够
比如存储型 XSS 攻击,比如二阶注入:进行追踪时,我们会追踪数据进入数据库并返回的整个过程,而不仅仅是从请求到第一个访问该数据的函数。
- 示例
@app.route("/greet")
def greet():
name = request.args.get("name")
return render_template_string(f"Hello {name}")
源: request.args.get("name")
汇: render_template_string
它会将参数编译成 Jinja2 模板。它们之间的数据流是一个 f-string,它会将 name 直接插入到模板文本中,没有任何验证。用户控制模板语法,这属于服务器端模板注入 (SSTI)。
对比一下安全版本,其中值作为数据传递给一个固定的模板,永远不会变成模板代码:
return render_template("greet.html", name=name)
- 使用工具进行追踪
Semgrep:污点模式会追踪从已声明的源到已声明的汇的值,并报告路径,这使得手动追踪技术能够扩展到整个代码库
CodeQL与上述类似,并进行了更深入的跨过程分析
使用 Grep 检索代码风险
- 搜索危险调用
grep -rn --include="*.py" -E "os\.system|subprocess|eval\(|exec\(|pickle\.loads|render_template_string|cursor\.execute|send_file|open\(" .
在大型代码库中,ripgrep(rg) 是更快的直接替代方案,默认会跳过 .gitignore 中的内容,从而避免引入的第三方包出现在搜索结果中。
- 使用 Grep 命令查找 Secrets 和 Config
寻找硬编码
$ grep -rnE "(SECRET|KEY|TOKEN|PASSWORD|API_KEY)\s*=\s*['\"]" --include="*.py" .
寻找配置反模式
$ grep -rnE "DEBUG\s*=\s*True|TESTING\s*=\s*True|verify\s*=\s*False" .
寻找 AWS 密钥
grep -rE "AKIA[0-9A-Z]{16}" .
- Semgrep 用于基于规则的分类
grep 匹配文本。Semgrep 匹配代码结构
$ pip install semgrep # already installed on the room's machine
$ semgrep --config p/owasp-top-ten . # registry ruleset, needs internet: run on a connected box, not the offline VM
--config 标志用于选择规则集。p/owasp-top-ten 将发现映射到 OWASP 十大分类,p/python 则是一个更广泛的 Python 规则集。
每条发现都会列出一个规则、一个文件、一行代码以及一个严重级别。将每条发现视为一个候选结果,就像我们解读 grep 的匹配项一样:Semgrep 标记了一个模式,但我们仍需确认该输入是否为用户可控的。
- 最简自定义规则
rules:
- id: render-template-string-usage
pattern: render_template_string(...)
message: render_template_string on possible user input, check for SSTI
severity: WARNING
languages: [python]
pattern 是要匹配的代码形态,message 是命中时输出的提示信息,severity 则用于对输出结果分级。这些已足够让我们将现有规则适配到目标项目;而编写复杂规则本身则是一门独立的技能。
如果我们需要完全开源的引擎,Opengrep 是 Semgrep 的 LGPL 分支,兼容相同的规则语法。
- 适时止步
分类排查的风险在于盲目自信。grep 能定位到 cursor.execute,却无法判断传入其中的字符串是来自用户输入,还是来自上方两行处的硬编码常量。按影响程度依次处理候选列表,通过阅读上下文代码逐一验证每个命中项,只有在确认后才将其定性为发现(finding)。开始之前先排序:命中 render_template_string 或 cursor.execute 的条目,应当优先于命中 open( 的条目得到关注——后者绝大多数情况下是无害的。一长串 grep 命中结果只是一份待办清单,而非报告。
代码中的注入漏洞
注入类漏洞全都遵循同一个模式:用户输入流入了某种解释器——数据库、Shell、模板引擎或反序列化器——而解释器会按输入的指令执行。一旦我们掌握了每一类漏洞中危险的接收点(sink)及其安全的对应写法,就能一眼识别出该缺陷。
- SQL注入
该漏洞在于通过 f-string 或字符串拼接将用户输入直接嵌入 SQL 字符串来构建查询,而非使用占位符:
# Vulnerable: q is formatted straight into the SQL text
q = request.args.get("q")
cursor.execute(f"SELECT * FROM items WHERE name = '{q}'")
安全写法将值作为参数传入,使数据库驱动程序将数据与代码分离开来:
# Safe: the ? is a placeholder, q is bound as data
cursor.execute("SELECT * FROM items WHERE name = ?", (q,))
也要注意二阶注入的情况:输入被安全地存储,但后续查询又将其通过 f-string 拼回 SQL。接收点(sink)相同,只是源头变成了数据库。
SQL 注入很少仅仅是读取数据库。根据查询语句和数据库引擎的不同,它可以修改行数据、绕过认证检查,或读取本地文件。它还隐藏在 ORM 之中——ORM 通常会为我们构建参数化查询,这也是使用 ORM 让人感到安全的原因。但一旦开发者使用了原始的"逃生通道"——如 SQLAlchemy 的 text()、Django 的 .raw() 或 .extra()——他们就会将手工拼接的字符串交给数据库,从而失去这层保护。因此,使用了 ORM 并不能保证查询就是参数化的。当我们在这些逃生通道内部发现 f-string 时,应将其视为原始的 cursor.execute 一样对待。
- 命令注入
该漏洞在于将用户输入放入会被 Shell 解析的命令字符串中:
# Vulnerable: shell=True means the shell parses the whole string
host = request.args.get("host")
subprocess.run(f"ping -c 1 {host}", shell=True)
像 127.0.0.1; cat /etc/passwd 这样的值会执行第二条命令。os.system 和 os.popen 也存在同样的风险。安全模式将参数作为列表传入,并去掉 shell,这样输入永远只能是一个单独的参数,绝不会被解析为新的语法:
# Safe: no shell, host is one argument and cannot add commands
subprocess.run(["ping", "-c", "1", host])
关键开关在于 shell。 当 shell=True 时,整个字符串被交给 /bin/sh 解析,而 sh 会将 ;、|、&& 和反引号视为可执行的语法。当使用列表且不带 shell 时,操作系统直接运行指定的程序,每个元素都是字面量参数,因此攻击者没有任何语法可供走私注入。
- 服务端模板注入

浙公网安备 33010602011771号