[Case study] Phát hiện 10 lỗ hổng SQL Injection qua phân tích mã nguồn

24/10/2025
Chào mọi người, tôi là Tín Phạm (hay TF1T) đến từ Red Team - VietSunshine Cyber Security Services. Trong quá trình học chứng chỉ OffSec Web Expert (OSWE), một thành viên trong Red Team đã hỏi tôi về phương pháp tìm kiếm các vị trí có khả năng bị SQL Injection, tương tự như trong bài Lab của khóa học mang tên "Frappe". Tôi đã quyết định thành lập một nhóm (bao gồm datnlq, dtro, Hiw0rl4, phl) để cùng nhau thực hành kỹ năng rà soát mã nguồn trên phần mềm Frappe Web Framework được đề cập trong bài Lab. Kết quả là chúng tôi đã phát hiện ra 10 lỗ hổng SQL Injection như sau:
  • CVE-2025-52044
  • CVE-2025-52043
  • CVE-2025-52042
  • CVE-2025-52041
  • CVE-2025-52040
  • CVE-2025-52039
  • CVE-2025-52047
  • CVE-2025-52048
  • CVE-2025-52049
  • CVE-2025-52050

CVE-2025-52044

Frappe

Trong bài viết này, tôi sẽ chia sẻ chi tiết về quá trình rà soát mã nguồn, nhận diện và thử nghiệm khai thác các lỗ hổng đã nêu.

Khái quát về Frappe/ERPNext

Frappe (Full-stack Rapid Application Programming Platform for Enterprises) là một Web Application Framework mã nguồn mở, được phát triển bằng ngôn ngữ lập trình Python, được sử dụng để xây dựng nhiều hệ thống quản lý doanh nghiệp (ERP), CRM, HRM, CMS.

  • Được thiết kế để xây dựng các ứng dụng web động, với kiến trúc kiểu Model-View-Controller (MVC).
  • Frappe là nền tảng chính để phát triển ERPNext – một hệ thống ERP mã nguồn mở nổi tiếng.

Hệ sinh thái:

  • Frappe: Framework chính để phát triển nhiều nền tảng ứng dụng khác nhau.
  • ERPNext: Hệ thống ERP được xây dựng trên Frappe.
  • Frappe HR, LMS, Helpdesk: Các ứng dụng mở rộng khác dựa trên Frappe.

SQL Injection (SQLi)

Đây là một dạng lỗ hổng bảo mật kinh điển - SQLi cho phép kẻ tấn công chèn các tham số có khả năng thay đổi logic của câu truy vấn gốc của ứng dụng, từ đó kiểm soát việc truy vấn dữ liệu trong Database Server. Nguyên nhân chủ yếu dẫn đến lỗ hổng này là do các nhà phát triển phần mềm chưa sàng lọc chặt chẽ dữ liệu đầu vào từ người dùng.

Để hiểu rõ hơn về SQLi, có thể tham khảo tại đây.

SQLi trong Frappe/ERPNext

Phân tích cách Frappe truy vấn Database

Trong Frappe, decorator @frappe.whitelist() được sử dụng để đánh dấu các hàm có thể được gọi thông qua HTTP.

Ví dụ file: frappe/frappe/realtime.py

@frappe.whitelist(allow_guest=True)
def get_user_info():
    user_type = frappe.session.data.user_type
    # For requests with Bearer tokens, user_type is not set in the session data
    if not user_type:
        user_type = frappe.get_cached_value("User", frappe.session.user, "user_type")
    return {
        "user": frappe.session.user,
        "user_type": user_type,
        "installed_apps": frappe.get_installed_apps(),
    }

 

 

 

 

 

 

 

 

Hàm này có thể được gọi theo 2 cách:

  • GET: /api/method/frappe.realtime.get_user_info
  • POST: / với payload cmd=frappe.realtime.get_user_info

