{"slug": "from-padding-oracle-to-shell-unauthenticated-rce-in-telerik-ui-for-asp-net-ajax", "title": "From Padding Oracle to Shell: Unauthenticated RCE in Telerik UI for Asp.net Ajax", "summary": "Tanto Security discovered an unauthenticated AES-CBC padding oracle in Telerik UI for ASP.NET AJAX, chained with two other vulnerabilities to achieve remote code execution affecting versions 2010.1.309 through 2026.2.519. Progress Software released version 2026.2.708 (2026 Q2 SP1) to prevent exploitation, but the attack requires non-default configuration including a reachable RadAsyncUpload control and an explicit encryption key.", "body_md": "*The vulnerabilities described in this post were discovered, analysed, and exploited with some AI assistance, and a lot of old-fashioned human persistence.*\n\n**What you need to know**\n\nTanto Security found an unauthenticated AES-CBC padding oracle in Telerik UI for ASP.NET AJAX and chained it with two other vulnerabilities to achieve remote code execution.\n\n[Progress Software says](https://www.telerik.com/products/aspnet-ajax/documentation/knowledge-base/kb-security-critical-rce-chain-bulletin-july-2026) that the vulnerability affects versions 2010.1.309 through 2026.2.519 inclusive, and that version 2026.2.708 (2026 Q2 SP1) prevents exploitation of the chain.\n\nThe vulnerability chain has preconditions that are not met by a default installation of Telerik UI for ASP.NET AJAX:\n\n- There must be a reachable page containing a `RadAsyncUpload` control where the page’s server-side`FileUploaded` handler reads`UploadResult`\n- The site must be configured with an explicit, non-default `Telerik.AsyncUpload.ConfigurationEncryptionKey` , which is a[recommended hardening setting](https://www.telerik.com/products/aspnet-ajax/documentation/controls/asyncupload/security/security#recommended-settings)\n\nIf the site has `customErrors` set to `On`, then the padding oracle vulnerability must be exploited using timing analysis. This increases the complexity and effort of a successful attack, but it does not prevent exploitation.\n\nUsers of Telerik UI for ASP.NET AJAX should upgrade to 2026.2.708 (2026 Q2 SP1) or later.\n\nTelerik UI for ASP.NET AJAX is a pretty popular framework that shows up regularly in enterprise ASP.NET applications. It ships as a suite of server-side handlers and UI controls, and if you spend enough time doing penetration tests on web apps you will run into it sooner or later.\n\nSome time ago I ran into a Telerik UI for ASP.NET app doing something odd. Its AES-CBC scheme derived its encryption key straight from the `ASP.NET_SessionId` cookie. A cookie is not a server-side secret. It is a value the client can observe and manipulate in the request, so anyone could freely decrypt and forge the application’s protected parameters.\n\nThis behaviour made me wonder where it was coming from. A user-controlled key is bad on its own, but the bigger question was whether the developers of that app had introduced this behaviour themselves or whether it was baked into Telerik. If it was the framework, this would not be one app’s problem. It would ship with Telerik itself, in every version that carried that behaviour.\n\nA quick Google search confirmed that Telerik does use AES-CBC in its handlers and encrypted parameters. Then I pulled up the [Telerik documentation on how it handles encryption keys for its handlers](https://www.telerik.com/products/aspnet-ajax/documentation/controls/asyncupload/security/security). Based on what I read, I began to doubt that Telerik was deriving an encryption key from the `ASP.NET_SessionId` cookie.\n\nIn its documentation, Telerik explicitly tells developers to configure their own encryption key values. When it’s not configured, Telerik falls back to using the ASP.NET machine key as the encryption key. That key is a server-wide value ASP.NET keeps for protecting things like view state and forms-auth cookies. This made me think that the `ASP.NET_SessionId` encryption key derivation was coming from the application, not the framework.\n\nHowever, that search left me with a different question. If Telerik is using `AES-CBC`, could it be vulnerable to padding oracle attacks?\n\n## How padding oracle attacks work\n\n**New to padding oracles?**\n\nThis section teaches the attack from the ground up, for readers who have not met a padding oracle before or who want a refresher, and it takes each part slowly. If that is you, read on. If you already know how padding oracles work, feel free to skip ahead to [A quick tour of Telerik and its building blocks](#a-quick-tour-of-telerik-and-its-building-blocks).\n\nBlock ciphers like AES work on fixed-size chunks called blocks. An AES block consists of 16 bytes. Real messages are rarely a neat multiple of that, so the final chunk has to be “topped up” to a full block before it can be encrypted. That “topping up” is called padding.\n\nThe common scheme is PKCS#7. It works by encoding the number of padding bytes in the padding bytes themselves. Every padding byte holds the number of padding bytes that were added. If the final block is missing one byte, that byte is filled with `0x01`. Two bytes short are filled with `0x02 0x02`. Three bytes short are filled with `0x03 0x03 0x03`, and so on. When the receiver decrypts the message, it reads the last byte to know how many bytes to remove. Before removing them, it validates the padding, and if the trailing bytes do not follow the rule above, the decryption fails with an invalid padding error.\n\nPKCS#7 has one more rule worth knowing. What if the message is already an exact multiple of the block size, with nothing to top up? It still gets padded, with a whole extra block. For a 16-byte block that means sixteen bytes each holding `0x10`. The scheme always adds between one byte and a full block, never nothing, so the receiver can always trust the last byte to tell it how much to strip.\n\nBlock ciphers like AES only encrypt one block at a time. Encrypting a longer message means running the cipher over each block in turn, and encrypting them independently leaks structure. To avoid that, a block cipher is paired with a [“mode of operation”](https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation) that makes multi-block encryption safe.\n\nCBC, short for Cipher Block Chaining, is a popular mode of operation. When decrypting, the process happens in two steps per block. Each encrypted block (known as a ciphertext block) is decrypted using the block cipher, and that result is then XORed against the previous ciphertext block to give the plaintext. The very first block has no previous block, so it uses the initialisation vector, the IV, in that role instead.\n\nNone of this is specific to AES. CBC chains blocks the same way regardless of the block cipher that sits underneath it.\n\nCBC has one more gap that matters here. Unlike some block cipher modes, CBC has no built-in way to tell whether the ciphertext has been tampered with. It leaves that to the library or the developer to bolt on separately, usually with a Message Authentication Code, or MAC, whose whole job is to detect changes to the ciphertext.\n\nWithout one, an adversary is free to fiddle with the CBC-encrypted bytes before they are decrypted. The server decrypts whatever it is handed and checks the padding all the same.\n\nPicture an application that does three things with every request it receives. First, it CBC-decrypts the ciphertext, with no MAC, so nothing checks whether the bytes were tampered with on the way in. Second, it checks the PKCS#7 padding on the decrypted plaintext. Third, if the padding is fine, it hands the bytes to a JSON decoder.\n\nNow watch what you can do with that. You cannot read the key. But you can change the ciphertext bytes and send them back, and the server will decrypt whatever you hand it. The block whose plaintext you are after is the target block. The ciphertext block right before it is XORed into the target block, and you control this block because you control all ciphertext blocks.\n\nStart by changing a single byte of the block you control, position 10:\n\nThe block you control is itself ciphertext, so changing one of its bytes scrambles its whole decryption. Its intermediate and plaintext, the dimmed left column, turn to garbage. You never wanted that block’s plaintext, so that is no loss.\n\nWhat matters is the target block. There, only the byte at position 10 changes, and the reason is the rule CBC decryption follows for every byte:\n\n`plaintext2[n] = intermediate2[n] ⊕ ciphertext1[n]`\n\nHere `n` is the byte’s position in the 16-byte block, `0` at the front to `15` at the end. `intermediate2` is the target block after the cipher decrypts it, and `ciphertext1` is the block you control. Each target plaintext byte is one intermediate byte XORed with the byte at the same position in the controlled block. So changing a single controlled byte moves only that one position of the target’s plaintext and leaves the rest alone. Position 10 held the closing quote of the string, so the padding is still valid. But the JSON no longer parses (the scrambled previous block probably breaks it too). The server replies “Invalid JSON, check the data and re-submit”.\n\nNow put that byte back and change position 15 instead:\n\nThis time the target byte that changes is the last one, position 15, which was a padding byte. The padding no longer holds, so the server rejects the message before it reaches the JSON decoder and replies “Invalid padding, check the data and re-submit”.\n\nThose are two distinguishable replies. Bad padding returns one error. Padding that passed the check but then failed the JSON parse returns the other. So for any ciphertext you craft, the error that comes back tells you whether the padding check passed. That difference between the good-padding and bad-padding answers is what we call a padding oracle.\n\nThose two diagrams also hand you the mechanism for turning the oracle into plaintext. You just saw the rule behind it: changing byte `n` of the controlled block moves only byte `n` of the target’s plaintext.\n\nTo recover the `intermediate2` bytes, start with the last byte of the block you control, position 15. Cycle it (try all 256 values from `0x00` to `0xff`) and ask the oracle each time. For almost every value, the target block’s last plaintext byte comes out as something arbitrary and the padding is invalid. When the oracle reports valid padding, the last plaintext byte of the target block has landed on `0x01`, a valid single-byte pad.\n\nNow the algebra. You know the plaintext byte is `0x01` (because it passed the oracle check), and you know the byte you just sent. Rearrange the rule and the intermediate for that position drops out:\n\n```\nFormula:    intermediate2[15] = ciphertext1[15] ⊕ plaintext2[15]\nResolving:        0x68        =       0x69      ⊕      0x01\n```\n\nTo recover the genuine plaintext, XOR the intermediate byte value against the original byte from the block you control:\n\n```\nFormula:    plaintext2[15] = intermediate2[15] ⊕ original[15]\nResolving:       0x04      =        0x68       ⊕     0x6c\n```\n\nPlaintext byte recovered without the need of the encryption key.\n\nNow move one byte to the left. This time you want the target block to end in a valid two-byte pad, `0x02 0x02`. You already know the intermediate for position 15, so you can force that last byte to decrypt to `0x02`. Set your controlled byte at position 15 to `intermediate2[15] ⊕ 0x02`:\n\n```\nFormula:    ciphertext1[15] = intermediate2[15] ⊕ plaintext2[15]\nResolving:        0x6a      =       0x68        ⊕      0x02\n```\n\nTo see why that forces the byte to `0x02`, substitute the controlled byte back into the plaintext rule:\n\n```\nplaintext2[15] = intermediate2[15] ⊕ ciphertext1[15]\n               = intermediate2[15] ⊕ (intermediate2[15] ⊕ 0x02)\n               = (intermediate2[15] ⊕ intermediate2[15]) ⊕ 0x02\n               = (0x00) ⊕ 0x02\n               = 0x02\n```\n\nThe intermediate cancels against itself, leaving the value you chose.\n\nWith position 15 pinned at `0x02`, cycle position 14 in the block you control until the oracle validates again. A valid pad now means position 14 decrypted to `0x02`, so the block ends `0x02 0x02`. Recover its intermediate and plaintext the same way:\n\n```\nFormula:    intermediate2[14] = ciphertext1[14] ⊕ plaintext2[14]\nResolving:        0xb3        =       0xb1      ⊕      0x02\nFormula:    plaintext2[14] = intermediate2[14] ⊕ original[14]\nResolving:       0x04      =        0xb3       ⊕     0xb7\n```\n\nEach step to the left grows the padding by one. When you take that step, you reset every byte you have already solved to the new padding value, using each one’s known intermediate, then cycle the next unsolved byte. Repeat right to left until every byte of the target block is solved.\n\nOnce you have every intermediate in the block, you have full control of the plaintext, so you can now forge it to any value you want. Reading a byte meant working out `intermediate2[n]`. You forced the plaintext to a known pad and read the intermediate off it. Forging is that same equation but applied with a different purpose now. Now `intermediate2[n]` is the part you already know, so you pick the plaintext you want and solve for the controlled byte that produces it:\n\n```\nciphertext1[n] = intermediate2[n] ⊕ chosen[n]\n```\n\nIt is the exact move from before. Forcing position 15 to `0x02` was this same equation with `chosen[15] = 0x02`, a pad value picked only so you could brute-force the next byte. The difference now is that every intermediate in the block is already recovered, so you are no longer stuck choosing pad values. You can set `chosen[n]` to anything, byte by byte, and write the whole block to whatever content you want.\n\nThis forges a ciphertext that decrypts to whatever you choose, still without the key, and is the primitive used by the exploit we developed for Telerik.\n\nForging usually does not need a real, captured ciphertext to start from. You recover a block’s intermediate through the oracle the same way no matter what you feed into the vulnerable functionality, whether a genuine block, all zeros, all `0x01`, or random bytes. Any of them works as a seed.\n\nWhat forging does depend on is the IV, the value the very first plaintext block is XORed against. Control the IV and you can forge a whole message out of arbitrary blocks. When it is fixed by the implementation and is not given as part of the ciphertext, the first block cannot be completely controlled, and that is the catch I come back to later.\n\n**A lot to take in?**\n\nIf that read like a wall of mathematical mumbling, don’t stress out! It took me a while to fully internalise it the first time too (or the first 100 times? 😅).\n\nThe interactive walkthrough below steps through the attack one byte at a time. Take the guided tour, or switch to free-play and drive it yourself. Go at your own pace.\n\n**A rare false positive**\n\nThere is one edge case worth knowing about, now that you have the shape of the attack. Sometimes cycling the last byte reports valid padding not because that byte became `0x01`, but because it became `0x02`. The byte just before it already happened to decrypt to `0x02`, giving a valid two-byte pad by luck.\n\nIt is a rare false positive, but left unchecked it hands you the wrong value for that byte. And because each recovered byte feeds the next, the mistake results in errors in the rest of the block.\n\nIf your decryption fails, change the second-to-last byte of the block you control and retry decrypting the block. Changing that byte cannot disturb a genuine single-byte `0x01` pad, which depends only on the last byte, but it does break the lucky `0x02 0x02`. So the retry locks onto the real `0x01`, and the whole block decrypts as expected.\n\n## A quick tour of Telerik and its building blocks\n\nBefore I drag you into source code analysis and ciphertext, let me explain what Telerik actually is. The rest of this post only really lands if you have a feel for the moving parts. If you have never had the pleasure, a lot of the names are going to fly past otherwise.\n\nTelerik UI for ASP.NET AJAX is a suite of server-side UI controls for ASP.NET WebForms. You drop a `<telerik:RadEditor>` or a `<telerik:RadAsyncUpload>` tag onto an `.aspx` page, and at runtime the control renders its own HTML and JavaScript, wires up its client-side behaviour, and talks back to a matching server-side handler to do the heavy lifting.\n\nIf you have worked with WebForms, it slots in exactly where you would expect, with server controls, postbacks, the lot. If you have not, the mental model is just “prebuilt widgets that each have a server half and a client half, and the two halves are constantly talking to each other.”\n\nAll of it, every control and every handler, ships inside a single assembly, `Telerik.Web.UI.dll`. One DLL. That is convenient for us later, because there is exactly one thing to decompile.\n\nSo how does a request actually reach the code inside that one DLL? The shape of it is easier to show than to describe:\n\nStart on the left, with the page and its control tags. Each of those controls has a server half, and at runtime the client half calls back to it. Almost every one of those calls goes through a single entry point, `Telerik.Web.UI.WebResource.axd`.\n\nDespite the name, that is not a file in the site folder. `.axd` is an ASP.NET handler extension, so the name is registered in `web.config` and mapped to a class, and a request for it runs code inside `Telerik.Web.UI.dll` rather than reading anything off disk.\n\nBehind `WebResource.axd` sits a `HandlerRouter`, which reads a `type` value off the request and dispatches to whichever handler that value maps to:\n\n- Requests for `type=rau` lead to the`RadAsyncUpload` handler\n- Requests for `type=rcu` lead to the`RadCloudUpload` handler\n- Requests for `type=rbi` lead to the`RadBinaryImage` handler\n\nAnd so on and so forth, for each `type` mapping that `Telerik.Web.UI.dll` provides.\n\nOne control does not follow this pattern. `RadEditor`, the rich text editor, has its own dedicated handler, `Telerik.Web.UI.DialogHandler.aspx`. It is not our focus here, but it sits at the centre of several other vulnerabilities we reported to Telerik, and I will cover those in a later post.\n\nFor now, let’s focus on `RadAsyncUpload`, the asynchronous chunked upload control. This is the control where I ended up spending most of my time. However, there is one more thing to understand about this handler before we go any deeper.\n\n`RadAsyncUpload` (and every one of these controls) is really two halves. You drop a single tag on a page, `<telerik:RadAsyncUpload>`, and at runtime Telerik renders it into a widget with a server half and a client half. The server half is the handler we just routed to (in this case `type=rau`). The client half is the HTML and JavaScript in the browser, along with a few hidden fields that carry the control’s state:\n\n- `rau_ClientState`\n- `_serializedConfiguration`\n- `_serializedConfigurationType`\n\nThat state is the server-side configuration and the details of how to rebuild the server-side half. It is a sensitive state, and so Telerik encrypts it before it leaves the server. From there the two halves just pass it back and forth. The server emits the encrypted state to the client, and the client only holds it. On the next postback (each time the page sends its form back to the server) the server takes the same encrypted blobs back, decrypts them, and acts on them.\n\nThe client is trusted with all of this on the reasoning that an encrypted state cannot be read or forged without the key. Most of the controls in that first diagram work the same way. The whole chain builds on that single design choice, encrypting server state and trusting it back on the next request.\n\n## Finding the oracle: RadAsyncUpload encrypted blobs\n\nWe discussed how `RadAsyncUpload` keeps its state between the browser and the application. Now let’s understand what each of those encrypted elements actually are.\n\nThe first, `_serializedConfiguration`, is the AES-CBC ciphertext of the `AsyncUploadConfiguration` JSON. This is the object that holds the interesting fields like `AllowedFileExtensions` and `TempTargetFolder`, and is the thing you actually want to tamper with.\n\nThe second, `_serializedConfigurationType`, decrypts to a .NET type name. After decrypting it, the handler resolves the type and runs it through an allowlist check that demands it be, by default, exactly the literal `Telerik.Web.UI.AsyncUploadConfiguration`. Both blobs are pulled apart in `AsyncUploadHandler.GetConfiguration`:\n\n```\n// Telerik.Web.UI/AsyncUploadHandler.cs\ninternal IAsyncUploadConfiguration GetConfiguration(string rawData)\n{\n    string[] array = rawData.Split(new char[1] { '&' });\n    // _serializedConfiguration: the config blob\n    string obj = array[0];\n    // _serializedConfigurationType: the type name\n    Type type = Type.GetType(CryptoService.GetService().Decrypt(array[1]));\n    // _serializedConfigurationType pinned to this literal\n    CryptoService.GetService().CheckWhitelistTypes(type,\n        ConfigurationManager.AppSettings[\n          \"Telerik.Upload.AllowedCustomMetaDataTypes\"\n          ], \"Telerik.Web.UI.AsyncUploadConfiguration\"); \n    // _serializedConfiguration: decrypt + parse\n    IAsyncUploadConfiguration asyncUploadConfiguration =\n        (IAsyncUploadConfiguration)SerializationService.Deserialize(\n          obj, type, decrypt: true\n        );  \n    // ...\n}\n```\n\nThe type name `_serializedConfigurationType` (`array[1]`) is decrypted, resolved, and allowlist-checked. Every one of those steps runs through `CryptoService`’s exception wrapper, which swallows whatever goes wrong and rethrows one uniform `CryptographicException`:\n\n```\n// Telerik.Web.UI/CryptoExceptionThrower.cs\npublic T ThrowIfFails<T>(Func<T> function)\n{\n  try { return function(); }\n  catch (Exception) { \n    // throw new CryptographicException(\"The cryptographic operation has failed!\")\n    return ThrowGenericCryptoException<T>(); \n  }   \n}\n```\n\nSo `_serializedConfigurationType` gives you nothing to work with. It is pinned to a literal, and every failure along its path is flattened to the same exception.\n\nThe config blob `_serializedConfiguration` (`array[0]`), on the other hand, goes to `SerializationService.Deserialize(obj, type, decrypt: true)`, which decrypts it and then feeds the plaintext to `JavaScriptSerializer` with no such wrapper around the parse:\n\n```\n// Telerik.Web.UI.AsyncUpload/SerializationService.cs\ninternal static object Deserialize(string obj, Type type, bool decrypt)\n{\n    // wrapped -> CryptographicException on bad padding\n    if (decrypt) obj = CryptoService.GetService().Decrypt(obj); \n    // -> JavaScriptSerializer.Deserialize, NOT wrapped  \n    return Deserialize(obj, type);                                \n}\n```\n\nThe oracle takes a simple shape. Bad PKCS#7 padding fails inside the wrapped decrypt and comes back as a `CryptographicException`. Ciphertext that decrypts to garbage JSON with valid padding is returned happily by the decryption function and handed to a `JavaScriptSerializer`. The serialiser fails to parse it and throws a different error, a `TargetInvocationException` wrapping an `InvalidOperationException`. Two different exceptions, two distinguishable responses: one saying “your padding was wrong”, the other saying “your padding was fine but the plaintext was rubbish”.\n\nYou do need `customErrors` set to `Off` to read the exceptions straight out of the response body, which some real deployments do. That is the ASP.NET setting deciding whether detailed exceptions are shown at all. Even with it `On` and the error text hidden, though, the two paths still differ in ways you can measure rather than read, so the signal does not really go away. More on that later.\n\nThe handler is not the only route to this oracle. A normal page postback reaches the same decrypt-then-parse split. When a page containing `RadAsyncUpload` posts back, the control deserialises its `rau_ClientState` field. A custom `JavaScriptConverter`, a class that plugs custom deserialisation logic into `JavaScriptSerializer`, does the work. That converter is `AsyncUploadClientStateConverter`. For each uploaded-file entry in the client state, its `Deserialize` method pulls a separate encrypted `metaData` blob and, in one statement, decrypts it and parses the result.\n\n```\n// Telerik.Web.UI.AsyncUpload/AsyncUploadClientStateConverter.cs\nMetaData metaData = serializer.Deserialize<MetaData>(\n    CryptoService.GetService().Decrypt((string)item[\"metaData\"]));\n```\n\nThat statement runs inside-out. `CryptoService.GetService().Decrypt(...)` goes first, wrapped by the same `ThrowIfFails`, so bad PKCS#7 padding comes back as a `CryptographicException`. If the padding is valid, the decrypted plaintext goes to `serializer.Deserialize<MetaData>`, a `JavaScriptSerializer` that nothing wraps, so plaintext that is not valid JSON for the `MetaData` type throws `InvalidOperationException`. These are two distinguishable exceptions, from the same decrypt-then-parse shape I traced through the handler.\n\nThe converter is reached through `RadAsyncUpload.PlayClientState`, which `LoadPostData` calls on postback with no try/catch around it.\n\n```\n// Telerik.Web.UI/RadAsyncUpload.cs  (PlayClientState, called bare by LoadPostData)\nprotected internal virtual void PlayClientState(string clientStateValue)\n{\n    JavaScriptSerializer serializer = new JavaScriptSerializer\n    {\n        MaxJsonLength = clientStateValue.Length\n    };\n    serializer.RegisterConverters(new[] { new AsyncUploadClientStateConverter() });\n    RadAsyncUploadClientState clientState =\n        serializer.Deserialize<RadAsyncUploadClientState>(clientStateValue);\n    AddValidFilesFromClientState(clientState);\n}\n```\n\nWhichever of the two exceptions fires propagates straight up out of `PlayClientState`, out of `LoadPostData`, and into the ASP.NET runtime, still as two distinguishable failures on the wire. So there are two doors into one flaw: the handler through `GetConfiguration`, and the page postback through `PlayClientState` and the converter. Both are the same padding oracle, **CVE-2026-13182**, which Telerik titles [“RadAsyncUpload Client-State Decrypt-vs-Parse Oracle Vulnerability”](https://www.telerik.com/products/aspnet-ajax/documentation/knowledge-base/kb-security-rau-padding-oracle-cve-2026-13182).\n\n## The static IV problem, and the sacrificial block\n\nBeing able to read the config through the oracle was one thing. But what I actually wanted was to forge one. As [how padding oracle attacks work](#how-padding-oracle-attacks-work) showed, the oracle can be used to forge plaintext as well. Recover a block’s intermediate bytes, then set the previous ciphertext block to that intermediate XORed with any plaintext you choose, and the block decrypts to exactly what you want. No encryption key needed.\n\nHowever, forging one block disturbs the plaintext of the block before it. The previous block you just chose is itself a ciphertext block, so modifying it scrambles that block’s own intermediate after decryption. That is fine, you move one block to the left and pin that one too, using its own intermediate, and keep going. Block by block, right to left, you assemble a ciphertext that decrypts to a plaintext you picked. This right-to-left move is what I call backwards-CBC forging.\n\nExcept for one block. Every block I have forged so far was XORed with the ciphertext block before it, the one I set. The very first block has nothing before it, so CBC feeds the IV there instead. The IV is what decides the first block’s plaintext, which makes it the gate to a full forge.\n\nOwn the IV and the first block is yours, and the whole message with it. But in Telerik the IV is fixed, password-derived, and never sent with the message, so I cannot substitute it with my own IV to take full control of the first plaintext block.\n\nBoth the encrypt and decrypt paths derive the key and the IV from the password configured in `Telerik.AsyncUpload.ConfigurationEncryptionKey`, over a hardcoded salt:\n\n```\n// Telerik.Web.UI/CryptoService.cs\nprivate static readonly byte[] SALT = new byte[13]\n{\n    58, 84, 91, 25, 10, 34, 29, 68, 60, 88, 44, 51, 1\n};\n\ninternal static string Encrypt(string clearText, string password)\n{\n    byte[] bytes = Encoding.Unicode.GetBytes(clearText);\n    Rfc2898DeriveBytes rfc2898DeriveBytes = new Rfc2898DeriveBytes(password, SALT);\n    // key = GetBytes(32), IV = GetBytes(16), both from (password, SALT)\n    return Convert.ToBase64String(Encrypt(bytes,\n        rfc2898DeriveBytes.GetBytes(32), rfc2898DeriveBytes.GetBytes(16)));\n}\n\ninternal static string Decrypt(string encryptedString, string password)\n{\n    byte[] encryptedBytes = Convert.FromBase64String(encryptedString);\n    Rfc2898DeriveBytes rfc2898DeriveBytes = new Rfc2898DeriveBytes(password, SALT);\n    // same derivation, so the IV is re-created, not read from the ciphertext\n    byte[] bytes = Decrypt(encryptedBytes,\n        rfc2898DeriveBytes.GetBytes(32), rfc2898DeriveBytes.GetBytes(16));\n    return Encoding.Unicode.GetString(bytes);\n}\n```\n\nThe first 32 bytes become the key, the next 16 the IV, and both depend on nothing but the password and that constant salt. The ciphertext carries no IV of its own, just the base64 of the AES output, so on decrypt the IV is re-derived rather than read from the message.\n\nThat leaves the first block beyond my control, and a clean forge from scratch out of reach. On its own, a static IV is a known weakness in CBC, because it makes encryption deterministic and the same plaintext always produces the same ciphertext. Here, though, it is the one thing in the way of a full plaintext forgery. Annoying.\n\nAnd knowing the exact plaintext format does not save me. The configuration is JSON, so the first block would have to begin with `{\"`, encoded as UTF-16LE (two ASCII characters, represented across four bytes). With no control over the first block, I could never produce a document that even parses as valid JSON, let alone one that says what I want. Brute-forcing that block is not on the table either. I cannot run the AES decryption myself, so every guess is another request to the server. Landing even the two leading `{\"` bytes by chance is a one-in-four-billion shot (2³²). At the tens of requests a second the oracle sustains, that is years of traffic for just four bytes. A whole valid first block is 2¹²⁸.\n\nBy this point I had also been talking the problem through with an AI. I asked it whether the oracle could be used for encryption, and whether we could forge a config block with it. On the forgery question it was consistent and, on its own terms, correct, “the IV is private, so you cannot control the first plaintext block, so this is a decryption oracle only”. That is exactly where the maths points, and I stared at that conclusion far longer than I should have.\n\nThen I remembered something. A while back my colleague Daniel had shown me a neat server-side JSON injection trick. The injection point sat inside a JSON string value, so he broke out of that string and injected a second key of his own, something like `\",\"accountid\":\"...\"`. The parser honoured the last occurrence of a repeated key, so his injected `accountid` quietly overrode the original one, a field the injection point otherwise gave no way to touch.\n\nMy problem had the same shape as the one that trick solved. I did not need to forge a clean config from nothing. Rather than start a message from scratch and be stuck with the first block, I could splice my forgery onto a genuine ciphertext and cut it at a point that falls inside a JSON string.\n\nTo illustrate this, let’s start from a real encrypted `_serializedConfiguration` pulled from a Telerik instance. The diagram shows the IV block that I don’t control, the decrypted JSON, and the PKCS#7 padding on the end.\n\nUsing the padding oracle, I decrypt and walk the ciphertext from right to left, one AES block at a time. `CryptoService` runs the JSON through `Encoding.Unicode` using UTF-16LE before encrypting, so each ASCII character is two bytes and every sixteen-byte block is exactly eight characters of plaintext.\n\nI keep decrypting and walking left until a block lands inside the string `AllowedFileExtensions`. That string is the JSON key of the allowlist that decides which file types the upload accepts. It is the first string I reach going left that is long enough to fit a whole block between its quotes, so it is the target to cut into and inject my plaintext. Everything past the cut point (the rest of the key string, the genuine allowlist values, and the padding) I throw away and rebuild.\n\nNow the splice itself. I forge the tail with backwards-CBC, working from the end of the message leftward. To forge the first block after the cut, I set the ciphertext block right before it, and that garbles the block’s own plaintext. This is the one block whose plaintext I cannot control, like the first block in the static IV problem. I place the cut inside the literal string `AllowedFileExtensions`, so whatever this block decrypts to reads as ordinary string content. From here on I’ll call that block whose plaintext I cannot control **the sacrificial block**.\n\nThe sacrificial block’s decrypted garbage rewrites the middle of the JSON key, so the genuine `AllowedFileExtensions` stops being that key. It becomes something like `AllowedFileq9%Kf2#z`. Everything after the sacrificial block I do control, so the forged blocks close that junk key off with a throwaway value, `\":0`. Then I forge a fresh `AllowedFileExtensions` of my own, `,\"AllowedFileExtensions\":[\"dll\"]}`. The original allowlist is gone and only mine is left, so the handler starts accepting `.dll`.\n\nOnce I own the whole tail I can also override other fields the same way. I can append a new `TempTargetFolder`, for example, because many JSON parsers keep the last occurrence of a key if duplicate keys are submitted, including the `JavaScriptSerializer` that Telerik parses this config with:\n\n``` js\nvar serializer = new JavaScriptSerializer();\nvar obj = (Dictionary<string, object>)\n    serializer.DeserializeObject(\"{\\\"key\\\":1,\\\"key\\\":2}\");\nConsole.WriteLine(obj[\"key\"]);   // 2\n```\n\nSo the long key name gave me a safe place to spend the one block I could not control, and the document still parses as valid JSON.\n\n**When the sacrificial block breaks the JSON**\n\nThe sacrificial block carries a small risk. I do not get to choose its plaintext. It is garbage the backwards-CBC construction throws out, and it has to clear two checks: it must be valid UTF-16LE, and it must not hold a byte JSON forbids inside a string, like a double quote, a backslash, or a control character. UTF-16LE is forgiving enough that most random blocks pass, but once in a while one does not. The padding still checks out, yet the JSON no longer parses, so the server rejects the token. The fix is simple. I add another random sacrificial block ahead of the one I forge from, which reshuffles the garbage, and retry until a block lands clean.\n\nWhen I took that construction back to the AI and asked it to prove it rather than tell me why it could not exist, it agreed the idea was sound, and it was. The forgery held. One random block of noise, hidden inside a string, and a decryption oracle becomes a full encryption oracle for the fields I need, with the fixed IV sidestepped.\n\nI want to be straight about that sequence. The AI checked the CBC construction and helped me build the exploit far faster than I would have by hand. But the unlock came from a memory of a vulnerability Daniel had shown us.\n\nThere is a catch in that solution, though. While this technique works for `_serializedConfiguration` as it is JSON, `_serializedConfigurationType` has the same block-zero problem, and here nothing rescues it. It has to decrypt to the exact literal string `Telerik.Web.UI.AsyncUploadConfiguration`, so there is nowhere to hide a sacrificial block in. Therefore I cannot tamper with `_serializedConfigurationType`.\n\nFor this attack to work, I need a real, server-issued pair of blobs, genuine `_serializedConfiguration` prefix blocks to splice my forgery onto and the genuine `_serializedConfigurationType` carried over untouched. That is why the attack has to be aimed at a page that actually renders a `RadAsyncUpload` rather than being fabricated from nothing. More on why that matters when we run the chain end to end.\n\n## Following the type name to a gadget\n\nForging config is the foundation, but on its own it just rewrites some settings. Turning that into code execution starts with one field in the forged state, the type name. It happens in a handful of small steps.\n\nWhen you `POST` to `RadAsyncUpload`, the control takes the `rau_ClientState` field and deserialises it into a `RadAsyncUploadClientState` using a custom `JavaScriptConverter` called `AsyncUploadClientStateConverter`. Nothing exciting yet. It is just turning your `POST` ed state back into an object.\n\nThe first hop happens inside `AsyncUploadClientStateConverter.Deserialize`. For every uploaded-file entry it reaches back in and pulls out a completely separate encrypted `metaData` blob, decrypts it, and deserialises that with `JavaScriptSerializer` into a tiny `MetaData` object:\n\n```\nMetaData metaData = serializer.Deserialize<MetaData>(\n    CryptoService.GetService().Decrypt((string)item[\"metaData\"]));\n```\n\n**For clarity**\n\nThese two “metadatas” look almost the same but are different things:\n\n- the lowercase `metaData` blob is the encrypted string carried in the client state, the same one the postback oracle decrypts\n- the capitalised `MetaData` class is the object that blob decrypts into\n\nNow, that `MetaData` class has just two properties, `TempFileName` and `AsyncUploadTypeName`. And because the padding oracle lets us forge this blob without ever knowing the key, we control both of those values completely. The converter then copies them straight out:\n\n```\nuploadedFileInfo.FileType = metaData.AsyncUploadTypeName;\n```\n\nIt then stashes the raw fileInfo JSON as `uploadedFileInfo.SerializedData`, which we also control. The `fileInfo` object is an unencrypted piece of the client state we `POST` directly, so we set its contents ourselves.\n\nThose two values ride along on the `AsyncUploadedFile` as `.FileType` and `.SerializedData`, unused until postback.\n\nOn postback, when the upload result gets read, the `FileUploadedEventArgs.UploadResult` getter runs this:\n\n```\npublic IAsyncUploadResult UploadResult\n{\n    get\n    {\n        AsyncUploadedFile obj = File as AsyncUploadedFile;\n        return (IAsyncUploadResult)SerializationService.Deserialize(\n            type: Type.GetType(obj.FileType),\n            obj: obj.SerializedData);\n    }\n}\n```\n\nThere it is. `Type.GetType(obj.FileType)`. The type is resolved straight from our string. No allowlist, no base-type constraint, no “is this actually one of the handful of result types I expect” check. Whatever we drop into `AsyncUploadTypeName` is the type that gets built. The first time I properly registered what that line was doing, I remember thinking, surely not, surely there is a check somewhere I have missed. There was not. This unguarded type resolution is [**CVE-2026-13181**](https://www.telerik.com/products/aspnet-ajax/documentation/knowledge-base/kb-security-rau-asyncuploadtypename-deserialization-cve-2026-13181).\n\n`UploadResult` is a property on `FileUploadedEventArgs`. The gadget fires the moment the application reads `e.UploadResult` inside its `FileUploaded` event handler, because that getter is what runs the `Type.GetType` and `SerializationService.Deserialize` we just saw. Telerik never reads it on its own. The application’s own server-side code has to read it, which usually happens in the handler when it does something with the file, like validating it or saving it somewhere.\n\nIf a page reads the result server-side, the getter runs and the sink fires. And plenty do, since that is the whole reason to handle an upload on the server. A page that just accepts the file and never looks at the result never runs the getter, and this particular sink stays dormant. So the flaw sits in Telerik, but whether an adversary can reach it comes down to what the page does with the upload afterwards.\n\nIt keeps going. `SerializationService.Deserialize` takes that type and, using reflection (which lets code pick and call a method at runtime from its string name), fires the generic `JavaScriptSerializer` at it:\n\n```\nreturn typeof(JavaScriptSerializer)\n    .GetMethod(\"Deserialize\", new[] { typeof(string) }, null)\n    .MakeGenericMethod(type)\n    .Invoke(serializer, new object[] { obj });\n```\n\nSo look at what we have. We pick the type, via `AsyncUploadTypeName` feeding `Type.GetType`. And we pick the JSON that populates that type’s properties, via `SerializedData`. Arbitrary type, plus arbitrary property values. If you have looked at a .NET deserialisation bug before, you already know where this goes. We need to find a gadget to turn it into arbitrary code execution.\n\nA gadget is an existing class in the runtime whose ordinary behaviour turns dangerous once you control its property values. Controlling the type and its properties, as we now do, is the classic recipe for reaching one. The only question left is which type does something dangerous just by having its properties set.\n\nA well-known gadget for this type of sink is to use `System.Configuration.Install.AssemblyInstaller`. Set the type name to it, and hand it a JSON like `{\"Path\":\"/path/to/<our-planted-file>.dll.tmp\"}`. When the deserialiser sets the object’s `Path` property, the setter calls `Assembly.LoadFrom(Path)` for us, loading an assembly from a path we chose.\n\nBut loading an assembly is not the same as running code in it. A stock .NET assembly just sits there until a method is called, so we need one that runs our code the moment it loads.\n\n## The mixed-mode DLL payload, or how a file load leads to native code execution\n\nThe `AssemblyInstaller` gadget is not a new idea, and neither is this problem. A common idea is to use what is called an IJW (“It Just Works”) mixed-mode DLL to bridge managed code loading into native execution.\n\nManaged code is code that runs under the CLR, the Common Language Runtime that executes .NET and manages its memory. Native code is compiled straight to machine instructions the processor runs on its own.\n\nA mixed-mode DLL is a C++/CLI library compiled as a `/clr` image, and it is both at once. It is a legitimate managed .NET assembly with a proper manifest, so the CLR accepts it when you call `Assembly.LoadFrom`. And it is a native Windows PE, the Windows binary format, with a real `DllMain` entry point, the same as any regular C DLL.\n\nWhen the CLR loads it, the Windows loader calls `DllMain(DLL_PROCESS_ATTACH)` before a single managed method runs. The managed stub exists only to satisfy the CLR’s requirement for a valid assembly manifest. The actual payload logic lives entirely in native code.\n\n**Why 'It Just Works'**\n\nThe name is just how Microsoft playfully refers to these mixed-mode DLLs. The usual way to get managed .NET code and native machine code to cooperate is explicit interop. That means writing P/Invoke signatures that declare each native function you want to call, and handwritten marshalling to convert data across the boundary. C++/CLI skips that, carrying both kinds of code, managed and native, in one DLL where they call each other directly and the compiler wires up the transition. The usual interop ceremony is reduced to one DLL that “just works”.\n\n[Alvaro Muñoz and Oleksandr Mirosh](https://blackhat.com/docs/us-17/thursday/us-17-Munoz-Friday-The-13th-JSON-Attacks-wp.pdf) worked out the mixed-mode DLL gadget chain. [Markus Wulftange of Code White](https://codewhitesec.blogspot.com/2019/02/telerik-revisited.html) found CVE-2019-18935 (the insecure-deserialisation RCE in the same Telerik `RadAsyncUpload` handler) and referenced the technique. [Caleb Gross, then of Bishop Fox,](https://bishopfox.com/research/cve-2019-18935-remote-code-execution-in-telerik-ui) went further and published a full write-up of that same CVE, with a working example built around a connect-back shell.\n\nI was not starting from scratch here, and I want to be clear about that. Everything below builds on their work, not rediscovering it, and it was a much shorter road because they had already walked most of it.\n\nWhat I needed was a bit different from what was already out there. The published payloads use a connect-back shell, which needs the server to reach back out to us, and plenty of IIS boxes have no route to the internet at all. Those payloads also tend to be built for one target at a time. I wanted a single binary I could compile once and fire at any target with no changes.\n\nMy first thought was to write a webshell into the web root, but every IIS install lays its folders out differently, so I could not just hardcode a path and expect it to work anywhere. The payload would have to find the web root by itself. But I am not much of a C++ programmer, so I described what I wanted to an AI and worked through the code with it. That gave me the first payload.\n\n### Finding the web root and dropping a shell: write-webshell\n\nAs we covered earlier, a mixed-mode DLL runs a chunk of our native code the instant it loads, before any of the normal managed code gets going. That is where this payload does all its work, and it is early enough that a lot of the usual conveniences are not set up yet. So the payload sticks to a small set of basic Windows calls and does not go poking around the filesystem while it is still in this fragile state.\n\nBecause this is a mixed-mode image, our code runs inside `DllMain`, and `DllMain` runs while the Windows loader holds the loader lock, as it does for the load of any DLL. Under that lock a lot of ordinary work is unsafe. Anything that pulls in another library, or otherwise re-enters the loader, can deadlock. In our testing (while building the payload) a filesystem lookup here, `GetFileAttributesW` or a directory enumeration, crashed the worker with a CLR exception (`0xe0434352`, the code Windows records for an unhandled .NET exception). So the payload works by exact paths only, writing to a location it already knows in full and never asking the filesystem to look anything up.\n\nTo solve this constraint, it locates the web root without knowing anything about the target in advance. When IIS starts a worker process (`w3wp.exe`), it hands it the path to that worker’s own configuration file on the command line. Our DLL is loaded inside that worker process, so it runs as part of `w3wp.exe` and can read the command line the process was started with. That read is safe even under the loader lock, because the command line lives in the Process Environment Block, a structure already mapped into the process. So `GetCommandLineW` returns it with no disk access and no library loading.\n\nIt pulls the config path out of that command line, opens the file, and finds the `physicalPath` entry inside it. That entry is the web root, the exact path IIS is serving the site from.\n\nThen it drops a self-decrypting `.aspx` there. On disk the file looks like noise. The real code is encrypted and only unpacks itself when the page is first requested (the key is derived from the filename, so each deployment is unique). Once unpacked it is a plain command shell. You POST a command, it runs it through `cmd.exe`, and it sends back the output.\n\nIt has a caveat, though: this needs the app pool account to have write access to the web root. In exchange, the shell sits on disk and survives app pool recycles and reboots until someone removes it. This is the [write-webshell payload](https://github.com/tantosec/telerik_research/tree/main/2026/radasyncupload_padding_oracle/ijw-dll-payload/write-webshell).\n\n### When you can’t touch disk: inmemory-webshell\n\nWrite-webshell gets us onto most targets, but not all. Some locked-down servers do not let the web process write to the web root at all, and there the file-drop step just fails. But we can still load our DLL into the running worker process, and that turns out to be enough, and stealthier (as a bonus). So instead of writing a file, the second payload hooks into the web server process itself and answers requests from memory, with nothing ever touching disk.\n\nThe idea is to splice a small handler into the server’s request pipeline so it sees every incoming request. Doing that to a process that is already up and serving traffic can be a little bit fiddly. There is a timing element, because our code has to wait for the right moment after the DLL loads before it can safely reach into the running application. It also reaches into some private .NET internals to attach itself to request handlers that already exist.\n\nWiring the handler in so it actually fires on live requests was the trickiest part, and I have left the blow-by-blow to the source rather than reproducing it here. The [inmemory-webshell payload](https://github.com/tantosec/telerik_research/tree/main/2026/radasyncupload_padding_oracle/ijw-dll-payload/inmemory-webshell) has the full detail.\n\nOnce it is in place, any URL on the site becomes a shell. A normal request passes straight through untouched. Add a secret HTTP header, and the handler runs the header’s value through `cmd.exe` and returns the output instead. Because nothing is written to disk, this works on the servers where write-webshell cannot, and it leaves far less behind.\n\nThe downside (or upside, depends on your point of view) is that it lives only in the worker’s memory, so an app pool recycle, a crash, or a reboot wipes it out. On a pentest or red team this is actually a good thing, since you only need the shell for the short window of the assessment, and leaving nothing on disk keeps your footprint small.\n\n**A caching gotcha**\n\nBoth payloads depend on `DllMain` firing when my DLL loads into the worker process. During testing that worked the first time and then quietly stopped, which cost me some head-scratching. The first upload would fire `DllMain` and the shell would land, but running the exploit again with the same DLL did nothing. `DllMain` never fired a second time. The reason is a caching behaviour in the .NET loader.\n\nThe CLR caches loaded assemblies by manifest name, and `DllMain(DLL_PROCESS_ATTACH)` only fires once per assembly per process. Uploading the same assembly name twice into the same worker process returns the cached assembly and `DllMain` never runs again. To handle this, our exploit (as we will see later) patches the .NET manifest name in the DLL bytes before each upload so every DLL the server receives looks like a new assembly it has never seen.\n\n**For defenders**\n\nBoth payloads run their commands through `cmd.exe`, so the clearest signal is process lineage. `w3wp.exe` spawning `cmd.exe` almost never happens in normal operation and is worth alerting on outright. The write-webshell drops an encrypted `.aspx` with no recognisable webshell strings at rest, so signature scans miss it. Hunt instead for a new or off-schedule `.aspx` in the web root. And lock the web root so the app pool account cannot write there, which stops the payload as well as flags it. The in-memory variant never touches disk, so file-integrity monitoring will not catch it, but it dies on an app pool recycle, so repeated exploitation attempts become the tell.\n\nThe DLL itself is one more artefact worth hunting, common to both payloads. It is uploaded into the `RadAsyncUpload` temp folder and loaded from there, and it is a mixed-mode file, a native PE that also carries a .NET assembly manifest. A DLL like that under an upload temp path, or `w3wp.exe` loading one out of `App_Data`, is well off the normal path and worth an alert. It also catches the in-memory variant, which leaves nothing else on disk to find.\n\n## An unexpected patch\n\nBy the end of my research week in March I had all the pieces I needed to exploit Telerik, but not enough time to build an end-to-end exploit. A new pipeline of client work was about to start, so I had to put the research on hold. To leave myself something concrete to come back to, I scripted a rough proof-of-concept that only detected both oracles, the one in the handler and the one on the postback. It confirmed both were present in 2026.1.225.\n\nA few weeks had passed, and my next research week arrived in mid-May. It was time to finish the exploit and prepare our report for Progress Software, the company behind Telerik. But before I continued, I noticed that Progress had shipped a new release while I was away, 2026.1.421.\n\nSo I downloaded a demo build of 2026.1.421 and deployed it. Then I ran the detect-only proof-of-concept against it. To my surprise, the handler oracle was silent, but the postback oracle was still alive. So I decompiled the new Telerik version and started digging through the decompiled source to work out what had changed.\n\nHere is why the handler oracle went quiet. The `GetConfiguration` in `AsyncUploadHandler.cs` was now wrapped in a try/catch that rethrows a single uniform `CryptographicException`.\n\n```\ninternal IAsyncUploadConfiguration GetConfiguration(string rawData)\n{\n    try\n    {\n        string[] tokens = rawData.Split('&');\n        string serializedConfig = tokens[0];\n        Type type = Type.GetType(\n            CryptoService.GetService().Decrypt(tokens[1]));\n        CryptoService.GetService().CheckWhitelistTypes(\n            type,\n            ConfigurationManager.AppSettings[Allowed_Custom_MetaData_Types],\n            uploadMetaDataFullName);\n        // both oracle signals came from this decrypt-then-parse call:\n        //   bad padding             -> CryptographicException\n        //   valid padding, bad JSON -> InvalidOperationException\n        var config = (IAsyncUploadConfiguration)\n            SerializationService.Deserialize(serializedConfig, type, true);\n        return config;\n    }\n    catch (Exception)\n    {\n        // 2026.1.421: the catch-all flattens both into one\n        throw new CryptographicException(\n            \"The cryptographic operation has failed!\");\n    }\n}\n```\n\nThe oracle depends on telling two failures apart. Bad padding surfaced as a `CryptographicException`. Valid padding that then decrypted to garbage JSON surfaced as an `InvalidOperationException`. Those are the two answers it reads. After this change both come back as the same `CryptographicException`. One answer for everything.\n\nWhether silencing the oracle was deliberate or a side effect of other work on `RadAsyncUpload`, I can’t tell, and I would rather not speculate. No CVE was raised for it, and there were other changes to the handler in this build.\n\nNow the postback. When we first found the oracle, there were two ways in. One was the handler, `GetConfiguration` decrypting `_serializedConfiguration`. The other was the postback path, the `rau_ClientState` field running through `AsyncUploadClientStateConverter`, reached by `PlayClientState` from `LoadPostData` with no try/catch anywhere on that road. The catch-all in 2026.1.421 landed on `GetConfiguration` and nothing else. The postback path was left untouched, so both exceptions still escape to the wire there, exactly as they did in March. The patch closed one of the two doors and left the other open.\n\nOn top of that, `RadAsyncUpload` also received two additional protections. The first guardrail is a CSRF token in the upload. A CSRF (cross-site request forgery) attack tricks a logged-in user’s browser into sending a request they did not intend. Progress documents this as ordinary [CSRF protection, added in 2026.1.421 and enabled by default](https://www.telerik.com/products/aspnet-ajax/documentation/controls/asyncupload/security/crsf-protection).\n\n2026.1.421 added a `ValidateCsrfToken()` call to the handler’s `EnsureSetup()`. It reads a `CsrfToken` out of the decrypted `AsyncUploadConfiguration` JSON, the same structure that I need to forge via the padding oracle. It then compares that token against an HMAC-SHA256 token that the server stashed in the session when the page rendered. If the two do not match, the handler throws 403 Forbidden before any upload logic runs.\n\nThe second guardrail is a session-bound upload identifier. On 2026.1.421 and later the server prepends the page’s `_pageGUID`, a unique identifier minted fresh on each render, onto the temp filename. The upload’s `UploadID` then has to match the `_pageGUID` registered against that session. Unlike the CSRF token, this one is not inside the config I forge, but it still lands on the attack at the final step. My DLL upload has to carry the matching `UploadID`. And because the GUID is folded into the temp filename, it also fixes the exact path the DLL lands at, the path the gadget then has to load from.\n\nStep back, and neither guardrail is really a crypto control, just ordinary web hardening that on most uploads would sit well clear of a padding-oracle attack. Here, as we have seen, they are both wired into the mechanics I am attacking, and so bear on the chain. Each also ties back to a single live page render, the CSRF check to the token that render produced, the `UploadID` check to its `_pageGUID`.\n\nLuckily, neither can stop our chain, because of where they sit. Both checks live on the handler’s upload path, inside `EnsureSetup`. The oracle probes run through the page postback, which never calls `EnsureSetup`, so the oracle probes side-step both new protections. However, the final DLL upload goes back through the handler, and only that request has to clear both checks.\n\nTake the CSRF check first. We can bypass it without needing to forge its contents. `CsrfToken` is declared fifth in the `AsyncUploadConfiguration`, after `TargetFolder`, `TempTargetFolder`, `MaxFileSize` and `TimeToLive`, but most importantly, before `AllowedFileExtensions`.\n\n```\npublic class AsyncUploadConfiguration\n{\n    public string TargetFolder { get; set; }\n    public string TempTargetFolder { get; set; }\n    public int MaxFileSize { get; set; }\n    public TimeSpan TimeToLive { get; set; }\n    // CsrfToken goes here\n    public string CsrfToken { get; set; } \n    public bool UseApplicationPoolImpersonation { get; set; }\n    // our forged suffix goes here\n    public string[] AllowedFileExtensions { get; set; }\n}\n```\n\nSerialisation follows declaration order, so `CsrfToken` at fifth lands in the prefix of `_serializedConfiguration`, well ahead of the `AllowedFileExtensions` field we forge at the end. That prefix is the exact part of the ciphertext where our forgery already had to carry verbatim from a genuine captured blob. The first block cannot be forged, and everything up to the cut point rides along with it.\n\nThe `CsrfToken` sits inside that carried prefix. Load the target page once more on a live session and the response hands back a `_serializedConfiguration` whose prefix carries a currently-valid `CsrfToken`. Reuse that prefix and the token is genuine, captured seconds ago, not forged. The attack theory is better visualised in the diagram below:\n\nThe same page load also returns the current `_pageGUID`, which is what the `UploadID` check wants. One fresh page fetch answers both guardrails at once.\n\nSwapping that fresh prefix into the forged token is safe, and the reason is the sacrificial block we set up earlier. The forged ciphertext is a genuine captured prefix, then a sacrificial block, then the forged suffix built with backwards-CBC.\n\nThe sacrificial block absorbs the join, so the forged `AllowedFileExtensions` and the gadget after it stay exactly as built. Drop in a prefix from a live page load and it carries a live `CsrfToken` and `_pageGUID` without touching the forgery.\n\nSo on 2026.1.421 the handler oracle is patched, the postback oracle is still alive, and both new upload guardrails can be defeated from a single fresh page load after performing our padding oracle attack to forge our `AllowedFileExtensions`. The theory for a full end-to-end chain is still alive, even with the new protections added in 2026.1.421. It was finally time to implement our final exploit with this new theory.\n\n## The exploit\n\nEverything we learned from the previous sections was folded into one Go tool, `telerik-rau-exploit`, that drives the chain from the first oracle probe to a shell. On 2026.1.421 it runs against the surviving postback oracle only. On 2026.1.225 and earlier versions, it can run against both oracles, the handler and the postback.\n\nHere is the tool running end to end against 2026.1.421 in our lab.\n\n```\n$ ./telerik-rau-exploit \\\n    -target      \"http://telerik.tanto.lab:8088/Telerik.Web.UI.WebResource.axd?type=rau\" \\\n    -page        \"http://telerik.tanto.lab:8088/AsyncUpload/Examples/ImageUploader/DefaultCS.aspx\" \\\n    -oracle-mode postback \\\n    -dll         \"payload/payload.dll\"\n\n[*] Oracle mode: postback (http://telerik.tanto.lab:8088/AsyncUpload/Examples/ImageUploader/DefaultCS.aspx)\n[*] Target: http://telerik.tanto.lab:8088/Telerik.Web.UI.WebResource.axd?type=rau\n[*] Mode: read (will oracle-decrypt TempTargetFolder from token)\n[*] Fetching tokens from http://telerik.tanto.lab:8088/AsyncUpload/Examples/ImageUploader/DefaultCS.aspx\n[*] Client-state field: ctl00_ContentPlaceholder1_RadAsyncUpload1_ClientState\n[*] _pageGUID: 6bbf6d8c-4c91-475f-bf38-d637cae98568\n[*] _serializedConfiguration: 1360 bytes (85 blocks)\n[*] Phase 1: scanning for injection point ...\n[scan] block 84/84  ........ (တတတတတတတတ)  2362 req (31/s)\n    ...\n[scan] block 82/84  xtension (xtension)  5226 req (31/s)\n[+] Phase 1: AllowedFileExtensions = []\n[*] Cut at block 81 (cutInKey=true)\n[*] Phase 2: scanning token for TempTargetFolder inner CT ...\n[scan-ttf] block 39  4=\",\"Max (4=\",\"Max)  49573 req (30/s)\n[*] Phase 2: TempTargetFolder HMAC key is CUSTOM — -tmpfolder injection would be rejected; read mode is required\n[+] Phase 2: inner TempTargetFolder CT located (112 bytes); folder resolved after upload via MetaData IV\n[*] Phase 4: encrypting JSON suffix (5 blocks) ...\n[backwards-CBC] block 5/5  3d66916044d08fb3f2bff9111e04067a  52036 req (30/s)\n    ...\n[backwards-CBC] block 1/5  b85a6cd904c302f32bb94959de2d8c4e  62383 req (30/s)\n[*] Phase 5: assembling forged token ...\n[*] Forged CT: 1408 bytes (88 blocks)\n[*] Phase 6: assembly name patched: payload → dzlieix\n[*] Phase 6: session refreshed, _pageGUID=4c99b85f-2358-47d6-a950-9eea99c03d50\n[*] Phase 6: forged CT prefix swapped with fresh session CsrfToken (oracle mode)\n[*] Phase 6: uploading DLL \"dzlieix.dll\" (36352 bytes) ...\n[upload] chunk 1/2 status=200 body=next\n[upload] chunk 2/2 status=200 body={\"fileInfo\":{\"FileName\":\"dzlieix.dll\",\"ContentType\":\"text/plain\",\"ContentLength\":36352,\"DateJson\":null,\"Index\":0}, \"metaData\":\"E3lnqqN1HI2H...<snip>\" }\n[+] Phase 6: DLL uploaded, server fileInfo: {\"FileName\":\"dzlieix.dll\",\"ContentType\":\"text/plain\",\"ContentLength\":36352,\"DateJson\":null,\"Index\":0}\n[*] Phase 7: recovering CryptoService IV from the MetaData response ({\"TempFileName ...) ...\n[recoverIV] MetaData CT[0]  482abdb588ac2e228b0bf28860a517c5  64570 req (30/s)\n[+] recoverIV: IV = 332a9fb5dcac4b22e60b828826a57ec5 (from MetaData block 0)\n[decrypt-ttf] block 1/7  C:\\Teler (C:\\Teler)  65605 req (30/s)\n    ...\n[decrypt-ttf] block 7/7  emp..... (empਊਊਊਊਊ)  74439 req (30/s)\n[+] Phase 7: TempTargetFolder = \"C:\\\\TelerikLab\\\\v421-livedemos\\\\App_Data\\\\RadUploadTemp\" (recovered via MetaData IV)\n[+] Phase 7: TempFileName derived from pageGUID+dllName: 4c99b85f-2358-47d6-a950-9eea99c03d50_dzlieix.dll.tmp\n[*] Phase 8: server DLL path: C:/TelerikLab/v421-livedemos/App_Data/RadUploadTemp/4c99b85f-2358-47d6-a950-9eea99c03d50_dzlieix.dll.tmp (base: C:\\TelerikLab\\v421-livedemos\\App_Data\\RadUploadTemp)\n[*] Phase 8: forging MetaData via padding oracle (no key needed) ...\n[*] Phase 8: CT=28 blocks, cut=24, suffix=22 blocks to oracle-encrypt\n[backwards-CBC] block 22/22  3fc25517febada5e2eb3fec0ce3d78f1  77001 req (30/s)\n    ...\n[backwards-CBC] block 1/22  8ca74bdc1f1565846b01cb2379bf963b  126971 req (30/s)\n[+] Phase 8: MetaData forged via oracle (768 bytes CT)\n[*] Phase 9: POST forged rau_ClientState → AssemblyInstaller.set_Path(C:/TelerikLab/v421-livedemos/App_Data/RadUploadTemp/4c99b85f-2358-47d6-a950-9eea99c03d50_dzlieix.dll.tmp)\n[submit] POST → HTTP 500\n[submit] HTTP 500 inside get_UploadResult — gadget fired (expected); verifying shell\n[+] Phase 9: Assembly.LoadFrom(C:/TelerikLab/v421-livedemos/App_Data/RadUploadTemp/4c99b85f-2358-47d6-a950-9eea99c03d50_dzlieix.dll.tmp) triggered via e.UploadResult\n[*] Verify: probing http://telerik.tanto.lab:8088/shell.aspx (up to 15s) ...\n[+] Verify: shell response: iis apppool\\telerikpool-v421ld\n```\n\nThe run is a pipeline of numbered phases:\n\n- **Phase 1:** The exploit decrypts`_serializedConfiguration` through the oracle and scans the plaintext for the`AllowedFileExtensions` field. That field is the cut point for the forged tail: a sacrificial block followed by our own`AllowedFileExtensions` .\n- **Phase 2:** It keeps decrypting`_serializedConfiguration` to locate the server’s own`TempTargetFolder` blob. Once that blob is extracted, the exploit checks its HMAC to tell whether the target is vulnerable to CVE-2026-13184. The HMAC key on our lab target is not the default, so we cannot forge a`TempTargetFolder` ; the run stays in read mode and recovers the real folder later, in Phase 7.\n- **Phase 3:** In injection mode, where we plant our own`TempTargetFolder` instead, this phase forges it with backwards-CBC and signs it with[the predictable default HMAC key](https://www.telerik.com/products/aspnet-ajax/documentation/knowledge-base/kb-security-rau-unauth-deserialization-chain-cve-2026-13184) , the fallback tracked as CVE-2026-13184 (it applies when`Telerik.Upload.ConfigurationHashKey` is absent and no explicit`machineKey` is set). It only runs for injection, so it is skipped here and the numbering jumps.\n- **Phase 4:** It forges the`AllowedFileExtensions` override with backwards-CBC, using the last-key-wins suffix and the sacrificial block, so a`.dll` gets past the extension check.\n- **Phase 5:** It assembles the forged token from the genuine captured prefix, the sacrificial block, and the forged suffix, then swaps the prefix for one from a fresh page load so the carried`CsrfToken` and`_pageGUID` are live.\n- **Phase 6:** It uploads the mixed-mode DLL as two chunks so it takes the chunk-assembly path and lands under a name we chose. The server returns the encrypted`MetaData` .\n- **Phase 7:** It recovers the IV by decrypting block 0 of that`MetaData` through the oracle, giving it a crafted preceding block so the oracle has something to cycle, and XORing the result with the known plaintext`{\"TempFi` . Then it strips the HMAC off the inner`TempTargetFolder` blob and decrypts that, XORing its block 0 with the recovered IV, which every blob shares because`CryptoService` derives it from the password. That gives the real path the DLL landed at. From there it works out the`TempFileName` .\n- **Phase 8:** It forges the`MetaData` blob so`AsyncUploadTypeName` is the`System.Configuration.Install.AssemblyInstaller` gadget, with`Path` pointing at the planted DLL.\n- **Phase 9:** It posts the forged`rau_ClientState` to the upload page, which reads`UploadResult` , resolves the type, deserialises, and calls`Assembly.LoadFrom` on the DLL, running its`DllMain` . The DLL finds the web root and writes`shell.aspx` there, and the exploit then calls that webshell with`whoami` , which comes back as`iis apppool\\telerikpool-v421ld` . The`HTTP 500` in the log is expected, not a failure. While building the object, the deserialiser runs`Assembly.LoadFrom` , which fires our`DllMain` . Only then does it try to cast the result to`IAsyncUploadResult` , which`AssemblyInstaller` does not implement, so the request ends in a cast error, long after our code has run.\n\nEnd to end, that run took roughly 127,000 oracle queries at about 30 a second, a little over an hour against the lab. A remote or rate-limited target would stretch that out.\n\nThe `telerik-rau-exploit` is available [on GitHub](https://github.com/tantosec/telerik_research/tree/main/2026/radasyncupload_padding_oracle/telerik-rau-exploit). The exploit was verified across three builds. It ran on 2026.1.225, where the oracles were first confirmed; on 2026.1.421, shown here; and on 2026.2.519, the last vulnerable build before the fixed version 2026.2.708.\n\n## A timeless padding oracle\n\nEverything so far has relied on the server giving its two failures two different error messages. The 2026.1.421 patch already took that away on the handler by flattening both into a single `CryptographicException`.\n\nBut suppose Progress had gone further and normalised the error on every path and every handler. Or suppose the target runs with `customErrors` set to `On`, so the exception never reaches the response body at all and you are left on a generic error page. Would either be enough to close the oracle? Hiding the error, whether by normalising it or by never emitting it, kills the visible signal in the response, but it leaves untouched the thing that produced it.\n\nThe two failures still run different amounts of code. Bad padding fails early, right at the decrypt. Good padding that decrypts to garbage JSON fails later, after the serialiser has been handed the plaintext and has tried to make sense of it. Two different amounts of work, which means two different amounts of time.\n\nYou can normalise every message in the codebase and the server will still spend longer on one case than the other. The error text was only one way to read that difference. The only question left is how you read that subtle difference in processing time, and it turns out you can.\n\nThis is where my colleague Justin Steven picked it up. Reading a sub-millisecond difference in server-side work across the internet is not something you do naively. Ordinary network jitter, the packet-to-packet variation in round-trip time, dwarfs the signal you are chasing.\n\nJustin reached for a Timeless Timing Attack, the [technique from Van Goethem et al. at USENIX Security 2020](https://www.usenix.org/conference/usenixsecurity20/presentation/van-goethem). It sidesteps network jitter by racing two requests against each other in the same packet, so the order they come back in carries the signal, rather than the absolute time on the clock.\n\nThe idea held up. From a box in Melbourne against an instance in us-east-1, roughly 221 ms of round-trip latency between them, Justin raced a bad-padding ciphertext against a good-padding one, over and over. The bad-padding request, taking the shorter code path, came back first consistently enough to be a real, repeatable signal rather than noise. Small but stable, which is what a timing oracle runs on. Amplify a steady edge with enough races per byte and the noise washes out.\n\nThat split is why the work ended up as two CVEs rather than one. The decrypt-versus-parse oracle I have spent this whole post on is **CVE-2026-13182**. The timing variant that walks straight through the error normalisation is [** CVE-2026-13183**](https://www.telerik.com/products/aspnet-ajax/documentation/knowledge-base/kb-security-rau-timing-oracle-cve-2026-13183), credited to Justin and me. The real fix, the move to authenticated encryption I will come to shortly, closes both. Once there is no separate padding step and no separate parse step, there is no second code path left to time.\n\nHow Justin pulled the clean signal out of a jittery WAN link is his to tell, in a follow-up post.\n\n## The fix\n\nI began this post with one question, “Could Telerik be vulnerable to a padding oracle?” It was. But thankfully, 2026.2.708 closes all of this by dropping AES-CBC and encrypting the handler’s blobs with AES-GCM instead.\n\nGCM is authenticated encryption. Every ciphertext carries a short tag, and the server checks that tag before it decrypts anything. Tamper with one byte and the check fails and the request is rejected right there, the same way every time. GCM also has no padding, so there is no PKCS#7 step left to probe. That kills both oracles at once. No padding to validate means no padding oracle. And since tampered data is never decrypted or parsed at all, there is no decrypt-versus-parse split left to tell apart, by error text or by timing.\n\nNone of the later moves, the splice, the sacrificial block, the type-name gadget, the mixed-mode DLL, would have gone anywhere if the first tampered byte had been rejected. It was not, because CBC on its own checks nothing. It decrypts whatever it is handed and reports how the plaintext landed, and everything after that just followed. Authenticated encryption fixes that at the source. Hiding the error message never could, because the timing was always still there.\n\n## Disclosure timeline\n\nWhat follows is the coordinated disclosure timeline with Progress Software, start to finish:\n\n- **22 May 2026.** Reported the findings to Progress via their security disclosure channel.\n- **25 May 2026.** Progress acknowledged receipt of our report.\n- **23 June 2026.** Reported two additional findings.\n- **1 July 2026.** Progress delivered a preview build for patch validation.\n- **3 July 2026.** Sent back my best-effort review of the preview build. Most of what we had reported was closed, but reading the code turned up a handful of new issues, including an unauthenticated XXE in RadLayoutBuilder that I flagged for early attention, and that shipped fixed in 2026.2.708 with everything else.\n- **8 July 2026.** Product release shipped to customers, version 2026.2.708.\n- **22 July 2026.** Telerik published CVEs and the associated KB articles informing the public about the patched vulnerabilities.\n- **7 September 2026.** This post.\n\nA shoutout to Lance McCarthy, our point of contact at Progress who handled the coordinated disclosure. Thanks for making the whole process as smooth as possible and for being so open and supportive of the security community.", "url": "https://wpnews.pro/news/from-padding-oracle-to-shell-unauthenticated-rce-in-telerik-ui-for-asp-net-ajax", "canonical_source": "https://tantosec.com/blog/2026/09/telerik-padding-oracle-to-shell/", "published_at": "2026-09-07 12:49:52+00:00", "updated_at": "2026-09-07 12:56:43.268290+00:00", "lang": "en", "topics": ["ai-research"], "entities": ["Tanto Security", "Telerik UI for ASP.NET AJAX", "Progress Software"], "alternates": {"html": "https://wpnews.pro/news/from-padding-oracle-to-shell-unauthenticated-rce-in-telerik-ui-for-asp-net-ajax", "markdown": "https://wpnews.pro/news/from-padding-oracle-to-shell-unauthenticated-rce-in-telerik-ui-for-asp-net-ajax.md", "text": "https://wpnews.pro/news/from-padding-oracle-to-shell-unauthenticated-rce-in-telerik-ui-for-asp-net-ajax.txt", "jsonld": "https://wpnews.pro/news/from-padding-oracle-to-shell-unauthenticated-rce-in-telerik-ui-for-asp-net-ajax.jsonld"}}