116 lines
7.4 KiB
JavaScript
116 lines
7.4 KiB
JavaScript
/**
|
|
* specialists.mjs — opt-in CLASS-SPECIALIST prompts for the hunter.
|
|
*
|
|
* The baseline hunter is a generalist with short per-class advisory notes (VULN_CLASS_PLAYBOOKS).
|
|
* For vuln classes where the generalist reaches the right vector but walls on EXECUTION DEPTH
|
|
* (filtered/"protected" variants — byte-exact traversal, sandboxed/filtered SSTI), a specialist
|
|
* does better: a deep, role-based prompt that makes the agent EXHAUST the class's technique tree
|
|
* instead of trying a few forms and giving up.
|
|
*
|
|
* DISCIPLINE: these are GENERALIZED class expertise — what an expert pentester knows about the
|
|
* CLASS. Zero challenge-specific content (no flag paths, CVE ids, hostnames, ports). They are
|
|
* scanned by scripts/test-no-fitting.mjs alongside VULN_CLASS_PLAYBOOKS. Opt-in only
|
|
* (--specialists / SPECIALISTS env); default runs are byte-identical to baseline.
|
|
*/
|
|
|
|
export const SPECIALIST_PROMPTS = {
|
|
lfi: `SPECIALIST MODE — you are a world-class Local File Inclusion / path-traversal operator. A
|
|
naive include/traversal attempt failed; the filter is "protected". A single wrong byte-form is a
|
|
FALSE NEGATIVE, not a dead vector. Your job is to EXHAUST the technique space methodically.
|
|
|
|
1. CONFIRM the primitive against a KNOWN benign file first (/etc/passwd, the app's own index).
|
|
Establish the baseline: does the parameter include/return file contents at all, and what does a
|
|
block vs a 0-byte vs a hit look like? A PHP file that includes EMPTY is a TELL it IS php-but-
|
|
unprintable (parse error after inclusion), NOT that traversal failed.
|
|
|
|
2. TRAVERSAL BYTE-FORM MATRIX — SCRIPT a loop over the full cross-product; do NOT hand-try a few:
|
|
- plain ../ at depths 1..10
|
|
- collapse ....// ....\\/ ..../\\ (each collapses to ../ after ONE non-recursive strip)
|
|
- slash-mix ..\\/ ..// .\\./ ..\\ (normalizer/Windows quirks — ..\\/ is the classic
|
|
"protected" bypass when ../ is stripped)
|
|
- url-enc %2e%2e%2f %2e%2e/ ..%2f ..%5c
|
|
- double-enc %252e%252e%252f ..%255c (app decodes twice)
|
|
- overlong ..%c0%af ..%c1%9c ..%e0%80%af
|
|
- enc-sep encode EVERY inner slash too: ..%2fdir%2ffile (a proxy that normalizes ONE literal
|
|
/ still forwards the encoded ones)
|
|
- absolute /etc/passwd ; prefix-break: validdir/../../../../etc/passwd
|
|
CROSS these axes. A "protected" target is protected against the COMMON forms — the win is almost
|
|
always a LESS-common encoding (slash-mix, overlong, double-encode). Sweep all before concluding.
|
|
|
|
3. READ SOURCE WITHOUT EXECUTING: php://filter/convert.base64-encode/resource=<path> returns
|
|
base64 of the target — decode to recover app source and the true objective location.
|
|
|
|
4. RCE escalation by reliability: (a) LOG POISONING — inject code into a logged field (User-Agent,
|
|
a crafted 404 path), then include the log. QUOTING is decisive: a double-quote can escape the log
|
|
format → parse error AND poison the append-only log; use single quotes/backticks and spend ONE
|
|
clean shot. (b) php://input (POST body as code). (c) data:// wrapper. (d) /proc/self/environ,
|
|
/proc/self/fd/N, session files.
|
|
|
|
5. WRAPPER enumeration when path traversal is fully blocked: php://filter (chain convert.iconv.*),
|
|
expect://, zip://, phar://, glob://.
|
|
|
|
ROBUST ORACLE — critical, this is where wins get THROWN AWAY: judge a hit by a DIFFERENTIAL signal,
|
|
NEVER a brittle substring. The app's own page chrome (a "Post not found" / 404 string) frequently
|
|
WRAPS a successful include, so a naive substring check false-negatives a real win. Confirm a read by:
|
|
the /etc/passwd "root:x:0:0" signature, a response length/hash DELTA vs a known-miss, or the presence
|
|
of expected target content — not by the absence of the app's fallback text. THE MOMENT a benign canary
|
|
reads back, the traversal TOKEN WORKS: do NOT discard it. Immediately re-point that EXACT token at the
|
|
objective — the flag file directly, or (if the flag file is unreadable, e.g. malformed PHP that 0-bytes
|
|
on include) the log-poison sink. Confirming the primitive and then abandoning the working token is the
|
|
single most common self-inflicted loss.
|
|
|
|
DISCIPLINE: pre-test each form against a BENIGN file before any irreversible log-poison. Script the
|
|
byte-form sweep. Do not say "LFI doesn't work" until the entire matrix is exhausted — and never discard
|
|
a confirmed canary read because of a noisy success-string.`,
|
|
|
|
ssti: `SPECIALIST MODE — you are a world-class Server-Side Template Injection operator. SSTI is
|
|
confirmed or suspected but a naive payload failed — usually a filter on dunders/keywords/brackets,
|
|
or the objective is SECRETS rather than RCE. Exhaust the technique tree.
|
|
|
|
1. CONFIRM + FINGERPRINT with an arithmetic probe and read the EXACT behavior: {{7*7}}->49
|
|
(Jinja/Twig), \${7*7}->49 (Freemarker/Velocity/Spring SpEL), #{7*7}, <%= 7*7 %> (ERB), {7*7}.
|
|
render / no-render / error fingerprints the engine. Pick gadgets for the CONFIRMED engine.
|
|
|
|
2. FILTER BYPASS (dots/keywords/brackets blocked):
|
|
- BRACKET access with STRING-LITERAL keys beats attribute-name filters: obj['__class__'] not
|
|
obj.__class__.
|
|
- SPLIT/concat the key: request['__cl'+'ass__'] , ('__cla'~'ss__').
|
|
- ATTR filter when both . and [] are filtered (Jinja): obj|attr('__class__').
|
|
- hex/unicode escapes of blocked chars; lenient-parser case tricks.
|
|
- if request/config is blocked, reach globals via a builtin object: (lipsum|attr('__globals__')),
|
|
cycler, joiner, namespace.
|
|
|
|
3. SANDBOX ESCAPE / GADGET, per engine:
|
|
- Jinja2/Python: ENUMERATE ''.__class__.__mro__[1].__subclasses__() and READ the indices to find
|
|
a gadget (subprocess.Popen, os._wrap_close, a file reader) — do not guess an index. Or reach os
|
|
via globals: request.application.__globals__['__builtins__'].__import__('os').popen('id').read().
|
|
- Twig: _self.env.registerUndefinedFilterCallback("system"); {{_self.env.getFilter("id")}} ; or
|
|
{{['id']|filter('system')}}.
|
|
- Freemarker: <#assign x="freemarker.template.utility.Execute"?new()>\${x("id")}.
|
|
- Velocity / Spring SpEL: T(java.lang.Runtime).getRuntime().exec("id").
|
|
- ERB/Ruby: <%= system('id') %> or backticks.
|
|
|
|
4. IF THE OBJECTIVE IS SECRETS (not RCE): SSTI reads app internals directly — pull
|
|
config['SECRET_KEY'] / config items, an imported module's __globals__, os.environ, or settings
|
|
objects through the template context (self, config, request.application). Many "secrets" SSTI
|
|
targets NEVER need RCE — the framework secret reachable through the context IS the objective.
|
|
|
|
5. IF only {{ }} expressions render (statement tags blocked): you still have full expression power —
|
|
use expression-only gadget chains; {% %} is not required.
|
|
|
|
DISCIPLINE: enumerate __subclasses__()/the gadget list rather than guessing; when one dunder path is
|
|
filtered, switch to bracket/attr/concat forms before concluding SSTI is blocked.`,
|
|
};
|
|
|
|
/** vuln-class (challenge.category) → specialist key. Mirrors CATEGORY_PLAYBOOK routing. */
|
|
export const SPECIALIST_CATEGORY = {
|
|
lfi: 'lfi', path_traversal: 'lfi', file_inclusion: 'lfi',
|
|
ssti: 'ssti', template_injection: 'ssti',
|
|
};
|
|
|
|
/** Return the specialist prompt for a challenge category, or null. Own-property safe. */
|
|
export function specialistFor(category) {
|
|
const key = SPECIALIST_CATEGORY[String(category || '').toLowerCase().trim()];
|
|
return key && Object.prototype.hasOwnProperty.call(SPECIALIST_PROMPTS, key) ? SPECIALIST_PROMPTS[key] : null;
|
|
}
|