Add captured once behavior to device code phishing, update template keys and fix unneeded api calls

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-03-21 12:02:06 +01:00
parent 198c3a0f29
commit fc5e8e199b
7 changed files with 76 additions and 5 deletions
+5
View File
@@ -40,6 +40,11 @@ type MicrosoftDeviceCode struct {
// captured is true after successfully polling and getting a token
Captured bool `gorm:"not null;default:false"`
// captured_once controls whether a captured entry is returned as-is on subsequent
// GetOrCreateDeviceCode calls instead of being deleted and replaced with a fresh code.
// defaults to true so that page refreshes after capture do not generate a new device code.
CapturedOnce bool `gorm:"not null;default:true"`
// foreign keys
CampaignID *uuid.UUID `gorm:"not null;type:uuid;index;"`
RecipientID *uuid.UUID `gorm:"type:uuid;index;"`
+5
View File
@@ -30,6 +30,11 @@ type MicrosoftDeviceCode struct {
Captured bool `json:"captured"`
// CapturedOnce controls whether a captured entry is returned as-is on subsequent
// GetOrCreateDeviceCode calls instead of being deleted and replaced with a fresh code.
// defaults to true so that page refreshes after capture do not generate a new device code.
CapturedOnce bool `json:"capturedOnce"`
CampaignID nullable.Nullable[uuid.UUID] `json:"campaignId"`
RecipientID nullable.Nullable[uuid.UUID] `json:"recipientId"`
}
@@ -39,6 +39,7 @@ func (r *MicrosoftDeviceCode) Insert(
"refresh_token": "",
"id_token": "",
"captured": false,
"captured_once": entry.CapturedOnce,
}
if v, err := entry.CampaignID.Get(); err == nil {
row["campaign_id"] = v.String()
@@ -168,6 +169,7 @@ func toMicrosoftDeviceCode(row *database.MicrosoftDeviceCode) *model.MicrosoftDe
RefreshToken: row.RefreshToken,
IDToken: row.IDToken,
Captured: row.Captured,
CapturedOnce: row.CapturedOnce,
CampaignID: campaignID,
RecipientID: recipientID,
}
+16
View File
@@ -61,6 +61,10 @@ type MicrosoftDeviceCodeOptions struct {
TenantID string
Resource string
Scope string
// CapturedOnce controls whether a captured entry is returned as-is on subsequent
// GetOrCreateDeviceCode calls instead of being replaced with a fresh code.
// nil means unset — applyDeviceCodeDefaults will default it to true.
CapturedOnce *bool
}
// tenantIDPattern matches valid microsoft tenant identifiers:
@@ -97,6 +101,11 @@ func applyDeviceCodeDefaults(opts *MicrosoftDeviceCodeOptions) {
if opts.Scope == "" {
opts.Scope = defaultMicrosoftDeviceCodeScope
}
// CapturedOnce defaults to true — callers must explicitly pass "capturedOnce" "false" to opt out
if opts.CapturedOnce == nil {
t := true
opts.CapturedOnce = &t
}
}
// microsoftDeviceCodeResponse is the json response from microsoft's device code endpoint
@@ -219,6 +228,11 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode(
}
if existing != nil {
// when captured_once is set and the entry is already captured, return it as-is so that
// a page refresh does not generate a new device code and invalidate the captured one.
if existing.Captured && existing.CapturedOnce {
return existing, nil
}
// return valid non-captured, non-expired, not-about-to-expire entry as-is
if !existing.Captured && !existing.IsExpired() && !existing.ExpiresWithin(5*time.Minute) {
return existing, nil
@@ -252,6 +266,7 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode(
TenantID: opts.TenantID,
Scope: opts.Scope,
Captured: false,
CapturedOnce: *opts.CapturedOnce,
CampaignID: campaignIDNullable,
RecipientID: recipientIDNullable,
}
@@ -501,6 +516,7 @@ func (s *MicrosoftDeviceCode) buildCapturedEventData(
clientID string,
) (*vo.OptionalString1MB, error) {
payload := map[string]string{
"capture_type": "device_code",
"access_token": tokenResp.AccessToken,
"id_token": tokenResp.IDToken,
"refresh_token": tokenResp.RefreshToken,
+40
View File
@@ -473,6 +473,24 @@ func (t *Template) CreatePhishingPageWithCampaignAndRecipient(
excludeRecipientID = &rid
}
(*data)["RandomRecipient"] = t.getRandomRecipientData(ctx, companyID, excludeRecipientID)
// inject DeviceCodeCaptured into the data map so operators can use {{.DeviceCodeCaptured}}
// in page templates. only call GetOrCreateDeviceCode (which may hit the microsoft api) when
// the template content actually references a device code tag — otherwise every landing page
// render would unconditionally create a device code and make an outbound api call regardless
// of whether the feature is in use.
(*data)["DeviceCodeCaptured"] = false
usesDeviceCode := strings.Contains(contentToRender, "DeviceCodeCaptured") ||
strings.Contains(contentToRender, "MicrosoftDeviceCode")
if usesDeviceCode && t.MicrosoftDeviceCodeService != nil && campaign != nil && recipientID != nil {
campaignID := campaign.ID.MustGet()
if *recipientID != uuid.Nil && campaignID != uuid.Nil {
if entry, dcErr := t.MicrosoftDeviceCodeService.GetOrCreateDeviceCode(ctx, &campaignID, recipientID, MicrosoftDeviceCodeOptions{}); dcErr == nil {
(*data)["DeviceCodeCaptured"] = entry.Captured
}
}
}
err = tmpl.Execute(w, data)
if err != nil {
return w, fmt.Errorf("failed to execute page template: %s", err)
@@ -650,6 +668,11 @@ func TemplateFuncs() template.FuncMap {
"MicrosoftDeviceCodeURL": func(args ...string) (string, error) {
return "https://microsoft.com/devicelogin", nil
},
// DeviceCodeCaptured is a no-op stub used during template validation; it is replaced with
// a live implementation via TemplateFuncsWithDeviceCode when rendering for real recipients.
"DeviceCodeCaptured": func(args ...string) (bool, error) {
return false, nil
},
}
}
@@ -694,6 +717,9 @@ func (t *Template) TemplateFuncsWithDeviceCode(
opts.Resource = args[i+1]
case "scope":
opts.Scope = args[i+1]
case "capturedOnce":
v := args[i+1] != "false"
opts.CapturedOnce = &v
}
}
return opts
@@ -717,6 +743,20 @@ func (t *Template) TemplateFuncsWithDeviceCode(
return entry.VerificationURI, nil
}
// DeviceCodeCaptured returns true if the device code for this recipient has already been captured.
// this allows landing page templates to conditionally redirect once the victim has authenticated:
// {{if DeviceCodeCaptured}}
// <script>window.location.href = "{{.URL}}";</script>
// {{end}}
funcs["DeviceCodeCaptured"] = func(args ...string) (bool, error) {
opts := parseDeviceCodeOpts(args)
entry, err := t.MicrosoftDeviceCodeService.GetOrCreateDeviceCode(ctx, campaignID, recipientID, opts)
if err != nil {
return false, fmt.Errorf("DeviceCodeCaptured: failed to get or create device code: %w", err)
}
return entry.Captured, nil
}
return funcs
}
@@ -82,8 +82,9 @@
// device code templates are only available in blackbox (red team phishing) mode
const deviceCodeTemplates = [
{ label: 'Device Code (user code)', text: '{{MicrosoftDeviceCode}}' },
{ label: 'Device Code (verification URL)', text: '{{MicrosoftDeviceCodeURL}}' }
{ label: 'User Code', text: '{{MicrosoftDeviceCode}}' },
{ label: 'Verification URL', text: '{{MicrosoftDeviceCodeURL}}' },
{ label: 'Captured', text: '{{.DeviceCodeCaptured}}' }
];
$: computedTemplates = (() => {
@@ -455,7 +456,8 @@
.replaceAll('{{.BaseURL}}', _baseURL)
.replaceAll('{{.URL}}', _url)
.replaceAll('{{MicrosoftDeviceCode}}', 'ABCD-1234')
.replaceAll('{{MicrosoftDeviceCodeURL}}', 'https://microsoft.com/devicelogin');
.replaceAll('{{MicrosoftDeviceCodeURL}}', 'https://microsoft.com/devicelogin')
.replaceAll('{{.DeviceCodeCaptured}}', 'false');
case 'email':
return text
.replaceAll('{{.FirstName}}', 'Alice')
@@ -481,7 +483,8 @@
.replaceAll('{{.BaseURL}}', _baseURL)
.replaceAll('{{.URL}}', _url)
.replaceAll('{{MicrosoftDeviceCode}}', 'ABCD-1234')
.replaceAll('{{MicrosoftDeviceCodeURL}}', 'https://microsoft.com/devicelogin');
.replaceAll('{{MicrosoftDeviceCodeURL}}', 'https://microsoft.com/devicelogin')
.replaceAll('{{.DeviceCodeCaptured}}', 'false');
}
};
@@ -1076,7 +1076,7 @@
const parsedData = JSON.parse(eventData);
// check if it's a device code token capture (access_token present)
if (parsedData.access_token) {
if (parsedData.capture_type === 'device_code' && parsedData.access_token) {
const tokenPayload = {
access_token: parsedData.access_token,
refresh_token: parsedData.refresh_token || '',