we need to validate what characters you expect in the HTTP_HOST

from SA http://drupal.org/node/324824

original report:

From: "Anthony Ferrara"

"
Hello. While at Drupal Camp, I found and confirmed a vulnerability in
Drupal 6 (and 7). Here are the details

In /includes/bootstrap.inc, the function conf_path is defined as such:

function conf_path($require_settings = TRUE, $reset = FALSE) {
static $conf = '';

if ($conf && !$reset) {
return $conf;
}

$confdir = 'sites';
$uri = explode('/', $_SERVER['SCRIPT_NAME'] ?
$_SERVER['SCRIPT_NAME'] : $_SERVER['SCRIPT_FILENAME']);
$server = explode('.', implode('.', array_reverse(explode(':',
rtrim($_SERVER['HTTP_HOST'], '.')))));
for ($i = count($uri) - 1; $i > 0; $i--) {
for ($j = count($server); $j > 0; $j--) {
$dir = implode('.', array_slice($server, -$j)) .
implode('.', array_slice($uri, 0, $i));
if (file_exists("$confdir/$dir/settings.php") ||
(!$require_settings && file_exists("$confdir/$dir"))) {
$conf = "$confdir/$dir";
return $conf;
}
}
}
$conf = "$confdir/default";
return $conf;
}

Notice that it's using both HTTP_HOST and SCRIPT_NAME (and
SCRIPT_FILENAME) are used unvalidated. This provides an injection in
both. Take the following case.

There are 2 Drupal installs on the same server (note, not same
account). The server uses IP based virtual hosts (at least for the
"source" of the attack). As an outsider, all I care, is that the 2
sites are on the same server, and that I can find the absolute path to
the 2nd one (think error messages as a possible source of this
information).

So, at this point I have the following data
Site1: IP 192.168.1.101
Site2: absolute path /home/othersite/public_html

Now, consider the following php code:
$f = fsockopen('192.168.1.101:80');
$headers = 'GET index.php HTTP/1.1
HOST: ../../../../../../../home/othersite/public_html/sites';

Now, since it's IP based virtual hosting, the drupal site is still hit.
The host header ($_SERVER['HTTP_HOST']) now contains the sting
'../../../../../../../home/othersite/public_html/sites';

This line is where the vulnerability is:
$server = explode('.', implode('.', array_reverse(explode(':',
rtrim($_SERVER['HTTP_HOST'], '.')))));

Let's step through the variable at each stage
1: $_SERVER['HTTP_HOST'] =
'../../../../../../../home/othersite/public_html/sites';
2. rtrim(1) = '../../../../../../../home/othersite/public_html/sites';
3. explode(':', 2) =
array('../../../../../../../home/othersite/public_html/sites');
4. implode('.', 3) =
'../../../../../../../home/othersite/public_html/sites';
5. explode('.', 4) = array('', '', '/', '', '', ......
'/home/othersite/public_html/sites');
6. $server = array('', '', '/', '', '', ......
'/home/othersite/public_html/sites');

Next, the function loops over the array, and does a:
$dir = implode('.', array_slice($server, -$j)) . implode('.',
array_slice($uri, 0, $i));

