Skip to content
Merged
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
297 changes: 165 additions & 132 deletions dementor/assets/Dementor.toml

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions dementor/config/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,13 @@ class SessionConfig(TomlConfig):
ntlm_challenge: bytes
ntlm_disable_ess: bool
ntlm_disable_ntlmv2: bool
ntlm_target_type: str
ntlm_version: bytes
ntlm_nb_computer: str
ntlm_nb_domain: str
ntlm_dns_computer: str
ntlm_dns_domain: str
ntlm_dns_tree: str
analysis: bool
loop: asyncio.AbstractEventLoop

Expand Down
30 changes: 24 additions & 6 deletions dementor/config/toml.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,12 +212,25 @@ def _set_field(
# Resolve the default value (if any) by walking the hierarchy.
# --------------------------------------------------------------- #
if default_val is not _LOCAL:
# Priority: own section > alternative section > Globals (if allowed)
sections = [
get_value(section or "", key=None, default={}),
get_value(alt_section or "", key=None, default={}),
]
# When the original qname was dotted (e.g. "NTLM.Challenge"),
# the own-section lookup checks the nested sub-dict first
# (e.g. SMB.NTLM.Challenge), then the flat key (e.g.
# SMB.Challenge), then the alt section (e.g. [NTLM]), then
# Globals. The nested-first order ensures that explicit
# overrides like ``NTLM.NetBIOSComputer = "NTLMBOX99"``
# shadow the SMB-layer ``NetBIOSComputer = "SMBBOX01"``.
own_section_dict = get_value(section or "", key=None, default={})

sections = []
if alt_section:
# 1. Nested sub-dict within own section (e.g. SMB.NTLM.X)
sections.append(own_section_dict.get(alt_section, {}))
# 2. Own section flat key (e.g. SMB.X — doubles as default)
sections.append(own_section_dict)
# 3. Alt section (e.g. [NTLM])
sections.append(get_value(alt_section or "", key=None, default={}))
if not section_local:
# 4. Globals
sections.append(get_value("Globals", key=None, default={}))

for section_config in sections:
Expand All @@ -229,7 +242,12 @@ def _set_field(
# Pull the actual value from the caller-supplied ``config`` dict,
# falling back to the default we just resolved.
# ----------------------------------------------------------------- #
value = config.get(qname, default_val)
# For dotted qnames, also check the nested sub-dict in the
# instance config (e.g. [[SMB.Server]] with NTLM.X = ...).
if alt_section:
value = config.get(alt_section, {}).get(qname, default_val)
else:
value = config.get(qname, default_val)
if value is _LOCAL:
# ``_LOCAL`` means “required but not supplied”.
raise ValueError(
Expand Down
12 changes: 12 additions & 0 deletions dementor/log/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,28 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# pyright: reportAny=false, reportExplicitAny=false
import os
import sys
import threading

from typing import Any
from rich.console import Console

# When stdout is not a TTY (e.g., redirected to a file), Rich defaults to
# 80 columns and wraps long lines. Use COLUMNS env var if set, otherwise
# force a wide width (200) for file output so log lines stay on one line.
# On a real TTY, Rich auto-detects the terminal width.
_width: int | None = None
if not sys.stdout.isatty():
_width = int(os.environ.get("COLUMNS", "200"))

dm_console: Console = Console(
soft_wrap=True,
tab_size=4,
highlight=False,
highlighter=None,
width=_width,
no_color=not sys.stdout.isatty(),
)
"""Rich Console instance for thread-safe terminal output.

Expand Down
41 changes: 22 additions & 19 deletions dementor/protocols/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,9 @@
from dementor.db import _CLEARTEXT, normalize_client_address, _NO_USER
from dementor.paths import HTTP_TEMPLATES_PATH
from dementor.protocols.ntlm import (
NTLM_AUTH_CreateChallenge,
ATTR_NTLM_CHALLENGE,
ATTR_NTLM_DISABLE_ESS,
ATTR_NTLM_DISABLE_NTLMV2,
NTLM_report_auth,
NTLM_split_fqdn,
NTLM_build_challenge_message,
NTLM_handle_negotiate_message,
NTLM_handle_authenticate_message,
)


Expand Down Expand Up @@ -140,9 +137,6 @@ class HTTPServerConfig(TomlConfig):
A("http_cert", "Cert", None, section_local=False),
A("http_cert_key", "Key", None, section_local=False),
A("http_use_ssl", "TLS", False, factory=is_true),
ATTR_NTLM_CHALLENGE,
ATTR_NTLM_DISABLE_ESS,
ATTR_NTLM_DISABLE_NTLMV2,
]

if typing.TYPE_CHECKING:
Expand All @@ -159,9 +153,6 @@ class HTTPServerConfig(TomlConfig):
http_cert: str | None
http_cert_key: str | None
http_use_ssl: bool
ntlm_challenge: bytes
ntlm_disable_ess: bool
ntlm_disable_ntlmv2: bool

def set_http_templates(self, templates_dirs: list[str]):
dirs: list[str] = []
Expand Down Expand Up @@ -252,6 +243,11 @@ class HTTPHeaders:


class HTTPHandler(BaseHTTPRequestHandler):
# NTLM is a connection-based auth — the 3-message handshake must happen
# on a single persistent connection. HTTP/1.0 closes after each response,
# breaking the handshake. HTTP/1.1 keeps the connection alive by default.
protocol_version = "HTTP/1.1"

def __init__(
self,
session: SessionConfig,
Expand Down Expand Up @@ -444,29 +440,36 @@ def auth_ntlm(self, token, logger, scheme=None):
match message:
case ntlm.NTLM_HTTP_AuthNegotiate():
self.display_request("NTLMSSP_NEGOTIATE", logger)
challenge = NTLM_AUTH_CreateChallenge(
self._ntlm_negotiate_fields = NTLM_handle_negotiate_message(
message, logger
)
challenge = NTLM_build_challenge_message(
message,
*NTLM_split_fqdn(self.config.http_fqdn),
challenge=self.config.ntlm_challenge,
disable_ess=self.config.ntlm_disable_ess,
disable_ntlmv2=self.config.ntlm_disable_ntlmv2,
challenge=self.session.ntlm_challenge,
nb_computer=self.session.ntlm_nb_computer,
nb_domain=self.session.ntlm_nb_domain,
disable_ess=self.session.ntlm_disable_ess,
disable_ntlmv2=self.session.ntlm_disable_ntlmv2,
log=logger,
)
self.send_response(HTTPStatus.UNAUTHORIZED, "Unauthorized")
data = base64.b64encode(challenge.getData()).decode()
self.send_header(
HTTPHeaders.WWW_AUTHENTICATE, f"{scheme or 'NTLM'} {data}"
)
self.send_header("Content-Length", "0")
self.end_headers()

case ntlm.NTLM_HTTP_AuthChallengeResponse():
self.display_request("NTLMSSP_AUTH", logger)
NTLM_report_auth(
NTLM_handle_authenticate_message(
message,
challenge=self.config.ntlm_challenge,
challenge=self.session.ntlm_challenge,
client=self.client_address,
session=self.session,
logger=logger,
extras=self.get_extras(),
negotiate_fields=getattr(self, "_ntlm_negotiate_fields", None),
)
self.finish_request(logger)

Expand Down
42 changes: 17 additions & 25 deletions dementor/protocols/imap.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,9 @@
from dementor.config.session import SessionConfig
from dementor.loader import BaseProtocolModule, DEFAULT_ATTR
from dementor.protocols.ntlm import (
NTLM_AUTH_CreateChallenge,
NTLM_report_auth,
NTLM_split_fqdn,
ATTR_NTLM_CHALLENGE,
ATTR_NTLM_DISABLE_ESS,
ATTR_NTLM_DISABLE_NTLMV2,
NTLM_build_challenge_message,
NTLM_handle_authenticate_message,
NTLM_handle_negotiate_message,
)
from dementor.servers import (
ServerThread,
Expand Down Expand Up @@ -97,9 +94,6 @@ class IMAPServerConfig(TomlConfig):
A("imap_auth_mechanisms", "AuthMechanisms", IMAP_AUTH_MECHS),
A("imap_banner", "Banner", "IMAP4rev2 service ready"),
A("imap_downgrade", "Downgrade", True),
ATTR_NTLM_CHALLENGE,
ATTR_NTLM_DISABLE_ESS,
ATTR_NTLM_DISABLE_NTLMV2,
ATTR_KEY,
ATTR_CERT,
ATTR_TLS,
Expand All @@ -112,12 +106,6 @@ class IMAPServerConfig(TomlConfig):
imap_auth_mechanisms: list[str]
imap_banner: str
imap_downgrade: bool
ntlm_challenge: bytes
ntlm_disable_ess: bool
ntlm_disable_ntlmv2: bool
ntlm_key: str
ntlm_cert: str
ntlm_tls: bool


class IMAP(BaseProtocolModule[IMAPServerConfig]):
Expand Down Expand Up @@ -177,7 +165,7 @@ def _push(self, msg: str, seq=True) -> None:
self._write_line(line)

def _write_line(self, msg: str) -> None:
self.logger.debug(repr(msg), is_server=True)
self.logger.debug(f"S: {msg!r}")
self.send(f"{msg}\r\n".encode("utf-8", "strict"))

# There are three possible server completion responses:
Expand Down Expand Up @@ -227,7 +215,7 @@ def challenge_auth(
# If the client wishes to cancel an authentication exchange, it issues a line consisting
# of a single "*"
resp = self.rfile.readline(1024).strip()
self.logger.debug(repr(resp), is_client=True)
self.logger.debug(f"C: {resp!r}")
if resp == b"*":
self.bad("Authentication canceled")
raise StopHandler
Expand Down Expand Up @@ -284,7 +272,7 @@ def recv_line(self, size: int) -> str | None:
data = self.rfile.readline(size)
if data:
text = data.decode("utf-8", errors="replace").strip()
self.logger.debug(repr(data), is_client=True)
self.logger.debug(f"C: {data!r}")
return text

# implementation
Expand Down Expand Up @@ -365,12 +353,15 @@ def auth_NTLM(self, initial_response=None):
return self.bad("NTLM negotiation failed")

# IMAP4_AUTHENTICATE_NTLM_Blob_Response
challenge = NTLM_AUTH_CreateChallenge(
negotiate_fields = NTLM_handle_negotiate_message(negotiate, self.logger)
challenge = NTLM_build_challenge_message(
negotiate,
*NTLM_split_fqdn(self.server_config.imap_fqdn),
challenge=self.server_config.ntlm_challenge,
disable_ess=self.server_config.ntlm_disable_ess,
disable_ntlmv2=self.server_config.ntlm_disable_ntlmv2,
challenge=self.config.ntlm_challenge,
nb_computer=self.config.ntlm_nb_computer,
nb_domain=self.config.ntlm_nb_domain,
disable_ess=self.config.ntlm_disable_ess,
disable_ntlmv2=self.config.ntlm_disable_ntlmv2,
log=self.logger,
)

# IMAP4_AUTHENTICATE_NTLM_Blob_Command
Expand All @@ -382,12 +373,13 @@ def auth_NTLM(self, initial_response=None):
self.logger.debug(f"NTLM authentication failed: {e}")
return self.bad("NTLM authentication failed")

NTLM_report_auth(
NTLM_handle_authenticate_message(
auth_message,
challenge=self.server_config.ntlm_challenge,
challenge=self.config.ntlm_challenge,
client=self.client_address,
logger=self.logger,
session=self.config,
negotiate_fields=negotiate_fields,
)

if self.server_config.imap_downgrade:
Expand Down
55 changes: 25 additions & 30 deletions dementor/protocols/ldap.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,9 @@
)
from dementor.db import _CLEARTEXT
from dementor.protocols.ntlm import (
NTLM_AUTH_CreateChallenge,
NTLM_report_auth,
NTLM_split_fqdn,
ATTR_NTLM_CHALLENGE,
ATTR_NTLM_DISABLE_ESS,
ATTR_NTLM_DISABLE_NTLMV2,
NTLM_build_challenge_message,
NTLM_handle_authenticate_message,
NTLM_handle_negotiate_message,
)

__proto__ = ["LDAP"]
Expand Down Expand Up @@ -106,9 +103,6 @@ class LDAPServerConfig(TomlConfig):
A("ldap_tls_key", "Key", None, section_local=False),
A("ldap_tls_cert", "Cert", None, section_local=False),
A("ldap_error_code", "ErrorCode", "unwillingToPerform"),
ATTR_NTLM_CHALLENGE,
ATTR_NTLM_DISABLE_ESS,
ATTR_NTLM_DISABLE_NTLMV2,
]

if typing.TYPE_CHECKING:
Expand Down Expand Up @@ -174,20 +168,16 @@ def handle_NTLM_Negotiate(
) -> None | bool:
negotiate = NTLMAuthNegotiate()
negotiate.fromString(nego_token_raw)
self.negotiate_fields = NTLM_handle_negotiate_message(negotiate, self.logger)

fqdn = self.server.server_config.ldap_fqdn
if "." in fqdn:
name, domain = fqdn.split(".", 1)
else:
name, domain = fqdn, ""

ntlm_challenge = NTLM_AUTH_CreateChallenge(
ntlm_challenge = NTLM_build_challenge_message(
negotiate,
name,
domain,
challenge=self.server.server_config.ntlm_challenge,
disable_ess=self.server.server_config.ntlm_disable_ess,
disable_ntlmv2=self.server.server_config.ntlm_disable_ntlmv2,
challenge=self.config.ntlm_challenge,
nb_computer=self.config.ntlm_nb_computer,
nb_domain=self.config.ntlm_nb_domain,
disable_ess=self.config.ntlm_disable_ess,
disable_ntlmv2=self.config.ntlm_disable_ntlmv2,
log=self.logger,
)
# return bind success with challenge message
self.send(self.server.bind_result(req, matched_dn=ntlm_challenge.getData()))
Expand All @@ -196,12 +186,13 @@ def handle_NTLM_Negotiate(
def handle_NTLM_Auth(self, req: LDAPMessage, blob: bytes) -> None | bool:
auth_message = NTLMAuthChallengeResponse()
auth_message.fromString(blob)
NTLM_report_auth(
NTLM_handle_authenticate_message(
auth_token=auth_message,
challenge=self.server.server_config.ntlm_challenge,
challenge=self.config.ntlm_challenge,
client=self.client_address,
logger=self.logger,
session=self.config,
negotiate_fields=getattr(self, "negotiate_fields", None),
)
self.send(
self.server.bind_result(
Expand Down Expand Up @@ -270,12 +261,15 @@ def handle_sasl_GSS_SPNEGO(self, message, bind_auth):
if data[8] == 0x01:
token = ntlm.NTLMAuthNegotiate()
token.fromString(data)
ntlm_challenge = NTLM_AUTH_CreateChallenge(
self.negotiate_fields = NTLM_handle_negotiate_message(token, self.logger)
ntlm_challenge = NTLM_build_challenge_message(
token,
*NTLM_split_fqdn(self.server.server_config.ldap_fqdn),
challenge=self.server.server_config.ntlm_challenge,
disable_ess=self.server.server_config.ntlm_disable_ess,
disable_ntlmv2=self.server.server_config.ntlm_disable_ntlmv2,
challenge=self.config.ntlm_challenge,
nb_computer=self.config.ntlm_nb_computer,
nb_domain=self.config.ntlm_nb_domain,
disable_ess=self.config.ntlm_disable_ess,
disable_ntlmv2=self.config.ntlm_disable_ntlmv2,
log=self.logger,
)
return self.send(
self.server.bind_result(
Expand All @@ -288,12 +282,13 @@ def handle_sasl_GSS_SPNEGO(self, message, bind_auth):
if data[8] == 0x03: # AUTH
token = ntlm.NTLMAuthChallengeResponse()
token.fromString(data)
NTLM_report_auth(
NTLM_handle_authenticate_message(
auth_token=token,
challenge=self.server.server_config.ntlm_challenge,
challenge=self.config.ntlm_challenge,
client=self.client_address,
logger=self.logger,
session=self.config,
negotiate_fields=getattr(self, "negotiate_fields", None),
)
return self.send(
self.server.bind_result(
Expand Down
Loading