最近这小半年都在搞java,但是对java安全方面的知识还停留在原地,因此想系统的回顾、学习一遍Java中基本的安全漏洞,包括一些在java项目中常用的组件和中间件,以及对应的处理措施。
这里推荐一个Java安全学习开源项目Hello-Java-Sec,已经有1.7k星标,
仓库地址:j3ers3/Hello-Java-Sec: ☕️ Java Security,安全编码和代码审计
本篇文章可能更多的会从开发的角度去讲述怎样的代码是安全的,怎样的代码存在漏洞,对打ctf的师傅而言可能提供一些新的视角。
项目启动
项目的application.properties中默认使用的是8888端口,数据库连接账号密码是root 1234567,如果和自己本机环境不一样的可以修改一下application-dev.properties文件配置内容然后重新编译打包一下,如果有idea的话更好了直接启动就行。然后还有附带的文件中sql文件需要导入一下,用来做SQL注入的。这个项目需要java1.8,数据库需要mysql5.7及以上。项目启动图标

SQL注入
JDBC漏洞
JavaDataBaseConnect,可以通过其创建连接、语句,执行语句得到结果集。但是这三者使用完之后需要手动释放。
Statement语句拼接
首先给不熟悉Java开发的师傅们讲一下什么是statement。statement是JDBC里一个执行SQL语句的接口 ,即通过JDBC的连接对象可以创建statement,statement对象可以用来执行静态的语句,比如像是一个字符串内的SQL语句就是静态的(动态的可以参考mybatis中xml映射那样的)。既然是静态,那有些情况就不可能一次性写完,因为谁也无法预知接下来要执行的SQL中参数到底是什么,一万个用户有一万条SQL,因此需要拼接。将用户传入的参数作为SQL的一部分,拼接到最终的静态SQL语句中,那么显而易见,一定会产生SQL注入漏洞。
解决的方法可以对用户的输入进行黑名单过滤,但是可能会被绕过,也可能误伤。总之这种原始的写法是不推荐的。
PrepareStatement语句拼接
PrepareStatement是JDBC连接对象的一个预编译SQL的接口,通过传入一个可以是不完全的SQL语句进行预编译,之后要执行的时候,假如说这个预编译的SQL不完整,那么可以在传入缺少的参数之后进行executeQuery。但是此时还是使用一个拼接的语句进行预编译,还是会有SQL注入的漏洞。正确的做法是预编译一个没有携带用户参数的SQL语句,之后传入用户的参数再进行executeQuery,就能有效防止SQL注入漏洞。
JdbcTemplate
JdbcTemplate是Spring对Jdbc封装的模版类,底层默认使用PrepareStatement来执行语句,因此拥有预编译功能,但还是那个问题,不能直接拼接而是要用“?”占位符进行预编译处理,执行时传入参数。
总而言之,使用原生的JDBC进行操作时,需要保证sql语句不是被拼接的,不管是什么开发语言,这都是防止sql注入的最重要的手段。
MyBatis漏洞
MyBaits是一个基于JDBC构建的持久层框架,通过动态代理的技术生成代理对象,并生成对应的sql语句进行执行。其中可以使用#{}预编译和参数绑定来避免sql注入,但是使用不当或是配置不正确的话,仍然可能会有sql注入的风险。
order by注入
Mybatis的#{}使用的是PreparedStatement进行预编译,在执行时通过JDBC的setXxx()方法设置,保持了对象的原始类型。因此,假如传入的参数时String类型,并且其作为order by进行排序的参数,最后的语句就是类似这样的
ORDER BY 'name' DESC
就会导致是用一个常量值进行排序,而不是数据库中的列名,排序的效果就消失了。
而${}在SQL构建时只进行简单的文本替换,丢失原始类型信息。开发者为了简化操作,想使用${}来解决order by排序效果丢失的问题,那么就可能会导致sql注入。因为这个效果和直接拼接是一样的。
不仅是order by,任何需要SQL标识符的地方都会存在这个问题,比如说select * from #{tableName},传入字符串的话会导致数据库无法识别到这个表名,而使用${}又会造成sql注入。
解决办法是对传入的SQL标识符的值进行映射:
<mapper namespace="com.best.hello.mapper.UserMapper">
<select id="orderBySafe" resultType="com.best.hello.entity.User">
select * from users
<choose>
<!-- 根据字段选择排序方式,避免 SQL 注入 -->
<when test="field == 'id'">
order by id desc
</when>
<when test="field == 'user'">
order by user desc
</when>
<otherwise>
<!-- 默认排序方式,防止不合法输入 -->
order by id asc limit 1
</otherwise>
</choose>
</select>
</mapper>
当然,在正常的开发规范中,是不可能让用户传入的值作为SQL标识符的。正确的开发规范不会导致这个问题出现。
搜索注入
在使用模糊查询的时候,假如直接使用like '%#{q}%'会导致语法错误,因为#{}本质是参数占位符,在第一次进行预编译的时候,生成的SQL语句是这样的
SELECT * FROM users WHERE user LIKE '%?%'
本来like后面期待的是一个? ,这样一来预编译like后面就变成了字符串'%?%',在预编译的时候就已经确定了最终的sql,不管后面怎么设置参数都无济于事了。导致模糊查询的效果失效。
程序员如果偷懒,用'%${q}%进行替代,就会导致sql注入。
// 攻击者输入:
String maliciousInput = "admin' OR user LIKE '%";
// 使用 ${} 的代码:
@Select("SELECT * FROM users WHERE user LIKE '%${q}%'")
List<User> searchVul(String q);
// 生成的SQL:
SELECT * FROM users WHERE user LIKE '%admin' OR user LIKE '%%'
// 这相当于:SELECT * FROM users WHERE user LIKE '%admin' OR true
// 返回所有用户记录!
正确的写法是,使用CONCAT将通配符与参数拼接成字符串,然后这个字符串作为LIKE的条件。CONCAT函数是数据库函数,在SQL执行时计算,因此在预编译阶段时,CONCAT内的结构不会被破坏,只是将占位符替换成 ?
SELECT * FROM users WHERE user LIKE CONCAT('%', ?, '%')
或者说,如果不想使用CONCAT,就要在传入参数时的业务代码中将通配符与LIKE需要的参数拼接好,LIKE后直接接上#{titleQuery}
if (!ObjectUtils.isEmpty(exampleName)) {
titleQuery = "%" + exampleName + "%";
}
List<CusSceneExamplePo> poList = cusSceneExampleMapper.getSceneExampleList(titleQuery);
scene_title like #{titleQuery}
任意文件操作
任意文件上传
@PostMapping("/uploadVul")
public String uploadVul(@RequestParam("file") MultipartFile file) {
try {
byte[] bytes = file.getBytes();
Path dir = Paths.get(UPLOADED_FOLDER);
Path path = Paths.get(UPLOADED_FOLDER + file.getOriginalFilename());
Files.write(path, bytes);
} catch (Exception e) {
return e.toString();
}
return "redirect:uploadStatus";
}
这是Spring框架中最基本的接收文件上传的controller,不过一般解析文件这一步会放在Service中去做,这里只是为了方便展示。
上面这种写法是最不安全的写法,因为没有任何过滤,会有路径遍历漏洞、任意文件上传漏洞、文件名冲突与覆盖漏洞、信息泄露等等。
安全代码流程
安全的写法,首先要进行基础验证,即文件是否为空、文件大小;之后获取文件的文件名,这一步如果是在Spring框架中,就要用到StringUtils.cleanPath()函数,这个函数会对文件名的斜杠统一转换成正斜杠(/),并把所有路径中的../移除,防止路径遍历漏洞
之后就是用文件类型白名单过滤,content-type白名单过滤。再之后可以针对文件内容进行验证,比如说对于图片,团队内部可能会有一个isValidImage()的轮子,对图片文件前面几个字节码判断,判断这个文件到底是不是跟content-type所一致的,content-type是否是经过伪造的,这一步都能判断。
在最后就是进行安全的文件名生成,还有要保存的路径的创建,最后保存文件。当然,这里注意不要给前端回显文件的保存路径。
如果是想了解一些文件上传的攻击手法,可以看我之前发的文章Upload-Labs通关记录 – 杜腐腐の小窝
目录遍历
上面文件上传的代码中,提到了目录遍历的漏洞,文件上传点的这种目录遍历漏洞主要用处是用上传的文件把对应的服务器上目标文件覆盖,比如目标服务器的Java应用程序恰好使用root权限运行,然后递归使用../遍历到目标服务器的根目录,然后把etc/password下的密码文件给覆盖掉,从而达到ssh连接或者是其它的目的。
任意文件下载
@GetMapping("/download")
public ResponseEntity<Resource> download(@RequestParam String filename) {
try {
// 获取当前工作目录
String currentDir = System.getProperty("user.dir");
// 拼接路径:当前目录 + 文件名
Path filePath = Paths.get(currentDir, filename);
Resource resource = new FileSystemResource(filePath);
if (resource.exists()) {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + resource.getFilename() + "\"")
.body(resource);
}
return ResponseEntity.notFound().build();
} catch (Exception e) {
return ResponseEntity.internalServerError().build();
}
}
以上代码是Spring中最基本的文件下载的Controller,最基本的功能就存在最多的安全问题,用户可以传入../进行服务器上的任意文件下载
任意文件访问
如果服务端的这段代码是访问文件内容,也是用这样的指定目录+用户输入拼接的路径,一样能造成任意文件访问漏洞。
安全代码流程
站在安全的视角,想要利用漏洞,首先就是搞清楚目标点到底是文件上传、文件下载还是文件内容访问。对于文件上传,我可以上传木马,也可以通过目录遍历的方式覆盖掉原有的文件达成一些目的;对于文件下载,我可以通过目录遍历的方式下载到我想要的服务器中的敏感信息;对于文件内容访问点,一样也可以通过目录遍历获取我想要的文件内容。
站在开发的视角,想要写出避免目录遍历漏洞的代码,如果说提供下载的文件或是允许访问的文件内容就那么几个,那么禁止使用用户传入的数据作为目录的组成,而是通过映射或是其它方式,由后端代码自己去已经设定好的路径下返回文件或是内容。
像是图库网站,或是视频网站,提供海量的资源让用户下载或访问,安全的写法依旧是层层过滤。首先对用户的输入进行过滤,判断用户输入是否合法;之后使用预设的安全的路径作为构建路径的基础,拼接出完整的路径;之后检查对应路径下的文件是否存在;存在的话,文件类型是否是这个接口期望返回的类型,比如这个接口只能返回jpg,这个文件类型会不会是其它的类型;最后检查一遍这个文件的合法性,可以根据文件的权限属性,比如文件不可读,那么肯定不能提供下载访问,也可以根据具体的业务,比如只能下载2025.11.30以后的文件;最后才是返回下载文件。
失效的身份认证
JWT弱加密
JSON Web Token是服务端用来验证用户身份、获取用户基本信息的字符串,生成JWT需要服务端的秘钥进行签名,服务端收到用户传递的JWT也需要签名秘钥进行验证签名。假如说这个签名秘钥是123456,那么很容易被爆破出来,从而造成CSRF漏洞
验证码复用
用户使用验证码登录时,可以不请求发送验证码的接口而直接调用登录接口(前端操作一下就能实现),并且服务端对验证码进行校验后并没有删去SESSION中的验证码,那么就可以达到多次登录复用同一个验证码的操作。
XSS
反射型
通过在URL中拼接参数,诱使受攻击的目标点击URL,点击后,用户发送携带这些参数的请求到服务器,服务器将这些参数内容返回给前端,浏览器解析执行这些参数,造成XSS攻击。
解决的办法有很多:
1.后端接口可以手动对用户的输入的特殊字符进行转义
2.Spring自带的HtmlUtils.htmlEscape(content)方法可以对content中特殊字符进行转义,类似的还有owasp.encoder.Encode包下的Encode.forHtml()方法,owasp.esapi.ESAPI包下的ESAPI.encoder().encodeForHTML()方法
3.富文本标签,设置一些允许的html标签与属性的白名单
存储型
存储型是将恶意代码存储到Web应用的数据库或文件系统中,并且能在Web页面中展示,下次用户访问到这个界面时,恶意代码就会执行
因此,解决方案可以围绕两个方面,一个是后端进行存储时将用户输入的信息的特殊字符进行转义,一个是前端输出时的转义。就泛用性而言,这里更推荐使用前端输出转义,因为数据可能会存在多种用途,并不仅仅是作为前端展示,只使用前端转义时可以保证数据的原始性。当然,也可以两个转义一起使用,安全性更强。
只使用前端转义的话,实际上也不一定能保证完全能避免存储型XSS。在目前主流前后端分离架构中,前端分为CSR(客户端渲染)和SSR(服务端渲染)。在CSR的情况下,转义逻辑依赖JS执行,如果JS执行被干扰(如浏览器扩展、网络劫持等),转义可能失效,造成XSS漏洞;对于SSR,已经提前渲染好了之后,如果转义很完全、够彻底,将已经转义了的渲染好的数据返回给前端,那么就能避免XSS漏洞。对于开发者而言,如何取舍又是一个问题,因为想使用SSR的话又需要一个node.js服务器。
Vue框架似乎本身就会对插值进行html转义,若是在vue中进行开发,可以省去考虑这些问题。因为对前端开发不太了解,有问题的地方请指正!
越权访问
水平越权
即同一权限等级的用户之间,通过非法手段访问或操作其他用户的资源。
造成这个漏洞的原因很低级,没有对当前登录的用户身份作判断,即后端查询的时候没有携带当前登录用户的身份信息进行查询。
举个最简单的例子,用户A登录后查看自己的订单详情页,URL中订单ID为order_id=1001。若用户A将ID改为order_id=1002,直接访问后成功显示用户B的订单信息,并且可以对该订单进行删除等操作。假如说查询时携带where = 当前登录用户id,就不会造成这个情况。
垂直越权
造成这个漏洞的原因有很多,因为垂直越权本身就有很多方式,比如注册用户时抓包,把请求中role=user改成role=admin;普通用户直接找到了后台管理的入口,并且后台管理的界面没有对sessionId进行判断和拦截;或者是抓到了一个后台超管添加管理员的请求包,将其中的userId参数替换成普通用户的id,成功添加。
这些漏洞大量存在于那些很有年代感的开源CMS系统中,老辈子程序员当时写的时候没有这方面意识,现在的web应用中基本上遇不到这样的漏洞,比如SpringBoot项目中,对于后台管理的接口都会添加拦截器,判断你的sessionId或是其它登录凭证是不是管理员,不是的话就拦截;后台添加管理员时,也不会和普通用户注册共用一个接口,想抓包也抓不到。
SSRF
即服务器端请求伪造的安全漏洞,攻击者通过构造恶意请求,让目标服务器发起请求到攻击者指定的内部网站或者外部网站,从而达到获取内部网站信息或者攻击外部网站的目的。
产生这个漏洞的主要原因是因为后端提供的接口中,使用用户传递的url进行访问,并且没有对用户传入的url进行过滤。
