Skip to content

Commit 4b7bddc

Browse files
JSCU-CNISchamper
andauthored
Add certificate parsing to webserver plugins (#1415)
Co-authored-by: Erik Schamper <[email protected]>
1 parent e0cbfed commit 4b7bddc

File tree

13 files changed

+342
-39
lines changed

13 files changed

+342
-39
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
from __future__ import annotations
2+
3+
import base64
4+
import binascii
5+
import hashlib
6+
from pathlib import Path
7+
8+
from flow.record import RecordDescriptor
9+
10+
try:
11+
from asn1crypto import pem, x509
12+
13+
HAS_ASN1 = True
14+
15+
except ImportError:
16+
HAS_ASN1 = False
17+
18+
19+
COMMON_CERTIFICATE_FIELDS = [
20+
("digest", "fingerprint"),
21+
("varint", "serial_number"),
22+
("datetime", "not_valid_before"),
23+
("datetime", "not_valid_after"),
24+
("string", "issuer_dn"),
25+
("string", "subject_dn"),
26+
("bytes", "pem"),
27+
]
28+
29+
CertificateRecord = RecordDescriptor(
30+
"certificate",
31+
[
32+
*COMMON_CERTIFICATE_FIELDS,
33+
],
34+
)
35+
36+
# Translation layer for asn1crypto names to RFC4514 names.
37+
# References: https://github.com/wbond/asn1crypto/blob/master/asn1crypto/x509.py @ NameType
38+
# References: https://github.com/pyca/cryptography/blob/main/src/cryptography/x509/name.py
39+
NAMEOID_TO_NAME = {
40+
"common_name": "CN", # 2.5.4.3
41+
"country_name": "C", # 2.5.4.6
42+
"locality_name": "L", # 2.5.4.7
43+
"state_or_province_name": "ST", # 2.5.4.8
44+
"street_address": "STREET", # 2.5.4.9
45+
"organization_name": "O", # 2.5.4.10
46+
"organizational_unit_name": "OU", # 2.5.4.11
47+
"domain_component": "DC", # 0.9.2342.192.00300.100.1.25
48+
"user_id": "UID", # 0.9.2342.192.00300.100.1.1
49+
}
50+
51+
52+
def compute_pem_fingerprints(pem: str | bytes) -> tuple[str, str, str]:
53+
"""Compute the MD5, SHA-1 and SHA-256 fingerprint hash of a x509 certificate PEM."""
54+
55+
if pem is None:
56+
raise ValueError("No PEM provided")
57+
58+
if isinstance(pem, bytes):
59+
pem = pem.decode()
60+
61+
elif not isinstance(pem, str):
62+
raise TypeError("Provided PEM is not str or bytes")
63+
64+
stripped_pem = pem.strip().removeprefix("-----BEGIN CERTIFICATE-----").removesuffix("-----END CERTIFICATE-----")
65+
66+
try:
67+
der = base64.b64decode(stripped_pem)
68+
except binascii.Error as e:
69+
raise ValueError(f"Unable to parse PEM: {e!s}") from e
70+
71+
md5 = hashlib.md5(der).hexdigest()
72+
sha1 = hashlib.sha1(der).hexdigest()
73+
sha256 = hashlib.sha256(der).hexdigest()
74+
75+
return md5, sha1, sha256
76+
77+
78+
def parse_x509(file: str | bytes | Path) -> CertificateRecord:
79+
"""Parses a PEM file. Returns a CertificateREcord. Does not parse a public key embedded in a x509 certificate."""
80+
81+
if isinstance(file, str):
82+
content = file.encode()
83+
84+
elif isinstance(file, bytes):
85+
content = file
86+
87+
elif isinstance(file, Path) or hasattr(file, "read_bytes"):
88+
content = file.read_bytes()
89+
90+
else:
91+
raise TypeError("Parameter file is not of type str, bytes or Path")
92+
93+
if not HAS_ASN1:
94+
raise ValueError("Missing asn1crypto dependency")
95+
96+
md5, _, _ = compute_pem_fingerprints(content.decode())
97+
_, _, der = pem.unarmor(content)
98+
crt = x509.Certificate.load(der)
99+
100+
issuer = []
101+
for key, value in crt.issuer.native.items():
102+
issuer.append(f"{NAMEOID_TO_NAME.get(key, key)}={value}")
103+
104+
subject = []
105+
for key, value in crt.subject.native.items():
106+
subject.append(f"{NAMEOID_TO_NAME.get(key, key)}={value}")
107+
108+
return CertificateRecord(
109+
not_valid_before=crt.not_valid_before,
110+
not_valid_after=crt.not_valid_after,
111+
issuer_dn=",".join(issuer),
112+
subject_dn=",".join(subject),
113+
fingerprint=(md5, crt.sha1.hex(), crt.sha256.hex()),
114+
serial_number=crt.serial_number,
115+
pem=crt.dump(),
116+
)

dissect/target/plugins/apps/webserver/apache.py

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
from typing import TYPE_CHECKING, NamedTuple
99

1010
from dissect.target.exceptions import FileNotFoundError, UnsupportedPluginError
11-
from dissect.target.helpers.fsutil import open_decompress
11+
from dissect.target.helpers.certificate import parse_x509
12+
from dissect.target.helpers.fsutil import TargetPath, open_decompress
1213
from dissect.target.plugin import OperatingSystem, export
1314
from dissect.target.plugins.apps.webserver.webserver import (
1415
WebserverAccessLogRecord,
16+
WebserverCertificateRecord,
1517
WebserverErrorLogRecord,
1618
WebserverHostRecord,
1719
WebserverPlugin,
@@ -423,6 +425,7 @@ def access(self) -> Iterator[WebserverAccessLogRecord]:
423425

424426
yield WebserverAccessLogRecord(
425427
ts=datetime.strptime(log["ts"], "%d/%b/%Y:%H:%M:%S %z"),
428+
webserver=self.__namespace__,
426429
remote_user=clean_value(log["remote_user"]),
427430
remote_ip=log["remote_ip"],
428431
local_ip=clean_value(log.get("local_ip")),
@@ -472,6 +475,7 @@ def error(self) -> Iterator[WebserverErrorLogRecord]:
472475

473476
yield WebserverErrorLogRecord(
474477
ts=ts,
478+
webserver=self.__namespace__,
475479
pid=log.get("pid"),
476480
remote_ip=remote_ip,
477481
module=log["module"],
@@ -495,34 +499,69 @@ def hosts(self) -> Iterator[WebserverHostRecord]:
495499
- https://httpd.apache.org/docs/2.4/mod/core.html#virtualhost
496500
"""
497501

502+
def _map_path(path: str | None) -> TargetPath:
503+
return self.target.fs.path(path) if path else None
504+
498505
for path in self.virtual_hosts:
499506
# A configuration file can contain multiple VirtualHost directives.
500-
current_vhost = {}
507+
vhost = {}
501508
for line in path.open("rt"):
502509
line_lower = line.lower()
503510
if "<virtualhost" in line_lower:
504511
# Currently only supports a single addr:port combination.
505512
if match := RE_VIRTUALHOST.match(line.lstrip()):
506-
current_vhost = match.groupdict()
513+
vhost = match.groupdict()
507514
else:
508515
self.target.log.warning("Unable to parse VirtualHost directive %r in %s", line, path)
509-
current_vhost = {}
516+
vhost = {}
510517

511518
elif "</virtualhost" in line_lower:
512519
yield WebserverHostRecord(
513520
ts=path.lstat().st_mtime,
514-
server_name=current_vhost.get("servername") or current_vhost.get("addr"),
515-
server_port=current_vhost.get("port"),
516-
root_path=current_vhost.get("documentroot"),
517-
access_log_config=current_vhost.get("customlog", "").rpartition(" ")[0],
518-
error_log_config=current_vhost.get("errorlog"),
521+
webserver=self.__namespace__,
522+
server_name=vhost.get("servername") or vhost.get("addr"),
523+
server_port=vhost.get("port"),
524+
root_path=_map_path(vhost.get("documentroot")),
525+
access_log_config=_map_path(vhost.get("customlog", "").rpartition(" ")[0]),
526+
error_log_config=_map_path(vhost.get("errorlog")),
527+
tls_certificate=_map_path(vhost.get("sslcertificatefile")),
528+
tls_key=_map_path(vhost.get("sslcertificatekeyfile")),
519529
source=path,
520530
_target=self.target,
521531
)
522532

523533
else:
524534
key, _, value = line.strip().partition(" ")
525-
current_vhost[key.lower()] = value
535+
vhost[key.lower()] = value
536+
537+
@export(record=WebserverCertificateRecord)
538+
def certificates(self) -> Iterator[WebserverCertificateRecord]:
539+
"""Return host certificates for found Apache ``VirtualHost`` directives."""
540+
certs = set()
541+
542+
for host in self.hosts():
543+
if host.tls_certificate and (cert_path := self.target.fs.path(host.tls_certificate)).is_file():
544+
certs.add(cert_path)
545+
546+
if self.server_root:
547+
for cert_path in itertools.chain(self.server_root.glob("**/*.crt"), self.server_root.glob("**/*.pem")):
548+
if cert_path not in certs:
549+
certs.add(cert_path)
550+
551+
for cert_path in certs:
552+
try:
553+
cert = parse_x509(cert_path)
554+
yield WebserverCertificateRecord(
555+
ts=cert_path.lstat().st_mtime,
556+
webserver=self.__namespace__,
557+
**cert._asdict(),
558+
host=host.server_name,
559+
source=cert_path,
560+
_target=self.target,
561+
)
562+
except Exception as e: # noqa: PERF203
563+
self.target.log.warning("Unable to parse certificate %s :%s", cert_path, e)
564+
self.target.log.debug("", exc_info=e)
526565

527566
def _iterate_log_lines(self, paths: list[Path]) -> Iterator[tuple[str, Path]]:
528567
"""Iterate through a list of paths and yield tuples of loglines and the path of the file where they're from."""

dissect/target/plugins/apps/webserver/caddy.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ def access(self) -> Iterator[WebserverAccessLogRecord]:
150150
log = match.groupdict()
151151
yield WebserverAccessLogRecord(
152152
ts=datetime.strptime(log["ts"], "%d/%b/%Y:%H:%M:%S %z"),
153+
webserver="caddy",
153154
remote_ip=log["remote_ip"],
154155
method=log["method"],
155156
uri=log["uri"],

dissect/target/plugins/apps/webserver/iis.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ def access(self) -> Iterator[WebserverAccessLogRecord]:
168168
for iis_record in self.logs():
169169
yield WebserverAccessLogRecord(
170170
ts=iis_record.ts,
171+
webserver="iis",
171172
remote_user=iis_record.username,
172173
remote_ip=iis_record.client_ip,
173174
method=iis_record.request_method,

dissect/target/plugins/apps/webserver/nginx.py

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
import json
44
import re
55
from datetime import datetime
6+
from itertools import chain
67
from typing import TYPE_CHECKING
78

89
from dissect.target.exceptions import UnsupportedPluginError
9-
from dissect.target.helpers.fsutil import open_decompress
10+
from dissect.target.helpers.certificate import parse_x509
11+
from dissect.target.helpers.fsutil import TargetPath, open_decompress
1012
from dissect.target.plugin import export
1113
from dissect.target.plugins.apps.webserver.webserver import (
1214
WebserverAccessLogRecord,
15+
WebserverCertificateRecord,
1316
WebserverErrorLogRecord,
1417
WebserverHostRecord,
1518
WebserverPlugin,
@@ -245,6 +248,7 @@ def access(self) -> Iterator[WebserverAccessLogRecord]:
245248

246249
yield WebserverAccessLogRecord(
247250
ts=ts,
251+
webserver="nginx",
248252
bytes_sent=bytes_sent,
249253
**log,
250254
source=path,
@@ -287,6 +291,7 @@ def error(self) -> Iterator[WebserverErrorLogRecord]:
287291

288292
yield WebserverErrorLogRecord(
289293
ts=ts,
294+
webserver="nginx",
290295
**log,
291296
source=path,
292297
_target=self.target,
@@ -300,34 +305,71 @@ def hosts(self) -> Iterator[WebserverHostRecord]:
300305
- https://nginx.org/en/docs/http/ngx_http_core_module.html#server
301306
"""
302307

303-
def yield_record(current_server: dict) -> Iterator[WebserverHostRecord]:
304-
yield WebserverHostRecord(
305-
ts=host_path.lstat().st_mtime,
306-
server_name=current_server.get("server_name") or current_server.get("listen"),
307-
server_port=current_server.get("listen"),
308-
root_path=current_server.get("root"),
309-
access_log_config=current_server.get("access_log"),
310-
error_log_config=current_server.get("error_log"),
311-
source=host_path,
312-
_target=self.target,
313-
)
314-
315308
for host_path in self.host_paths:
316-
current_server = {}
309+
server = {}
317310
seen_server_directive = False
318311
for line in host_path.open("rt"):
319312
if "server {" in line:
320-
if current_server:
321-
yield from yield_record(current_server)
322-
current_server = {}
313+
if server:
314+
yield construct_hosts_record(self.target, host_path, server)
315+
server = {}
323316
seen_server_directive = True
324317

325318
elif seen_server_directive:
326319
key, _, value = line.strip().partition(" ")
327-
current_server[key] = value.rstrip(";")
328-
329-
if current_server:
330-
yield from yield_record(current_server)
320+
server[key] = value.rstrip(";").strip()
321+
322+
if server:
323+
yield construct_hosts_record(self.target, host_path, server)
324+
325+
@export(record=WebserverCertificateRecord)
326+
def certificates(self) -> Iterator[WebserverCertificateRecord]:
327+
"""Return found server certificates in the NGINX configuration."""
328+
certs = set()
329+
330+
for host in self.hosts():
331+
# Parse x509 certificate
332+
if host.tls_certificate and (cert_path := self.target.fs.path(host.tls_certificate)).is_file():
333+
certs.add(cert_path)
334+
335+
root = self.target.fs.path("/etc/nginx")
336+
for cert_path in chain(root.glob("**/*.crt"), root.glob("**/*.pem")):
337+
if cert_path not in certs:
338+
certs.add(cert_path)
339+
340+
for cert_path in certs:
341+
try:
342+
cert = parse_x509(cert_path)
343+
yield WebserverCertificateRecord(
344+
ts=cert_path.lstat().st_mtime,
345+
webserver="nginx",
346+
**cert._asdict(),
347+
host=host.server_name,
348+
source=cert_path,
349+
_target=self.target,
350+
)
351+
except Exception as e: # noqa: PERF203
352+
self.target.log.warning("Unable to parse certificate %s :%s", cert_path, e)
353+
self.target.log.debug("", exc_info=e)
354+
355+
356+
def construct_hosts_record(target: Target, host_path: Path, server: dict) -> WebserverHostRecord:
357+
def _map_path(path: str | None) -> TargetPath:
358+
return target.fs.path(path) if path else None
359+
360+
return WebserverHostRecord(
361+
ts=host_path.lstat().st_mtime,
362+
webserver="nginx",
363+
server_name=server.get("server_name") or server.get("listen"),
364+
server_port=server.get("listen", "").replace(" ssl", "") or None,
365+
root_path=_map_path(server.get("root")),
366+
access_log_config=_map_path(server.get("access_log")),
367+
error_log_config=_map_path(server.get("error_log")),
368+
tls_certificate=_map_path(server.get("ssl_certificate")),
369+
tls_key=_map_path(server.get("ssl_certificate_key")),
370+
source=host_path,
371+
_target=target,
372+
)
331373

332374

333375
def parse_json_line(line: str) -> dict[str, str] | None:

0 commit comments

Comments
 (0)