Files
reverse-skills-max/vendor-new/apk-reverse/skills/apk-reverse/scripts/device_shell.py

302 lines
13 KiB
Python

#!/usr/bin/env python3
"""Device-side command construction: quoting, argv, and identifier validation.
Why this module exists: `adb shell` hands your string to a **device shell** (toybox `sh`), and two of
this kit's scripts had different, partially-wrong ideas about that. One escaped only double quotes, so
a `;`, a `$(...)` or a backtick in a value still reached the shell as syntax; the other quoted
correctly but let a malformed value go through to fail later, far from the caller, as a confusing
`grep: no such file` or an empty result. Both are the same defect seen from two sides: **a value that
becomes shell syntax**.
None of this is exotic. The values are package names, component names, paths and sizes that the user
typed or that came out of a manifest, and the failure mode when one is malformed is a wasted round,
not an exploit. The rules are still worth enforcing in one place:
* **Validate what has a known shape.** An Android package name has a defined grammar; a value that
does not match it will never select a package, so rejecting it here is strictly better than
passing it to the device and reading an empty result as "not installed".
* **Quote what does not.** Paths and free-form text are escaped with POSIX single quotes -- the
only form that makes `$`, backticks, `;`, `|` and newlines literal to `sh`.
* **Prefer an argv array over a string** whenever the command is not being run through `su -c`.
`['adb', 'shell', 'cmd', arg]` sends `arg` as a separate argument; adb quotes it for the device
shell, and there is no string to get the quoting wrong in.
Usage as a module:
from device_shell import validate_package, validate_component, sh_quote, su_wrap, adb_shell
adb_shell(serial, ['dumpsys', 'package', validate_package(pkg)]) # argv, no string
su_wrap(['cat', validate_device_path(path)]) # -> one quoted string
Usage as a tool:
python device_shell.py --check com.example.app
python device_shell.py --quote "a b;c"
python device_shell.py --selftest
Exit codes: 0 success / 1 the value was refused / 2 usage error / 4 internal error.
"""
import argparse
import re
import sys
# Android's own grammar: one or more Java-package segments, each starting with a letter.
PACKAGE_RE = re.compile(r'^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$')
# A component is `pkg/Class`, `pkg/.Class` or `pkg/full.Class.Path`.
COMPONENT_RE = re.compile(
r'^(?P<pkg>[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+)'
r'/(?P<cls>\.?[A-Za-z][A-Za-z0-9_.$]*)$')
# Device paths we are willing to interpolate: absolute, no control characters, no shell metachars.
PATH_SAFE_RE = re.compile(r'^/[A-Za-z0-9_./@+:=~-]*$')
# A user id as `pm`/`dumpsys` reports it, or a process id.
PID_RE = re.compile(r'^[1-9][0-9]{0,6}$')
class Refused(ValueError):
"""A value failed validation. Callers translate this into exit code 1, never a traceback."""
def _reject(kind, value, why):
raise Refused('%s is not valid: %r -- %s' % (kind, value, why))
def validate_package(value):
"""A package name, or refuse. Raises `Refused`."""
if not isinstance(value, str) or not value:
_reject('package name', value, 'empty')
if len(value) > 255:
_reject('package name', value, 'longer than the platform maximum')
if not PACKAGE_RE.match(value):
_reject('package name', value,
'must be dot-separated segments each starting with a letter (Android grammar)')
return value
def validate_component(value, default_package=None):
"""`pkg/Class`, `pkg/.Class` or a bare `.Class` when `default_package` is given."""
if not isinstance(value, str) or not value:
_reject('component', value, 'empty')
if '/' not in value and default_package:
value = '%s/%s' % (validate_package(default_package), value)
m = COMPONENT_RE.match(value)
if not m:
_reject('component', value, 'expected package/Class with a valid package part')
validate_package(m.group('pkg'))
if m.group('cls').endswith('.'):
_reject('component', value, 'class part ends with a dot')
return value
def validate_device_path(value):
"""An absolute device path with no character that could be shell syntax."""
if not isinstance(value, str) or not value:
_reject('device path', value, 'empty')
if len(value) > 4096:
_reject('device path', value, 'unreasonably long')
if not PATH_SAFE_RE.match(value):
_reject('device path', value,
'must be absolute and free of whitespace, quotes and shell metacharacters')
if '..' in value.split('/'):
_reject('device path', value, 'contains a parent-directory segment')
return value
def validate_pid(value):
if isinstance(value, int):
value = str(value)
if not isinstance(value, str) or not PID_RE.match(value):
_reject('pid', value, 'expected a positive decimal pid')
return value
def sh_quote(value):
"""POSIX single-quote quoting: the one form that makes every metacharacter literal."""
if not isinstance(value, str):
value = str(value)
return "'" + value.replace("'", "'\\''") + "'"
def su_wrap(argv):
"""Wrap already-validated argv into a single `su -c '<cmd>'` string for the device shell."""
if isinstance(argv, str):
argv = [argv]
return 'su -c %s' % sh_quote(' '.join(argv))
def adb_shell(serial=None, argv=(), root=False, quote=True):
"""Build an adb argv list. `root=True` routes through `su -c`; `quote=False` sends argv directly."""
if isinstance(argv, str):
argv = [argv]
base = ['adb'] + (['-s', serial] if serial else []) + ['shell']
if root:
return base + [su_wrap(argv)]
if not quote:
return base + list(argv)
return base + [' '.join(sh_quote(a) for a in argv)]
def selftest():
"""Assert the properties this module claims. Returns a list of (name, ok, detail)."""
cases = []
payloads = ['a; rm -rf /sdcard/x', 'a$(id)b', 'a`id`b', 'a b', 'a\nb', 'a|b', "a'b"]
for p in payloads:
quoted = sh_quote(p)
# A single-quoted POSIX string must contain no unescaped quote and must round-trip.
ok = quoted.startswith("'") and quoted.endswith("'")
inner = quoted[1:-1]
ok = ok and re.fullmatch(r"[^']*('\\''[^']*)*", inner) is not None
cases.append(('sh_quote makes %r inert' % p, ok, quoted))
for good in ('com.example.app', 'a.b.c_d.e1'):
try:
validate_package(good)
cases.append(('accepts valid package %r' % good, True, ''))
except Refused as exc:
cases.append(('accepts valid package %r' % good, False, str(exc)))
for bad in ('', 'nodots', 'com..app', '1com.example', 'com.example.app;rm -rf /',
'com.example.app$(id)', 'com.example.app`id`'):
try:
validate_package(bad)
cases.append(('refuses invalid package %r' % bad, False, 'it was accepted'))
except Refused as exc:
cases.append(('refuses invalid package %r' % bad, True, str(exc)))
for good in ('/data/local/tmp/x', '/sdcard/Android/data/com.example.app/files'):
try:
validate_device_path(good)
cases.append(('accepts valid path %r' % good, True, ''))
except Refused as exc:
cases.append(('accepts valid path %r' % good, False, str(exc)))
for bad in ('relative/path', '/data/../etc/passwd', '/sdcard/x;id', '/sdcard/x$(id)',
'/sdcard/a b'):
try:
validate_device_path(bad)
cases.append(('refuses unsafe path %r' % bad, False, 'it was accepted'))
except Refused as exc:
cases.append(('refuses unsafe path %r' % bad, True, str(exc)))
try:
validate_component('com.example.app/.MainActivity')
validate_component('.MainActivity', default_package='com.example.app')
cases.append(('accepts component forms', True, ''))
except Refused as exc:
cases.append(('accepts component forms', False, str(exc)))
for bad in ('com.example.app', 'com.example.app/', 'nodots/Main'):
try:
validate_component(bad)
cases.append(('refuses bad component %r' % bad, False, 'it was accepted'))
except Refused as exc:
cases.append(('refuses bad component %r' % bad, True, str(exc)))
argv = adb_shell('SERIAL', ['dumpsys', 'package', validate_package('com.example.app')])
# With quote=True the adb argv is `adb -s SERIAL shell "<one string for the device shell>"`, so
# the package must appear as a *quoted* token inside that string; with quote=False it must be its
# own argv element. Both forms are asserted -- an assertion that accepts only one of them is how
# this selftest first failed, by hard-coding a length instead of checking the property.
quoted_form = argv[-1] if argv[-2] == 'shell' else ''
direct = adb_shell('SERIAL', ['dumpsys', 'package', 'com.example.app'], quote=False)
cases.append(('quoted form carries the package as one quoted token',
"'com.example.app'" in quoted_form, quoted_form))
cases.append(('direct form carries the package as one argv element',
direct[-1] == 'com.example.app' and direct[-2] == 'package', ' '.join(direct)))
cases.append(('root form routes through su -c as a single string',
adb_shell('SERIAL', ['id'], root=True)[-1].startswith('su -c '),
adb_shell('SERIAL', ['id'], root=True)[-1]))
wrapped = su_wrap(['cat', '/data/local/tmp/x'])
cases.append(('su wrapper is a single quoted string',
wrapped.startswith('su -c \'') and wrapped.endswith('\''), wrapped))
return cases
def exit_code_for(main_fn):
"""Run a script's `main()` and turn a refusal into exit 2 with one line, never a traceback.
Kept here so every script that validates device values reports the same way: a malformed
identifier is a *usage* error (2), distinguishable from "the device said no" (1) and from a
defect in the tool (4).
"""
try:
return main_fn()
except Refused as exc:
print('refused: %s' % exc, file=sys.stderr)
print('RESULT=refused')
return 2
def main(argv=None):
ap = argparse.ArgumentParser(
prog='device_shell.py',
description='Validate Android identifiers and quote values for the device shell. '
'Importable as a module; the CLI is for checking a value by hand.',
epilog='examples:\n'
' device_shell.py --check com.example.app\n'
' device_shell.py --check com.example.app/.MainActivity\n'
' device_shell.py --quote "a b;c"\n'
' device_shell.py --selftest\n')
ap.add_argument('--check', metavar='VALUE',
help='validate as a package name')
ap.add_argument('--check-component', metavar='VALUE',
help='validate as package/Class, or as .Class together with --package')
ap.add_argument('--package', metavar='PKG', help='default package for --check-component')
ap.add_argument('--check-path', metavar='PATH', help='validate a device path')
ap.add_argument('--quote', metavar='VALUE', help='print the POSIX-quoted form')
ap.add_argument('--su', metavar='ARGV', help='print a su -c wrapper for a space-separated argv')
ap.add_argument('--selftest', action='store_true', help='assert this module\'s own claims')
ap.add_argument('--json', action='store_true')
args = ap.parse_args(argv)
if args.selftest:
cases = selftest()
bad = [c for c in cases if not c[1]]
for name, ok, detail in cases:
print(' %-52s %s' % (name, 'ok' if ok else 'FAIL'))
print()
print('RESULT=%s' % ('selftest_ok' if not bad else 'selftest_failed'))
return 0 if not bad else 4
try:
if args.check:
print('accepted: %s' % validate_package(args.check))
print('RESULT=accepted')
return 0
if args.check_component:
print('accepted: %s' % validate_component(args.check_component, args.package))
print('RESULT=accepted')
return 0
if args.check_path:
print('accepted: %s' % validate_device_path(args.check_path))
print('RESULT=accepted')
return 0
if args.quote:
if args.su:
print(su_wrap([args.quote]))
else:
print(sh_quote(args.quote))
print('RESULT=quoted')
return 0
if args.su:
print(su_wrap(args.su.split()))
print('RESULT=quoted')
return 0
except Refused as exc:
print('refused: %s' % exc, file=sys.stderr)
print('RESULT=refused')
return 1
ap.print_help()
print('RESULT=usage_error')
return 2
if __name__ == '__main__':
if hasattr(sys.stdout, 'reconfigure'):
try:
sys.stdout.reconfigure(encoding='utf-8')
except (ValueError, OSError):
pass
sys.exit(main())