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 步(真正的第一步):先判断框架
PixPin_2026-11-10_19-35-55

# 看构建文件里的依赖,一目了然
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>

PixPin_2026-11-10_22-46-19
于是依赖链就拼出来了:

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)最终也是走这两条路,但它们的参数要么是主键数字、要么是对象,注入面小。所以优先盯这两个"带字符串参数"的入口,性价比最高。
PixPin_2026-11-10_23-03-22
IDEA里搜索DAO.paginateByColumns
光 grep DAO.paginateByColumns 并不会直接告诉你是哪个文件——它只会给你一个候选名单,真正钉死文件的是"追 orderBy 变量到 HTTP 入口"这一步。
PixPin_2026-11-10_23-15-03

第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"
PixPin_2026-11-11_14-49-46
但是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" 字面量 唯一要看的

PixPin_2026-11-11_15-05-36
排除到最后,只剩下一处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
PixPin_2026-11-11_15-12-58

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
PixPin_2026-11-11_15-19-33

  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层,接着在这个文件往上翻
PixPin_2026-11-11_15-26-36

@RequestMapping("/api/article")
public class ArticleApiController extends ApiControllerBase {

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

@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

再跳
PixPin_2026-11-11_15-49-48
在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
PixPin_2026-11-11_15-12-58

@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框架我已经开始写了,那就将错就错吧。

posted @ 2026-11-11 16:15  你好,星  阅读(0)  评论(0)    收藏  举报