Часть I
Анонсированная тема: блог о том как я буду строить автоматизированный пайплайн для поиска уязвимостей.
Это не мануал, как нужно делать и не статья - вот вам зеродей. А попытка почти в режиме реального времени добиться каких-то результатов.
Стимулировал меня конкурс на одном из мелких форумов, с мелкими же призами, где в том числе в тематику входил
и автоматический поиск уязвимых гаджетов (цепочек) в JS-фреймворках. И заодно понимание, что найденные уязвимости это $$ по-любому.
Зачем нужен такой формат? Хотелось бы развития форума именно как форума, площадки дял обсуждений, а не как маркетплейса или библиотеки.
Пару человек обязались покритиковать содержание. Так что - поехали..
Введение
Появление дешевых/бесплатныхAPI для LLM (например, GLM-4-Flash) рождаетиллюзию, что безопасность кода можнопроанализировать, просто закинуврепозиторий в нейросеть.
Наивный подход «LLM пофайлам» захлебывается в false positives. Чтобыполучить реальный инструмент, нужнозаставить нейросеть не «думать», а строго отвечать на вопросы по доказаннымфактам.
Как это было =) Пошли по порядку.
- Получение токенов GLM-4. И других моделей GLM - 2млн халявных токенов простой модели.
Подробно (регистрация, обходкапчи и скриншоты халявы — здесь: Как получить миллионы токенов API ИИ нахаляву [мануал] )
- Затем я выбрала несколько фреймворков для анализа. Почему ts, js?
Считала что найти уязвимостипрестижней, на фоне массовых CVE10 появился азарт, ну и распространенность немалая. Отточить ядро и модифицировать под другие цели будет проще.
- Обратилась в чате к GLM 5.2 с просьбой применить полученные токены.
Быстро стало понятно, что флагман GLM простоват. Пришлось дальше общаться с Qwen3.7 plus
1. Наивные подходы и почему они не работают
На форуме есть статьи о применении LLM к анализу кода. Вот два направления.1.1. Чанки и статика: «LLM по файлам»
Как делается: Берем .ts файлы, бьем на чанки по 1000 токенов, отправляем в LLM с промптом «найди уязвимости».Чем это плохо:
Потеря контекста: Переменная определяется в fileA.ts, передается через fileB.ts, а используется в fileC.ts. LLM видит только кусок и фантазирует.
Галлюцинации: Модель видит fetch и http:// в payloads и тут же репортит SSRF, не понимая, статический это URL или пользовательский ввод.
Вывод: LLM не может быть Static Analysis движком. Она не умеает строить графы потоков данных (data flow).
Кто-то может возразить «мне помогло». Да, наверное в частном случае что-то можно найти. Но упустить можно еще больше#Пример раз (как красиво упаковать и продать лабуду):
"AI-Scanner на стероидах: Свой SAST на базе Local LLM v1.0":
Тема с использованием AI для анализа кода не новая, но 90% скриптов в паблике - это детский сад из трех строк: "прочитай файл -> отправь в чат". Это не работает в реальных условиях.
Если вы пробовали скармливать нейронке реальные исходники, вы знаете боль:
Сегодня мы напишем взрослый инструмент на Python. Это будет CLI-утилита, которая умеет нарезать код на чанки с перекрытием (overlap), нумерует строки перед отправкой и сохраняет результат в JSON-отчет.
- Она теряет контекст на больших файлах.
- Она галлюцинирует с номерами строк.
- Она не видит баги, если они "разрезаны" между частями запроса.
#Пример два: - отставим
1.2. Два прохода со скорингом: «Попытка отфильтровать в динамике»
Вот это и предложил мне сделать GLM 5.2Как делали: Первый проход — LLM ищет всё подозрительное и ставит risk_score.
Под спойлером два файла -
Спойлер: hunter.py
Python:
# hunter.py
import os
import json
import sys
from zhipuai import ZhipuAI
from dotenv import load_dotenv
load_dotenv()
client = ZhipuAI(api_key=os.getenv("ZHIPU_API_KEY"))
SYSTEM_PROMPT = """Ты автоматический сканер безопасности Red Team.
Тебе передается исходный код. Найди цепочки от точек входа (Source) до опасных функций (Sink), ведущие к RCE, SSRF или Prototype Pollution.
ВАЖНО: Определяй контекст! Если код использует Node.js/Express/NestJS (например, class-transformer, req.body), это серверный код. Для RCE на сервере используй пейлоады типа require('child_process').exec('command'), а НЕ браузерный alert(1).
Ответь ТОЛЬКО валидным JSON, без каких-либо других текстов до или после:
{
"risk_score": 0-10,
"vuln_type": "string",
"source": "string",
"sink": "string",
"exploit_payload": "string (JSON body to send to the server)"
}
Если уязвимости нет, верни: { "risk_score": 0, "vuln_type": "none", "source": "", "sink": "", "exploit_payload": "" }"""
def analyze_file(filepath: str) -> dict:
try:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
code = f.read()
except Exception as e:
return {"error": f"Ошибка чтения файла: {e}"}
response = client.chat.completions.create(
model="glm-4-flash",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Проанализируй файл {filepath}:\n\n{code[:4000]}"}
],
temperature=0.1
)
content = response.choices[0].message.content
# Очистка от markdown-оберток
if "```json" in content:
content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
content = content.split("```")[1].split("```")[0].strip()
else:
start = content.find('{')
end = content.rfind('}')
if start != -1 and end != -1:
content = content[start:end+1]
try:
return json.loads(content)
except json.JSONDecodeError:
return {"error": "LLM вернула невалидный JSON", "raw_response": content}
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Использование: python hunter.py <путь_к_файлу>")
sys.exit(1)
target = sys.argv[1]
result = analyze_file(target)
if 'error' in result:
print("\n❌ ОШИБКА ПАРСИНГА:")
print(result.get('raw_response', result.get('error')))
else:
print(f"\n📄 Файл: {target}")
print(f"🔥 Риск: {result.get('risk_score', 'N/A')}/10")
print(f"🎯 Тип: {result.get('vuln_type', 'N/A')}")
print(f"📥 Источник (Source): {result.get('source', 'N/A')}")
print(f"📤 Сток (Sink): {result.get('sink', 'N/A')}")
print(f"🚀 Эксплойт:\n{result.get('exploit_payload', 'N/A')}")
Python:
import os
import json
from pathlib import Path
from hunter import analyze_file
from rich.console import Console
console = Console()
# Папка, где лежат скачанные репозитории
REPOS_DIR = "./repos"
# Слова-маркеры для первичной фильтрации (экономим токены!)
SUSPICIOUS_KEYWORDS = [
"plainToInstance", "plainToClass", "@Transform", "deserialize",
"exec(", "execSync(", "spawn(", "fetch(", "axios", "serialize",
"JSON.parse", "child_process"
]
# Папки, которые мы полностью игнорируем (там мусор и тесты)
IGNORE_DIRS = ["node_modules", ".next", "dist", "test", ".hygen", "__tests__", ".git"]
def scan_repository(repo_path: str):
"""Сканирует один репозиторий"""
repo_name = Path(repo_path).name
console.print(f"\n[bold blue]📂 Сканируем репозиторий: {repo_name}[/bold blue]")
vulnerable_files = []
error_files = [] # Список для логирования ошибок API
files_to_scan = []
skipped_count = 0
scanned_count = 0
# Собираем JS/TS файлы
for path in Path(repo_path).rglob("*"):
# Игнорируем мусорные и тестовые папки!
if any(x in str(path) for x in IGNORE_DIRS):
continue
if path.suffix in [".ts", ".js"]:
files_to_scan.append(str(path))
console.print(f"[green]Найдено файлов (без тестов и мусора): {len(files_to_scan)}. Фильтруем по ключевым словам...[/green]\n")
for filepath in files_to_scan:
try:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
code = f.read()
except:
continue
# Бесплатная проверка на ключевые слова
if not any(keyword in code for keyword in SUSPICIOUS_KEYWORDS):
skipped_count += 1
continue
scanned_count += 1
# Показываем относительный путь для красоты
relative_path = filepath.replace(repo_path, "")
console.print(f"⏳ [{scanned_count}] Анализируем: [cyan]{relative_path}[/cyan]...", end=" ")
# Отправляем в ИИ
result = analyze_file(filepath)
# Если API упало (500, таймаут и т.д.)
if 'error' in result:
console.print(f"[yellow]⚠️ Ошибка ИИ[/yellow]")
error_files.append({
"file": filepath,
"error": result['error']
})
continue
# Добавляем мета-информацию
result["file"] = filepath
result["repo"] = repo_name
risk = result.get("risk_score", 0)
# Сортируем результаты
if risk >= 8:
console.print(f"[bold red]🔥 КРИТИЧЕСКИЙ RISK {risk}/10 | {result.get('vuln_type')}[/bold red]")
vulnerable_files.append(result)
elif risk >= 5:
console.print(f"[yellow]🟡 СРЕДНИЙ RISK {risk}/10 | {result.get('vuln_type')}[/yellow]")
vulnerable_files.append(result)
else:
console.print(f"[dim]✅ Чисто (Риск {risk})[/dim]")
# Статистика сканирования
console.print(f"\n[bold]📊 Статистика по {repo_name}:[/bold]")
console.print(f" Всего файлов: {len(files_to_scan)}")
console.print(f" Скипнуто (нет ключевых слов): {skipped_count}")
console.print(f" Отправлено в AI: {scanned_count}")
# Сохраняем лог ошибок, если они были
if error_files:
error_filename = f"errors_{repo_name}.json"
with open(error_filename, "w", encoding="utf-8") as f:
json.dump(error_files, f, indent=2, ensure_ascii=False)
console.print(f"[red] Ошибок API: {len(error_files)} (сохранено в {error_filename})[/red]")
return vulnerable_files
def main():
if not Path(REPOS_DIR).exists():
console.print(f"[red]❌ Папка {REPOS_DIR} не найдена! Сначала склонируй репозитории.[/red]")
return
all_vulnerabilities = []
# Проходим по всем папкам внутри ./repos/
for repo_dir in Path(REPOS_DIR).iterdir():
if repo_dir.is_dir():
vulns = scan_repository(str(repo_dir))
all_vulnerabilities.extend(vulns)
# Финальный вывод
console.print(f"\n[bold green]✅ МАССОВОЕ СКАНИРОВАНИЕ ЗАВЕРШЕНО![/bold green]")
console.print(f"🔴 Всего обнаружено уязвимостей (Risk >= 5): [bold red]{len(all_vulnerabilities)}[/bold red]")
# Сохраняем финальный красивый отчет
with open("final_report.json", "w", encoding="utf-8") as f:
json.dump(all_vulnerabilities, f, indent=2, ensure_ascii=False)
console.print(f"💾 Полный отчет сохранен в [bold]final_report.json[/bold]")
if __name__ == "__main__":
main()
Второй проход — LLM перепроверяет находки с verified_risk.
Методология: Трехуровневый AI-Пайплайн
Уровень 1: AST + Shallow LLM (То, что уже работает)
Действие: Парсим код, ищем пары Source-Sink (JSON.parse -> plainToInstance, fetch(userInput)).
Результат: JSON-отчет с сотнями кандидатов (много False Positives).
Уровень 2: Context Verifier (Фильтрация шума)
Проблема: ИИ не понимает, запускается ли код на сервере (Web) или на компьютере разработчика (CLI/Script).
Решение: Пишем скрипт verifier.py, который берет final_report.json и делает второй проход только по найденным файлам, но уже с другим, строгим промптом.
Промпт для Уровня 2 (Context Verifier):
Ты эксперт по безопасности на уровне архитектуры. Тебе дан файл, в котором найден подозрительный паттерн.
Твоя задача — определить КОНТЕКСТ ВЫПОЛНЕНИЯ.
1. Находится ли этот код в веб-эндпоинте (HTTP handler, @Get, @post, app.get, req.body)?
2. Или это CLI-утилита, тест, build-скрипт или локальный воркер?
Если код НЕ доступен через сеть (нет HTTP входа), уязвимость нерелевантна для удаленной атаки.
Ответь ТОЛЬКО JSON:
Code:
{
"is_web_accessible": true/false,
"http_entry_point": "string (например, 'req.query.data' или 'None')",
"adjusted_risk_score": 0-10 (если нет HTTP входа - ставь 0!),
"reason": "string"
}
Уровень 3: Cross-File Taint Tracking (Глубокий анализ)
Проблема: Данные приходят в req.body (Хэндлер), но утекают в exec() где-то глубоко в Сервисе. Файл Хэндлера выглядит безопасно, файл Сервиса выглядит безопасно, но их цепочка — RCE.
Решение: Скрипт tracer.py.
Когда Уровень 2 подтверждает Web-доступность, скрипт:
1. Ищет импорты в уязвимом файле (например, import { findOneOperation } from './ops.js').
2. Находит файл ./ops.js локально в репозитории.
3. Скармливает ИИ ОБА файла сразу с промптом трекинга.
Промпт для Уровня 3 (Taint Tracker):
Ты анализируешь поток данных (Taint Analysis) между двумя файлами.
В Файле 1 (Handler) пользовательские данные из веба попадают в функцию X.
В Файле 2 находится реализация функции X.
Проследи, доходят ли данные из Файла 1 до опасного Sink (exec, merge, ORM save) в Файле 2 без санитизации.
Ответь JSON:
Code:
{
"chain_confirmed": true/false,
"full_source": "string (откуда пришли данные)",
"full_sink": "string (где они нанесли урон)",
"exploit_path": "string (кратко: req.query.data -> findOne -> Object.assign)"
}
Вот готовый скрипт Второго уровня (verifier.py), который пройдется по final_report.json и отфильтрует мусор.Не нужно писать сложный AST-парсер для трекинга. Мы заставим GLM-4-Flash работать вместо AST
Скрипт возьмет final_report.json, достанет пути к файлам из поля "file", прочтет каждый файл и спросит ИИ: "Эй, это реально веб-эндпоинт или локальный скрипт?"
ИИ сразу прихлопнет scripts/seed-app-store.ts (поставит 0), и оставит только те файлы, где есть req.body или роутеры Express/NestJS.
В verified_report.json останутся только реалистичные векторы. Автоматизация процентов на 70- не более. Ибо оставшееся нужно верифицировать в динамике.
Спойлер: verifer.py
Python:
import os
import json
from zhipuai import ZhipuAI
from dotenv import load_dotenv
from rich.console import Console
load_dotenv()
console = Console()
client = ZhipuAI(api_key=os.getenv("ZHIPU_API_KEY"))
VERIFIER_PROMPT = """Ты эксперт по безопасности на уровне архитектуры. Тебе дан файл, в котором ранее ИИ нашел подозрительный паттерн (Source -> Sink).
Твоя задача — определить КОНТЕКСТ ВЫПОЛНЕНИЯ этого кода.
1. Находится ли этот Sink в коде, доступном через сеть (HTTP handler, @Get, @Post, app.get, req.body, req.query)?
2. Или это CLI-утилита, скрипт сборки (scripts/, test/, cli), воркер или локальная утилита, до которой удаленный атакующий не может достучаться через HTTP?
Если код НЕ доступен через сеть, уязвимость НЕ представляет интереса для Red Team.
Ответь ТОЛЬКО валидным JSON:
{
"is_web_accessible": true/false,
"http_entry_point": "string (укажи точку входа или 'None')",
"adjusted_risk_score": 0-10 (если нет HTTP входа - ставь 0!),
"reason": "string (почему доступен или нет)"
}"""
def verify_finding(finding: dict) -> dict:
filepath = finding.get("file")
if not filepath or not os.path.exists(filepath):
return {**finding, "verified_risk": 0, "is_web_accessible": False, "reason": "File not found"}
try:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
code = f.read()
except Exception as e:
return {**finding, "verified_risk": 0, "reason": str(e)}
try:
response = client.chat.completions.create(
model="glm-4-flash",
messages=[
{"role": "system", "content": VERIFIER_PROMPT},
{"role": "user", "content": f"Файл: {filepath}\nКод:\n```\n{code}\n```"}
],
temperature=0.1
)
content = response.choices[0].message.content
if "```json" in content: content = content.split("```json")[1].split("```")[0].strip()
verification = json.loads(content)
# Обновляем находку данными из верификатора
finding["is_web_accessible"] = verification.get("is_web_accessible", False)
finding["verified_risk"] = verification.get("adjusted_risk_score", 0)
finding["verification_reason"] = verification.get("reason", "")
except Exception as e:
finding["verified_risk"] = finding.get("risk_score", 0) # Оставляем как есть при ошибке API
finding["verification_reason"] = f"API Error: {str(e)}"
return finding
def main():
report_path = "final_report.json"
if not os.path.exists(report_path):
console.print("[red]❌ Файл final_report.json не найден![/red]")
return
with open(report_path, "r", encoding="utf-8") as f:
findings = json.load(f)
console.print(f"[cyan]🔍 Начинаем верификацию {len(findings)} находок (Уровень 2)...[/cyan]")
verified_findings = []
for i, finding in enumerate(findings):
filepath = finding.get("file", "Unknown")
console.print(f"⏳ [{i+1}/{len(findings)}] Верифицируем: {filepath.split('/')[-1]}...", end=" ")
verified = verify_finding(finding)
if verified.get("verified_risk", 0) >= 7:
console.print(f"[bold green]✅ ПОДТВЕРЖДЕНО (Web-вход: {verified.get('http_entry_point')})[/bold green]")
verified_findings.append(verified)
else:
console.print(f"[dim]❌ Отклонено (Причина: {verified.get('verification_reason', 'Low risk')})[/dim]")
# Сохраняем чистый отчет
with open("verified_report.json", "w", encoding="utf-8") as f:
json.dump(verified_findings, f, indent=2, ensure_ascii=False)
console.print(f"\n[bold green]✅ Верификация завершена![/bold green]")
console.print(f"🏆 Реальных Web-уязвимостей (Risk >= 7): [bold cyan]{len(verified_findings)}[/bold cyan]")
console.print(f"💾 Чистый отчет сохранен в [bold]verified_report.json[/bold]")
if __name__ == "__main__":
main()
Что произойдет: Скрипт возьмет final_report.json, достанет пути к файлам из поля "file", прочтет каждый файл и спросит ИИ: "Эй, это реально веб-эндпоинт или локальный скрипт?"
ИИ сразу прихлопнет scripts/seed-app-store.ts (поставит 0), и оставит только те файлы, где есть req.body или роутеры Express/NestJS.
В verified_report.json останутся только боевые, реалистичные векторы. Это и есть та самая автоматизация, для начального уровня
Получили файл отчета verified_report.json. Прилагаю в аттаче.
Вот этим я собираюсь заняться "вручную" несколько позже. фразмент из отчета "третьего уровня":
JSON:
{
"risk_score": 8,
"vuln_type": "SSRF",
"source": "req.body.config",
"sink": "configToJSONSchema",
"exploit_payload": {
"config": {
"url": "http://attacker.com"
}
},
"file": "repos\\payload-main\\packages\\plugin-mcp\\src\\mcp\\getMcpHandler.ts",
"repo": "payload-main",
"is_web_accessible": true,
"verified_risk": 7,
"verification_reason": "The code is part of a plugin for a web application, and it uses the '/api' path for handling requests. This indicates that the code is accessible via the web and can be targeted by an attacker."
},
Как вообще выглядит построенная архитектура?
Не удалось прогрузить медиа
Дальше я опускаю беседы с GLM 5.2 виду их непродуктивности. Обращаюсь с файлом к Qwen3.7 plus. Его ответ
Не удалось прогрузить медиа
ТОП-4 КРИТИЧЕСКИХ НАХОДКИ (Risk 8-10)
:first_place: #1:
Файл: repos\payload-main\packages\payload\src\uploads\endpoints\getFileFromURL.tsЦепочка:
req.url (HTTP endpoint) → fetch(fileURL) → загрузка файла с внешнего URLПочему это Critical:
- Это HTTP-эндпоинт (/api/uploads/getFileFromURL)
- Атакующий контролирует URL через req.url
- Сервер делает fetch() на этот URL
- Если загруженный файл потом выполняется (например, как JS/JSON с eval или импортируется) — это RCE
Что проверить прямо сейчас:
- Открой файл getFileFromURL.ts
- Найди, что происходит с загруженным файлом после fetch()
- Если есть eval(), new Function(), import(), или файл сохраняется и потом выполняется — это RCE
Я провел автоматический трассировочный анализ (Taint Analysis) файла getFileFromURL.ts. Вот жесткий, технический разбор того, что происходит с данными после fetch(), и почему это НЕ RCE, но может быть SSRF-прокси.
Автоматический анализ потока данных (Data Flow Analysis)
1. Источник (Source):
Code:
const { searchParams } = new URL(req.url)
const src = searchParams.get('src') // Пользовательский ввод
2. Ворота безопасности (Gates):
Code:
if (!req.user) throw new Forbidden(req.t) // Требуется авторизация
const accessResult = await executeAccess({ req }, config.access.update) // Проверка прав
3. Сток (Sink) - Выполнение запроса:
Code:
// Если есть allowList и URL в нем -> обычный fetch (обход safeFetch!)
if (hasAllowList && isURLAllowed(fileURL, config.upload.pasteURL.allowList)) {
response = await fetch(fileURL, { headers: { 'Accept-Encoding': 'identity' }, redirect: 'manual', signal: AbortSignal.timeout(30_000) })
} else {
// Иначе -> safeFetch (блокирует localhost, 169.254.x.x и внутренние IP)
response = await safeFetch(fileURL, { headers: { 'Accept-Encoding': 'identity' }, signal: AbortSignal.timeout(30_000) })
}
4. Пост-обработка (Post-Sink Processing):
Code:
const rawFileName = decodeURIComponent(new URL(fileURL).pathname.split('/').pop() || '')
const safeFileName = sanitizeFilename(rawFileName) // Защита от Directory Traversal (../)
5. Финальный вывод (Final Output):
Code:
return new Response(response.body, {
headers: {
'Content-Disposition': `attachment; filename="${asciiFileName}"; filename*=UTF-8''${encodedFileName}`,
'Content-Type': response.headers.get('content-type') || 'application/octet-stream',
// ...
}
})
Вердикт анализатора: ПОЧЕМУ ЭТО НЕ RCE
Автоматический анализ показывает отсутствие опасного гаджета после fetch().- Файл НЕ сохраняется на диск сервера. Нет вызовов fs.writeFileSync или загрузчиков Payload (payload.create({ collection: 'media' })).
- Файл НЕ выполняется. Нет eval(), new Function(), require() или парсинга JSON на стороне сервера.
- Тело ответа просто стримится обратно клиенту. new Response(response.body) действует как простой прокси-сервер.
Вывод: Превратить это в RCE невозможно. Это тупик для эксплойта.
Я опускаю перебор, практически ручной "бриллиантов", после трех скриптов и одного флэш-рояля (ИИ).
Очень трудоемкий процесс. и сильно ручной.
С другой стороны Мифоса, который развернет код на своих серверах и все проверит сам - нет
Часть 2 будет посвящена автоматизации "на максималках" - я имею ввиду все-таки кастомные скрипты, а не мощные профессиональные решения.
п.с. в аттаче анализ
cal.diy-main - Cal.diy is the open source community edition of Cal.com
payload-main - PayloadCMS
На все-про-все ушло 70 000 токенов из которых почему то потом большая часть вернулась.
Напомню, что подарили 2млн на три месяца.
👍18
❤️4
😢4
💯4
LIMB