Dựa trên tài liệu query builder của Frappe, có thể thấy các hàm như frappe.db.sql(), frappe.db.multisql(), frappe.db.sql_ddl(), frappe.db.sql_list() cho phép thực hiện “raw query” đến database. Có thể đọc thêm định nghĩa các hàm này tại tập tin frappe/frappe/database/database.py. Khi dữ liệu người dùng được truyền vào các hàm ở trên, cách thức chèn dữ liệu sẽ quyết định liệu có tồn tại lỗ hổng SQL Injection hay không.

Phần lớn các truy vấn trong Frappe/ERPNext sử dụng %s để chèn biến an toàn, ví dụ:

File Path: erpnext/erpnext/accounts/doctype/journal_entry/journal_entry.py

@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_against_jv(doctype, txt, searchfield, start, page_len, filters):
    if not frappe.db.has_column("Journal Entry", searchfield):
        return []

    return frappe.db.sql(
        f"""
        SELECT jv.name, jv.posting_date, jv.user_remark
        FROM `tabJournal Entry` jv, `tabJournal Entry Account` jv_detail
        WHERE jv_detail.parent = jv.name
            AND jv_detail.account = %(account)s
            AND IFNULL(jv_detail.party, '') = %(party)s
            AND (
                jv_detail.reference_type IS NULL
                OR jv_detail.reference_type = ''
            )
            AND jv.docstatus = 1
            AND jv.`{searchfield}` LIKE %(txt)s
        ORDER BY jv.name DESC
        LIMIT %(limit)s offset %(offset)s
        """,
        dict(
            account=filters.get("account"),
            party=cstr(filters.get("party")),
            txt=f"%{txt}%",
            offset=start,
            limit=page_len,
        ),
    )

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Khi thử chèn các ký tự đặc biệt vào biến txt và kiểm tra log truy vấn SQL, chúng tôi nhận thấy các ký tự này được escape bằng ký tự \. Điều này cho thấy rằng nếu truyền biến qua tham số thứ hai của frappe.db.sql(), hệ thống sẽ không bị SQL Injection.

nếu truyền biến qua tham số thứ hai của frappe.db.sql(), hệ thống sẽ không bị SQL Injection.

nếu truyền biến qua tham số thứ hai của frappe.db.sql(), hệ thống sẽ không bị SQL Injection. (2)

Tuy nhiên, nếu sử dụng các phương pháp nối chuỗi không an toàn dưới đây, dữ liệu đầu vào có thể gây ra lỗi SQL Injection:

  • f-string:
sql = f"SELECT * FROM users WHERE username = '{USER_INPUT}'"
frappe.db.sql(sql)

 

 

  •  .format():
sql = "SELECT * FROM users WHERE username = '{}'".format(USER_INPUT)
frappe.db.sql(sql)

 

 

  • Nối chuỗi:
sql = "SELECT * FROM users WHERE username = '" + USER_INPUT + "'"
frappe.db.sql(sql)

 

 

  • %s trong chuỗi (khác với %s tham số an toàn):
sql = "SELECT * FROM users WHERE username = '%s'"%(USER_INPUT)
frappe.db.sql(sql)

 

 

Chúng tôi nhận thấy vị trí của user input khi gọi đến hàm frappe.db.sql() trong trường hợp này là tham số đầu tiên.

Rà soát lỗ hổng sử dụng CodeQL

Sau khi xác định rõ Source (dữ liệu đầu vào từ các hàm có decorator @frappe.whitelist) và Sink (các hàm cho phép thực thi "raw query"), chúng tôi có thể sử dụng Code QL - một công cụ hữu ích để xác định các vị trí dòng chảy dữ liệu từ Source đến Sink, một phương pháp hiệu quả để nhận diện một số lượng lớn lỗ hổng tương tự.

Xác định Source

Truy tìm các biến đầu vào tại các hàm có @frappe.whitelist:

