The Clash of Clans bot has received important security fixes (CRIT-001 through CRIT-003) addressing hardcoded session secrets, ADB command injection, and config path traversal. The remaining attack surface contains significant weaknesses: wildcard CORS combined with absent CSRF tokens allows cross-site request forgery against all state-changing endpoints, and unsanitised strategy file names enable arbitrary JSON writes anywhere the process can write. Together these form a chained RCE path documented as CRIT-001.
Multiple overlapping paths combine HIGH-001 (unrestricted config write), MED-003 (strategy path traversal write), and MED-004 (strategy path traversal read) to achieve arbitrary file writes and potentially code execution. These paths are individually serious but become critical when chained.
curl -b "session=<VALID>" -X POST http://localhost:5000/api/strategy/record/start
curl -b "session=<VALID>" -X POST http://localhost:5000/api/strategy/tap \
-H "Content-Type: application/json" -d '{"x":1,"y":1}'
curl -b "session=<VALID>" -X POST http://localhost:5000/api/strategy/record/stop \
-H "Content-Type: application/json" \
-d '{"name":"../../web/templates/index"}'
# Result: web/templates/index.json overwritten — dashboard DoS
POST /api/config body: {"config": "logging:\n file: ../strategies/payload.json\n..."}
# Trigger log messages with "tap_down" JSON structure
POST /api/strategy/replay body: {"name": "payload"}
# Result: ADB taps at attacker-specified coordinates
import re, os
from pathlib import Path
def _safe_strategy_name(name: str) -> str:
clean = re.sub(r'[^a-zA-Z0-9_\-]', '_', str(name))[:64]
return clean or "default"
def _safe_strategy_path(name: str) -> Path:
base = Path("strategies").resolve()
candidate = (base / f"{_safe_strategy_name(name)}.json").resolve()
if not str(candidate).startswith(str(base) + os.sep):
raise ValueError("Path traversal attempt")
return candidate
# Also: add CSRF protection, restrict logging.file to logs/
/api/config POST accepts arbitrary YAML after only a syntax check. An attacker can set safety.dry_run=false, attack.min_loot to 0, device.serial to any device, or logging.file to any writable path. Combined with MED-001 (CSRF), this is exploitable cross-site.
new_config_text = request.json.get("config", "")
yaml.safe_load(new_config_text) # syntax only — no schema validation
with open(config_path, "w") as f:
f.write(new_config_text) # unrestricted write
Add a schema validator checking allowed keys, value types, and ranges. Restrict logging.file to within logs/. Add CSRF protection.
WebLogHandler emits all log records via global socketio.emit(), not per-session. In debug mode this streams every tap coordinate, template match result, OCR loot value, and ADB stderr to all connected WebSocket clients.
socketio.emit("log", {"message": msg}) # global broadcast
Use per-session rooms. Never emit DEBUG-level messages to the web UI.
login_required checks only session.get("logged_in") — a boolean with no timestamp, IP binding, or rotation. No session lifetime is configured. A stolen session cookie grants unrestricted access indefinitely.
from datetime import timedelta app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(hours=8) app.config["SESSION_PERMANENT"] = True # On login: session.clear() session["logged_in"] = True session["login_time"] = time.time()
_relog() uses "adb shell monkey -p <package>". Arguments after "shell" are assembled into a command string on the Android device. If the package name is ever sourced from config (via HIGH-001), shell metacharacters would inject arbitrary Android shell commands.
self.adb._run("shell", "monkey", "-p", self._coc_package, "-c",
"android.intent.category.LAUNCHER", "1")
# Replace monkey with am start:
self.adb._run("shell", "am", "start",
"-a", "android.intent.action.MAIN",
"-c", "android.intent.category.LAUNCHER",
"-n", f"{self._coc_package}/.MainActivity")
None of the POST endpoints implement CSRF protection. Combined with wildcard CORS (MED-002), any webpage visited by an authenticated operator can start/stop the bot, save arbitrary config, or trigger attacks.
// PoC from attacker-controlled page:
fetch("http://localhost:5000/api/start", {
method: "POST",
body: JSON.stringify({mode: "attack"}),
credentials: "include",
headers: {"Content-Type": "application/json"}
});
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect(app)
# In JS: headers: {"X-CSRFToken": getCsrfToken()}
SocketIO is initialised with cors_allowed_origins="*", permitting any origin to connect and issue requests.
socketio = SocketIO(app, cors_allowed_origins="*")
socketio = SocketIO(app, cors_allowed_origins=[
"http://localhost:5000",
"http://127.0.0.1:5000"
])
Strategy name from /api/strategy/record/stop is used directly in os.path.join() with no sanitisation, allowing directory traversal to overwrite any writable file.
filepath = os.path.join(STRATEGIES_DIR, f"{name}.json")
# Payload: {"name": "../../web/templates/index"}
Regex-sanitize name with [a-zA-Z0-9_\-] and resolve-path check within STRATEGIES_DIR.
/api/strategy/replay constructs a file path from client-supplied name with unsanitised join. An attacker can point replay at any JSON file on the system to execute its events as ADB taps.
Apply the same _safe_strategy_path() function (see MED-003) to both stop_recording() and replay().
The /api/coc/proxy endpoint validates only that path starts with "/". No allowlist of valid API path prefixes is enforced.
ALLOWED_PREFIXES = ("/clans/", "/players/", "/leagues/", "/locations/",
"/labels/", "/goldpass/", "/warleagues/")
if not any(path.startswith(p) for p in ALLOWED_PREFIXES):
return jsonify({"error": "Path not allowed"}), 400
The CoC API key is persisted to localStorage, readable by any same-origin JavaScript. An XSS payload (MED-007) can exfiltrate it.
Store the API key in the server-side Flask session. If client storage is unavoidable, use sessionStorage instead of localStorage.
Troop names from SocketIO stats are inserted into innerHTML without escaping. If a troop name contains HTML/JS (e.g. from a config value set via HIGH-001), this results in stored XSS.
return `<tr><td>${timeStr}</td><td>${h.troop}</td></tr>`;
// No escaping applied
// escH() already exists — apply it:
return `<tr><td>${escH(timeStr)}</td><td>${escH(h.troop)}</td></tr>`;
app.config["SESSION_COOKIE_SECURE"] = True app.config["SESSION_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
An <img src="/logout"> on any page visited while authenticated silently logs the user out.
@app.route("/logout", methods=["POST"])
@login_required
def logout():
session.clear()
return redirect(url_for("login"))
All dependencies use >= constraints. A supply-chain compromise could silently execute malicious code.
pip-compile --generate-hashes requirements.in # Or: flask~=3.0.3, flask-socketio~=5.3.6
Downgrade loot values and device resolution logging to DEBUG. Restrict log file permissions: chmod 600 logs/bot.log.
gunicorn --worker-class eventlet -w 1 "web.app:app"
users.json was committed in a past revision containing a username and pbkdf2:sha256 hash, subject to offline dictionary attack.
git filter-repo --path users.json --invert-paths # Rotate the account password immediately
When COC_BOT_SECRET is unset, a random secret is generated. Sessions are lost on every restart.
Document COC_BOT_SECRET as required. Consider sys.exit(1) if unset.
Prefer USB-only mode. For TCP ADB, tunnel via SSH.
from flask_limiter import Limiter
limiter = Limiter(app, key_func=get_remote_address)
@limiter.limit("10 per 10 minutes")
@app.route("/login", methods=["GET", "POST"])
def login(): ...
Three strategy files with real device resolution (1080x2340) and 35+ tap event coordinates are committed, revealing automation patterns.
Add strategies/ to .gitignore.
| # | ID | Title | Severity | Effort | Fix |
|---|---|---|---|---|---|
| 1 | CRIT-001 | Strategy path traversal + config write RCE chain | Critical | 2 hrs | Sanitize strategy names; add CSRF; schema-validate config |
| 2 | MED-001 | No CSRF protection | Medium | 3 hrs | Flask-WTF CSRFProtect + JS token header |
| 3 | MED-003 | Path traversal in strategy save | Medium | 30 min | Regex-sanitize name; resolve-path check |
| 4 | HIGH-001 | Unrestricted YAML config write | High | 4 hrs | YAML schema validator; restrict logging.file |
| 5 | MED-002 | Wildcard CORS | Medium | 15 min | Restrict to localhost origins |
| ID | Issue | Status | Notes |
|---|---|---|---|
| CRIT-001 (orig) | Hardcoded SECRET_KEY | FIXED | Random ephemeral key generation is correct. No regression. |
| CRIT-001 (new) | Strategy path traversal + config write RCE chain | FIXED | Strategy names sanitized with strict [a-zA-Z0-9_\-] regex and resolved-path validation in both save and replay. Config schema validation closes the log-redirect exploit path. |
| CRIT-002 | ADB command injection via coordinates | FIXED | int() casting + 0–4096 range validation is sound. No bypass found. |
| CRIT-003 | Path traversal in config read/write | FIXED | Config path validated against project root. Strategy path traversal (previously partial gap) now also closed by _safe_strategy_path(). |
| HIGH-001 | Unrestricted YAML config write | FIXED | YAML schema validator added — checks allowed keys, value types, and ranges. logging.file restricted to the logs/ directory. |
| HIGH-002 | WebSocket logs broadcast to all clients | FIXED | Per-session rooms implemented. Logs emitted only to authenticated rooms. DEBUG-level messages filtered out of the web UI. |
| HIGH-003 | No session expiry | FIXED | Sessions expire after 8 hours via PERMANENT_SESSION_LIFETIME. Session is cleared on login to prevent session fixation. |
| HIGH-004 | adb shell monkey — Android shell injection | FIXED | Replaced adb shell monkey with adb shell am start to eliminate shell injection vector. |