From cryptographic weakness to code execution: Progress Telerik’s CVE-2017-9248

Occasionally, our team is asked to help understand a specific vulnerability and its potential impact.  On June 14th, one of our clients sent us a simple link they received that looked like the following, with the FQDN of one of their web servers as the host, and an exceptionally long base64 value for the dp parameter:

http://.../Telerik.Web.UI.DialogHandler.aspx?DialogName=DocumentManage&UseRSM=true&renderMode=2&Skin=Silk&Title=Document+Manager&doid=&dpptn=&isRtl=false&RadScriptManager1_TSM=&RadStyleSheetManager1_TSSM=&dp=ZG...

Upon clicking the link, we were presented with a file manager for the webroot of our client’s web server, with the option to upload .aspx files.  This could easily lead to remote code execution, by uploading a malicious .aspx file to the remote server and then navigating to it.  In the case of one of our clients, IIS was running as a local administrator, which would allow this vulnerability to lead to taking over the entire system in one swoop.

But how did this vulnerability come to be?  We discuss our team’s efforts in detail below.

Explaining the purpose of the dp parameter

Members of our team quickly tried reproducing this issue with other web servers used by our client, but these other servers merely produced an error that the dialog parameters couldn’t be deserialized.  We considered it a strong possibility that dp stood for dialog parameters, and that the value was somehow system-specific.

We downloaded a trial copy of the Telerik UI for ASP.NET AJAX library, and used JetBrains’ dotPeek, one of several freely available .NET decompilers, to get a rough idea of what some of the code is doing.

The URL given to us was a useful starting place; we observed that the class Telerik.Web.UI.DialogHandler was a subclass of Telerik.Web.UI.DialogHandlerNoSession, and found this function:

private JsParameterObtainer _parameterObtainer;

// ...

private DialogParameters GetDialogParameters()
{
    // ...
    if (this.DialogParametersProviderType == typeof (JavascriptDialogParametersProvider))
    {
        // ...
        if (this._parameterObtainer != null)
        {
            if (this._parameterObtainer.ParametersAvailable)
            {
                this.storedDialogParameters = this._parameterObtainer.GetDialogParameters();
                // ...
            }
            // ...
        }
        // ...
    }
    return this.storedDialogParameters;
}

Now, we look at Telerik.Web.UI.JSParameterObtainer:

private string SerializedParameters
{
    get
    {
        if (this.ParameterPassMode == ParameterPassMode.QueryString)
            return this.QueryString["dp"];
        // ...
    }
}

public bool ParametersAvailable
{
    get
    {
        if (this.ParameterPassMode == ParameterPassMode.QueryString)
            return true;
        // ...
    }
}

public DialogParameters GetDialogParameters()
{
    if (this.ParametersAvailable)
        return DialogParameters.Deserialize(this.SerializedParameters);
    // ...
}

We can now confirm the purpose of the dp parameter is to hold serialized dialog parameters.

Explaining the format of the dp parameter

Let’s now look at Telerik.Web.UI.DialogParameters:

internal static DialogParameters Deserialize(string source)
{
    DialogParameters dialogParameters = DialogParametersSerializer.Deserialize(source);
    // ...
}

And now Telerik.Web.Dialogs.DialogParametersSerializer:

public static DialogParameters Deserialize(string serialized)
{
    string[] strArray = DialogParametersSerializer.DecodeString(XorCrypter.Decrypt(serialized)).Split(';');
    // ...
}

The class Telerik.Web.UI.Common.XorCrypter looks like it could be a homegrown encryption algorithm, which could turn out to be very problematic.  Let’s look at it:

private static string _encryptionKey;

private static string EncryptionKey
{
    get
    {
        if (XorCrypter._encryptionKey == null)
        {
            XorCrypter._encryptionKey = ConfigurationManager.AppSettings["Telerik.Web.UI.DialogParametersEncryptionKey"];
            if (XorCrypter._encryptionKey == null)
                XorCrypter._encryptionKey = XorCrypter.ReadMachineDecryptionKey();
            if (XorCrypter._encryptionKey == null)
                XorCrypter._encryptionKey = XorCrypter.GenerateAppSettingsKey();
            if (XorCrypter._encryptionKey == null)
                XorCrypter._encryptionKey = XorCrypter.GetServerNameKey();
            if (XorCrypter._encryptionKey == null)
                XorCrypter._encryptionKey = "663@aae)0d-7(b8@5-46#2*2-83$0a-fb&830^0de7~73b";
        }
        return XorCrypter._encryptionKey;
    }
}

private static string ReadMachineDecryptionKey()
{
    string str1 = (string) null;
    // ...
    string decryptionKey = ((MachineKeySection) WebConfigurationManager.GetSection("system.web/machineKey")).DecryptionKey;
    if (decryptionKey.Length >= 48)
    {
        string str2 = decryptionKey.Substring(0, 48);
        ConfigurationManager.AppSettings["Telerik.Web.UI.DialogParametersEncryptionKey"] = str2;
        str1 = str2;
    }
    // ...
    return str1;
}

