前面十一章,我们一直在"敲打"系统——从外面发请求、看响应、猜逻辑。 这一章,我们换一种方式:阅读系统。 当你手上有了源码,很多"黑盒里要靠猜"的问题,都变成了"一眼就能看见"。 这是安全工程师从"会用工具"走向"真正理解"的必经之路。
18.0 开篇:有了源码,一切都变了
黑盒测试时,你在做这样的推理:
"我提交这个参数,它报错了……说明可能有 SQL 注入?
那我试试引号,试试布尔盲注,试试时间盲注……
好累,而且不一定找得全。"
有源码时,你只需要打开文件,看到:
String sql = "SELECT * FROM users WHERE name='" + name + "'";
statement.execute(sql);
一眼就确定了。 不用猜,不用试,直接看到"这就是注入点"。
🔥 代码审计的价值:
黑盒是"猜一个黑箱子里有什么",白盒是"把箱子打开看"。 有了源码,你不仅能找到漏洞,还能找到为什么会有这个漏洞,以及还有哪些地方有同样的问题。
但注意,白盒也不是"无敌"的:
- 大型项目源码几十万、上百万行,从哪里看起?
- 业务逻辑复杂,看不懂在干什么?
- 框架封装太深,追踪一个数据流要跳十几个文件?
所以,代码审计需要的是一套"方法论"。 这一章就是讲这套方法。
18.1 代码审计的核心模型:Source → Sink
整个代码审计,可以浓缩成一个模型:
Source(污点源) Sink(危险函数)
用户输入进入系统的地方 ─────数据流─────▶ 会执行危险操作的地方
(或:需要编码的地方)
│ ▲
└──── Propagation(传播 / 污点传导)──────┘
数据经过:变量赋值、函数调用、
拼接、编解码、跨文件传递……
审计的全部工作,就是三个问题:
① Source 在哪?(用户输入从哪些地方进来?)
② Sink 在哪?(有哪些会出问题的地方?)
③ 从 Source 到 Sink 的"数据流"路上,有没有"清洗"(校验/编码/参数化)?
├─ 有清洗,且清洗正确 → 安全
└─ 没有清洗,或清洗可被绕过 → 漏洞 ★
💡 一句话记住:
审计 = 找到所有 Source,找到所有 Sink,然后看它们之间的路径上有没有"护栏"。
18.2 Source:用户输入从哪里进来
Source 就是"污点源"——所有外部可控的输入。 找出全部 Source,是审计的第一步。
18.2.1 各语言的 Source
| 语言 / 框架 | 常见 Source |
|---|---|
| Java | request.getParameter、getHeader、getCookie、getInputStream、@RequestParam、@PathVariable、@RequestBody |
| PHP | $_GET、$_POST、$_REQUEST、$_COOKIE、$_SERVER、$_FILES |
| Python (Flask) | request.args、request.form、request.json、request.cookies、request.headers |
| Python (Django) | request.GET、request.POST、request.body、request.META |
| Node.js | req.query、req.body、req.params、req.headers、req.cookies |
| C#/.NET | Request.QueryString、Request.Form、Request.Headers、Request.Cookies |
18.2.2 更"隐蔽"的 Source
别只盯着"参数"——很多 Source 容易被忽略:
□ HTTP 请求头:User-Agent、Referer、X-Forwarded-For、自定义头
(常被记进日志、数据库 → 存储型 XSS / 命令注入 / 日志注入)
□ 文件上传:文件名、文件内容、文件元数据
□ 上传的图片 / 文档(解析时的漏洞)
□ URL 路径本身(路径参数、REST 风格)
□ 第三方数据:支付回调、Webhook、OAuth 回调
□ 消息队列 / 缓存里的数据
□ 数据库里的数据("二次注入":数据存进去时被"洗过",取出用时又变成污点)
□ 配置文件的某些字段(如果能被用户改)
□ DNS / 主机名
🔥 "二次注入"是审计里最容易漏的一类:数据第一次进库时做了转义,取出来用时"以为它是安全的",直接拼接 → 又变成漏洞。审计时要注意"从数据库读出来的数据"也可能成为 Source。
18.3 Sink:危险函数清单(最重要的一张表)
Sink 是"危险终点"——数据流到这里,如果没被清洗,就出事。
熟记这张表,你就能"一眼看出"危险代码。
18.3.1 命令执行类
Java: Runtime.exec()、ProcessBuilder、ProcessImpl
PHP: system()、exec()、shell_exec()、passthru()、popen()、
proc_open()、反引号形式的命令、pcntl_exec()
Python: os.system()、os.popen()、subprocess.call/run/Popen(shell=True)、
commands.getoutput()
Node: child_process.exec()、execSync()、spawn(shell:true)
18.3.2 代码执行类
Java: ScriptEngine.eval()、GroovyShell、SpEL(parseExpression)、OGNL
PHP: eval()、assert()、create_function()、preg_replace(/e)、
call_user_func()、call_user_func_array()、array_map()
Python: eval()、exec()、compile()、pickle.loads()
Node: eval()、Function()、setTimeout(string)、vm.runIn*
18.3.3 SQL 执行类
Java: Statement.execute*()(拼接)、createQuery(HQL 拼接)
PHP: mysqli_query()、mysql_query()、PDO::query()(拼接)
Python: cursor.execute()(拼接)、raw()/extra()(Django)
Node: sequelize.query()(拼接)、connection.query()
18.3.4 文件操作类
读取: FileInputStream、readfile()、fopen()、file_get_contents()、
open()、Files.readAllBytes()、send_file()
写入: FileOutputStream、file_put_contents()、fwrite()、copy()
删除: unlink()、File.delete()、os.remove()
包含: include()、require()、include_once()、template.render(string)
上传后的落盘路径拼接
18.3.5 反序列化类
Java: ObjectInputStream.readObject()、readValue(autoType)(Jackson)、
XStream.fromXML()、SnakeYAML.load()
PHP: unserialize()
Python: pickle.loads()、yaml.load()(非 safe_load)
.NET: BinaryFormatter、LosFormatter、JavaScriptSerializer
18.3.6 网络请求类(SSRF)
Java: URL.openStream()、HttpURLConnection、HttpClient、
RestTemplate、WebClient、URLConnection
PHP: curl_exec()、file_get_contents()、fsockopen()、fopen(http://)
Python: requests.get()、urllib.request.urlopen()、httpx
Node: axios、fetch()、http.request()、request()
18.3.7 模板与输出类(XSS / SSTI)
Java: response.getWriter().write()、模板引擎(FreeMarker/Velocity)、
render_template_string()(把用户输入当模板 → SSTI)
PHP: echo / print(直接输出用户输入)
Python: render_template_string()、Jinja2 的 from_string()
Node: res.send()
前端: innerHTML、document.write、eval、v-html、dangerouslySetInnerHTML
18.3.8 XML 解析类(XXE)
Java: DocumentBuilderFactory(未禁用 DTD)、SAXParserFactory、
XMLInputFactory、XMLReader、Digester
PHP: DOMDocument::loadXML、simplexml_load_string、
XMLReader、libxml(未禁用实体)
Python: lxml、xml.etree、ElementTree、xml.dom.minidom
.NET: XmlReader、XmlDocument、XPathNavigator
18.3.9 其他
日志: logger.info(userInput) → 日志注入 / Log4Shell 类
响应头: response.setHeader(name, userInput) → 响应头注入 / CRLF 注入
重定向: response.sendRedirect(userInput) → 开放重定向
LDAP: InitialDirContext.search() → LDAP 注入
表达式: SpEL / OGNL / MVEL / Groovy → 表达式注入
💡 这张表值得打印出来。 审计时,你的工作就是——在这些 Sink 上"下断点",然后往回看"谁能把污点送到这里"。
18.4 审计方法论:两条路线
代码审计有两条主要路线,实际中常常结合使用。
18.4.1 路线一:正向审计(从 Source 到 Sink)
思路:从"用户能控制什么"出发,追着数据往下走,看它最终到哪。
① 列出所有入口:Controller / 路由 / API
② 对每个入口,找出它接收的参数(Source)
③ 追踪这些参数在代码里怎么被使用
④ 看它们最终有没有流到 Sink
⑤ 路上有没有校验 / 编码 / 参数化?
优点:贴合真实攻击路径,找到的漏洞"可利用"。 缺点:入口多的时候很累,容易顾此失彼。
18.4.2 路线二:逆向审计(从 Sink 到 Source)
思路:从"危险函数"出发,往回看"谁能让它收到污点"。
① 搜索所有 Sink(用 18.3 的函数清单,全局搜索)
② 对每个 Sink,看它的参数来自哪里
③ 往回追:这个参数能不能追溯到某个 Source?
④ 中间有没有清洗?
优点:聚焦"真正危险的地方",效率高。推荐初学者用这条路。 缺点:可能漏掉"非典型 Sink"的漏洞。
18.4.3 实战:两种路线结合
第一步(逆向):全局搜危险函数,列出所有"可疑点"
第二步(正向):对每个可疑点,追它的数据来源,判断是否可达
第三步(交叉验证):结合"入口清单",确认哪些可疑点真的能被触发
💡 我的建议:
- 入门:从"逆向"开始(搜危险函数),因为目标明确、反馈快
- 进阶:训练"正向"能力(从入口追数据流),因为这更接近真实的白盒测试
- 高手:两条线同时走,用交叉验证提高覆盖率
18.5 数据流追踪:污点是怎么"传播"的
从 Source 到 Sink 的路上,数据会经过各种"变换"。审计的关键,是判断这些变换"能不能洗掉污点"。
18.5.1 会"传播"污点的操作
□ 直接赋值: a = userInput; b = a; → b 还是有污点
□ 字符串拼接: sql = "..." + userInput + "..."; → 污点还在
□ 格式化: String.format("...%s", userInput)
□ 集合存储: list.add(userInput); map.put("k", userInput);
□ 函数返回: return userInput; → 返回值有污点
□ 编码后再解码:urlencode 后再 urldecode → 污点"复活"
□ 存进数据库,再取出来 → 二次注入
□ 拆分 / 截取 / 替换 → 一般来说污点仍在
18.5.2 能"洗掉"污点的操作(但要看是否正确)
【真正的清洗】
✅ 参数化查询(PreparedStatement + 占位符)→ 洗掉 SQL 注入污点
✅ 强类型转换 + 白名单(is_numeric、Integer.parseInt)→ 洗掉注入污点
✅ 按上下文的输出编码 → 洗掉 XSS 污点
✅ 严格白名单校验(不是黑名单!)
【"假清洗"(仍然危险)】
❌ 黑名单过滤(可绕,第 20 章)
❌ 只做了一次的转义(可能被二次利用绕过)
❌ 转义了但用错了上下文(如 HTML 编码后放到 JS 里)
❌ "过滤了单引号"就以为防住了 SQL 注入
❌ 前端校验(服务端没做,等于没做)
🔥 判断一个"清洗"是否有效,问三个问题:
① 它是不是"根本解"(分离数据与代码 / 输出编码 / 白名单)?
② 它是否覆盖了"所有"到达 Sink 的路径?(可能有一条路径绕过了它)
③ 它是否是"在正确的上下文"做的?(HTML 编码不能防 JS 注入)
18.5.3 "绕过清洗"的几种路径(审计时要特别关注)
□ 存在"第二条路径":参数一边被清洗、一边又被直接使用
□ 清洗在"某个分支里",某些分支跳过了清洗
□ 清洗"类型不对":比如对"数字型参数"做了字符串转义
□ 清洗"顺序错":先拼接后清洗,或先检查后用(TOCTOU)
□ 数据"二次进入":存库后取出再用
□ 编码差异:检查时是一种编码,执行时是另一种
💡 审计时,最该找的就是"清洗的漏洞"——因为"没清洗"太明显,而"清洗不彻底"才是真实世界的主流。
18.6 审计工具
18.6.1 静态分析工具(SAST)
| 工具 | 特点 |
|---|---|
| Semgrep | 轻量、规则可自定义、支持多语言、易上手(强烈推荐入门) |
| CodeQL | 最强、跨文件数据流分析、支持复杂查询(学习曲线陡) |
| Fortify / Checkmarx | 商业级,企业常用 |
| SonarQube | 综合代码质量 + 安全 |
| SpotBugs + FindSecBugs | Java 专用 |
Semgrep 简单示例(一条规则就能找出"拼接 SQL"):
rules:
- id: sql-injection-concat
languages: [java]
message: 可能存在 SQL 注入(字符串拼接)
severity: WARNING
patterns:
- pattern: $STMT.execute("..." + $X + "...")
CodeQL 示例(用官方库做污点追踪):
import java
import semmle.code.java.dataflow.TaintTracking
from Source source, Sink sink
where sourceFlowsToSink(source, sink)
select sink, "污点从 " + source + " 流到 " + sink
18.6.2 IDE 辅助
□ 全局搜索:直接在 IDE 里搜 18.3 的危险函数
□ "Find Usages"(查找引用):看一个函数/参数被谁用了
□ "Go to Definition":跳转到函数定义,看清实现
□ 调用层次(Call Hierarchy):看"谁调用了它"
□ 书签 / 高亮:标记可疑点
💡 对入门者来说,IDE 的"全局搜索 + 查找引用"就是最强的审计工具。 不要迷信自动化。
18.6.3 反编译(无源码时)
Java: jadx、CFR、Procyon、JD-GUI
.NET: dnSpy、ILSpy
Android: jadx、apktool
很多"白盒"审计,其实就是"先反编译,再审计"。 比如审计一个只给了 jar 包 / apk 的系统。
18.7 一个完整的审计实例(Java)
用一个"接用户 ID 查询订单"的接口,走一遍完整审计流程。
18.7.1 第一步:找到入口
@RestController
public class OrderController {
@Autowired
private OrderService orderService;
// 入口 1
@GetMapping("/api/order")
public Order getOrder(@RequestParam("id") String id, HttpServletRequest req) {
return orderService.getOrder(id, req);
}
// 入口 2
@GetMapping("/api/order/export")
public void export(@RequestParam("filename") String name, HttpServletResponse resp) {
orderService.export(name, resp);
}
}
两个入口,两个 Source:id 和 name。
18.7.2 第二步:追踪到 Service
@Service
public class OrderService {
@Autowired
private OrderDao orderDao;
public Order getOrder(String id, HttpServletRequest req) {
Long userId = (Long) req.getSession().getAttribute("userId");
// ★ 只传了 id,没传 userId!
return orderDao.findById(Long.parseLong(id));
}
public void export(String filename, HttpServletResponse resp) {
// ★ 直接用用户给的 filename 拼路径
File file = new File("/data/exports/" + filename);
// ... 读取并写入 resp
}
}
已经能看到两个问题:
□ getOrder:OrderDao 只按 id 查,没带 userId → 潜在【越权】(第 13 章)
□ export:filename 拼进文件路径 → 潜在【路径穿越】(第 11 章)
18.7.3 第三步:追到 DAO / Sink
@Repository
public class OrderDao {
@Autowired
private JdbcTemplate jdbcTemplate;
public Order findById(Long id) {
// ★ 拼接 SQL
String sql = "SELECT * FROM orders WHERE id = " + id;
return jdbcTemplate.queryForObject(sql, new OrderRowMapper());
}
}
又是一个问题:SQL 拼接(虽然 id 被 Long.parseLong 包了一下——这里 parseLong 反而"洗掉"了 SQL 注入的污点,但越权问题依然存在)。
🔥 注意这个细节:
id 是 String → 经过 Long.parseLong(id)
→ 如果 id 不是数字,会抛异常(不会进 SQL)
→ 如果 id 是数字,拼进去也是安全的
→ 所以【SQL 注入在这里被"意外地"堵住了】
但【越权】没有被堵:
→ 任何合法数字都能查
→ 没有校验"这个订单属不属于当前用户"
这就是审计的精妙之处——你要能区分"哪个漏洞被堵了""哪个还开着"。
18.7.4 第四步:形成结论
【漏洞 1:水平越权(IDOR)】
位置:OrderService.getOrder → OrderDao.findById
原因:查询没带 userId 条件
利用:改 id 就能看别人的订单
修复:findByIdAndUserId(id, currentUserId)
【漏洞 2:路径穿越】
位置:OrderService.export
原因:用户 filename 直接拼进路径,且没做规范化校验
利用:filename=../../../../etc/passwd
修复:realpath 规范化后校验前缀 / 用 ID 映射
【被堵住的点:SQL 注入】
原因:Long.parseLong 强制类型转换(相当于白名单)
结论:此处安全 —— 但要说明"这是'碰巧'安全,
如果哪天改成 String 拼接,就会出问题"
18.7.5 从实例中提炼的审计"手感"
□ 顺着"入口 → Service → DAO"的调用链走,比"到处乱看"高效
□ 每个 Sink 都要问:"参数从哪来?中间有没有清洗?"
□ 要能看出"哪些清洗是有效的"(parseLong 是有效的类型校验)
□ 要能看出"哪些问题'技术型'检查没发现"(越权是"业务型"的)
□ 最好顺手记下"被堵住的点"——这能帮你理解系统的防护习惯
18.8 针对常见框架的审计要点
不同框架"藏漏洞的地方"不一样,审计时要有侧重。
18.8.1 Java / Spring
□ Controller 里的 @RequestParam / @PathVariable / @RequestBody(Source)
□ Service 层的业务逻辑(越权 / 逻辑漏洞)
□ DAO 层:拼接 SQL、MyBatis 的 ${name}(危险)vs #{name}(安全)
□ 拦截器 / 过滤器:鉴权是不是每个接口都生效?有没有 exclude 路径漏网?
□ 中间件配置:Spring Boot Actuator 是否开放、CORS 配置
□ 反序列化:Jackson 是否开了 enableDefaultTyping / @JsonTypeInfo
□ SpEL 表达式:有没有把用户输入丢进 parseExpression
💡 MyBatis 的经典坑:${name} 是"字符串拼接"(危险),而 #{name} 是"参数化"(安全)。审计 MyBatis 时,全局搜那个 dollar 加大括号的写法,就能找到大量可疑点。
18.8.2 PHP
□ 全局搜 `$_GET` / `$_POST` 等,看它们怎么被使用
□ 危险函数:eval、system、include、unserialize、preg_replace(/e)
□ 文件包含:include 的参数是否可控
□ 上传处理:后缀校验 / 存储位置
□ 框架层面:ThinkPHP 的历史 RCE(路由解析、Request 类)、
Laravel 的某些反序列化链
□ 配置:display_errors=On、allow_url_include=On(危险)
18.8.3 Python
□ Flask:render_template_string(userInput) → SSTI
□ Django:DEBUG=True、SECRET_KEY 硬编码、raw()/extra()
□ pickle.loads、yaml.load(非 safe)
□ subprocess(shell=True)
□ 模板注入:Jinja2 的用户可控模板
18.8.4 Node.js
□ eval / Function / vm
□ child_process.exec(拼接命令)
□ eval 型的模板引擎
□ prototype pollution(原型链污染)★ Node 特有
□ 依赖投毒:node_modules 里的恶意包
💡 原型链污染是 Node 特有的、很"值得研究"的一类漏洞(proto / constructor.prototype 被污染,可能导致权限绕过、RCE)。
18.9 动手任务
任务 1:用 Semgrep 扫描一个开源项目
# 安装
pip install semgrep
# 用官方规则集扫描(选一个你熟悉语言的公开开源项目)
semgrep --config "p/security-audit" ./your-project
# 自定义一条规则:找 Java 里拼接 SQL
# (把规则写进 rule.yaml,再 semgrep --config rule.yaml)
目标:体会"自动找到可疑点",但也要亲手验证每一个结果是不是真的(减少误报)。
任务 2:手工审计一个小项目
① 选一个小型开源项目(几百到几千行)
② 用 18.3 的 Sink 清单,全局搜索危险函数
③ 对每个可疑点,用 IDE 的"Find Usages"追数据来源
④ 判断:可达吗?有清洗吗?清洗有效吗?
⑤ 写出一份审计报告(哪怕只有一两个发现)
💡 推荐的小项目类型:简单的 Java/PHP 练手靶场源码(如 DVWA、Java 的 vuln 项目)。
任务 3:反编译练习
① 找一个公开的、**允许分析**的 Java jar / Android apk
② 用 jadx / JD-GUI 反编译
③ 全局搜危险函数
④ 体会"没有源码也能审计"的感觉
任务 4:Academy 的代码审计相关实验
PortSwigger Academy 里虽然没有专门"代码审计"模块,但可结合:
□ 白盒部分(每个实验都提供源码)
□ 用"看源码"的方式理解每个漏洞的成因
任务 5:给自己建一个"Sink 速查卡片"
把 18.3 的所有 Sink,整理成一张按语言分类的卡片,打印或存成笔记。以后审计时,这就是你的"检查清单"。
18.10 本章小结
核心结论
- 代码审计的核心模型:Source → 数据流 → Sink。审计 = 找所有 Source、找所有 Sink、看路径上有没有护栏。
- Source:所有外部可控输入。别只看参数——请求头、文件名、第三方回调、数据库取出的数据(二次注入)都是。
- Sink:危险函数。熟记那张分类清单(命令 / 代码 / SQL / 文件 / 反序列化 / SSRF / 模板 / XML),你就能"一眼看出"危险代码。
- 两条路线:正向(Source→Sink,贴近真实攻击)+ 逆向(Sink→Source,效率高)。入门从逆向开始。
- 污点传播:赋值、拼接、集合存储、编码后再解码都会传播污点;"二次注入"最容易漏。
- 审查"清洗"是否有效:是根本解还是创可贴?覆盖所有路径吗?在正确上下文吗?
- 最该找的是"清洗不彻底"——"没清洗"太明显,"清得不彻底"才是真实世界的主流。
- 工具:Semgrep(入门)、CodeQL(最强)、Fortify(商业);但IDE 的"全局搜索 + Find Usages"对入门者最强。
- 反编译(jadx / JD-GUI / dnSpy)让你在"没有源码"时也能白盒审计。
- 框架差异:Spring(MyBatis 的 dollar-brace / 拦截器漏网)、PHP(危险函数 / 框架历史漏洞)、Python(SSTI / pickle)、Node(原型链污染)——知道"框架在哪藏漏洞"能极大提高效率。
随身口诀
审计就一句话:Source 到 Sink,看路上有没有护栏;危险函数记心间,逆向审计先找它;清洗要看"彻不彻底",二次注入最容易漏;工具是助手不是主角,IDE 搜索最常见。
18.11 预告:下一章我们去哪
有了代码审计能力,你就能"找到"很多问题。但**"一个一个手工找",效率太低了**。
下一章讲的是——怎么把"手工经验"变成"自动化能力":
- 爬虫 + 代理池,批量发现资产和接口
- 参数发现、目录爆破
- 把"检测逻辑"写成可复用的模板(nuclei)
- 编写自己的扫描脚本
- 以及"Dork 语法"这种"用搜索引擎挖资产"的技巧
第 19 章:自动化与漏洞挖掘思路。
📌 本章金句 "代码审计的真正门槛,不是'看不懂代码',而是'看不懂业务'。最好的审计者,不是最会搜危险函数的人,而是最快理解'这个系统在干什么、数据从哪流到哪'的人。"