So, what does that first implode do? Simple: restores the previous
path... So if $uri is empty (it happens cause of the clause in the for
($i = count($uri) - 1; $i > 0; $i--) {), the raw path gets ported to
$dir

So at that point, $dir =
'../../../../../../../home/othersite/public_html/sites';

When it hits the piont:
if (file_exists("$confdir/$dir/settings.php") ||
(!$require_settings && file_exists("$confdir/$dir"))) {

remember tha earlier: $confdir = 'sites'

so that first part of the if breaks down to
if(file_exists('sites/../../../../../../../home/othersite/public_html/sites/settings.php'));

remember, that the file DOES exist (because it's site 2)...

So now, I have site1's filebase running on site2's database. Now,
picture site1 is running a module that I can use to inject SQL, or php,
or do bad things, etc...

The fix:
validate what characters you expect in the HTTP_HOST...
So
$host = preg_replace('[^a-z0-9\.-]', '', $_SERVER['HTTP_HOST']);

Here's the patch that was applied to 6.x. But we mght change it to be like what's in install.php

Comments

pwolanin’s picture

also pointed out by Anthony Ferrara, we should also blacklist \ as well as / (this part will need backport).

pwolanin’s picture

Status: Patch (to be ported) » Needs review
StatusFileSize
new952 bytes

thet 2x calls to strpos is still much faster than 1 call to preg_match() (approx 4x faster on my machine).

webchick’s picture

Version: 7.x-dev » 6.x-dev
Status: Needs review » Patch (to be ported)

Thanks, committed to HEAD. Looks like the other part needs porting back to 6.x now.

pwolanin’s picture

Version: 6.x-dev » 7.x-dev

setting back to 7.x - I think we should actually be doing this check in conf_init() before the calls to conf_path(). Also, in conf_init(), there is already a call to preg_replace() which is not safe for ipv6 addresses. So, I propose we move that preg_replace() to before the calls to conf_path() and revise the pattern to be mostly or totally ipv6 safe, and then we can omit these strpos calls all together.

pwolanin’s picture

Status: Patch (to be ported) » Needs review

DamZ posted this on on the original sec issue:

For reference, here is a review of literature of the definition of the "host" part of HTTP URLs.

RFC 3986: Uniform Resource Identifier (URI): Generic Syntax defines the "host" part as:

host        = IP-literal / IPv4address / reg-name

Where:

      IP-literal = "[" ( IPv6address / IPvFuture  ) "]"
      IPvFuture  = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" )

      IPv6address =                            6( h16 ":" ) ls32
                  /                       "::" 5( h16 ":" ) ls32
                  / [               h16 ] "::" 4( h16 ":" ) ls32
                  / [ *1( h16 ":" ) h16 ] "::" 3( h16 ":" ) ls32
                  / [ *2( h16 ":" ) h16 ] "::" 2( h16 ":" ) ls32
                  / [ *3( h16 ":" ) h16 ] "::"    h16 ":"   ls32
                  / [ *4( h16 ":" ) h16 ] "::"              ls32
                  / [ *5( h16 ":" ) h16 ] "::"              h16
                  / [ *6( h16 ":" ) h16 ] "::"

      ls32        = ( h16 ":" h16 ) / IPv4address
                  ; least-significant 32 bits of address

      h16         = 1*4HEXDIG
                  ; 16 bits of address represented in hexadecimal

      IPv4address = dec-octet "." dec-octet "." dec-octet "." dec-octet
      reg-name    = *( unreserved / pct-encoded / sub-delims )

And, for that last part:

   unreserved    = ALPHA / DIGIT / "-" / "." / "_" / "~"
   pct-encoded   = "%" HEXDIG HEXDIG
   sub-delims    = "!" / "$" / "&" / "'" / "(" / ")"
                 / "*" / "+" / "," / ";" / "="

The HTTP 1.1 Specification (RFC 2616), doesn't add any particular restriction.

pwolanin’s picture

StatusFileSize
new2.73 KB

here's a new patch for consideration.

separated out the validation into a separate function so that it's potentially testable via a simple unit test.

pwolanin’s picture

StatusFileSize
new4.74 KB

now with tests...

pwolanin’s picture

Gabor suggested that we should be using the UTF-8 safe strtolower function, http://api.drupal.org/api/function/drupal_strtolower/7

However, this function is not yet available at this point in bootstrap, so trying that causes a fatal error. Perhaps we can just use mb_strtolower()?

gábor hojtsy’s picture

Is mb_*() available at all times with PHP 5.x?

gábor hojtsy’s picture

Hum, actually IDNA defines a backwards compatible mapping to ASCII chars for international domain names, so the server should end up handling ASCII based hosts. Should not require handling UTF-8, but it might make sense to double check on a testing server.

pwolanin’s picture

note, the reason for lowercasing here is that if you are using a conf directory like sites/example.com, Drupal is likely to redirect you to install.php if you try to visit the site (e.g. using curl) with an address like Example.com or EXAMPLE.COM. Thus, we should force the host name to lowercase before looking for the conf directory. I don't see how this can used as an attack, but seems like a sensible rule to enforce lowercase.

(cross-ref to dup issue: http://drupal.org/node/325778)

damien tournoud’s picture

Let's do a positive check:

  HOSTNAME = (?:[a-zA-Z0-9-:_~%!$&\'\(\)\*\+,;=]*\.?)+
  IPv4 = [0-9]{1,3}(\.[0-9]{1,3}){3}
  IPv6 = [0-9a-fA-F]{1,4}(\:[0-9a-fA-F]{1,4}){7}

and

  HTTP_HOST = (HOSTNAME|IPv4|\[IPv6\])
pwolanin’s picture

StatusFileSize
new4.51 KB

per discussion with chx and DamZ, we should be safe with "a-zA-Z-" (as per RFC 952), plus "_" as per (virtually RFC 2181)

ipv6 loopback address with a port may have HTTP_HOST [::1]:8888, and in general may have [] So here's a simplified version that does a preg_match that allows that.

pwolanin’s picture

StatusFileSize
new4.66 KB

DamZ prefers to block .. too.

lilou’s picture

typo in bootstrap.test :

t('Get the IP address from the current visitor from the server variables, check hostname vlidation.'),

pwolanin’s picture

StatusFileSize
new4.66 KB
damien tournoud’s picture

Status: Needs review » Reviewed & tested by the community

Even if we spent *way* too much time on this, I think we came up with a not so ugly solution. Good enough for me :)

dries’s picture

Status: Reviewed & tested by the community » Fixed

Reviewed and this looks good, so committed to CVS HEAD. Thanks for the high-quality issue/comments. Great work.

pwolanin’s picture

Version: 7.x-dev » 6.x-dev
Status: Fixed » Patch (to be ported)

this better version should be backported too.

pwolanin’s picture

Status: Patch (to be ported) » Needs review
StatusFileSize
new2.55 KB

Here's a backport for testing.

gábor hojtsy’s picture

Looks good. Would use some more testing on Drupal 6 to make sure it is the right fix for this version as well.

pwolanin’s picture

patch still applies cleanly. @Gabor - what sort of testing d you think is needed?

pwolanin’s picture

StatusFileSize
new2.45 KB

6.x patch still applies cleanly. What kind of additional testing (if any) is required?

Here's a version for 5.x also.

gábor hojtsy’s picture

Version: 6.x-dev » 5.x-dev

Seeing that it was committed with a working test for D7, I've committed this to Drupal 6 as well. It is a pretty well abstracted API function change and is well tested on D7. Moving to Drupal 5.

drumm’s picture

Status: Needs review » Fixed

Committed to 5.x.

gábor hojtsy’s picture

Status: Fixed » Needs work

Seems like the answer to #22 is that tests were missing to ensure it works with a missing HTTP_HOST. Please add such a test on 7.x, once we get the fix in. Unfortunately the code is broken for clients not sending over a HTTP_HOST: http://drupal.org/node/346285

drumm’s picture

Status: Needs work » Fixed

I am confused about why this issue got re-opened. Is there anything that needs to be done here that #346285: Drupal 5.14 & Drupal 6.8 will not load on clients that do not transmit HTTP_HOST does not cover?

gábor hojtsy’s picture

I've reopened in the expectation to have a 7.x test committed here, but if the other issue covers that as well, then fine for me to keep closed.

Status: Fixed » Closed (fixed)

Automatically closed -- issue fixed for two weeks with no activity.

mcarbone’s picture

So I don't completely understand the thread above, but I do know that this fix has broken some command-line Drupal scripts I have written. Namely, the bootstrap fails because $_SERVER['HTTP_HOST'] is an empty string. I can fix this by adding one in of course, but I wonder if the drupal_valid_http_host function should accept an empty host variable, or if you all think there should be another way to indicate a command-line bootstrap so that the validation is skipped.

mcarbone’s picture

Status: Closed (fixed) » Active

Switched back to active. Let me know if I should start a new thread.

drumm’s picture

Status: Active » Closed (fixed)

Both drupal.sh and Drush set $_SERVER['HTTP_HOST'], so I think setting it is the best practice.

pwolanin’s picture

tagging