private static string GenerateAppSettingsKey()
{
    int length = 48;
    byte[] numArray = new byte[length];
    new TelerikRandom().GetBytes(numArray);
    for (int index = 0; index < length; ++index)
        numArray[index] = Convert.ToByte((int) numArray[index] % 93 + 33);
    string str = Encoding.ASCII.GetString(numArray);
    // ...
    ConfigurationManager.AppSettings["Telerik.Web.UI.DialogParametersEncryptionKey"] = str;
    return str;
}
private static string GetServerNameKey()
{
    // ...
    string machineName = HttpContext.Current.Server.MachineName;
    string str = machineName;
    while (str.Length < 48)
        str += machineName;
    return str.Substring(0, 48);
}

public static string Decrypt(string encoded)
{
    return XorCrypter.Decrypt(encoded, XorCrypter.EncryptionKey);
}

public static string Decrypt(string encoded, string key)
{
    // ...
    return Encoding.UTF8.GetString(XorCrypter.xorBytes(Convert.FromBase64String(encoded), XorCrypter.GetStringBytes(key)));
}

private static byte[] GetStringBytes(string s)
{
    return Encoding.UTF8.GetBytes(s);
}

private static byte[] xorBytes(byte[] source, byte[] keys)
{
    int currentKeyBytePos = 0;
    for (int index = 0; index < source.Length; ++index)
    {
        source[index] = (byte) ((uint) source[index] ^ (uint) keys[currentKeyBytePos]);
        currentKeyBytePos = XorCrypter.getNextKeyBytePos(keys, currentKeyBytePos);
    }
    return source;
}

private static int getNextKeyBytePos(byte[] keyBytes, int currentKeyBytePos)
{
    if (currentKeyBytePos >= keyBytes.Length - 1)
        return 0;
    return currentKeyBytePos + 1;
}

Put simply, this code base64-decodes the dp parameter to gain the ciphertext, and then uses a bitwise XOR operation between each byte of the ciphertext and the corresponding byte of the key to gain the plaintext.  When the code runs out of bytes in the key, it simply repeats the key, creating a situation where one-time-pads are being reused.  This weakness will be useful to us later.

We also see that the various methods to automatically generate keys appear to be limited to a length of 48 characters.

Let’s go back to Telerik.Web.Dialogs.DialogParametersSerializer and see what happens to the plaintext, so we can understand a little bit more about what is expected:

public static DialogParameters Deserialize(string serialized)
{
    string[] strArray = DialogParametersSerializer.DecodeString(XorCrypter.Decrypt(serialized)).Split(';');
    // ...
}

private static string DecodeString(string toDecode)
{
    return Encoding.UTF8.GetString(Convert.FromBase64String(toDecode));
}

Okay, we now know that the plaintext is expected to be a base64-encoded string.

Breaking an example ciphertext

We now know that each byte of ciphertext must decrypt to a valid base64 character, which helps us eliminate a lot of possibilities for each character in the key – if a given proposed key character results in a character that isn’t valid for base64-encoded text, that proposed key character isn’t valid.

We also know that the key is repeated as necessary, and is typically 48 characters – the same key character that’s used with the 1st character of ciphertext would also be used with the 49th character of ciphertext.  We can eliminate proposed key characters for both the 1st and 49th characters of ciphertext using the base64 requirement, and further eliminate proposed key characters that don’t work for both the 1st and 49th characters.

Finally, we know that base64-encoded text is always a multiple of 4 characters.  Rather than try to sort through all the permutations of what the above leaves us with, we can work with 4 bytes at a time.

Let’s write some pseudo-code to sort this out:

dp = "..."
ciphertext = base64decode(dp)

keyCandidates = new array(48)

keyIdxLoop: for (keyIdx = 0; keyIdx < 48; keyIdx++) {
    keyCandidates[keyIdx] = new list

    proposedKeyLoop: for (proposedKey = 33; proposedKey < 128; proposedKey++) {
        for (ciphertextIdx = keyIdx; ciphertextIdx < ciphertext.length; ciphertextIdx += 48) { // check all chars the proposed key would apply to
            proposedPlaintext = ciphertext[ciphertextIdx] XOR proposedKey

            if (proposedPlaintext is NOT a base64 character) { // check base64 character requirement
                continue proposedKeyLoop
            }
        }

        keyCandidates[keyIdx].add(proposedKey)
    }
}

