TL;DR:
Examining some error messages received from the Starbucks News' CMS led to the discovery of the source code of a third-party plugin. This source code revealed that the plugin accepts a POST parameter 'group-id'
unsanitized and put directly inside an SQL query, thus exposing the server to blind injection vulnerability.
While enjoing an ordinary Friday evening, I wanted to check out the latest HackerOne activities to see how it was going overall. In the updates timeline I saw there was a bunch of closed reports for Starbucks, which I then learned it opened its public vulnerability program from relatively short time. Nice!
I was curious to see if it could be some interesting area to explore for some fresh bugs, so I decided to give it a try.
Reading the policy rules I noticed that the domains valid for bounties were restricted to a very few, but I did not care and explored all the reachable targets regardless, one of the first being news.starbucks.com
.
I opened one of the first articles I got in the homepage, and saw that the article page featured a button to download all the multimedia assets present (pictures, videos, ...) and in various formats (high quality, low quality, ...).
All of these download links are structured in a particular way: each URL included two parameters, ACT
and lv
.
The second parameter appeared to contain some information in an untrivial encoding, but the first parameter name ACT was suggesting that it might be interesting to make some test.
Changing that value from the same URL gave particular interesting responses from the website, for example changing from ACT=105
to ACT=106
redirected to the local path of the related image:
It was interesting but not much meaningful, so I decided to investigate more. I fired up the trusty Burp and launched an Intruder attack session with ACT parameter as target position, to see what could have been all the possible effects by varying that parameter.
When the attack session terminated, I looked up the results and noticed various things:
- Most responses were a standard error message, for it wasn't used a valid parameter value; conversely, all the other different responses came from groups of consecutive values for ACT in the request (e.g. 104/105/106/107, 71/72/73, etc);
- one of the responses was some javascript code, and in the comments it is referenced "ExpressionEngine - by EllisLab", which I then discovered to be a CMS similar to Wordpress;
- Almost all responses were html error pages with different details about the error, but except for a few values, in particular
ACT=55
andACT=56
:
The response obtained from the second one was definitely inviting to check for more clues about what was happening behind that "ACT" parameter.
I wanted to know if those messages were somewhat standard, i.e. coming from a common platform/tool/whatever, so I simply googled for those two specific messages in hope to find something relevant.
Luckily enough, only three results showed up pointing to searchcode.com, which two of them were related to a PHP file named mod.zoo_flexible_admin.php
.
Inside this file there is a bunch of code related to a plugin named NDG Flexible Admin for Expression Engine, so it appeared to be PHP code related to the same CMS used on the website!
And indeed, there were the two lines that I was looking for:
It then apparently meant that the "ACT" parameter operates as a sort of handler that served to call specific functions inside the CMS implementation. So a request for ACT=55
would call the function ajax_save_tree()
and a request for ACT=56
would call the function ajax_remove_tree()
.
At this moment I was very interested to see what operations were involved behind the output messages, especially if they were related to interactions with the database and what was possible to do with them.
By reviewing the code inside the PHP source I found, I noticed that inside the function there were some interesting rows. Let's see again the code related to ajax_save_tree()
:
Holy Source, Batman! It appeared that inside the function it is present an unsafe hard-coded query. Maybe due to the fact that this is a third-party plugin code made by a not-so-aware author.
But it wasn't all so easy. The row 140 just takes the value for $group_id
variable from the POST parameter of the same name, which it is directly used at the row 150 inside the query, but the problem is that at row 145 it is present an IF statement which promptly redirects the execution flow to the echo
instruction at row 147.
Inspecting the condition at the IF instruction, it appeared that for it to evaluate to True, the POST request must be crafted to include the jsontree
parameter; moreover, that parameter must be used as an argument to get_tree_html()
function which must return a non-empty string.
The get_tree_html()
function (defined at row 59, not pictured here) simply transforms a json tree structure into an html list of links, so for the function to output something it was sufficient to give it as input the simplest non-empty json structure, like {x:1}
.
Wrapping all up: to successfully jump to row 150 the URL must be include at least these parameters: ACT=55
, jsontree={whatever:whatever}
and group_id=whatever
(with some trial and error I discovered that even site_id=whatever
was necessary for all to work). Assumed that, any content inside the group_id
value would be included inside the SQL query and executed server side.
Confident of all these reasoning, I finally created the POST request:
POST / HTTP/1.1
Host: news.starbucks.com
Connection: close
Content-Length: 81
Cache-Control: max-age=0
Origin: https://news.starbucks.com
Content-Type: application/x-www-form-urlencoded
ACT=55&jsontree={"x":1}&site_id=1&group_id=1'-IF(1=2,SLEEP(1),0) AND group_id='1
and fed it all into Burp.
As shown, to check for the custom SQL query to be executed or not, I decided to insert a SLEEP
command in some way (quite unelegant in fact). Since the response to this query will be empty, it will be a blind technique, so the evidence will only rely upon response times to the HTTP requests.
It was now time to verify if it all would have work as expected. A couple of clicks and...
The notable difference of time between the SLEEP time set and the actual amount relies on how the backend managed the query, maybe because the implentation was unoptimized and interrogated the database more than once. Still, it worked like a charm.
In fact, now I could have inserted any SQL code I wanted to. First and foremost, the mandatory DBMS major number version check (given it was already verified to be present MySQL under the hood):
The next step was to deploy the big weaponry, such as sqlmap.
After a brief tweaking in the command line, I managed to set up a first mild attack as the enumeration of the databases present.
Coming at this point I had to stop having fugoing any further, and issue a report through the disclosure process.
Timeline:
14/1/2017 05:52: First submission through HackerOne, report #198292
26/1/2017 02:37: Report closed as Resolved, no bounty awarded due to out-of scope target
24/2/2017 20:47: Report publicly disclosed