Alibaba Sentinel SSRF漏洞分析(CVE-2021-44139)
一、Alibaba Sentienl 简介
随着微服务的流行,服务和服务之间的稳定性变得越来越重要。Sentinel 是面向分布式、多语言异构化服务架构的流量治理组件,主要以流量为切入点,从流量路由、流量控制、流量整形、熔断降级、系统自适应过载保护、热点流量防护等多个维度来帮助开发者保障微服务的稳定性。
Alibaba Sentinel 官方文档:https://sentinelguard.io/zh-cn/docs/introduction.html
二、漏洞代码分析
2.1、环境部署
源码地址:https://github.com/alibaba/Sentinel/releases/tag/v1.8.0
解压之后使用IDEA打开,打开之后自动加载依赖项
自动加载完成依赖项后,访问sentinel-dashboard\src\main\java\com\alibaba\csp\sentinel\dashboard\DashboardApplication.java
文件,点击左侧绿色按钮启动项目,如下图所示:
启动完成后,访问http://127.0.0.1:8080
显示如下即为启动成功,如下图所示:
默认的登录账号密码为sentinel/sentinel
2.2、漏洞代码分析
这里可以先看一下threedr3am 师傅的提交的报告原文:https://github.com/alibaba/Sentinel/issues/2451
漏洞的触发点在 MetricFetcher 类中,位于sentinel-dashboard\src\main\java\com\alibaba\csp\sentinel\dashboard\metric\MetricFetcher.java
。代码如下
/*
* Copyright 1999-2018 Alibaba Group Holding Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.alibaba.csp.sentinel.dashboard.metric;
import java.net.ConnectException;
import java.net.SocketTimeoutException;
import java.nio.charset.Charset;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.ThreadPoolExecutor.DiscardPolicy;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import com.alibaba.csp.sentinel.Constants;
import com.alibaba.csp.sentinel.concurrent.NamedThreadFactory;
import com.alibaba.csp.sentinel.config.SentinelConfig;
import com.alibaba.csp.sentinel.dashboard.datasource.entity.MetricEntity;
import com.alibaba.csp.sentinel.dashboard.discovery.AppInfo;
import com.alibaba.csp.sentinel.dashboard.discovery.AppManagement;
import com.alibaba.csp.sentinel.dashboard.discovery.MachineInfo;
import com.alibaba.csp.sentinel.node.metric.MetricNode;
import com.alibaba.csp.sentinel.util.StringUtil;
import com.alibaba.csp.sentinel.dashboard.repository.metric.MetricsRepository;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.concurrent.FutureCallback;
import org.apache.http.entity.ContentType;
import org.apache.http.impl.client.DefaultRedirectStrategy;
import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
import org.apache.http.impl.nio.client.HttpAsyncClients;
import org.apache.http.impl.nio.reactor.IOReactorConfig;
import org.apache.http.protocol.HTTP;
import org.apache.http.util.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* Fetch metric of machines.
*
* @author leyou
*/
@Component
public class MetricFetcher {
public static final String NO_METRICS = "No metrics";
private static final int HTTP_OK = 200;
private static final long MAX_LAST_FETCH_INTERVAL_MS = 1000 * 15;
private static final long FETCH_INTERVAL_SECOND = 6;
private static final Charset DEFAULT_CHARSET = Charset.forName(SentinelConfig.charset());
private final static String METRIC_URL_PATH = "metric";
private static Logger logger = LoggerFactory.getLogger(MetricFetcher.class);
private final long intervalSecond = 1;
private Map<String, AtomicLong> appLastFetchTime = new ConcurrentHashMap<>();
@Autowired
private MetricsRepository<MetricEntity> metricStore;
@Autowired
private AppManagement appManagement;
private CloseableHttpAsyncClient httpclient;
@SuppressWarnings("PMD.ThreadPoolCreationRule")
private ScheduledExecutorService fetchScheduleService = Executors.newScheduledThreadPool(1,
new NamedThreadFactory("sentinel-dashboard-metrics-fetch-task"));
private ExecutorService fetchService;
private ExecutorService fetchWorker;
public MetricFetcher() {
int cores = Runtime.getRuntime().availableProcessors() * 2;
long keepAliveTime = 0;
int queueSize = 2048;
RejectedExecutionHandler handler = new DiscardPolicy();
fetchService = new ThreadPoolExecutor(cores, cores,
keepAliveTime, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(queueSize),
new NamedThreadFactory("sentinel-dashboard-metrics-fetchService"), handler);
fetchWorker = new ThreadPoolExecutor(cores, cores,
keepAliveTime, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(queueSize),
new NamedThreadFactory("sentinel-dashboard-metrics-fetchWorker"), handler);
IOReactorConfig ioConfig = IOReactorConfig.custom()
.setConnectTimeout(3000)
.setSoTimeout(3000)
.setIoThreadCount(Runtime.getRuntime().availableProcessors() * 2)
.build();
httpclient = HttpAsyncClients.custom()
.setRedirectStrategy(new DefaultRedirectStrategy() {
@Override
protected boolean isRedirectable(final String method) {
return false;
}
}).setMaxConnTotal(4000)
.setMaxConnPerRoute(1000)
.setDefaultIOReactorConfig(ioConfig)
.build();
httpclient.start();
start();
}
private void start() {
fetchScheduleService.scheduleAtFixedRate(() -> {
try {
fetchAllApp();
} catch (Exception e) {
logger.info("fetchAllApp error:", e);
}
}, 10, intervalSecond, TimeUnit.SECONDS);
}
private void writeMetric(Map<String, MetricEntity> map) {
if (map.isEmpty()) {
return;
}
Date date = new Date();
for (MetricEntity entity : map.values()) {
entity.setGmtCreate(date);
entity.setGmtModified(date);
}
metricStore.saveAll(map.values());
}
/**
* Traverse each APP, and then pull the metric of all machines for that APP.
*/
private void fetchAllApp() {
List<String> apps = appManagement.getAppNames();
if (apps == null) {
return;
}
for (final String app : apps) {
fetchService.submit(() -> {
try {
doFetchAppMetric(app);
} catch (Exception e) {
logger.error("fetchAppMetric error", e);
}
});
}
}
/**
* 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);
}
private void doFetchAppMetric(final String app) {
long now = System.currentTimeMillis();
long lastFetchMs = now - MAX_LAST_FETCH_INTERVAL_MS;
if (appLastFetchTime.containsKey(app)) {
lastFetchMs = Math.max(lastFetchMs, appLastFetchTime.get(app).get() + 1000);
}
// trim milliseconds
lastFetchMs = lastFetchMs / 1000 * 1000;
long endTime = lastFetchMs + FETCH_INTERVAL_SECOND * 1000;
if (endTime > now - 1000 * 2) {
// to near
return;
}
// update last_fetch in advance.
appLastFetchTime.computeIfAbsent(app, a -> new AtomicLong()).set(endTime);
final long finalLastFetchMs = lastFetchMs;
final long finalEndTime = endTime;
try {
// do real fetch async
fetchWorker.submit(() -> {
try {
fetchOnce(app, finalLastFetchMs, finalEndTime, 5);
} catch (Exception e) {
logger.info("fetchOnce(" + app + ") error", e);
}
});
} catch (Exception e) {
logger.info("submit fetchOnce(" + app + ") fail, intervalMs [" + lastFetchMs + ", " + endTime + "]", e);
}
}
private void handleResponse(final HttpResponse response, MachineInfo machine,
Map<String, MetricEntity> metricMap) throws Exception {
int code = response.getStatusLine().getStatusCode();
if (code != HTTP_OK) {
return;
}
Charset charset = null;
try {
String contentTypeStr = response.getFirstHeader("Content-type").getValue();
if (StringUtil.isNotEmpty(contentTypeStr)) {
ContentType contentType = ContentType.parse(contentTypeStr);
charset = contentType.getCharset();
}
} catch (Exception ignore) {
}
String body = EntityUtils.toString(response.getEntity(), charset != null ? charset : DEFAULT_CHARSET);
if (StringUtil.isEmpty(body) || body.startsWith(NO_METRICS)) {
//logger.info(machine.getApp() + ":" + machine.getIp() + ":" + machine.getPort() + ", bodyStr is empty");
return;
}
String[] lines = body.split("\n");
//logger.info(machine.getApp() + ":" + machine.getIp() + ":" + machine.getPort() +
// ", bodyStr.length()=" + body.length() + ", lines=" + lines.length);
handleBody(lines, machine, metricMap);
}
private void handleBody(String[] lines, MachineInfo machine, Map<String, MetricEntity> map) {
//logger.info("handleBody() lines=" + lines.length + ", machine=" + machine);
if (lines.length < 1) {
return;
}
for (String line : lines) {
try {
MetricNode node = MetricNode.fromThinString(line);
if (shouldFilterOut(node.getResource())) {
continue;
}
/*
* aggregation metrics by app_resource_timeSecond, ignore ip and port.
*/
String key = buildMetricKey(machine.getApp(), node.getResource(), node.getTimestamp());
MetricEntity entity = map.get(key);
if (entity != null) {
entity.addPassQps(node.getPassQps());
entity.addBlockQps(node.getBlockQps());
entity.addRtAndSuccessQps(node.getRt(), node.getSuccessQps());
entity.addExceptionQps(node.getExceptionQps());
entity.addCount(1);
} else {
entity = new MetricEntity();
entity.setApp(machine.getApp());
entity.setTimestamp(new Date(node.getTimestamp()));
entity.setPassQps(node.getPassQps());
entity.setBlockQps(node.getBlockQps());
entity.setRtAndSuccessQps(node.getRt(), node.getSuccessQps());
entity.setExceptionQps(node.getExceptionQps());
entity.setCount(1);
entity.setResource(node.getResource());
map.put(key, entity);
}
} catch (Exception e) {
logger.warn("handleBody line exception, machine: {}, line: {}", machine.toLogString(), line);
}
}
}
private String buildMetricKey(String app, String resource, long timestamp) {
return app + "__" + resource + "__" + (timestamp / 1000);
}
private boolean shouldFilterOut(String resource) {
return RES_EXCLUSION_SET.contains(resource);
}
private static final Set<String> RES_EXCLUSION_SET = new HashSet<String>() {{
add(Constants.TOTAL_IN_RESOURCE_NAME);
add(Constants.SYSTEM_LOAD_RESOURCE_NAME);
add(Constants.CPU_USAGE_RESOURCE_NAME);
}};
}
漏洞触发点位于第 212 和 214 行,是 fetchOnce() 方法
fetchOnce():该方法会向给定的地址发送 HTTP GET 请求,该地址由应用程序的管理类 AppManagement
提供。然后使用回调函数来处理异步的 HTTP 响应,该响应包含了度量数据。在获取到响应后,该方法会解析响应的内容,将其中的度量数据保存在内存仓库中,以便后续使用。
漏洞代码
final HttpGet httpGet = new HttpGet(url);
httpGet.setHeader(HTTP.CONN_DIRECTIVE, HTTP.CONN_CLOSE);
httpclient.execute(httpGet, new FutureCallback<HttpResponse>()
这里可以看到使用了httpGet,他是Apache HttpClient 库中的一个类,用于创建 HTTP GET 请求,最终使用 execute 方法执行 HTTP 请求。
接下来追踪一下参数的传递过程
向上追踪发现 参数url是String url 中从 machine.getIp() 和 machine.getPort() 获取了 IP 地址和端口号,以及拼接了一些时间等,而 machine 是从 for-each 循环中的 machines 获取值后将值赋予给 machine
继续追踪 machines,发现在第 182 行处从 appInfo.getMachines(); 处获取的值,期间只做了一个判空处理,如下图所示:
接着进入 appInfo.getMachines(); 方法,在这段代码中 getMachines 中 return 了 machines。而变量 machines 使用了 ConcurrentHashMap.newKeySet()
方法创建了一个线程安全的 Set(集合),其中 machines 值是由 addMachine 方法添加进去的。
下面就是追踪 addMachine 方法了,可以看到调用关系,SimpleMachineDiscovery 类重写了 addMachine 方法,如下图所示:
继续追踪 addMachine 方法,查看调用关系,可以看到 MachineRegistryController 和 AppManagement 都有所调用,但仔细看会发现 MachineRegistryController 处的调用即是 appManagement.addMachine,所以进入那个最终都是可以到 MachineRegistryController 层的
直接进入 MachineRegistryController分析,其主要作用是,获取请求中的参数,并进行相应的处理和判断,最终将信息添加到应用管理中,并返回注册结果。通过代码可以得出接口地址为 /registry/machine
,得到传入参数有 app,appType,version,v,hostname,ip,port,并对传入的 app,ip 和 port 参数进行了判断是否为 null 的操作,如下图所示:
继续看下半部分代码,就是将从请求中获取到的数据,分别设置成 machineInfo
的属性值,最后调用appManagement.addMachine(machineInfo);
方法添加注册机器信息
至此,整个流程我们追踪完了。现在总结下大致流程就是:参数从 MachineRegistryController 传进来,其中涉及 IP 和 port,通过 appManagement.addMachine(machineInfo); 方法添加机器信息,最终在 MetricFetcher 中使用了 start() 方法定时执行任务,其中有个任务是调用 fetchOnce 方法执行 HTTP GET 请求。
2.3、漏洞验证
在师傅的报告中我们看到该接口存在未授权,这个未授权的原因是为什么呢
在resources-application.properties文件下,我们可以看到这边设置了一个auth.filter.exclude-urls
全局搜索一下auth.filter.exclude-urls,找到另一处auth.filter.exclude-urls
这边可以看到设置了有些url不需要auth也可以访问,所以存在未授权
接下来验证一下漏洞
在本地通过开启一个服务,构造漏洞接口http://127.0.0.1:8080/registry/machine?app=SSRF-TEST&appType=0&version=0&hostname=TEST&ip=xxx.xxx.xxx.xxxx&port=8000
,接收到请求信息