We recently completed a security review of ControlUp Agent by ControlUp Technologies. The software is responsible for remote management and analytics of agent hosts on which it runs. The software is typically deployed in virtualization infrastructure environments. This writeup details the steps taken to assess the software, bypass obfuscation, and get remote unauthenticated code execution as NT AUTHORITY\SYSTEM on any host running the ControlUp Agent software (< v8.2.5) so long as it’s reachable on the network.

We reported this issue to ControlUp on May 11, 2021. They acknowledged the report the next day, thanked us for our report, and informed us that the usage of static encryption keys had already been proactively fixed as of version 8.2.5. You can read the original report we sent to ControlUp. CVE-2021-45913 was assigned to track this vulnerability.

The ControlUp Agent (cuAgent) software is written in .NET. This is ideal for reverse engineering – .NET usually decompiles to nearly the same thing that the developer initially wrote. But we noticed something strange with the Smart-X ControlUp binaries. Firstly, there weren’t a lot of .NET assembly DLLs that came along with the project. That’s not strange by itself, but often .NET binaries come with a lot of extra DLLs. Looking in Process Explorer shows that there are many loaded assemblies – but we weren’t able to find them in DotPeek or on disk.

"Process Explorer showing loaded Assemblies in cuAgent.exe"

They also use some sort of obfuscation. Class, method, and variable names look like this:

"Obfuscated .NET method"

Not only do they have odd class, variable, and method names – they have a lot of dead code. Note that the case 0: on line 337 will never execute. It looks like obfuscation to slow down a reverser. Also a lot of “if (false)” statements are littered throughout the code.

Based on inspecting the running binary, we know that they’re creating WCF named pipes and TCP endpoints. But we couldn’t find any references in the code where this happens.

Reviewing more code, we stumbled across this:

"Suspicious charArray"

That seemed odd, so I spent a bit of time reversing what is going on. That charArray gets bitwise inverted. At that point, it is more readable:

AppLoadTimeTracer.AgentSideCommon, Version=, Culture=neutral, \
PublicKeyToken=null`pkC3o4ibHqbaW9eT+sfYIA==`SmartX.Common, Version=, \
Culture=neutral, PublicKeyToken=null`nZLN7WTbcAXIfmox4Owtnw==`...

After splitting on the backtick delimiter, we are left with a list of AssemblyInfo:base64 pairs. At this point, I exported the code that does the unpacking and copied it to a Visual Studio .NET project where I could rename variables and clean it up by removing those dead conditionals.

Reversing more of the code shows they’re getting the manifest resource stream based on the base64 name and passing it to a new function.

"Reversed code showing DES being used"

In this next function, they read and ignore the first 3 bytes of the stream. They read the 4th byte as a flag byte. The decoded bits of the flag are:

0: unknown
1: Encrypted assembly
2: unknown
3: Compressed assembly
4-7: unknown

An assembly resource file can be a raw assembly, encrypted, compressed, or encrypted and compressed. If it’s compressed, they simply call DeflateStream on it. If it’s encrypted, they use DESCryptoServiceProvider. But where are the key and IV?

In the encrypted case, the next 8 bytes in the resource stream are the IV and the following 8 bytes are the key:

"Reversed code showing DES decryption function and where key + IV are found"

So we now have everything we need to retrieve all the assemblies from the executables and write them to files. Once we write them to a file, we can load them in DotPeek and keep working:

"DotPeek showing decrypted assemblies"

In the end, we were able to recover 156 additional assemblies across 3 obfuscated service binaries. We ended up finding the named pipe and TCP WCF handlers, as well.

Once we recovered the inner assemblies, we found some other oddities. They store some encrypted RSA private keys in the ControlUp Monitor’s obfuscated resource files. One of the keys is used in the authentication between the ControlUp Monitor and the ControlUp Agent. Getting this encrypted RSA key was actually the reason to start digging so deeply into the second-level .NET Assembly packing. The RSA private key is encrypted, but it uses the same hard-coded 3DES key used in many ControlUp methods. The 3DES key doesn’t provide strong confidentiality since it’s easily found in all of the executable’s resources.

This RSA private key is used to decrypt the session key/IV returned from the PrepareConnection call. The session key/IV is used to 3DES encrypt the UserTokens that are then decrypted on the server. This is the root of the authentication bypass - if an unauthenticated user knows this 3DES key/IV, they can encrypt a UserTokens of valid@junk;$SID (where “junk” is ignored, “valid” is what they use to determine if the connection is valid, and $SID is the SID of an account in the Administrators group). That’s it! No password, kerberos ticket – nothing else is required.

The end result is unauthenticated remote code execution as NT AUTHORITY\SYSTEM. This affects all users of the ControlUp Agent software with version < 8.2.5.

