Pentesting web applications that use Blazor server comes with unique challenges, especially without tooling. In this post, we discuss why such challenges exist and provide a
Burp Suite Extension
to address them.
Introduction
During a web application assessment, we encountered ASP.NET’s “Blazor” server for the first time. After attempting a few basic test cases, we realized this was not like any app we’ve tested before. For starters, all the messages transmitted by the application included seemingly random binary characters. You can make out some strings within these messages, but most of the data is not easily readable (Figure 1).
Figure 1 – Example Blazor Message
Additionally, any attempts we made to tamper with or replay requests resulted in an error and the connection being renegotiated (Figure 2).
Figure 2 – Error After Replaying Message
These initial observations proved to be major hindrances for testing a Blazor server application: limited readability and the inability to tamper with data.
Blazor Basics
Before we dive into addressing these obstacles, we first need to cover some of the basics.
Blazor
is a framework that comes with a variety of hosting options: WebAssembly (WASM), Server-Side ASP.NET, and Native Client. For the purposes of this blog post, we’ll focus on server-side Blazor, which integrates with
SignalR
to send browser events and receive pre-rendered page updates. By default, Blazor server applications communicate via WebSockets, though other transports such as Long Polling over HTTP are available as well. Since the end goal here is a Burp Suite extension, we’ll need to use HTTP as the Burp Suite extender APIs previously had little to no WebSockets support.
Note: Since this project began, Portswigger has released newer versions of the Montoya APIs which offer better extension support for WS. Handling Blazor messages over WS is currently under development for the BlazorTrafficProcessor extension.
Forcing a Blazor application to use Long Polling is possible within the negotiation process. When a web browser connects to a Blazor server, the first request is a version negotiation. This specifies the version and transports that will be used by the application.
An example request would look like:
POST /_blazor/negotiate?negotiateVersion=1 HTTP/1.1
Content-Length: 0
[...]
The response is as follows:
HTTP/1.1 200 OK
Content-Length: 316
Content-Type: application/json
[...]
Using a Burp match and replace rule, you can remove the
WebSockets
transport from the response, forcing the browser to fall back to use Long Polling over HTTP. The BlazorTrafficProcessor extension will automatically downgrade any Blazor connections from WS to Long Polling.
After modifying the response to exclude
WebSockets
, the browser console will show a warning to indicate a WebSocket connection failed and Long Polling will be used instead.
Warning: Failed to connect via WebSockets, using the Long Polling fallback transport. This may be due to a VPN or proxy blocking the connection. To trouble shoot this, visit
https://aka.ms/blazor-server-using-fallback-long-polling
.
By forcing the application to use Long Polling, all Blazor traffic will now occur over HTTP which makes Blazor data more accessible to prospective Burp Suite extensions. However, we still don’t know how the Blazor messages are formatted.
We know that Blazor applications expect messages to be in a particular format and order, with any deviations resulting in an error. If we turn to the
documentation
, Microsoft identifies this format as MessagePack and outlines how it can be used in ASP.NET applications.
MessagePack
MessagePack
is another serialization format used to package structured data, like JSON, XML, etc. The key difference with MessagePack is that it is binary in nature, meaning specific bytes are used to indicate the types and length of serialized data.
While Blazor server uses MessagePack, the traffic is specifically formatted according to Blazor’s own Hub Protocol specification. Therefore, generic MessagePack parsers like the Burp Suite MessagePack extension available from the
BApp Store
will not work with Blazor traffic. Take the following BlazorPack message for example.
Figure 3 – Example BlazorPack Message Bytes
If we use the MessagePack extension on this message, the result is just
25
and the rest of the message is ignored. This is because
\x00 - \x7f
represent positive integers in the
MessagePack specification
. The extension sees the first
\x19
, converts it to the decimal value of
25
, and fails to parse the rest of the message. We’ll need a customized MessagePack parser to properly read these messages.
Blazor messages are formatted according to the Hub Protocol
specification
.
(
[Length]
[Body]
)(
[Length]
[Body]
)...
Length
is a variable-size integer representing the size of
Body
, which is the actual message. A variable-size integer is one that can occupy a varying number of bytes, depending on the integer’s value. For example, a small integer may occupy a single byte whereas a larger integer may occupy up to five bytes. This value is crucial for parsing messages accurately and is one of the reasons that our tampering attempts failed. If you modify a string to be a different length than the original message, you’d need to update the
Length
variable-size integer as well. Manually performing these calculations for
every
request you want to tamper with is tedious and inefficient.
For the
Body
field, there are different types of messages supported by Blazor (i.e.,
Invocation
,
StreamInvocation
,
Ping
, etc.). However, while proxying traffic and testing a sample Blazor application, we rarely saw any other types of messages being used other than
Invocation
. This is where we’ll focus for an example message breakdown.
InvocationMessage Analysis
The predominant message type observed while testing Blazor applications is an
InvocationMessage
, used to render page updates and submit data.
Taking a look at
the specification
, we see that there is the following structure:
1
– the message type,
InvocationMessage
types are 1.
Headers
– a map containing string key-value pairs. During testing of a sample Blazor app, this was observed to only be equal to a null/empty map.
InvocationId
– this can either be NIL to indicate a lack of an invocation ID or a string that holds the value. Again, this was always NIL during testing.
Target
– a string representing the backend function to call.
Arguments
– an array of arguments to pass to that backend function.
StreamIds
– an array of strings representing unique stream identifiers. Again, this was always NIL or non-existent in the messages observed whilst testing.
InvocationMessage Example and Byte Breakdown
The following request body was sent after modifying a text input field to be
foobar
:
\xae\x01
–
174
Variable-sized integer corresponding to the
Length
parameter for the Hub Protocol.
\x95
–
5
MessagePack array header representing the length of the whole
InvocationMessage
body (5).
\x01
–
1
First element in the
InvocationMessage
array corresponding to the message type (InvocationMessage = 1).
\x80
–
0
Second element in the
InvocationMessage
array indicating a map of length 0.
\xc0
–
NIL
Third element in the
InvocationMessage
array corresponding to the
InvocationID
(NIL)
\xb7
–
23
MessagePack header corresponding to the length of the fourth element in the
InvocationMessage
(23 bytes).
BeginInvokeDotNetFromJS
Fourth element in the
InvocationMessage
array representing the
Target
value (BeginInvokeDotNetFromJS).
\x95
–
5
Fifth element in the
InvocationMessage
array corresponding to MessagePack array header length (5 bytes).
\xa1
–
1
MessagePack array header representing the size in bytes for the first element in the
Arguments
array (1 byte).
1
Raw string representing the value of the first element in the
Arguments
array (1).
\xc0
–
NIL
Second element in the
Arguments
array (NIL).
\xb2
–
18
MessagePack header corresponding to the length in bytes of the third element in the
Arguments
array (18 bytes).
DispatchEventAsync
Raw string representing the value of the third element in the
Arguments
array (DispatchEventAsync).
\x01
–
1
MessagePack integer representing the value of the fourth element in the
Arguments
array (1).
\xd9\x78
–
120
MessagePack header corresponding to the length in bytes of the fifth element in the
Arguments
array (120 bytes).
[{“eventHandlerId”:4,…
MessagePack raw string corresponding to the value of the fifth element in the
Arguments
array ([{“eventHandlerId”:4,…)
There are no
StreamIds
in this message.
Each string in a Blazor message is preceded by a MessagePack string header to indicate the size of the string. This adds another layer of complexity to manually tampering with messages; not only do you have to update the
Length
variable-size integer, but you’d have to update the size headers for each tampered string as well.
BlazorTrafficProcessor (BTP) Burp Suite Extension
In summary, the two biggest obstacles when it comes to testing Blazor applications are:
Readability: the serialized messages can be difficult to read by themselves. As shown above, this is due to bytes in MessagePack representing specific values or types, resulting in seemingly random data to the naked eye.
While some data may be easily readable (i.e., the JSON array above containing user input), any attempt to modify these values will require modification to the preceding size bytes as well. This brings us to our next obstacle:
Tampering Difficulties: because the messages are serialized according to the Hub Protocol specification, tampering with a value without accounting for the respective size bytes will result in a malformed MessagePack stream that the server can’t read. When this happens, the Blazor application will often error out and renegotiate your connection.
The BlazorTrafficProcessor (BTP) Burp Suite extension addresses both of these issues by providing testers with the ability to convert request bodies from BlazorPack to JSON and vice versa. JSON is more common in web applications and is easier to read due to being text-based rather than binary-based. By providing both deserialization and serialization functionality, testers can leverage this extension to capture and deserialize a request, modify the desired parameter(s), then serialize back to BlazorPack and submit the request.
The initial version of the extension has two primary features. The first of which is a BTP Request/Response tab that appears on every in-scope BlazorPack message with the ability to convert BlazorPack to JSON. The second is a BTP Burp Suite tab that provides a sandbox for users to serialize/deserialize BlazorPack messages at-will. Any requests/responses that contain BlazorPack serialized data will be highlighted in the HTTP History tab as Cyan.
Reading
The BTP tab will appear on every in-scope request and response in your Burp history that is serialized using BlazorPack.
Figure 4 – BTP Request Editor Tab
This tab simply displays the BlazorPack body converted into a JSON array of message objects.
Figure 5 – BTP Deserialization Example
Tampering
Within Blazor server applications, the
JSInvokable
attribute allows developers to expose DotNet functions to the client-side JavaScript. Take the following snippet for example:
[JSInvokable("CallMe")]
public static void hiddenFunc(String var)
Console.WriteLine("Hidden function called!");
Console.WriteLine(var);
This is a simple example that just logs some user input to the console, but there are no web pages that allow the user to call this function. Instead, the DotNet.invokeMethodAsync JavaScript function can be used (as outlined here).
Figure 6 – Invoke Hidden Function from Browser Console
After the function has been called from the browser, Burp captured the serialized message that was sent:
Figure 7 – Hidden Function Invocation Request
Sending the above payload to repeater and using the extension to deserialize yields the following:
Figure 8 – Deserialized Invocation Request
The input of foo is contained within a 1-element array, so let’s try tampering and replaying the request. For demo purposes, we’ll change the JSON payload in the BTP tab to include a newline to see if newlines get written to the console output:
Clicking on the Raw tab to serialize the data back into MessagePack yields:
Figure 9 – Reserialized Request with Custom Input
Upon sending the request, the response is a simple 200 OK:
HTTP/1.1 200 OK Content-Length: 0 Connection: close Content-Type: text/plain Server: Kestrel
Important note about Blazor server Long Polling: sending any type of invocation POST request will not return data in the response. Instead, Long Polling keeps an open GET request that receives server updates as they become available. As such, if you send a request with malformed input that causes an error, you won’t see that error unless you look at subsequent GET requests.
Checking the console log and we see that the payload did in fact create two distinct lines with our input:
Figure 10 – Application Logs with Inserted Payload
Ad-Hoc Serialization & Deserialization
The BTP extension also includes a new tab added to Burp Suite, aptly named BTP. While you can copy and paste data into this tab, it is recommended to use the “Send body to BTP” right-click menu option (Figure 11) to ensure the whole message is copied appropriately. This feature applies to both requests and responses.
Figure 11 – Send to BTP Menu Item
The message will then appear in the BTP tab as shown below:
Figure 12 – BTP Tab Deserialization Example
This tab can be used for ad-hoc Blazor conversions with the following steps:
Select the conversion type (JSON->Blazor or Blazor-JSON)
Enter the message in the left-hand editor
Click Serialize/Deserialize Button
Results are shown in the right-hand editor
What are these “Target” values I keep seeing?
While testing Blazor server applications, you’re likely to run into various Target values such as: BeginInvokeDotNetFromJS, OnRenderCompleted, OnLocationChanged, etc. These functions themselves are not specific to the app that you’re testing. Rather, they are built-in to ASP.NET Core as part of ComponentHub and are used to facilitate communications between the frontend JavaScript and the backend .NET application. For example, OnLocationChanged is used to navigate between pages in the web application. The implementation of these functions can be found in the “aspnetcore” repository on GitHub here.
It is important to distinguish between what’s native and what’s custom in Blazor server applications, since your goal will likely be to find vulnerabilities in the app that you’re testing, not in Blazor itself. As such, focus your testing efforts on fields that contain your input (i.e., arguments to BeginInvokeDotNetFromJS) as opposed to normal Blazor operations (i.e., OnRenderCompleted or EndInvokeJSFromDotNet). With that said, security researchers can also utilize the BTP extension to test Blazor server itself since tampering inputs is now easier.
The following screenshot (Figure 13) displays a deserialized request that updates the value of a text box in the application to be asdf. The highlighted values are potential areas for tampering. The rest of the arguments are native to Blazor and tampering may cause a Blazor error, which would renegotiate your connection.
Figure 13 – Tamperable Values vs. Native Values
Consider a case where only client-side JavaScript is used to validate this input field. We could intercept the request, deserialize the message into JSON, enter a value that would otherwise fail validation, reserialize, and fire the request to bypass client-side input validation.
Further Research
Fuzzing
While this extension achieves the base proof-of-concept for a tool to process Blazor server traffic, a feature we believe would be valuable to testers is the ability to fuzz serialized inputs (similar to a Burp Suite intruder attack). However, application data is never returned in response to a POST request when using Long Polling. Instead, the client-side JavaScript polls the server with a GET request and passes an epoch timestamp. The response to these polling requests will contain app data and render updates, making it difficult to deduce which request caused an error or a specific response. For example, if you send a POST request that causes an error, the error would be returned to the next GET (“poll”) request. Sending multiple error-inducing requests could cause the application to return several errors in one response body or spread out across subsequent polling responses. With no clear way to map one input to one response/error, we decided it best to leave out fuzzing for the first iteration of this tool. We are still looking for effective ways to fuzz Blazor applications and a fuzzing feature is on the roadmap for the BlazorTrafficProcessor extension.
WebSockets
BTP is fully dependent on the ability to downgrade a Blazor server connection from WebSockets to Long Polling over HTTP. ASP.NET developers have considered removing Long Polling support altogether, which could render BTP unusable. Ultimately Long Polling was left in the framework due to “unforeseen effects” of removing it, though it remains possible that support will be discontinued in the future.
For BTP to be future proof, it will need WebSockets support eventually. However, Blazor messages can be distributed across different WebSocket frames with a max size of 4096 bytes per frame. In other words, a Blazor message could start in one WebSocket frame and end in another. With limited support for WebSockets in Burp Suite’s Montoya APIs, it was not plausible to include Blazor WebSocket parsing for the initial version of BTP. However, this feature is currently under development as there are new iterations of Burp Suite’s Montoya APIs being released frequently.
This material has been prepared for informational purposes only and should not be relied on for any other purpose. You should consult with your own professional advisors or IT specialists before implementing any recommendation or following the guidance provided herein. Further, the information provided and the statements expressed are not intended to address the circumstances of any particular individual or entity. Although we endeavor to provide accurate and timely information and use sources that we consider reliable, there can be no guarantee that such information is accurate as of the date it is received or that it will continue to be accurate in the future.