Archived article from 13 December 2013. Its content reflects the state of things at the time. Reconstructed from the web archive of secalert.net - view the original.
Summary. eBay's search on sea.ebay.com ran query values through a server-side keyword filter that was built on preg_replace() with the /e modifier. That modifier evaluates the replacement as PHP, so a crafted value reached eval() and executed in the context of the eBay application. I found it because my test handle secalert came back as sec: the filter had quietly stripped the alert substring, which told me the parameter was being processed, not just matched. Passing the query as an array and smuggling a PHP complex-curly expression into one element turned that processing into remote code execution. Reported through responsible disclosure in December 2013 and fixed by eBay within days.
Background
Last week I was looking for security issues on eBay websites. I ended up on the search on sea.ebay.com, the same subdomain where I had found an exploitable SQL injection the year before. A legitimate search request looked like this:
https://sea.ebay.com/search/?q=david&catidd=1The clue: a filter that eats "alert"
I always test with my own handle, secalert, so my first request was this:
https://sea.ebay.com/search/?q=secalert&catidd=1I expected a product listing for "secalert", or perhaps a default list such as recent searches. Instead, a server-side filter had removed the alert substring and returned products that matched the string sec. That small detail is the whole reason I found anything at all. Testing with sec on its own would have returned an ordinary result and revealed nothing. It had to be secalert, because the alert part was what the filter reacted to, and seeing it disappear told me the value was being processed on the server rather than simply searched for.
So I started to map how the parameter behaved. First a plain value:
https://sea.ebay.com/search/?q=david&catidd=1This returned products matching "david". Then I supplied the parameter twice:
https://sea.ebay.com/search/?q=david&q=dave&catidd=1 -> results for "dave"
https://sea.ebay.com/search/?q=dave&q=david&catidd=1 -> results for "david"The last value always won. I then confirmed the filter was tied to the value, not the position:
https://sea.ebay.com/search/?q=secalert&david&catidd=1
https://sea.ebay.com/search/?q=david&q=secalert&catidd=1 -> "alert" stripped againShaping the input as an array
A direct attempt with the complex-curly payload as a plain value did nothing:
https://sea.ebay.com/search/?q={${phpinfo()}}&catidd=1That told me the value was not sitting in a double-quoted string on its own, so I looked at how the search handled an array instead and switched the parameter to array notation.
https://sea.ebay.com/search/?q[]=david&catidd=1 -> results for "david"
https://sea.ebay.com/search/?q[]=dave&q[]=david&catidd=1 -> results for "david"Then with explicit indices:
https://sea.ebay.com/search/?q[0]=dave&q[1]=david&catidd=1 -> results for "david"
https://sea.ebay.com/search/?q[0]=david&q[1]=secalert&catidd=1 -> "alert" stripped, results for "david"The array elements were still passed through the same keyword filter, and that filter was the interesting part. A filter that merely searched for a blocked word would strip it and move on. This one visibly rewrote the value, which is the behaviour of a search-and-replace.
Root cause: preg_replace() with the /e modifier
eBay never published the code, so the exact expression is an inference, but the behaviour is only consistent with one PHP construct: preg_replace() used with the /e modifier (PREG_REPLACE_EVAL). With that modifier, PHP does not treat the replacement as a plain string. It first applies double-quoted-string processing to it and then runs the result through eval(). The modifier was so dangerous that PHP removed it entirely in PHP 7.0.
Two things follow from that. First, a keyword filter written as a preg_replace() that rewrites matches like alert explains exactly what I was seeing: the value was being substituted, not just matched. Second, and far more important, the replacement ends up inside a double-quoted-string evaluation. In a double-quoted PHP string, the complex curly syntax {${ expression }} executes the expression. So any value that reaches that evaluated replacement context, and contains {${ ... }}, runs arbitrary PHP.
The array notation was the means to deliver the payload into that processing cleanly, past the naive handling of a single string value. James Kettle later used this exact bug as a case study in his Black Hat Europe 2016 research "Backslash Powered Scanning", describing the key step as passing a second q parameter to create a malicious array server-side.
Proof of concept
Putting it together: an array whose second element carries the complex-curly payload.
https://sea.ebay.com/search/?q[0]=david&q[1]=sec{${phpinfo()}}&catidd=1
https://sea.ebay.com/search/?q[0]=david&q[1]=sec{${phpcredits()}}&catidd=1
https://sea.ebay.com/search/?q[0]=david&q[1]=sec{${ini_get_all()}}&catidd=1Both executed in the context of the eBay application. phpinfo() and ini_get_all() are harmless read-only calls, which was enough to prove arbitrary code execution to eBay's security team without touching anything on the system.
A correction to the original explanation
My original 2013 write-up explained this as a PHP "type juggling" issue, with the idea that because PHP strings are byte arrays, accessing a string through array brackets tricks the parser into evaluating code on its own. That explanation is wrong. Array access does not evaluate anything by itself, a point Gynvael Coldwind made in a careful analysis, concluding that there had to be an eval somewhere in the path. He was right, and the /e modifier is precisely that eval. The array was only the vehicle that carried the payload into the vulnerable preg_replace(). The code execution came from the /e evaluation, not from any property of arrays or strings.
Impact
Arbitrary PHP could be evaluated in the context of the eBay website. I had no intention of causing harm and stopped at read-only functions, but a malicious actor could have gone much further, for example running operating-system commands and compromising the web server.
Remediation
Never use the /e modifier. Where dynamic replacement logic is genuinely needed, use preg_replace_callback(), which passes matches to a normal function instead of evaluating a string. More generally, request-derived data must never reach eval() or any construct that evaluates strings as code. Removing /e in PHP 7.0 closed this class of bug at the language level.
Timeline
December 6th 2013: vulnerability discovered and reported to eBay (securityresearch@eBay.com)
December 6th 2013: recorded a screencast as proof of concept
December 9th 2013: eBay solved the issue and deployed it on production
December 13th 2013: this write-up publishedReferences
- James Kettle, Backslash Powered Scanning (PortSwigger Research, Black Hat EU 2016), which uses this bug as a case study.
- Gynvael Coldwind, eBay RCE analysis: the wrong question mark.
- PHP manual: PCRE pattern modifiers (the removed
emodifier). - Stefan Esser (@i0n1c): PHP Security Crash Course, Include and Eval (referenced in the original 2013 write-up).