前几天看了腾讯小哥七夜发的关于python rasp的文章,想到之前也做过类似的研究,也来谈谈我知道的几种解决方案吧
概述
RASP(Runtime application self-protection)运行时应用自我保护,在红方中的应用一般有以下几种:
1、配合实现IAST,在SDL中使用作为精准的检测方案
2、应用层agent,用来弥补系统层agent难以还原原始攻击手法的缺陷
3、动态解密,对于变形和动态执行的恶意代码或者webshell来说,rasp是最清晰的解密
几种实现方案概述
1、PEP 578 — python runtime audit hook
python新版本中对标powershell模块记录等功能实现的审计方案
优点:官方提供的审计方案,非侵入方案,且实现起来极其简单
缺点:依赖版本过高(>3.8),只能作为记录方案,不能实现阻断能力
2、USDT
感觉usdt dtrace的这些方案好像聊得比较少,文献也不太多,dtrace是一种动态追踪技术,最早是solaris上面提供,慢慢移植到了linux osx 最近连windows上都开始部分支持了,各类主流的语言其实也都有支持做了埋点(但是需要添加编译选项),而python在3.6版本开始添加支持
优点:也是官方的解决方案,侵入性小
缺点:对操作系统版本和python版本的依赖都比较高,而且需要重新编译,默认显示的内容也不算特别丰富
3、import hook
在load_module的过程中做的一种劫持方案
触发点不太一样,其实跟方案4差不多
4、monkey patch
这个是聊得最多的方案了,也是那个经典问题”如何让2+2=5”的一个解法
优点:能够实现的功能最为全面,可劫持可以记录,且客观支持的python版本最广
缺点:侵入式的解决方案,可能会影响业务稳定性,而且对于statement的语法(exec print等)劫持不到
具体实现
PEP 578 — python runtime audit hook
在python3.8+中,sys模块新增了addaudithook方法,用于记录模块的执行过程,实现起来也极其方便,是四种方案中最简单的,效果也是最好的
import sys
def audit_hook(event,args):
print("event is " + str(event) + " args is " + str(args))
sys.addaudithook(audit_hook)
USDT
最早看到python usdt是在bcc中( https://github.com/iovisor/bcc )
他的实现其实可以作为一套完整的主机安全agent了,在tools/pythonflow.sh中提供了显示python执行流的能力。
而单独使用systemtap的调用过程:
yum install systemtap kernel-devel systemtap-sdt-devel
然后重新编译python
./configure —prefix=/usr/local/python3 —with-dtrace
安装完成后可通过stap查看python提供的埋点
详情可参考
https://docs.python.org/zh-cn/3.8/howto/instrumentation.html
常用:
function__entry(str filename, str funcname, int lineno) #函数执行
import__find__load__start(str modulename) #模块加载
cat rasp.stp
#!/usr/bin/stap
probe begin {
printf("beginn");
}
probe process("/root/Python-3.9.0a6/python").mark("function__entry") {
filename = user_string($arg1);
funcname = user_string($arg2);
lineno = $arg3;
printf("filename:%s funcname:%s lineno:%d n",filename,funcname,lineno)
}
probe process("/root/Python-3.9.0a6/python").end {
exit()
}
执行效果
import hook
在import的模块加载过程中有以下几个重要过程
1、在sys.modules中寻找缓存避免重复加载
2、如果找不到则调用python的import协议来寻找和加载模块,包含
a) 查找器
使用sys.meta_path中的finder来寻找对应模块的spec
b) 加载器
使用上面找到的spec生成module object
实现了查找器和加载器接口的就是导入器,将其插入到sys.modules的最前面就可以劫持导入的过程
另外在site-packages目录下可创建sitecustomize.py或usercustomize.py,属于python自启动脚本
以劫持requests.get 为例,在site-packages目录下创建sitecustomize.py
import sys
import importlib
import functools
class MetaPathFinder:
def find_module(self, fullname, path=None):
hook_modules = ['requests']
if fullname in hook_modules:
return MetaPathLoader()
class MetaPathLoader:
def load_module(self, fullname):
if fullname in sys.modules:
return sys.modules[fullname]
finder = sys.meta_path.pop(0)
module = importlib.import_module(fullname)
hook_module(fullname, module)
sys.meta_path.insert(0, finder)
return module
def my_waper(func):
@functools.wraps(func)
def get_args(*args,**kwargs):
print("nFCN HOOK: %s %s (args=%s,kwargs=%s)" % (str(func),__name__,args,kwargs))
return func(*args,**kwargs)
return get_args
def hook_module(fullname,module):
if fullname == 'requests':
module.get = my_waper(module.get)
frame = sys._getframe(2)
code = frame.f_code
file_path = code.co_filename
sys.meta_path.insert(0, MetaPathFinder())
monkey patch
python需要hook的函数类型分为以下几种
1、exec 表面是函数,实际是statement(python2),在一切皆对象的python里面很罕见的非对象的东西了,所以在python3中就被改过去了。。
暂时还没看到有hook的解决方案,但问题不大,对于动态解密来说甚至可以直接正则替换
2、bound/unbound method
区别就是是否绑定方法对象,在hook的写法方面会有一些区别
3、builtin method
builtin method是_builtins_模块下面的函数,从函数地址上就能看的出来
>>> id(__builtins__.eval)
140568811177024
>>> id(eval)
140568811177024
>>>
但是直接替换_builtins_下方法时,在另一个namespace中不会生效,之前一直以为是有什么特殊的安全机制,后来发现_builtins_是__builtin的引用,所以直接替换是不会生效的,那直接修改__builtin就行了
4、builtin 模块中的方法
内置模块中的方法不支持修改
>>> str.decode = mydecode
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: can't set attributes of built-in/extension type 'str'
>>>
这个的解决方案最早应该是来自于”如何在python中实现让2+2=5”这个经典问题的一个解,git地址 https://github.com/fdintino/python-doublescript
具体涉及到的python c api的知识点简单说下
1、PyObject是cpython中的对象基石,其中包含obrefcnt的引用计数器和ob_type,ob_type则指向PyTypeObject
在ctypes中如果需要使用Structures,需要显示声明_fields,对于PyObject来说fields就是obrefcnt和obtype
2、from_address(id(target)) 直接使用地址作为线索,把python下的变量转成了c下的变量了
3、ctypes.pythonapi.PyDict_SetItem 将第二个变量作为key,第三个变量作为value,插入字典第一个变量,作为原始函数的保留,插入到原来对象的__dic中,后续方便保持原有逻辑的引用
Py_ssize_t = hasattr(ctypes.pythonapi, 'Py_InitModule4_64') and ctypes.c_int64 or ctypes.c_int
class PyObject(ctypes.Structure):
pass
PyObject._fields_ = [('ob_refcnt',Py_ssize_t),('ob_type',ctypes.POINTER(PyObject))]
class DictProxy(PyObject):
_fields_ = [('dict',ctypes.POINTER(PyObject))]
def patch_builtin(klass):
name = klass.__name__
target = klass.__dict__
proxy_dict = DictProxy.from_address(id(target))
namespace = {}
ctypes.pythonapi.PyDict_SetItem(
ctypes.py_object(namespace),
ctypes.py_object(name),
proxy_dict.dict,
)
return namespace[name]
def patch_innerfunction(klass,attr,value):
dikt = patch_builtin(klass)
old_value = dikt.get(attr,None)
old_name = '_c_%s' % attr
if old_value:
dikt[old_name] = old_value
if old_value:
dikt[attr] = value
try:
dikt[attr].__name__ = old_value.__name__
except:
pass
try:
dikt[attr].__qualname__ = old_value.__qualname__
except:
pass
else:
dikt[attr] = value
后记
从现状来看高版本python和linux内核的使用率都很低,方案一、二都难以作为大规模rasp部署的方案,但是方案一对于快速解密python脚本来说是最好的方案,方案二对于想以bcc类方案构建主机agent来说整体性很好,方案四作为最经常被讨论的方案整体的通用性还是最好的