Java咖啡馆
Last Update:
安全无止境,继续前进吧!
不得不说,凯尔希医生对知识的涉猎范围还挺广的,
除了丰富的医学知识储备和医疗经验,还有安全相关的知识……
嘎呜 ~ 我也要向她一样能够独当一面,才能带领罗德岛继续前行啊!
博士 ~ 先来点咖啡吧!
–by Mon3tr

这个小板块可能更偏向信息系统开发技术,主要讲述 Java Web 开发过程中的常见安全问题及相关原理,并且会提供解决这些问题的思路和方法
安全传递
身为博士开发者,CRUD大法是必备的基础开发技术,一切的CRUD都离不开基于用户、角色的操作;
不过,你真的有注意到那些潜在的安全问题吗?
潜在问题
SQL注入
黑客通过输入不合法的登录数据构造并传递恶意SQL语句,如果没有设置校验方法对登录数据进行拦截过滤,非法的SQL操作将被执行,会造成数据泄露、数据篡改等风险;中间人攻击
所有的网络请求都有可能被拦截。若存在中间人监听,密钥将会被截获(无论是否加密),黑客可以利用截获的密钥实施重放攻击以实现身份冒用(尤其是管理员账户);
可以说,所有的前端校验、前端加密、临时会话(Cookie、Session、token…)等都是不可靠的,不可依赖它们进行身份校验!
BurpSuite 真是太好用了会话窃取
用户完成身份认证后,系统会向用户创建临时会话以进行更多服务操作,常见方式有:Cookie、Session、Jwt Tokwn;
(1) Cookie
Cookie 能够保存用户登录态,然而它是存储在用户端的,用户完全可以随意获取并篡改其中的信息,所以一般不用它作为会话凭据,只用来临时保留浏览器登录信息;
(2) Session
相比于 Cookie,Session 存储在服务端,相对安全,但它继承了 Cookie 的缺陷,而且在集群环境下可能会出现登录态不同步的问题;
(3) Jwt Token
Jwt Token(令牌)使用了加密技术保存用户的账户信息、数字签名和过期时间,解决了 Cookie 和 Session 的安全问题,是相对安全的主流选择;
但是Jwt Token 本身是无状态的,如果服务端没有对令牌的算法等信息进行校验,也会被黑客拿来冒用身份;
现代企业一般会采用 Session + Jwt Token 结合的身份认证方式,两者优势互补,既保证了安全性,又可以借助 Session 和缓存技术实现 Jwt Token 黑名单的功能
应对策略
采用 RBAC 权限模型
访问控制策略有很多种,比较常用的是RBAC权限模型(基于角色的访问控制策略);
通过五张数据库表(用户表、角色表、菜单表、用户角色关联表、角色菜单关联表)实现对角色授权、赋予用户不同角色的权限控制过程;
这是数据库设计阶段的任务,如果是改造现有项目会比较麻烦设置字符过滤
对用户输入的信息进行字符校验。对于不同信息可以采用分级过滤策略:
(1) 对于用户名等涉及到构造查询条件的敏感用户信息,实行强校验(仅放行数字、字母、下划线等常规字符,防注入、截断、RCE);
(2) 对于个人简介等不被用于构造查询条件的一般用户信息,实行弱校验(额外放行汉字以及部分特殊字符,同时要考虑内容合规性);信息保密
(1) 数据加密
服务端将所有敏感信息(密码、手机号等)先加密再存入数据库(密码仅存密文摘要,推荐使用 Argon2 慢哈希摘要算法),后续调用只要判断密文摘要是否一致即可;
请注意:手机号绝对不可以直接采用摘要算法摘要处理,因为手机号是纯数字信息且长度和取值范围固定,很容易通过碰撞获取到,应先加密再摘要
(2) 信息脱敏
服务端向前端页面展示信息时,使用视图(VO)过滤敏感信息;后端传输查询结果时对敏感信息作脱敏处理;
(3) 传输保护
对于采用公钥密码机制的通信系统,通信双方传递消息之前,使用对方的公钥加密数据,收到数据后使用自己的私钥解密;完整性校验
采用对称加密算法(如 AES),通信双方事先约定密钥,利用该密钥加密请求信息,再对密文进行摘要生成消息认证码;
用户将密文和认证码发送到服务端,服务端使用相同的摘要算法生成校验码,与用户发送的认证码进行比对,两者一致即可证明消息未被篡改;身份认证
确认消息来源,防伪造,防抵赖:
(1) 基于公钥密码体系的单向认证
通信双方采用非对称加密算法(如 RSA、ECC等)各自生成公钥与私钥,用户使用私钥对请求信息(或摘要)签名后发送给服务器,服务器收到请求信息后使用对方公钥验签,验证通过后与用户建立临时会话;
(2) 基于挑战响应的单向 / 双向认证
通信双方事先约定一把共享密钥,客户端发起请求时,服务端向客户端发送一个随机数,客户端使用密钥加密随机数后发送给服务端,服务端以相同算法和密钥验算,两次计算结果一致则通过;
(3) 设置验证码
服务器向客户端发送验证码图片、行为验证弹窗等信息,客户端收集用户输入验证码或者行为轨迹后发送到服务器验证,由此限制爬虫机器人执行的操作;
(4) 验证会话凭据
严格校验用户传递的 Jwt Token;
(5) 密钥对验证
对于一些高安全需求的场景,在现有登录验证的基础上,服务端需要为每个用户发放两把密钥(accessKey, secretKey)以验证身份,其中 secretKey 仅用于构造签名,绝对不可以在网络上传输;防重放
从开发角度看,防御重放攻击主要有两种方式:
(1) 添加一次性Token
每次请求在发送时附带一个随机Token,值要足够大,用后需销毁(用过的 Token 存入缓存,在 Token 缓存过期之前不再接受相同的 Token);
(2) 添加时间戳
每次请求在发送时附带时间戳,后端需要对请求进行有效期验证,超时拒收;
以上两种方法配合使用,在防重放的同时能够缓解后端缓存压力慎重使用网络代理
在没有刚需的情况下,不要随意使用网络代理;即使一定要用,在没有确认代理方可靠之前,也不要执行任何涉及个人隐私的敏感操作,否则容易导致信息泄露、重放攻击等不良后果;
此外,有些网络代理商会对数据流量进行污染(如投放诈骗广告、钓鱼链接等);
大厂经验参考
以腾讯云为例:
服务器向用户发放密钥对 <SecretId, SecretKey> 用于登录云服务器等,其中 SecretKey 仅创建时向用户展示一次,后续无法再次查看;
用户必须妥善保管,如果忘记、意外丢失或泄露,就必须销毁整个密钥对并重新创建;以bilibili为例:
(1) 前端向后端服务器请求获取一次性token、挑战响应码challenge、公钥publicKey、模数modulus(后端生成这些参数后发送到前端,同步存入缓存并设置有效期);
(2) 前端要求用户进行人机验证,验证通过后产生32位校验码 validate(轨迹 + challenge + 私钥privateKey(浏览器内置)→ HMAC-SHA256 → Hex)和安全码 seccode(validate + “|jordan”);
(3) 前端使用 privateKey 对 challenge 签名,将用户密码与 token、challenge签名、seccode 拼接形成明文,让浏览器通过 WebCrypto RSA-OAEP 算法使用 publicKey 和 modulus 加密明文;
(4) 将得到的密文、签名信息与请求获取到的验证信息打包发送给服务器;
(5) 服务器验证有效性并使用私钥解密密文提取有效信息,验证 challenge 签名,验证通过后执行登录操作建立临时会话,同时废弃之前的所有一次性验证信息;
以下为登录请求头携带的一次性安全相关信息:1
2
3
4
5
6
7
8source=main_web
&username=RngAd33
&password=iaUSXHkiwgSzLSC3u2GoSGnPpe7hvwhEA2DxAJtl6m81%2BBYoKlW1IgnjSNH1aNo3lFNgFNMX2w4EHvujq6aoDT3uLsvm%2BrTkWhsHbyIL2LWfHiGBhZknq61ALrhqmPi0PjyFpbRWAgbrjb1x4StWWGwqfJ9g0bG4rag%2BfJCHLFI%3D
&go_url=https:%2F%2Fspace.bilibili.com
&token=534891539fb74a4bbd3807db1ed58659
&validate=9258a5061d99d23da7b757c52b2c4b27
&seccode=9258a5061d99d23da7b757c52b2c4b27%7Cjordan
&challenge=1f6ec7743656661e18b1b490987b7243
Java并发编程
身为博士开发者,你对Java并发编程应该不会陌生;
Java的高并发依赖于对线程的调教,能够更大程度地发挥CPU的效能,提高系统性能和吞吐量,适应处理海量数据和请求的需求;
推荐一本《Java并发编程之美》,可以啃一啃,对需要处理系统高并发的开发者有不小的帮助。
内存可见性问题
要研究这个问题,需要先了解多核CPU的系统架构和Java工作内存模型:

