Skip to content

Commit

Permalink
Adds Sensitive CMD token. (#155)
Browse files Browse the repository at this point in the history
  • Loading branch information
benjamin-thinkst authored and wleightond committed Jul 18, 2023
1 parent 52beac5 commit b798873
Show file tree
Hide file tree
Showing 12 changed files with 275 additions and 155 deletions.
32 changes: 32 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@


# Adding a new token
When adding a new token here are a set of steps / checkboxes that are useful to follow.
1. Add a file `canarytokens/{new_token}.py`. Use this file to define all `new_token` specific logic.
2. Create tests in `tests/units/test_new_token.py`. Check that a significant amount of this token specific code is covered by test.
Use: `coverage run --source=./canarytokens/{new_token}.py -m pytest tests/units/test_new_token.py` and view coverage: `coverage report -m`
3. Adding `new_token` models. Add `{new_token_type}TokenRequest`, `{new_token_type}TokenResponse` and `{new_token_type}` to `canarytokens/models.py::Class TokenTypes`.
Add `{new_token_type}TokenHit` and `{new_token_type}TokenHistory`.
Finally add these as entries to `AnyTokenHit, AnyTokenHistory, AnyTokenRequest, AnyTokenResponse`. This allows `parse_obj_as(AnyTokenXXX, data)` to return hydrated object.
4. Token creation happens in `./backend/app.py`. Add a `create_response` handler. This handler should hold all Token specific creation logic.
example:
```
@create_response.register
def _(
token_request_details: {new_token_type}TokenRequest,canarydrop:Canarydrop,
)->{new_token_type}TokenResponse:
...
# Save canarydrop with token specific details
```
5. Download happens in `./backend/app.py`. Add a `create_download_response` handler. This handler should hold all the token download specifics. Create a `Download{new_token_type}Request` and `Download{new_token_type}Response`
Example:
```
@create_download_response.register
def _(download_request_details:DownloadCMDRequest, canarydrop: Canarydrop)->DownloadCMDResponse:
"""Creates a download response for CMD token.
This holds a plain text `{token_value}.reg` file.
"""
return DownloadCMDResponse(...)
```

That should be all that is needed to create a new token.
50 changes: 48 additions & 2 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from sentry_sdk.integrations.redis import RedisIntegration

import canarytokens
from canarytokens import kubeconfig, queries
from canarytokens import kubeconfig, msreg, queries
from canarytokens import wireguard as wg
from canarytokens.authenticode import make_canary_authenticode_binary
from canarytokens.awskeys import get_aws_key
Expand All @@ -48,6 +48,8 @@
AWSKeyTokenResponse,
ClonedWebTokenRequest,
ClonedWebTokenResponse,
CMDTokenRequest,
CMDTokenResponse,
CustomBinaryTokenRequest,
CustomBinaryTokenResponse,
CustomImageTokenRequest,
Expand All @@ -56,6 +58,8 @@
DNSTokenResponse,
DownloadAWSKeysRequest,
DownloadAWSKeysResponse,
DownloadCMDRequest,
DownloadCMDResponse,
DownloadIncidentListCSVRequest,
DownloadIncidentListCSVResponse,
DownloadIncidentListJsonRequest,
Expand Down Expand Up @@ -202,6 +206,7 @@ async def _parse_for_x(request: Request, expected_type: Any) -> Any:
data = await request.form()
else:
raise HTTPException(status_code=422, detail="Invalid data")

return parse_obj_as(expected_type, data)


Expand Down Expand Up @@ -293,13 +298,15 @@ async def generate(request: Request) -> AnyTokenResponse:
Whatt
"""
if request.headers.get("Content-Type", "application/json") == "application/json":
token_request_details = parse_obj_as(AnyTokenRequest, await request.json())
data = await request.json()
token_request_details = parse_obj_as(AnyTokenRequest, data)
else:
# Need a mutable copy of the form data
token_request_form = dict(await request.form())
token_request_form["token_type"] = token_request_form.pop(
"type", token_request_form.get("token_type", None)
)

token_request_details = parse_obj_as(AnyTokenRequest, token_request_form)

if token_request_details.webhook_url:
Expand Down Expand Up @@ -477,6 +484,22 @@ def create_download_response(download_request_details, canarydrop: Canarydrop):
)


@create_download_response.register
def _(
download_request_details: DownloadCMDRequest, canarydrop: Canarydrop
) -> DownloadCMDResponse:
""""""
return DownloadCMDResponse(
token=download_request_details.token,
auth=download_request_details.auth,
content=msreg.make_canary_msreg(
token_hostname=canarydrop.get_hostname(),
process_name=canarydrop.cmd_process,
),
filename=f"{canarydrop.canarytoken.value()}.reg",
)


@create_download_response.register
def _(
download_request_details: DownloadMSWordRequest, canarydrop: Canarydrop
Expand Down Expand Up @@ -873,6 +896,29 @@ def _create_aws_key_token_response(
)


@create_response.register
def _(
token_request_details: CMDTokenRequest, canarydrop: Canarydrop
) -> CMDTokenResponse:
canarydrop.cmd_process = token_request_details.cmd_process_name
queries.save_canarydrop(canarydrop=canarydrop)
return CMDTokenResponse(
email=canarydrop.alert_email_recipient or "",
webhook_url=canarydrop.alert_webhook_url
if canarydrop.alert_webhook_url
else "",
token=canarydrop.canarytoken.value(),
token_url=canarydrop.get_url([canary_http_channel]),
auth_token=canarydrop.auth,
hostname=canarydrop.get_hostname(),
url_components=list(canarydrop.get_url_components()),
reg_file=msreg.make_canary_msreg(
token_hostname=canarydrop.get_hostname(),
process_name=canarydrop.cmd_process,
),
)


@create_response.register
def _(token_request_details: PDFTokenRequest, canarydrop: Canarydrop):
return PDFTokenResponse(
Expand Down
4 changes: 2 additions & 2 deletions backend/backend.env
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ CANARY_STATIC_FILES_APPLICATION_SUB_PATH = "/resources"
CANARY_STATIC_FILES_APPLICATION_INTERNAL_NAME = "resources"

# upload settings
CANARY_MAX_WEB_IMAGE_UPLOAD_SIZE=1024 * 1024 * 1
CANARY_MAX_WEB_IMAGE_UPLOAD_SIZE=1048576
CANARY_WEB_IMAGE_UPLOAD_PATH="../uploads"

# exe upload settings
CANARY_MAX_EXE_UPLOAD_SIZE=1024 * 1024 * 1
CANARY_MAX_EXE_UPLOAD_SIZE=1048576

# browser-based tokens return type
CANARY_TOKEN_RETURN="gif"
Expand Down
2 changes: 2 additions & 0 deletions canarytokens/canarydrop.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ class Canarydrop(BaseModel):
browser_scanner_enabled: Optional[bool]
# Wireguard specific stuff
wg_key: Optional[str]
# cmd specific stuff
cmd_process: Optional[str]

@root_validator(pre=True)
def _validate_triggered_details(cls, values):
Expand Down
54 changes: 51 additions & 3 deletions canarytokens/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ class TokenTypes(str, enum.Enum):
SLOW_REDIRECT = "slow_redirect"
KUBECONFIG = "kubeconfig"
LOG4SHELL = "log4shell"
CMD = "cmd"

def __str__(self) -> str:
return str(self.value)
Expand Down Expand Up @@ -320,6 +321,17 @@ class PDFTokenRequest(TokenRequest):
token_type: Literal[TokenTypes.ADOBE_PDF] = TokenTypes.ADOBE_PDF


class CMDTokenRequest(TokenRequest):
token_type: Literal[TokenTypes.CMD] = TokenTypes.CMD
cmd_process_name: str

@validator("cmd_process_name")
def check_process_name(value: str):
if not value.endswith(".exe"):
raise ValueError(f"cmd_process_name must end in .exe. Given: {value}")
return value


class KubeconfigTokenRequest(TokenRequest):
token_type: Literal[TokenTypes.KUBECONFIG] = TokenTypes.KUBECONFIG

Expand Down Expand Up @@ -462,6 +474,7 @@ class WindowsDirectoryTokenRequest(TokenRequest):

AnyTokenRequest = Annotated[
Union[
CMDTokenRequest,
FastRedirectTokenRequest,
QRCodeTokenRequest,
AWSKeyTokenRequest,
Expand Down Expand Up @@ -514,15 +527,18 @@ def normalize_names(cls, values: dict[str, Any]) -> dict[str, any]: # type: ign
("Auth", "auth_token"),
("Url", "token_url"),
]

for old_key, new_key in keys_to_convert: # pragma: no cover
if old_key in values and values[old_key] is not None:
values[new_key] = values.get(old_key)

return {k: v for k, v in values.items()}
return {k.lower(): v for k, v in values.items()}

def __init__(__pydantic_self__, **data: Any) -> None:
data["webhook_url"] = data.pop("webhook", "")
data["Url"] = data["token_url"]
if "token_url" in data:
data["Url"] = data.get("token_url")

super().__init__(**data)


Expand All @@ -539,6 +555,11 @@ class PDFTokenResponse(TokenResponse):
hostname: str # Hostname Local testing fails this check TODO: FIXME


class CMDTokenResponse(TokenResponse):
token_type: Literal[TokenTypes.CMD] = TokenTypes.CMD
reg_file: str


class QRCodeTokenResponse(TokenResponse):
token_type: Literal[TokenTypes.QR_CODE] = TokenTypes.QR_CODE
qrcode_png: str
Expand Down Expand Up @@ -571,7 +592,7 @@ def __init__(__pydantic_self__, **data: Any) -> None:
data["hostname"] = data.pop("Hostname")
if "Url_components" in data: # pragma: no cover
data["url_components"] = data.pop("Url_components")
if "Url" in data: # pragma: no cover
if "Url" in data and data["Url"]: # pragma: no cover
data["token_url"] = data.pop("Url")

if data.get("hostname", "") == "": # pragma: no cover
Expand Down Expand Up @@ -720,6 +741,7 @@ class MySQLTokenResponse(TokenResponse):

AnyTokenResponse = Annotated[
Union[
CMDTokenResponse,
CustomImageTokenResponse,
SMTPTokenResponse,
SvnTokenResponse,
Expand Down Expand Up @@ -1061,6 +1083,10 @@ class PDFTokenHit(TokenHit):
token_type: Literal[TokenTypes.ADOBE_PDF] = TokenTypes.ADOBE_PDF


class CMDTokenHit(TokenHit):
token_type: Literal[TokenTypes.CMD] = TokenTypes.CMD


class SMTPTokenHit(TokenHit):
token_type: Literal[TokenTypes.SMTP] = TokenTypes.SMTP
mail: SMTPMailField
Expand Down Expand Up @@ -1166,6 +1192,7 @@ class WireguardTokenHit(TokenHit):

AnyTokenHit = Annotated[
Union[
CMDTokenHit,
DNSTokenHit,
AWSKeyTokenHit,
PDFTokenHit,
Expand Down Expand Up @@ -1282,6 +1309,11 @@ class PDFTokenHistory(TokenHistory[PDFTokenHit]):
hits: List[PDFTokenHit]


class CMDTokenHistory(TokenHistory[CMDTokenHit]):
token_type: Literal[TokenTypes.CMD] = TokenTypes.CMD
hits: List[CMDTokenHit]


class SlowRedirectTokenHistory(TokenHistory[SlowRedirectTokenHit]):
token_type: Literal[TokenTypes.SLOW_REDIRECT] = TokenTypes.SLOW_REDIRECT
hits: List[SlowRedirectTokenHit]
Expand Down Expand Up @@ -1402,6 +1434,7 @@ class SvnTokenHistory(TokenHistory[SvnTokenHit]):
# TokenHistory where they differ only in `token_type`.
AnyTokenHistory = Annotated[
Union[
CMDTokenHistory,
DNSTokenHistory,
AWSKeyTokenHistory,
PDFTokenHistory,
Expand Down Expand Up @@ -1539,6 +1572,7 @@ class DownloadFmtTypes(str, enum.Enum):
INCIDENTLISTCSV = "incidentlist_csv"
MYSQL = "my_sql"
QRCODE = "qr_code"
CMD = "cmd"

def __str__(self) -> str:
return str(self.value)
Expand Down Expand Up @@ -1605,6 +1639,10 @@ class DownloadAWSKeysRequest(TokenDownloadRequest):
fmt: Literal[DownloadFmtTypes.AWSKEYS] = DownloadFmtTypes.AWSKEYS


class DownloadCMDRequest(TokenDownloadRequest):
fmt: Literal[DownloadFmtTypes.CMD] = DownloadFmtTypes.CMD


class DownloadKubeconfigRequest(TokenDownloadRequest):
fmt: Literal[DownloadFmtTypes.KUBECONFIG] = DownloadFmtTypes.KUBECONFIG

Expand All @@ -1616,6 +1654,7 @@ class DownloadSplackApiRequest(TokenDownloadRequest):
AnyDownloadRequest = Annotated[
Union[
DownloadAWSKeysRequest,
DownloadCMDRequest,
DownloadIncidentListCSVRequest,
DownloadIncidentListJsonRequest,
DownloadKubeconfigRequest,
Expand Down Expand Up @@ -1712,6 +1751,15 @@ class DownloadIncidentListCSVResponse(TokenDownloadResponse):
auth: str


class DownloadCMDResponse(TokenDownloadResponse):
contenttype: Literal[
DownloadContentTypes.TEXTPLAIN
] = DownloadContentTypes.TEXTPLAIN
filename: str
token: str
auth: str


class DownloadAWSKeysResponse(TokenDownloadResponse):
contenttype: Literal[
DownloadContentTypes.TEXTPLAIN
Expand Down
33 changes: 33 additions & 0 deletions canarytokens/msreg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from canarytokens.models import Hostname

REG_TEMPLATE = r"""Windows Registry Editor Version 5.00
; Sensitive command token generated by Thinkst Canary
; Run with admin privs on Windows machine as: reg import FILENAME
; command that will be watched for
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\{PROCESS}]
"GlobalFlag"=dword:00000200
; magic unique canarytoken that will be fired when this command is executed
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SilentProcessExit\{PROCESS}]
"ReportingMode"=dword:00000001
"MonitorProcess"="cmd.exe /c start /min powershell.exe -windowstyle hidden -command \\\"&{{$u=$(\\\\\\\"u$env:username\\\\\\\" -replace(\\\\\\\"[^\x00-\x7F]\s\\\\\\\", ''))[0..63] -join \\\\\\\"\\\\\\\";$c=$(\\\\\\\"c$env:computername\\\\\\\" -replace(\\\\\\\"[^\x00-\x7F]\s\\\\\\\", ''))[0..63] -join \\\\\\\"\\\\\\\";Resolve-DnsName -Name \\\\\\\"$c.UN.$u.CMD.{TOKEN_DNS}\\\\\\\"}}\\\""
"""


def make_canary_msreg(token_hostname: Hostname, process_name: str = "klist.exe") -> str:
"""Returns a Microsoft Register .reg file which has a canarytoken and
process name embedded in it.
The token is in a 'hostname' eg: {some}.{thing}.CMD.{token}.{canarytoken_hostname}
Args:
token_hostname (Hostname): {token}.{canarytoken_server_hostname} eg: 1234dsaa.canarytokens.com
process_name (str, optional): Name of the process to monitor.
If extension is not .exe .exe is appended. Defaults to 'klist.exe'.
Returns:
StringIO: a valid .reg file that is to be loaded on a windows machine.
"""
# TODO: use .endswith.
if process_name.find(".exe") == -1:
process_name += ".exe"

return REG_TEMPLATE.format(TOKEN_DNS=token_hostname, PROCESS=process_name)
16 changes: 16 additions & 0 deletions canarytokens/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
re.IGNORECASE,
)
log4_shell_pattern = re.compile(r"([A-Za-z0-9.-]*)\.L4J\.", re.IGNORECASE)
cmd_process_pattern = re.compile(r"(.+)\.UN\.(.+)\.CMD\.", re.IGNORECASE)

GIF = b"\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\xff\xff\xff\x21\xf9\x04\x01\x0a\x00\x01\x00\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x4c\x01\x00\x3b" # 1x1 GIF

Expand All @@ -93,6 +94,7 @@
"dtrace_file_open": dtrace_file_open,
"desktop_ini_browsing": desktop_ini_browsing_pattern,
"log4_shell": log4_shell_pattern,
"cmd_process": cmd_process_pattern,
}

# DESIGN: keeping the lib and apps separate called for some
Expand Down Expand Up @@ -307,6 +309,20 @@ def _dtrace_file_open(matches: Match[AnyStr]) -> Dict[str, str]:

# return data

@staticmethod
def _cmd_process(matches: Match[AnyStr]) -> dict[str, dict[str, AnyStr]]:
""""""
computer_name = matches.group(1)
user_name = matches.group(2)
data = {}
data["cmd_computer_name"] = "Not Obtained"
data["cmd_user_name"] = "Not Obtained"
if user_name and user_name != "u":
data["cmd_user_name"] = user_name[1:]
if computer_name and computer_name != "c":
data["cmd_computer_name"] = computer_name[1:]
return {"src_data": data}

@staticmethod
def _desktop_ini_browsing(matches: Match[AnyStr]) -> dict[str, dict[str, AnyStr]]:
username = matches.group(1)
Expand Down
Loading

0 comments on commit b798873

Please sign in to comment.