Running the PoC shows that we can connect to a socket, send it some packets, and get it to execute code for us as NT AUTHORITY\SYSTEM:

"PoC works, showing execution as SYSTEM"

More detail, including the PoC, can be found in our advisory. The code that I used to do the de-obfuscation is included here:

using System;
using System.IO;
using System.IO.Compression;
using System.Reflection;
using System.Security.Cryptography;

namespace StringArrayReverser
    class Program

        static string[] DecodeString(char[] input)
            for (int index = 0; index < input.Length; ++index)
                input[index] = (char)~(ushort)input[index];
            string[] strArray = new string(input).Split('`');
            return strArray;

        static void FalconReverser()
            char[] charArray = "ᄒママᄈミ゙ロᆱヨメレᆱヘ<SNIP>ᄐムヨᅥ゙ンᅯᄊ숴ネᅡᅡ".ToCharArray();
            var strArray = DecodeString(charArray);

            UnpackAssemblies("AppLoadTimeTracer.exe", strArray);

        static byte[] DESDecrypt(Stream InStream)
            MemoryStream memoryStream = new MemoryStream();
            Stream stream = InStream;

            for (int index = 1; index < 4; ++index)
            ushort Flags = (ushort)~InStream.ReadByte();
            // Skip 3 bytes, read 4th byte

            // Encrypted
            if (((int)Flags & 2) != 0)
                DESCryptoServiceProvider cryptoServiceProvider = new DESCryptoServiceProvider();
                byte[] DESIV = new byte[8];
                InStream.Read(DESIV, 0, 8);
                cryptoServiceProvider.IV = DESIV;
                byte[] DESKey = new byte[8];
                InStream.Read(DESKey, 0, 8);

                cryptoServiceProvider.Key = DESKey;

                memoryStream.Position = 0L;
                ICryptoTransform decryptor = cryptoServiceProvider.CreateDecryptor();
                int inputBlockSize = decryptor.InputBlockSize;
                int outputBlockSize = decryptor.OutputBlockSize;
                byte[] numArray1 = new byte[decryptor.OutputBlockSize];
                byte[] numArray2 = new byte[decryptor.InputBlockSize];
                int position;
                for (position = (int)InStream.Position; (long)(position + inputBlockSize) < InStream.Length; position += inputBlockSize)
                    InStream.Read(numArray2, 0, inputBlockSize);
                    int count = decryptor.TransformBlock(numArray2, 0, inputBlockSize, numArray1, 0);
                    memoryStream.Write(numArray1, 0, count);

                InStream.Read(numArray2, 0, (int)(InStream.Length - (long)position));
                byte[] buffer3 = decryptor.TransformFinalBlock(numArray2, 0, (int)(InStream.Length - (long)position));
                memoryStream.Write(buffer3, 0, buffer3.Length);
                stream = (Stream)memoryStream;
                stream.Position = 0L;
            if (((int)Flags & 8) != 0)
                var tmpStream = new MemoryStream();
                DeflateStream deflateStream = new DeflateStream(stream, CompressionMode.Decompress);

                memoryStream = tmpStream;

            if (Flags == 0xff00)

            return memoryStream.ToArray();

        static void UnpackAssemblies(string parent, string[] strArray)
            int i;

            Assembly ass = Assembly.LoadFrom(parent);

            Console.WriteLine("Loaded {0}", ass.FullName);
            var dirname = "output\\" + parent + "\\";
            for (i = 0; i < strArray.Length; i += 2)
                var info = strArray[i];
                var b64 = strArray[i + 1];

                var name = info.Split(',')[0];

                Console.WriteLine("{0} {1} {2}", name, b64, info);
                var mrs = ass.GetManifestResourceStream(b64);
                var decryptedBytes = DESDecrypt(mrs);
                if (b64.EndsWith("#"))
                    File.WriteAllBytes(dirname + "\\" + name + ".pdb", decryptedBytes);
                    File.WriteAllBytes(dirname + "\\" + name + ".dll", decryptedBytes);

        static void cuAgentReverser()
            /* Note: This needs to be fixed to include the array from the binary. */
            char[] charArray = "ᆲメ゙ヘヒᄃ\x<SNIP>\xFFC9ᆰレロᆴᅡᅡ".ToCharArray();
            var strArray = DecodeString(charArray);

            UnpackAssemblies("cuAgent.exe", strArray);


        static void ConsoleReverser()
            char[] charArray = "ᄒワヒヨミムᄎノ<SNIP>マᆴᅧᅩルリᅡᅡ".ToCharArray();
            var strArray = DecodeString(charArray);

            UnpackAssemblies("ControlUpConsole.exe", strArray);

        static void Main(string[] args)