Sentinel Dashboard SSRF(CVE-2021-44139)代码审计
环境搭建
https://github.com/alibaba/Sentinel/releases
版本:1.8.3
Java:1.8
启动DashboardApplication.java


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

漏洞分析
根据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
|
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); 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(); final Map<String, MetricEntity> metricMap = new ConcurrentHashMap<>(16); final CountDownLatch latch = new CountDownLatch(machines.size()); for (final MachineInfo machine : machines) { 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); } writeMetric(metricMap); }
|
通过观察代码一眼就能发现可能存在SSRF漏洞的代码段

这个请求的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循环里

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

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

查看getDetailApp方法

继续查看,追踪到这个MachineDiscovery接口发现这个是可以添加addMachine还可以removeMachine这些操作的
回到最初的代码中,发现app参数是fetchOnce直接传参到getDetailApp中的

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

调用doFetchAppMetric的方法fetchAllApp

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

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

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

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


最终的调用链:
无参构造方法 => start => fetchAllApp => doFetchAppMetric => fetchOnce
也就是说在容器启动时就会执行这个10s一次的定时任务
如何触发定时任务:
想办法注册Machine,在之前找到了addMachine的接口


查看被调用情况

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


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

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

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分析,先尝试域名

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

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

打入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

那测试下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的输入禁止输入除了小数点的字符了

当缺少了这几行代码,直接会绕过IP限制,可以打入任意域名或IP#截断进行攻击
同时还有个问题,为什么这个接口是未授权,全局搜索接口找到了原因

漏洞复现
注释掉补丁的修复代码

重新打入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
|


__END__