for (keyIdxBase = 0; keyIdxBase < 48; keyIdxBase += 4) { // run permutations 4 bytes at a time
    concatCandidates = new list
    concatCandidates.add(new array(0))

    for (keyIdxOffset = 0; keyIdxOffset < 4; keyIdxOffset++) { // iterate each byte
        newConcatCandidates = new list

        foreach (proposedKey in keyCandidates[keyIdxBase + keyIdxOffset]) {
            foreach (prevCandidate in concatCandidates) {
                candidate = new array(keyIdxOffset + 1)
                arraycopy(from: prevCandidate, to: candidate, length: keyIdxOffset)
                candidate[keyIdxOffset] = proposedKey
                newConcatCandidates.add(candidate)
            }
        }

        concatCandidates = newConcatCandidates
    }

    foreach (candidate in concatCandidates) {
        println("Candidate " + arraytostring(candidate))
        plaintext = new array(4)
        for (keyIdxOffset = 0; keyIdxOffset < 4; keyIdxOffset++) {
            plaintext[keyIdxOffset] = ciphertext[keyIdxBase + keyIdxOffset] XOR candidate[keyIdxOffset]
        }
        println(base64decode(plaintext))
    }
}

If we coded up the above and ran it, and used these permutations for each 4 bytes (which became 3 characters after being base64-decoded), we’d start to see ones that make sense and can be put together.  You eventually have something starting with:

EnableAsyncUpload,False,3,False;EnableEmbeddedBaseStylesheet,False,3,True;...

When you choose the base64-decoded plaintexts that make the most sense, you will have learned the corresponding key characters.

Once you decrypt the entire string and base64-decode it, you’ll notice the following:

ViewPaths,True,0,Zmc9PQo=;UploadPaths,True,0,Zmc9PQo=;DeletePaths,True,0,Zmc9PQo=;MaxUploadFileSize,False,1,204800;SearchPatterns,True,0,S2k0cQ==

Zmc9PQo= is the string “~” base64-encoded twice, suggesting the file manager uses these parameters to control which directories can be viewed or manipulated by the user; in this case, anything under the web root.  S2k0cQ== is the string “*.*” base64-encoded twice, which presumably allows the upload of any file type, including dangerous ones such as .aspx.

We now know that if attackers can find a XorCrypter-encrypted string in a web application, for example on a page with the RadEditor configured to allow dialogs of some description (spell check, image management, etc.), they can break the encryption of the string, modify it to allow malicious activity, re-encrypt it, and get a file browser without appropriate restrictions.

But what if an attacker can’t easily find such a string?

Abusing an encryption oracle

You’ll recall earlier that we mentioned we got error messages when trying the provided dp parameter value on other servers.  Those error messages were sufficiently verbose to reveal if the decrypted ciphertext was successfully base64-decoded, or if the decrypted ciphertext contained invalid base64 characters.  We can abuse this functionality, a variation of an encryption oracle, to strategically eliminate proposed keys.

Because base64 encoded text is multiples of 4 bytes, we will work on abusing the encryption oracle in 4 byte chunks.  We can start by guessing a ciphertext that we hope will be only base64 characters upon decryption, for example 4 null (0x00) bytes.  We then base64 the guessed ciphertext and send it to the server.  If it responds with an error about failing to base64-decode the input, try a different ciphertext.  If, instead, it responds with a different error (i.e., an index was out of bounds for an array, a value couldn’t be parsed correctly, etc.), we know we have some unknown base64 plaintext.

Once we have some base64 plaintext, we can start working on each individual byte.  Try replacing the first byte of ciphertext, and see if it’s still successfully base64-decoded.  For each proposed key character (generally printable ASCII characters as observed from XorCrypter’s GenerateAppSettingsKey function), XOR it with every possible base64 character, set the first byte of ciphertext to the result of the XOR, send that to the server, and expect that the server will successfully base64-decode the result.  If base64-decoding fails, the proposed key character is likely wrong, and you can try the next proposed key character with every base64 character.  Keep going until you find a proposed key character that can XOR every base64 character without the server giving any base64-related errors, and you’ve likely found the correct first key character.  Repeat with the second, third and fourth key character.

Once you have the first 4 key characters, you can then expand the ciphertext by 4 bytes, and work on the next 4 key characters.

You can optimize this some and break many keys in less than 10,000 requests to the encryption oracle.  Once you know the key, encrypt dialog parameters that allow you to do as you please.

Vendor resolution and warnings

On June 21st, Progress Telerik released an update, “R2 2017 SP1” (v2017.2.621), which we confirmed resolved the issue.  We then proceeded to advise some of our clients to have developers and software vendors update their libraries, without mentioning the specifics of the vulnerability.  On or around June 29th, Telerik sent notices to current customers advising them to update or add a patch.  They also added a notice to their website, which will likely help others reverse engineer the library as we have done here.  We also expect the researchers credited with the discovery of the vulnerability to release more verbose findings in due time.

Outcome

Using the knowledge we gained from this exercise, our initial client was able to enact appropriate defenses prior to the patch being widely available.  By promptly alerting other clients about the existence of the update and the possibility of remote code execution (without too many details), they too were able to defend against the attack prior to Progress Telerik releasing more information about it publicly.

Our team can incorporate in-depth application testing into engagements, and we frequently enjoy these types of tasks.  If you would like a critical application assessed for vulnerabilities such as this, drop our team a line!


Leave a Reply

Your email address will not be published. Required fields are marked *