为了缓解CPU和内存之间的读写速度差,通常会在两者之间设置高速缓存(Cache);
在多核CPU系统架构中,每一个内核都有自己的控制器、运算器和一级缓存,所有内核共享一个主内存(有些架构存在共享二级缓存);
一般情况下,当某个线程需要处理共享变量时,会先从主内存将共享变量复制到自己的工作缓存中,再对变量进行处理,处理完毕后将变量的值刷新到主内存中;
当两个及以上个线程同时处理一个共享变量,且两级缓存均为空时,缓存的存在将会导致不同线程之间共享变量内存不可见(伪共享问题);
synchronized 关键字
synchronized 是Java内置的一种原子锁,每个对象都可以将其当作同步锁使用;
此外,它还可以用来保证多操作的原子性,即所有操作要么都执行,要么都不执行(类似于数据库的事务);
1 | |
volatile 关键字
与 synchronize 不同,volatile 是一种弱形式的同步关键字,它可以让线程在处理变量时立即把值刷新到主内存中,从而确保变量的更新对其它线程立即可见;
但是 volatile 只能保证单次操作的原子性,不能保证改————写等多轮操作的原子性;
1 | |
CAS操作
非阻塞式原子性操作,Java原子类的底层依赖,能够避免传统锁机制导致的上下文切换开销;
线程安全
数据库系统一般支持多用户同时访问数据库。如果有多个线程同时读写同一个共享资源且没有任何同步措施时(主要是更新操作),可能会发生数据存储不一致的问题,
其中包括修改丢失、不可重复读、读取到脏数据,或者是其它不可预见的bug;
1 | |
单例模式
经典设计模式之一,用于保证一个线程只有一个实例化对象可以调用;
- 基于同步机制的懒汉式单例模式
实现了延迟初始化(懒加载),即项目启动时不加载,在首次使用时才创建实例,另外同步机制能够保证线程安全,
但每次调用 getEntity() 方法时都需要加锁,反复地加锁开锁会导致性能下降;1
2
3
4
5
6
7
8
9
10
11
12
13public class Entity {
private static Entity entity;
private Entity() {} // 私有构造函数,防止外部实例化破坏单例模式
public static synchronized Entity getEntity() {
if (entity == null){
entity = new Entity();
}
return entity;
}
} - 基于双检锁机制的懒汉式单例模式
相比于同步机制,双检锁机制避免了不必要的锁开销,是懒汉式单例模式的推荐实现方式;1
2
3
4
5
6
7
8
9
10
11
12
13
14public class Entity {
private static volatile Entity entity;
private Entity() {} // 私有构造函数,防止外部实例化破坏单例模式
public static Entity getEntity() {
if (entity == null) { // 一检
synchronized (Entity.class) // 加锁
if (entity == null) entity = new Entity(); // 二检
}
return entity;
}
} - 饿汉式单例模式
在类加载时就初始化实例,不存在线程安全问题,实现起来也很简单,
但缺点是实例在类加载时就会被创建,即使没有使用到也会占用资源,可能会产生额外的开销;1
2
3
4
5
6
7
8
9
10public class Entity {
private static final volatile Entity entity;
private Entity() {} // 私有构造函数,防止外部实例化破坏单例模式
public static Entity getEntity() {
return entity;
}
} - 静态内部类
1
2
3
4
5
6
7
8
9
10public class Entity {
private static class EntityHolder {
private static final Entity ENTITY = new Entity();
}
public static Entity getEntity() {
return EntityHolder.ENTITY;
}
} - 枚举类
利用Java枚举的特性,确保只有一个实例存在;1
2
3
4
5
6
7
8
9
10public enum Singleton {
FLAG("flag{************}");
private String flag;
public void getFlag() {
this.flag = flag;
}
}
上下文切换
CPU的资源分配采用时间片轮转策略;当某线程的时间片用完时,该线程会转换成就绪状态,将资源让给其它线程;
在切换线程上下文时,需要保存线程的执行现场(快照),恢复执行时读取快照恢复现场;
然而线程上下文切换时非常费时耗资源的,在日常开发中要尽可能避免上下文切换;
线程死锁
两个及以上的线程因为争夺资源而造成的相互等待现象,如果没有外力作用就会永远运行下去,造成程序卡死;
(死锁的出现本质上就是资源分配不当、进程推进顺序不当导致的问题)
产生条件
互斥条件
某个线程对获取到的资源添加互斥锁,不允许其它线程同时占用,其它线程只能等待;不可剥夺条件
某个线程获取到的资源只能由当前占用的线程释放,使用期间不可被其它线程剥夺;*请求并持有条件
某个线程已经获取到了资源却又申请获取其它资源,而其它资源已被其它线程占用;*环路等待条件
死锁发生时,必然存在一个线程————资源死循环,即线程和资源之间相互等待(类似于 Spring 框架的循环依赖);
当以上四个条件同时出现时,死锁就产生了
开锁方法
前两个产生条件无法避免,因为它们保证了线程的特性;当后两个条件同时出现时,破坏掉至少一个条件就可以打破死锁,如:手动强制剥夺资源、重启系统等;
守护线程与用户进程
Java线程分为守护线程daemon和用户线程user两种线程,main() 函数所在线程属于 user 线程;
两者之间的不同点在于:如果 user 线程没有全部结束,JVM 就不会退出,而 daemon 线程是否结束不影响JVM退出;
1 | |
ThreadLocal
由JDK提供的线程本地变量类;
每一个访问 ThreadLocal 变量的线程都会将变量复制到自己的本地内存,实际操作的是存在本地内存中的变量副本,避免了线程安全问题;
1 | |
Struts2框架漏洞
Struts2是用Java语言编写的一个基于MVC设计模式的Web应用框架,但因其复杂的配置、较低的开发效率和成堆的安全漏洞逐渐被市场淘汰,如今还剩一些老旧项目仍在采用,对于开发者而言几乎没有学习价值;
OGNL表达式
‘%’在标志的属性为字符串类型时,计算OGNL表达式%{}中的值;
‘#’访问非根对象属性,因为Struts2中值栈被视为根对象,所以访问其他非根对象时,需要加#前缀才可以调用;
‘$’在Struts2配置文件中,引用OGNL表达式;
一些常用功能表达式:
1 | |
Java反序列化漏洞
Java原生的序列化/反序列化机制存在较大安全缺陷:
它允许类的实例被转换为字节序列,然后在反序列化时,攻击者可能会篡改该类的序列化数据,利用精心构造的字节序列来触发某些类中的危险方法,从而执行恶意代码;
漏洞利用
ysoserial.jar会是你的得力助手;
防护策略
- 使用其它更安全的序列化库,如 Jackson、Gson、Kryo 等(阿里云的FastJson性能高,但也存在安全问题;Kryo线程不安全,需要使用ThreadLocal单例化,且不能提前注册相关类到IOC容器);
- 严格控制反序列化的类,防止不安全的类被反序列化;
- 对输入的数据进行校验;
- 及时更新JDK和相关依赖库,进行代码安全审计和测试;
<- To be continued _(:3 ∠)__
く__,.ヘヽ. / ,ー、 〉
\ ', !-─‐-i / /´
/`ー' L//`ヽ、
/ /, /| , , ',
イ / /-‐/ i L_ ハ ヽ! i
レ ヘ 7イ`ト レ'ァ-ト、!ハ| |
!,/7 '0' ´0iソ| |
|.从" _ ,,,, / |./ |
レ'| i>.、,,__ _,.イ / .i |
レ'| | / k_7_/レ'ヽ, ハ. |
| |/i 〈|/ i ,.ヘ | i |
.|/ / i: ヘ! \ |
kヽ>、ハ _,.ヘ、 /、!
!'〈//`T´', \ `'7'ーr'
レ'ヽL__|___i,___,ンレ|ノ
ト-,/ |___./
'ー' !_,.:
QUghIFRoYXQncyBmb3IgS2FsISEKV2FpdCwgd2hvIGFyZSB5b3U/CldobydzIHRoaXMgc2Fzc3kgbG9zdCBicmF0PwpXaHkgZG8geW91IGxpa2UgS2FsPyBObyBCbGF6ZSEhPwpBbnl3YXkhIENvbWUgd2l0aCBtZSEgSGVscCBtZSBmaW5kIFJlZCE=