- strip-escaping-links.py: the /home[N]/<user>/ exemption ran on the RAW
target (`/home/u/../../etc/shadow` was exempt) and on usernames taken
from the archive's own top-dir names (an archive could name itself
cpmove-root). Now only the username WHP passes (extract.sh <username> =
IMPORT_USERNAME, validated) is trusted; the target must be normalized; and
it is RE-ROOTED at the link's <top>/homedir in the extract and kept only
if that resolves (realpath strict) inside it. No trusted name: nothing
exempt.
- scan-symlinks.php: same trusted-name rule; a non-normalized absolute
target is DANGEROUS outright (never prefix-compared).
- tests: /home/u/../../etc/shadow, /home/u/./x, re-rooted dangling,
re-rooted through an escaping link, another user's /home, an
archive-named user (cpmove-root) via extract.sh, no/invalid trusted name.
8 checks fail on 72b8fa9.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
5.6 KiB
Python
136 lines
5.6 KiB
Python
#!/usr/bin/python3 -I
|
|
# strip-escaping-links.py — post-extract symlink authority (N2-1).
|
|
#
|
|
# python3 -I strip-escaping-links.py <dest> <actions.json> [username ...]
|
|
#
|
|
# The pre-extract classifier (scan-symlinks.php) resolves a relative target
|
|
# LEXICALLY, so a chain through another link (`a -> b/..` with `b -> x/y/z`,
|
|
# or `l1 -> l2/../../..`) can slip past it. This pass is the authority: it
|
|
# lstat-walks the WHOLE extracted tree (never following a link) and asks the
|
|
# kernel (realpath strict) where every symlink really resolves. A link is
|
|
# removed and recorded as a `stripped_escaping_symlink` action when:
|
|
# - it resolves outside <dest>, or to <dest> itself;
|
|
# - it is dangling (strict resolution fails), or loops.
|
|
# Own-account absolute links (cPanel's wp-content -> /home/<u>/shared and
|
|
# the like) are judged RE-ROOTED, never against the container's own /home:
|
|
# a target that is already normalized (no '.', '..' or empty component)
|
|
# and lies under /home[N]/<user>/ for the TRUSTED username only (the WHP
|
|
# account name WHP passes as extract.sh's <username>; names read from the
|
|
# archive are never trusted) is mapped to <dest>/<top>/homedir/<rest>, where
|
|
# <top> is the link's own top-level dir, and kept only if THAT resolves
|
|
# (realpath strict) inside <top>/homedir. Every other absolute target either
|
|
# dangles here or resolves into the container's own /etc, /usr, ... — both
|
|
# outside <dest> — so it is stripped. A link to the extract itself is
|
|
# stripped too.
|
|
#
|
|
# Repeats until a pass strips nothing (removing one link can leave another
|
|
# that resolved through it dangling). Any walk error fails closed (exit 1).
|
|
# stdout: number of links stripped. Exit 0 ok, 1 error, 2 usage.
|
|
|
|
import json
|
|
import os
|
|
import posixpath
|
|
import re
|
|
import stat
|
|
import sys
|
|
|
|
|
|
def main(argv):
|
|
if len(argv) < 3:
|
|
sys.stderr.write("usage: strip-escaping-links.py <dest> <actions.json> [trusted-username]\n")
|
|
return 2
|
|
dest = os.path.realpath(argv[1])
|
|
actions_path = argv[2]
|
|
trusted = argv[3] if len(argv) > 3 else ''
|
|
if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', trusted):
|
|
trusted = '' # no (valid) trusted name: exempt nothing
|
|
own_re = re.compile(r'\A/home\d*/' + re.escape(trusted) + r'(?:/(.*))?\Z') if trusted else None
|
|
|
|
def own_account_ok(link_path, target):
|
|
"""True when an absolute own-account target, re-rooted at the link's
|
|
<top>/homedir, resolves inside that homedir."""
|
|
if own_re is None or posixpath.normpath(target) != target.rstrip('/') or '/../' in target + '/' or '/./' in target + '/':
|
|
return False
|
|
m = own_re.match(target.rstrip('/'))
|
|
if not m:
|
|
return False
|
|
rel = os.path.relpath(link_path, dest).split(os.sep)
|
|
if len(rel) < 2:
|
|
return False
|
|
home = os.path.join(dest, rel[0], 'homedir')
|
|
try:
|
|
hst = os.lstat(home)
|
|
if not stat.S_ISDIR(hst.st_mode):
|
|
return False
|
|
home_real = os.path.realpath(home, strict=True)
|
|
cand = os.path.join(home_real, m.group(1)) if m.group(1) else home_real
|
|
real = os.path.realpath(cand, strict=True)
|
|
except OSError:
|
|
return False
|
|
return real == home_real or real.startswith(home_real + '/')
|
|
if not os.path.isdir(dest):
|
|
sys.stderr.write(f"strip-escaping-links: not a directory: {dest}\n")
|
|
return 1
|
|
|
|
def walk_error(err):
|
|
raise err
|
|
|
|
stripped = []
|
|
while True:
|
|
this_pass = 0
|
|
for dirpath, dirnames, filenames in os.walk(dest, topdown=True, onerror=walk_error, followlinks=False):
|
|
for name in list(dirnames) + list(filenames):
|
|
p = os.path.join(dirpath, name)
|
|
st = os.lstat(p)
|
|
if not stat.S_ISLNK(st.st_mode):
|
|
continue
|
|
target = os.readlink(p)
|
|
reason = None
|
|
if target.startswith('/') and own_re is not None and own_re.match(target.rstrip('/') or '/'):
|
|
if own_account_ok(p, target):
|
|
continue
|
|
reason = 'own-account target does not resolve inside the extracted homedir'
|
|
try:
|
|
if reason is None:
|
|
real = os.path.realpath(p, strict=True)
|
|
if not real.startswith(dest + '/'):
|
|
reason = 'resolves outside the extract'
|
|
except OSError:
|
|
reason = 'dangling or looping'
|
|
if reason is None:
|
|
continue
|
|
os.unlink(p)
|
|
if name in dirnames:
|
|
dirnames.remove(name)
|
|
stripped.append({
|
|
'action': 'stripped_escaping_symlink',
|
|
'archive_path': os.path.relpath(p, dest),
|
|
'target': target,
|
|
'reason': reason,
|
|
})
|
|
sys.stderr.write(f" STRIP {os.path.relpath(p, dest)} -> {target!r} ({reason})\n")
|
|
this_pass += 1
|
|
if this_pass == 0:
|
|
break
|
|
|
|
if stripped:
|
|
try:
|
|
with open(actions_path) as fh:
|
|
doc = json.load(fh)
|
|
except (OSError, ValueError):
|
|
doc = {'actions': []}
|
|
doc.setdefault('actions', []).extend(stripped)
|
|
doc['count'] = len(doc['actions'])
|
|
with open(actions_path, 'w') as fh:
|
|
json.dump(doc, fh, indent=2)
|
|
print(len(stripped))
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
sys.exit(main(sys.argv))
|
|
except OSError as e:
|
|
sys.stderr.write(f"strip-escaping-links: walk failed: {e}\n")
|
|
sys.exit(1)
|