Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for decrypting System DPAPI secrets #305

Merged
merged 15 commits into from
Sep 20, 2023
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ tests/data/plugins/browsers/firefox/places.sqlite filter=lfs diff=lfs merge=lfs
tests/data/plugins/browsers/chrome/History.sqlite filter=lfs diff=lfs merge=lfs -text
tests/data/plugins/browsers/edge/History.sqlite filter=lfs diff=lfs merge=lfs -text
tests/data/plugins/browsers/chromium/History.sqlite filter=lfs diff=lfs merge=lfs -text
tests/data/plugins/os/windows/dpapi/** filter=lfs diff=lfs merge=lfs -text
3 changes: 2 additions & 1 deletion dissect/target/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from dissect.target.exceptions import PluginError
from dissect.target.helpers import cache
from dissect.target.helpers.record import EmptyRecord
from dissect.target.helpers.utils import StrEnum

try:
from dissect.target.plugins._pluginlist import PLUGINS
Expand Down Expand Up @@ -52,7 +53,7 @@
log = logging.getLogger(__name__)


class OperatingSystem(enum.Enum):
class OperatingSystem(StrEnum):
LINUX = "linux"
WINDOWS = "windows"
ESXI = "esxi"
Expand Down
35 changes: 24 additions & 11 deletions dissect/target/plugins/apps/vpns/wireguard.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from collections import OrderedDict
from configparser import ConfigParser
from os.path import basename
from pathlib import Path
from typing import Iterator, Union

from dissect.target.exceptions import UnsupportedPluginError
from dissect.target.helpers.record import TargetRecordDescriptor
from dissect.target.plugin import Plugin, export
from dissect.target.plugin import OperatingSystem, Plugin, export

WireGuardInterfaceRecord = TargetRecordDescriptor(
"application/vpn/wireguard/interface",
Expand Down Expand Up @@ -62,33 +64,44 @@
- C:\\Program Files\\WireGuard\\Data\\Configurations
"""

config_globs = [
CONFIG_GLOBS = [
# Linux
"/etc/wireguard/*.conf",
# MacOS
"/usr/local/etc/wireguard/*.conf",
"/opt/homebrew/etc/wireguard/*.conf",
# Windows
"C:\\Windows\\System32\\config\\systemprofile\\AppData\\Local\\WireGuard\\Configurations\\*.dpapi",
"C:\\Program Files\\WireGuard\\Data\\Configurations\\*.dpapi",
]

def __init__(self, target) -> None:
super().__init__(target)
self.configs = []
for path in self.config_globs:
cfgs = list(self.target.fs.path().glob(path.lstrip("/")))
if len(cfgs) > 0:
for cfg in cfgs:
self.configs.append(cfg)
self.configs: list[Path] = []
for path in self.CONFIG_GLOBS:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is there a specific reason to attempt to read all files, instead of just the relevant ones?

Copy link
Member

Choose a reason for hiding this comment

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

Mostly readability and ease of expansion. Especially the Unix-y based paths would quickly get hairy and easy to forget when adding new operating system support to Dissect (e.g. BSD). It's a small amount of paths so I feel it's okay to "bruteforce" it.

self.configs.extend(self.target.fs.path().glob(path.lstrip("/")))
pyrco marked this conversation as resolved.
Show resolved Hide resolved

def check_compatible(self) -> bool:
if len(self.configs) > 0:
return True
if not self.configs:
raise UnsupportedPluginError("No Wireguard configuration files found")

Check warning on line 86 in dissect/target/plugins/apps/vpns/wireguard.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/apps/vpns/wireguard.py#L86

Added line #L86 was not covered by tests
return True

@export(record=[WireGuardInterfaceRecord, WireGuardPeerRecord])
def config(self) -> Iterator[Union[WireGuardInterfaceRecord, WireGuardPeerRecord]]:
"""Parses interface config files from wireguard installations."""

for config_path in self.configs:
config = _parse_config(config_path.read_text())
if self.target.os == OperatingSystem.WINDOWS and config_path.suffix == ".dpapi":
try:
config_buf = self.target.dpapi.decrypt_system_blob(config_path.read_bytes())
config_buf = config_buf.decode()
except ValueError:
self.target.log.warning("Failed to decrypt WireGuard configuration at %s", config_path)
continue

Check warning on line 100 in dissect/target/plugins/apps/vpns/wireguard.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/apps/vpns/wireguard.py#L95-L100

Added lines #L95 - L100 were not covered by tests
else:
config_buf = config_path.read_text()

config = _parse_config(config_buf)

for section in config.sections():
if "Interface" in section:
Expand Down
Empty file.
150 changes: 150 additions & 0 deletions dissect/target/plugins/os/windows/dpapi/blob.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
from typing import Optional
from uuid import UUID

from dissect.cstruct import cstruct

from dissect.target.plugins.os.windows.dpapi.crypto import (
CipherAlgorithm,
HashAlgorithm,
crypt_session_key_type1,
crypt_session_key_type2,
)

blob_def = """
struct DPAPIBlob {
DWORD dwVersion;
char provider[16];
DWORD mkVersion;
char guid[16];
DWORD flags;
DWORD descriptionLength;
char description[descriptionLength];
DWORD CipherAlgId;
DWORD keyLen;
DWORD saltLength;
char salt[saltLength];
DWORD strongLength;
char strong[strongLength];
DWORD CryptAlgId;
DWORD hashLen;
DWORD hmacLength;
char hmac[hmacLength];
DWORD cipherTextLength;
char cipherText[cipherTextLength];
DWORD signLength;
char sign[signLength];
};
"""

c_blob = cstruct()
c_blob.load(blob_def)


class Blob:
"""Represents a DPAPI blob."""

def __init__(self, data: bytes):
self._blob = c_blob.DPAPIBlob(data)

self.version = self._blob.dwVersion
self.provider = str(UUID(bytes_le=self._blob.provider))
self.mkversion = self._blob.mkVersion
self.guid = str(UUID(bytes_le=self._blob.guid))
self.flags = self._blob.flags
self.description = self._blob.description.decode("utf-16-le")
self.cipher_algorithm = CipherAlgorithm.from_id(self._blob.CipherAlgId)
self.key_len = self._blob.keyLen
self.salt = self._blob.salt
self.strong = self._blob.strong
self.hash_algorithm = HashAlgorithm.from_id(self._blob.CryptAlgId)
self.hash_len = self._blob.hashLen
self.hmac = self._blob.hmac
self.cipher_text = self._blob.cipherText

# All the blob data between the version, provider and sign fields
self.blob = data[20 : -(self._blob.signLength + 4)]
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm slightly worried about this . It's basically hardcoded magic values which (theoretically but super unlikely) could change.
I don't think that it will ever change, so if you prefer this as opposed to another solution, that's fine

Copy link
Member

Choose a reason for hiding this comment

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

Made it more dynamic, does that satisfy? I intend to add a offsetof function to cstruct to make this easier/more concise.

self.sign = self._blob.sign

self.clear_text = None
self.decrypted = False
self.sign_computed = None

def decrypt(
self,
master_key: bytes,
entropy: Optional[bytes] = None,
strong_password: Optional[str] = None,
smart_card_secret: Optional[bytes] = None,
) -> bool:
"""Try to decrypt the blob with the given master key.

Args:
master_key: Decrypted master key value.
entropy: Optional entropy for decrypting the blob.
strong_password: Optional password for decrypting the blob.
smart_card_secret: MS Next Gen Crypto secret (e.g. from PIN code).

Returns:
True if decryption is succesful, False otherwise.
"""
if self.decrypted:
return True

Check warning on line 91 in dissect/target/plugins/os/windows/dpapi/blob.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/os/windows/dpapi/blob.py#L91

Added line #L91 was not covered by tests

for algo in [crypt_session_key_type1, crypt_session_key_type2]:
session_key = algo(
master_key,
self.salt,
self.hash_algorithm,
entropy=entropy,
smart_card_secret=smart_card_secret,
strong_password=strong_password,
)
key = self.cipher_algorithm.derive_key(session_key, self.hash_algorithm)
self.clear_text = self.cipher_algorithm.decrypt(self.cipher_text, key)

padding = self.clear_text[-1]
if padding <= self.cipher_algorithm.block_length:
self.clear_text = self.clear_text[:-padding]

# Check against provided HMAC
self.sign_computed = algo(
master_key,
self.hmac,
self.hash_algorithm,
entropy=entropy,
smart_card_secret=smart_card_secret,
verify_blob=self.blob,
)

self.decrypted = self.sign_computed == self.sign
if self.decrypted:
return True

self.decrypted = False
return self.decrypted

Check warning on line 124 in dissect/target/plugins/os/windows/dpapi/blob.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/os/windows/dpapi/blob.py#L123-L124

Added lines #L123 - L124 were not covered by tests

def __repr__(self) -> str:
s = [

Check warning on line 127 in dissect/target/plugins/os/windows/dpapi/blob.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/os/windows/dpapi/blob.py#L127

Added line #L127 was not covered by tests
"DPAPI BLOB",
"\n".join(
(
"\tversion = %(version)d",
"\tprovider = %(provider)s",
"\tmkey = %(guid)s",
"\tflags = %(flags)#x",
"\tdescr = %(description)s",
"\tcipher_algo = %(cipher_algorithm)r",
"\thash_algo = %(hash_algorithm)r",
)
)
% self.__dict__,
"\tsalt = %s" % self.salt.hex(),
"\thmac = %s" % self.hmac.hex(),
"\tcipher = %s" % self.cipher_text.hex(),
"\tsign = %s" % self.sign.hex(),
]
if self.sign_computed is not None:
s.append("\tsign_computed = %s" % self.sign_computed.hex())
if self.clear_text is not None:
s.append("\tcleartext = %r" % self.clear_text)
return "\n".join(s)

Check warning on line 150 in dissect/target/plugins/os/windows/dpapi/blob.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/os/windows/dpapi/blob.py#L146-L150

Added lines #L146 - L150 were not covered by tests
Loading
Loading