Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions dissect/target/helpers/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,17 @@ def DynamicDescriptor(types: Sequence[str]) -> RecordDescriptor:
COMMON_UNIX_FIELDS,
)

AndroidUserRecord = TargetRecordDescriptor(
"android/user",
[
*COMMON_UNIX_FIELDS,
("string", "usertype"),
("datetime", "last_login"),
("datetime", "last_foreground"),
("varint", "flags"),
],
)

EmptyRecord = RecordDescriptor(
"empty",
[],
Expand Down
56 changes: 52 additions & 4 deletions dissect/target/plugins/os/unix/linux/android/_os.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from typing import TYPE_CHECKING

from dissect.target.helpers import configutil
from dissect.target.helpers.record import EmptyRecord
from dissect.target.helpers.record import AndroidUserRecord
from dissect.target.plugin import OperatingSystem, export
from dissect.target.plugins.os.unix.linux._os import LinuxPlugin
from dissect.target.plugins.os.unix.linux.android.util.abx import ABX, ABXSettingsFile

if TYPE_CHECKING:
from collections.abc import Iterator
Expand Down Expand Up @@ -59,6 +60,19 @@ def create(cls, target: Target, sysvol: Filesystem) -> Self:

@export(property=True)
def hostname(self) -> str | None:
"""Return the likely hostname of this Android device."""
# Try the first user's ``device_name`` first
if (path := self.target.fs.path("/data/system/users/0/settings_global.xml")).is_file():
try:
return ABXSettingsFile(path).get("device_name")
except ValueError:
pass

# Try ``net.hostname``
if net_hostname := self.props.get("net.hostname"):
return net_hostname

# Fallback to ``ro.build.host`` property
return self.props.get("ro.build.host")

@export(property=True)
Expand All @@ -82,9 +96,43 @@ def version(self) -> str:
def os(self) -> str:
return OperatingSystem.ANDROID.value

@export(record=EmptyRecord)
def users(self) -> Iterator[EmptyRecord]:
yield from ()
@export(record=AndroidUserRecord)
def users(self) -> Iterator[AndroidUserRecord]:
"""Yield all configured users of this Android system."""
if not (users_dir := self.target.fs.path("/data/system/users")).is_dir():
return

for path in users_dir.iterdir():
# Parse the /data/system/users/$id.xml ABX file for basic user information.
# Currently we do not parse /data/system/users/0/settings_(config|global|secure).xml
if not path.is_dir() or not (file := path.parent.joinpath(path.name + ".xml")).is_file():
continue
try:
abx = ABX(file)
user = abx.tree.find(".")
except ValueError as e:
self.target.log.warning("Unable to parse %s: %s", file, e)
continue

id = None
name = None
try:
id = user.attrib["id"]
name = user.find("./name").text
except Exception:
pass

yield AndroidUserRecord(
name=name,
uid=id,
home=f"/data/media/{id}",
last_login=user.attrib["lastLoggedIn"] / 1000,
last_foreground=user.attrib["lastEnteredForeground"] / 1000,
flags=user.attrib["flags"],
usertype=user.attrib["type"],
source=file,
_target=self.target,
)


def find_build_props(fs: Filesystem) -> Iterator[Path]:
Expand Down
Empty file.
225 changes: 225 additions & 0 deletions dissect/target/plugins/os/unix/linux/android/util/abx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
from __future__ import annotations

import base64
import struct
from enum import IntEnum
from pathlib import Path
from typing import TYPE_CHECKING, Any, BinaryIO
from xml.etree.ElementTree import Element, ElementTree, SubElement

from dissect.cstruct import u16, u32, u64

if TYPE_CHECKING:
from collections.abc import Callable


class XmlType(IntEnum):
START_DOCUMENT = 0
END_DOCUMENT = 1
START_TAG = 2
END_TAG = 3
TEXT = 4
CDSECT = 5
ENTITY_REF = 6
IGNORABLE_WHITESPACE = 7
PROCESSING_INSTRUCTION = 8
COMMENT = 9
DOCDECL = 10
ATTRIBUTE = 15


class DataType(IntEnum):
NULL = 1 << 4
STRING = 2 << 4
STRING_INTERNED = 3 << 4
BYTES_HEX = 4 << 4
BYTES_BASE64 = 5 << 4
INT = 6 << 4
INT_HEX = 7 << 4
LONG = 8 << 4
LONG_HEX = 9 << 4
FLOAT = 10 << 4
DOUBLE = 11 << 4
BOOLEAN_TRUE = 12 << 4
BOOLEAN_FALSE = 13 << 4


class ABX:
"""Android binary XML (ABX) implementation.

References:
- https://www.cclsolutionsgroup.com/post/android-abx-binary-xml
- https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/com/android/internal/util/BinaryXmlSerializer.java
- https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/com/android/internal/util/BinaryXmlPullParser.java
"""

def __init__(self, path: Path | BinaryIO, *, to_str: bool = False) -> None:
if isinstance(path, Path):
self.path = path
self.fh = path.open("rb")
elif hasattr(path, "read"):
self.path = None
self.fh = path
else:
raise ValueError("Expected Path or file-like object")

if (magic := self.fh.read(4)) != b"ABX\x00":
raise ValueError(f"Unexpected magic value {magic!r}")

self.READ_MAP: dict[DataType, Callable] = {
DataType.NULL: lambda: None,
DataType.BOOLEAN_TRUE: lambda: True,
DataType.BOOLEAN_FALSE: lambda: False,
DataType.INT: lambda: u32(self.fh.read(4), endian="big", sign=True),
DataType.INT_HEX: lambda: f"{u32(self.fh.read(4), endian='big', sign=True):x}",
DataType.LONG: lambda: u64(self.fh.read(8), endian="big", sign=True),
DataType.LONG_HEX: lambda: f"{u64(self.fh.read(8), endian='big', sign=True):x}",
DataType.FLOAT: lambda: struct.unpack(">f", self.fh.read(4))[0],
DataType.DOUBLE: lambda: struct.unpack(">d", self.fh.read(8))[0],
Comment thread
Schamper marked this conversation as resolved.
DataType.BYTES_HEX: self._read_bytes,
DataType.BYTES_BASE64: lambda: base64.b64encode(self._read_bytes()).decode().strip(),
DataType.STRING: self._read_string,
DataType.STRING_INTERNED: self._read_string_interned,
}

self.interned_strings = []
self.tree = self.read(to_str=to_str)

def __repr__(self) -> str:
return f"<AbxFile path={self.path.name if self.path else self.fh}>"

def _read_token(self) -> tuple[XmlType, DataType]:
"""Reads a byte to determine :class:`XmlType` and :class:`DataType`."""
token = self.fh.read(1)
xml_type = XmlType(token[0] & 0x0F) # lower nibble
data_type = DataType(token[0] & 0xF0) # upper nibble
return xml_type, data_type

def _read_bytes(self) -> bytes:
len = u16(self.fh.read(2), endian="big", sign=False)
return self.fh.read(len)

def _read_string(self) -> str:
len = u16(self.fh.read(2), endian="big", sign=False)
return self.fh.read(len).decode()

def _read_string_interned(self) -> str:
ref = u16(self.fh.read(2), endian="big", sign=True)
if ref == -1:
value = self._read_string()
self.interned_strings.append(value)
else:
value = self.interned_strings[ref]
return value

def read(self, to_str: bool = False) -> ElementTree:
"""Read the ABX file, returns XML :class:`ElementTree`."""
elements: list[Element] = []
document_open = False
root_closed = False

# Start by placing everything in a root element. If we later discover the document
# only has one element in this root, we replace the root element with that element.
root = Element("root")
elements.append(root)

while True:
xml_type, data_type = self._read_token()

if xml_type == XmlType.START_DOCUMENT:
if data_type != DataType.NULL:
raise ValueError(f"XmlType.START_DOCUMENT should have DataType.NULL, got {data_type!r}")
document_open = True

elif xml_type == XmlType.END_DOCUMENT:
if data_type != DataType.NULL:
raise ValueError(f"XmlType.END_DOCUMENT should have DataType.NULL, got {data_type!r}")
if len(elements) != 1:
raise ValueError(f"XmlType.END_DOCUMENT with unclosed elements ({elements!r}) at {self.fh.tell()}")
if not document_open:
raise ValueError(f"XmlType.END_DOCUMENT before XmlType.START_DOCUMENT at {self.fh.tell()}")
break

elif xml_type == XmlType.START_TAG:
if data_type != DataType.STRING_INTERNED:
raise ValueError(f"XmlType.START_TAG should have DataType.STRING_INTERNED, got {data_type!r}")
if not document_open:
raise ValueError(f"XmlType.START_TAG before XmlType.START_DOCUMENT at {self.fh.tell()}")
if root_closed:
raise ValueError(f"XmlType.START_TAG after XmlType.END_TAG for root element at {self.fh.tell()}")

name = self._read_string_interned()
if len(elements) == 0:
element = Element(name)
root = element
else:
element = SubElement(elements[-1], name)
elements.append(element)

elif xml_type == XmlType.END_TAG:
if data_type != DataType.STRING_INTERNED:
raise ValueError(f"XmlType.END_TAG should have DataType.STRING_INTERNED, got {data_type!r}")
if len(elements) == 1:
raise ValueError(f"XmlType.END_TAG without any elements in stack at {self.fh.tell()}")

name = self._read_string_interned()

if name != (other := elements[-1].tag):
raise ValueError(f"XmlType.END_TAG for {name!r} encountered, expected for {other!r}")

last = elements.pop()
if len(elements) == 0:
root_closed = True
root = last

elif xml_type == XmlType.TEXT:
value = self._read_string()
if len(elements[-1]):
if len(value.strip()) == 0:
continue
raise ValueError(f"XmlType.TEXT with mixed content encountered at {self.fh.tell()}")

if elements[-1].text is None:
elements[-1].text = value
else:
elements[-1].text += value

elif xml_type == XmlType.ATTRIBUTE:
if len(elements) == 1:
raise ValueError(f"XmlType.ATTRIBUTE encountered outside any open element at {self.fh.tell()}")

name = self._read_string_interned()
if name in elements[-1].attrib:
raise ValueError(
f"Duplicate XmlType.ATTRIBUTE {name} encountered for element {elements[-1]} at {self.fh.tell()}"
)

reader = self.READ_MAP.get(data_type)
if not reader:
raise ValueError(f"Unsupported DataType {data_type!r}")

value = reader()
elements[-1].attrib[name] = str(value) if to_str else value

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The to_str feels like it's a post processing step intertwined with a parsing step. Is there no alternative way to achieve the same?


if not (root_closed or (len(elements) == 1 and elements[0] is root)):
raise ValueError("Document contains unclosed elements")

# If the root we created at the start only contains one child, we make that node the root.
if len(children := root.findall("./")) == 1:
root = children[0]

return ElementTree(root)


class ABXSettingsFile(ABX):
"""Android binary ABX settings file parser."""

def get(self, key: str, *, value_only: bool = True) -> Any:
"""Return the value of the given setting name."""
if (node := self.tree.find(f"./settings/setting[@name='{key}']")) is not None:
return node.attrib["value"] if value_only else node

return None

def get_node(self, key: str) -> Any:
return self.get(key, value_only=False)
23 changes: 23 additions & 0 deletions dissect/target/plugins/os/unix/linux/android/util/xml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from __future__ import annotations

from typing import BinaryIO
from xml.etree.ElementTree import ElementTree

from defusedxml import ElementTree as ET

from dissect.target.plugins.os.unix.linux.android.util.abx import ABX


def read_android_xml(fh: BinaryIO) -> ElementTree:
"""Convert a readable stream of bytes which might be plaintext XML or ABX to an ElementTree."""
offset = fh.tell()
magic = fh.read(4)
fh.seek(offset)

if magic == b"ABX\x00":
return ABX(fh).tree

if magic == b"<?xm":
return ElementTree(ET.fromstring(fh.read().decode()))

raise ValueError(f"Unexpected magic {magic!r}")
21 changes: 21 additions & 0 deletions dissect/target/tools/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,14 @@
from datetime import datetime, timedelta, timezone
from enum import IntEnum
from pathlib import Path, PurePosixPath
from pprint import pprint
from tarfile import BLKTYPE, CHRTYPE, DIRTYPE, FIFOTYPE, LNKTYPE, REGTYPE, SYMTYPE
from typing import TYPE_CHECKING, Any, BinaryIO, ClassVar, TextIO

from defusedxml import ElementTree

from dissect.target.plugins.os.unix.linux.android.util.abx import ABX

try:
# Allow disabling prompt_toolkit via environment variable
if os.getenv("NO_PROMPT_TOOLKIT", "0") in {"1", "true", "True", "TRUE"}:
Expand Down Expand Up @@ -1504,6 +1509,22 @@ def cmd_hexdump(self, args: argparse.Namespace, stdout: TextIO) -> bool:

return False

@arg("path", type=TargetPathArgument)
def cmd_abx(self, args: argparse.Namespace, stdout: TextIO) -> bool:
"""Print the contents of an Android ABX file."""
paths = list(self.resolve_glob_path(args.path))
for path in paths:
if len(paths) > 1:
print(path)
try:
abx = ABX(path, to_str=True)
pprint(ElementTree.tostring(abx.tree.getroot()).decode(), stream=stdout)
except ValueError as e:
print(f"Failed to parse file {path}: {e}")
continue

return False

@arg("path", type=TargetPathArgument)
@alias("digest")
@alias("shasum")
Expand Down
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/unix/linux/android/users/0.xml
Git LFS file not shown
Git LFS file not shown
Git LFS file not shown
Loading