Two years ago, Progress released a security advisory about a cryptographic weakness issue in Telerik UI for ASP.NET AJAX components that can result in an arbitrary file upload, allowing unauthenticated attackers to compromise vulnerable websites via uploading a webshell. CMSes that use the component, such as DotNetNuke, Sitefinity, are also affected.
While the issue is already 2 years old, and there is no doubt that most of you already knew about it (a detailed analysis, or an automated tool to exploit the issue can be found easily on the internet), it’s still one of most interesting vulnerabilities I’ve found so far.
If even a silly love story has a place on my personal blog, then why isn’t this one?
Technical details
The Text Editor component of Telerik UI for ASP.NET AJAX has a built-in File Manager feature that allows users to upload files (images, documents, …) and then insert them into their posts.
Each kind of File Manager dialogs (Image Manager, Document Manager, …) is loaded through different URLs. For example, the following URL will be requested when users open the Image Manager dialog, with XXX is a pretty long Base64 string.
/Telerik.Web.UI.DialogHandler.aspx?DialogName=ImageManager&UseSRM=true&renderMode=2&Skin=Bootstrap&Title=Image%20Manager&doid=3ae9a364-e63a-48c7-a118-28e6d453d960&dpptn=&isRtl=false&dp=XXX
But what does XXX contain?
Let’s dig into the source code!
// Telerik.Web.UI.DialogHandlerNoSession private DialogParameters GetDialogParameters() { if (this.storedDialogParameters != null) { return this.storedDialogParameters; } if (this.DialogParametersProviderType == typeof(JavascriptDialogParametersProvider)) { this.EnsureChildControls(); if (this._parameterObtainer != null) { if (this._parameterObtainer.ParametersAvailable) { this.storedDialogParameters = this._parameterObtainer.GetDialogParameters(); if (this.storedDialogParameters == null) { this.storedDialogParameters = new DialogParameters(); } } else { base.Form.Controls.Add(new LiteralControl("<div style='text-align:center;'>Loading the dialog...</div>")); } } } else { this.storedDialogParameters = this.GetDialogParametersByName(); if (this.storedDialogParameters == null) { this.storedDialogParameters = new DialogParameters(); } } return this.storedDialogParameters; }
// Telerik.Web.UI.JsParameterObtainer public DialogParameters GetDialogParameters() { if (this.ParametersAvailable) { return DialogParameters.Deserialize(this.SerializedParameters); } return null; }
// Telerik.Web.UI.DialogParameters internal static DialogParameters Deserialize(string source) { DialogParameters dialogParameters = DialogParametersSerializer.Deserialize(source); if (dialogParameters == null) { throw new ArgumentException("The Dialog parameters are corrupted."); } return dialogParameters; }
// Telerik.Web.Dialogs.DialogParametersSerializer public static DialogParameters Deserialize(string serialized) { string text = DialogParametersSerializer.DecodeString(XorCrypter.Decrypt(serialized)); string[] array = text.Split(new char[] { ';' }); DialogParameters dialogParameters = new DialogParameters(); for (int i = 0; i < array.Length; i++) { DialogParametersSerializer.AddDeserializedItem(array[i], dialogParameters); } return dialogParameters; }
// Telerik.Web.Dialogs.DialogParametersSerializer private static string DecodeString(string toDecode) { return Encoding.UTF8.GetString(Convert.FromBase64String(toDecode)); }
// Telerik.Web.UI.Common.XorCrypter public static string Decrypt(string encoded) { return XorCrypter.Decrypt(encoded, XorCrypter.EncryptionKey); }
// Telerik.Web.UI.Common.XorCrypter public static string Decrypt(string encoded, string key) { XorCrypter.CheckNotEmpty(key); if (encoded.Contains(" ")) { encoded = encoded.Replace(" ", "+"); } byte[] source = Convert.FromBase64String(encoded); return Encoding.UTF8.GetString(XorCrypter.xorBytes(source, XorCrypter.GetStringBytes(key))); }
// Telerik.Web.UI.Common.XorCrypter 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; } }
In short, the application will decrypt the Base64-encoded string XXX
using the following algorithm:
dialog_parameters = extract_params(base64_decode(xor(base64_decode(XXX), key)))
Seeing XOR being involved here is quite strange, because in other places, the encryption/decryption is done with AES instead.
However, you may ask, what is the point of providing XXX
under an encrypted form?
Well, the answer is, the dialog configuration (dialog_parameters
) contains several important properties which are used to tell the File Manager how it should work, such as where to upload user files into, and which file extensions can be uploaded. For instance, back to the Image Manager dialog, here is its dialog configuration indicating which file extensions are allowed:
// Telerik.Web.UI.Editor.DialogControls.ImageManagerDialog protected override string[] DefaultSearchPatterns { get { return new string[] { "*.gif", "*.xbm", "*.xpm", "*.png", "*.ief", "*.jpg", "*.jpe", "*.jpeg", "*.tiff", "*.tif", "*.rgb", "*.g3f", "*.xwd", "*.pict", "*.ppm", "*.pgm", "*.pbm", "*.pnm", "*.bmp", "*.ras", "*.pcd", "*.cgm", "*.mil", "*.cal", "*.fif", "*.dsf", "*.cmx", "*.wi", "*.dwg", "*.dxf", "*.svf" }; } }
// Telerik.Web.UI.Dialogs.UserControlFileBrowser protected RadFileExplorer FileBrowser { get { if (this._fileBrowser == null) { this._fileBrowser = (RadFileExplorer)base.FindControlRecursive("RadFileExplorer1"); if (this._fileBrowser == null) { this._fileBrowser = new RadFileExplorer(); this._fileBrowser.ID = "RadFileBrowser1"; } this._fileBrowser.Configuration.SearchPatterns = this.DefaultSearchPatterns; this._fileBrowser.Language = base.Language; if (!string.IsNullOrEmpty(base.LocalizationPath)) { this._fileBrowser.LocalizationPath = base.LocalizationPath; } this._fileBrowser.Skin = base.RuntimeSkin; this._fileBrowser.EnableEmbeddedSkins = this.EnableEmbeddedSkins; this._fileBrowser.EnableEmbeddedBaseStylesheet = this.EnableEmbeddedBaseStylesheet; this._fileBrowser.ItemCommand += this.fileBrowser_ItemCommand; } return this._fileBrowser; } }
And how file extensions are checked when a file being uploaded by users:
// Telerik.Web.UI.RadFileExplorer private void ProcessUploadedFiles() { string text = string.Empty; string text2 = this.AppendTrailingPathSeparator(this.CurrentFolder); UploadedFileCollection uploadedFileCollection = (!this.Configuration.EnableAsyncUpload) ? this._upload.UploadedFiles : this._asyncUpload.UploadedFiles; if (this.Upload != null) { foreach (object obj in this.Upload.InvalidFiles) { UploadedFile uploadedFile = (UploadedFile)obj; text += this.CheckFileValidity(uploadedFile, text2); } } ... }
// Telerik.Web.UI.RadFileExplorer private string CheckFileValidity(UploadedFile uploadedFile, string uploadFolder) { string text = string.Empty; if (string.IsNullOrEmpty(uploadedFile.FileName)) { text = "NoUploadedFile"; } else if (!this.IsValidExtension(uploadedFile.GetExtension())) { text = "InvalidFileExtension"; } else if (uploadedFile.ContentLength > (long)this.Configuration.MaxUploadFileSize) { text = "InvalidFileSize"; } else if (!this.ContentProvider.CheckWritePermissions(uploadFolder)) { text = "MessageCannotWriteToFolder"; } if (text.Length > 0) { text = uploadedFile.FileName.Replace("\\r", "\\ r").Replace("\\n", "\\ n") + " : " + this.Localization.GetString(text) + "\\r\\n"; } return text; }
// Telerik.Web.UI.RadFileExplorer private bool IsValidExtension(string extension) { if (Array.IndexOf<string>(this.Configuration.SearchPatterns, "*.*") >= 0) { return true; } foreach (string text in this.Configuration.SearchPatterns) { if (text.Equals("*" + extension, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; }
So, if a user can control the dialog configuration, he will be able to upload any files he wants.
base64_decode(xor(base64_decode(XXX), key))
Because we don’t have the key, in order to be able to craft a malicious configuration, we need to break the decryption.
But it seems that there is nothing wrong with the algorithm, right?
At least, until I see these error messages:
We didn’t do anything wrong, but somehow, we lost
Nokia CEO Stephen Elop
The former error is understandable, but the latter… How can 1111
contain a non-Base64 character?
It turns out that, the second error was not occurred on the first base64_decode
step, but the second one, as being explained below:
base64_decode(xor(base64_decode('1111'), key)) -> base64_decode('1111') = '\xd7]u' -> xor('\xd7]u', key) = <3 random characters> -> base64_decode(<3 random characters>) = 'The input is not a valid Base-64 string...' error
To confirm our assumption, as well as get rid of the confusion about which base64_encode
step causes the error, let’s play with the endpoint a bit more. This time, we will use MTExMQ==
and YWFhYQ==
(which are Base64-encoded strings of 1111
and aaaa
, accordingly) for the dp
parameter instead. That means, the first base64_decode
step will be done without any errors.
Based on the error messages, we can guess that with MTExMQ==
, the whole decryption has done properly, but since the output data was not in the correct format, the application could not extract parameters from it to get a valid dialog configuration. With YWFhYQ==
, unfortunately, the output of the xor
step contained a non-Base64 character, so that the second call to base64_decode
failed.
As a result, we now figure out that we are able to determine whether our provided input, after being processed by the xor
step, contains any non-Base64 characters or not.
How can that help?
Breaking the decryption
To make the rest of this post simpler, let’s forget about the first base64_decode
step (as we have nothing to do with it). From now, the decryption algorithm becomes:
xor(base64_decode('1111'), key)
Suppose that we already found 1111
to be an input which will give us the Index was outside the bounds of the array
error (means that after being xor-ed, it contains valid Base64 characters only). Then, we examine the error messages returned by the application when we modify the input from 1111
to 2111
, 3111
, 4111
, and so on, as below:
In the above example, :111
and <111
give us a different error, which is The input is not a valid Base-64 string...
instead of Index was outside the bounds of the array
.
Because we only modified the first character of the input, so that the non-base 64 character
error must come from that new character. In other words, we know which input character, after being xor-ed with the first character of the encryption key, will result in a non-Base64 character, and vice versa.
If you are still confused, let’s imagine a smaller world where the full ASCII charset has just 16 values (instead of 256): 0
, 1
, 2
, 3
, 4
, 5
, 6
, 7
, 8
, 9
, a
, b
, c
, d
, e
, f
, and the Base64 charset has just 4 values (instead of 65): 3
, 4
, 5
, a
.
When we send each character of the full ASCII charset to the application sequently, if the key is 0
, then the output of xor
step will still be 0
, 1
, 2
, 3
, 4
, 5
, 6
, 7
, 8
, 9
, a
, b
, c
, d
, e
, f
, accordingly. The valid Base64 characters are at index 4
, 5
, 6
, and 11
of the output.
However, if the key is 4
, the output will be 4
, 5
, 6
, 7
, 0
, 1
, 2
, 3
, c
, d
, e
, f
, 8
, 9
, a
, b
. The valid Base64 characters are at index 1
, 2
, 8
, and 15
of the output.
The differences in the positions of valid/invalid Base64 characters allow us to find out the key, as being demonstrated by the following script:
def find_key(arr): full_charset = '0123456789abcdef' base64_charset = '345a' for k in xrange(len(full_charset)): k_arr = [] for c in xrange(len(full_charset)): if full_charset[c ^ k] not in base64_charset: k_arr.append(0) else: k_arr.append(1) if k_arr == arr: return k print 'Key:', find_key([0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]) print 'Key:', find_key([1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0])
Key: 0 Key: 4
Of course, we are happy with the script’s output. Nevertheless, being able to determine the key in some specified cases is not enough. We need to make sure that we are always able to do that.
As we can see from the above code, the reason we were able to find out the key correctly is that for each unique key, a unique output is returned. Therefore, we can write a small script to know if that condition is still true in our real world or not:
def arr_to_str(arr): arr = map(str, arr) return ''.join(arr) def get_arrays(full_charset, base64_charset): arrs = [] for key in xrange(len(full_charset)): arr = [] for k in xrange(len(full_charset)): arr = [] for c in xrange(len(full_charset)): if full_charset[c ^ k] not in base64_charset: arr.append(0) else: arr.append(1) arrs.append(arr) return arrs full_charset = ''.join(map(chr, xrange(256))) base64_charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=' arrs = get_arrays(full_charset, base64_charset) print 'Num of outputs:', len(arrs) print 'Num of unique outputs:', len(set(map(arr_to_str, arrs)))
Num of outputs: 256 Num of unique outputs: 256
Great!
Optimizing the attack
At the beginning of the attack described in the previous section, we were assuming that we already found a 4-bytes input which would give us the Index was outside the bounds of the array
error. The probability of finding such an input is about (65/256)^4
~ 1/256
, which means we need to send an average of 256 requests to get a valid initial input, and repeat this step for each Base64 block.
But, if you still remember the error message we got at the very first of this post:
You may realize that although on the second call to base64_decode
, our data contains only 3 characters, the error returned by the application is The input is not a valid Base-64 string...
, not Invalid length for a Base-64...
.
This behavior, fortunately, can be explained by stepping into .Net Framework’s source code:
// System.Convert public unsafe static byte[] FromBase64String(string s) { if (s == null) { throw new ArgumentNullException("s"); } char* ptr = s; if (ptr != null) { ptr += RuntimeHelpers.OffsetToStringData / 2; } return Convert.FromBase64CharPtr(ptr, s.Length); }
// System.Convert private unsafe static byte[] FromBase64CharPtr(char* inputPtr, int inputLength) { while (inputLength > 0) { int num = (int)inputPtr[inputLength - 1]; if (num != 32 && num != 10 && num != 13 && num != 9) { break; } inputLength--; } int num2 = Convert.FromBase64_ComputeResultLength(inputPtr, inputLength); byte[] array = new byte[num2]; fixed (byte* ptr = array) { int num3 = Convert.FromBase64_Decode(inputPtr, inputLength, ptr, num2); } return array; }
// System.Convert private static unsafe int FromBase64_Decode(char* startInputPtr, int inputLength, byte* startDestPtr, int destLength) { char* chPtr = startInputPtr; byte* numPtr = startDestPtr; char* chPtr2 = chPtr + inputLength; byte* numPtr2 = numPtr + destLength; uint num2 = 0xff; while (true) { ... } throw new FormatException(Environment.GetResourceString("Format_BadBase64Char")); TR_0008: throw new FormatException(Environment.GetResourceString("Format_BadBase64Char")); TR_0010: if (num2 != 0xff) { throw new FormatException(Environment.GetResourceString("Format_BadBase64CharArrayLength")); } return (int) ((long) ((numPtr - startDestPtr) / 1)); }
Taking advantage of this behavior allows us to leak the decryption key a bit faster as finding a "valid" 4-bytes input for each Base64-block is no longer needed, thus we can simply:
- Send 256 1-byte inputs to leak the 1st character of the key.
- Send 256 2-bytes inputs, where
input[0]
='a'
^key[0]
, to leak the 2nd character of the key. - Send 256 3-bytes inputs, where
input[0]
='a'
^key[0]
andinput[1]
='a'
^key[1]
, to leak the 3rd character of the key. - And so on…
It’s worth mentioning that if sending 256 requests for a key character is too much for you, there’s still room for reducing that number.
A teammate of mine said that he could leak the full key (48 bytes) in just about 400 requests, so let’s try and see whether you can beat him or not
After having the full key, escalating the vulnerability to a RCE is quite straightforward, since all you need to do is read the application’s source code in order to understand how to build a dialog configuration that allows you to upload files in any extensions.
Timeline
- 06 Jun 2017: Discovered the vulnerability.
- 13 Jun 2017: Reported to Progress.
- 03 Jul 2017: Security advisory released.
Two years have passed, and the swag is still there
But with an aged vulnerability like this, how many of you care?
Is there anything awesome on the internet that now you cannot find anywhere?
It might be merely because after you read, you forgot to share!
He he
so many tech, much swag, love