predicate isSource(DataFlow::Node source) {
    exists( Function func, Call call, Attribute attr, Parameter param |
        call = func.getADecorator()
        and attr = call.getFunc()
        and attr.getName() = "whitelist"
        and attr.getObject().toString() = "frappe"
        and param = func.getArgs().getAnItem()
        and source.asExpr() = param
    )
}

 

 

 

 

 

 

 

Xác định Source

Xác định Sink

Truy tìm các biểu thức truyền biến vào frappe.db.sql() và các hàm tương tự:

predicate isSink(DataFlow::Node sink) {
    exists(API::CallNode node, Call call |
            (node = API::moduleImport("frappe").getMember("db").getMember("sql").getACall()
            or node = API::moduleImport("frappe").getMember("db").getMember("multisql").getACall()
            or node = API::moduleImport("frappe").getMember("db").getMember("sql_list").getACall())
            and not node.getLocation().getFile().getRelativePath().regexpMatch(".*test.*")
            and call = node.asExpr()
            and sink.asExpr() = call.getArg(0)
    )
}

 

 

 

 

 

 

 

Xác định Sink

Kết quả

Sau khi thực thi Code QL, sẽ tìm thấy được rất nhiều vị trí “có khả năng” chứa lỗ hổng SQL Injection

Sau khi thực thi Code QL, sẽ tìm thấy được rất nhiều vị trí “có khả năng” chứa lỗ hổng SQL Injection

Sau khi xác minh lại, chúng tôi đã tìm thấy một số vị trí source-sink có thể khai thác thành công lỗi SQLi.

Minh họa hàm add_tag() nhận tham số dt từ người dùng:

File: frappe/frappe/desk/doctype/tag/tag.py

@frappe.whitelist()
def add_tag(tag, dt, dn, color=None):
    "adds a new tag to a record, and creates the Tag master"
    DocTags(dt).add(dn, tag)

    return tag

Tham số dt được nối vào câu SQL thông qua .format(), dẫn đến lỗi SQLi.

File: frappe/frappe/desk/doctype/tag/tag.py

frappe.db.sql(
    "update `tab{}` set _user_tags={} where name={}".format(self.dt, "%s", "%s"), (tags, dn)
)

 

 

 

Sau khi tái tạo request và thực hiện kỹ thuật error-based để khai thác lỗi SQLi, chúng tôi đã minh hoạ khả năng tiêm được câu truy vấn SQL để trích xuất được thông tin version() từ cơ sở dữ liệu.

Sau khi tái tạo request và thực hiện kỹ thuật error-based để khai thác lỗi SQLi, chúng tôi đã minh hoạ khả năng tiêm được câu truy vấn SQL để trích xuất được thông tin version() từ cơ sở dữ liệu.

Kết luận

Sau quá trình rà soát các gói phần mềm mã nguồn mở frappe/frappe phiên bản v15.55.4 và frappe/erpnext phiên bản v15.57.5, VSS Red Team đã phát hiện 10 lỗ hổng Error-Based SQL Injection. Chúng tôi đã thực hiện báo cáo cho Frappe Development Team và các lỗ hổng này đã được ghi nhận thông qua các mã định danh sau:

  • CVE-2025-52039
  • CVE-2025-52040
  • CVE-2025-52041
  • CVE-2025-52042
  • CVE-2025-52043
  • CVE-2025-52044
  • CVE-2025-52047
  • CVE-2025-52048
  • CVE-2025-52049
  • CVE-2025-52050

Tại thời điểm viết bài này, các lỗ hổng đã được cập nhật bản vá trong phiên bản mới nhất của Frappe. Red Team khuyến nghị các nhà phát triển phần mềm và đơn vị cung cấp giải pháp phần mềm (outsourcing) có sử dụng các sản phẩm liên quan đến Frappe Framework thực hiện cập nhật lên các phiên bản đã vá lỗi càng sớm càng tốt để giảm thiểu rủi ro ứng dụng bị tấn công.

Xem thêm: Dịch vụ Kiểm thử xâm nhập