第 18 章 代码审计基础

2 阅读15分钟

前面十一章,我们一直在"敲打"系统——从外面发请求、看响应、猜逻辑。 这一章,我们换一种方式:阅读系统。 当你手上有了源码,很多"黑盒里要靠猜"的问题,都变成了"一眼就能看见"。 这是安全工程师从"会用工具"走向"真正理解"的必经之路。


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
Javarequest.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.jsreq.query、req.body、req.params、req.headers、req.cookies
C#/.NETRequest.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 + FindSecBugsJava 专用

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);
    }
}

两个入口,两个 Sourceidname

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` 等,看它们怎么被使用
□ 危险函数:evalsystem、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 本章小结

核心结论

  1. 代码审计的核心模型Source → 数据流 → Sink。审计 = 找所有 Source、找所有 Sink、看路径上有没有护栏。
  2. Source:所有外部可控输入。别只看参数——请求头、文件名、第三方回调、数据库取出的数据(二次注入)都是。
  3. Sink:危险函数。熟记那张分类清单(命令 / 代码 / SQL / 文件 / 反序列化 / SSRF / 模板 / XML),你就能"一眼看出"危险代码。
  4. 两条路线:正向(Source→Sink,贴近真实攻击)+ 逆向(Sink→Source,效率高)。入门从逆向开始。
  5. 污点传播:赋值、拼接、集合存储、编码后再解码都会传播污点;"二次注入"最容易漏
  6. 审查"清洗"是否有效:是根本解还是创可贴?覆盖所有路径吗?在正确上下文吗?
  7. 最该找的是"清洗不彻底"——"没清洗"太明显,"清得不彻底"才是真实世界的主流。
  8. 工具:Semgrep(入门)、CodeQL(最强)、Fortify(商业);但IDE 的"全局搜索 + Find Usages"对入门者最强
  9. 反编译(jadx / JD-GUI / dnSpy)让你在"没有源码"时也能白盒审计。
  10. 框架差异:Spring(MyBatis 的 dollar-brace / 拦截器漏网)、PHP(危险函数 / 框架历史漏洞)、Python(SSTI / pickle)、Node(原型链污染)——知道"框架在哪藏漏洞"能极大提高效率。

随身口诀

审计就一句话:Source 到 Sink,看路上有没有护栏; 危险函数记心间,逆向审计先找它; 清洗要看"彻不彻底",二次注入最容易漏; 工具是助手不是主角,IDE 搜索最常见。


18.11 预告:下一章我们去哪

有了代码审计能力,你就能"找到"很多问题。但**"一个一个手工找",效率太低了**。

下一章讲的是——怎么把"手工经验"变成"自动化能力"

  • 爬虫 + 代理池,批量发现资产和接口
  • 参数发现、目录爆破
  • 把"检测逻辑"写成可复用的模板(nuclei)
  • 编写自己的扫描脚本
  • 以及"Dork 语法"这种"用搜索引擎挖资产"的技巧

第 19 章:自动化与漏洞挖掘思路。


📌 本章金句 "代码审计的真正门槛,不是'看不懂代码',而是'看不懂业务'。最好的审计者,不是最会搜危险函数的人,而是最快理解'这个系统在干什么、数据从哪流到哪'的人。"