diff --git a/backend/app/server.go b/backend/app/server.go index fce1a5c..801b59b 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -1750,7 +1750,25 @@ func (s *Server) renderPageTemplate( if err != nil { return fmt.Errorf("failed to create phishing page: %s", err) } - c.Data(http.StatusOK, "text/html; charset=utf-8", phishingPage.Bytes()) + + // apply obfuscation if enabled + pageContent := phishingPage.Bytes() + if campaign != nil { + if obfuscate, err := campaign.Obfuscate.Get(); err == nil && obfuscate { + s.logger.Debugw("obfuscating page", "campaignID", campaign.ID.MustGet().String(), "pageID", page.ID.MustGet().String()) + obfuscated, err := utils.ObfuscateHTML(string(pageContent), utils.DefaultObfuscationConfig()) + if err != nil { + s.logger.Errorw("failed to obfuscate page", "error", err) + } else { + s.logger.Debugw("page obfuscated successfully", "originalSize", len(pageContent), "obfuscatedSize", len(obfuscated)) + pageContent = []byte(obfuscated) + } + } else { + s.logger.Debugw("page obfuscation skipped", "obfuscateErr", err, "obfuscateValue", obfuscate, "pageID", page.ID.MustGet().String()) + } + } + + c.Data(http.StatusOK, "text/html; charset=utf-8", pageContent) c.Abort() s.logger.Debugw("served phishing page", "pageID", page.ID.MustGet().String(), diff --git a/backend/database/campaign.go b/backend/database/campaign.go index 1187838..8392e4e 100644 --- a/backend/database/campaign.go +++ b/backend/database/campaign.go @@ -42,6 +42,7 @@ type Campaign struct { SaveSubmittedData bool `gorm:"not null;default:false"` IsAnonymous bool `gorm:"not null;default:false"` IsTest bool `gorm:"not null;default:false"` + Obfuscate bool `gorm:"not null;default:false"` // has one CampaignTemplateID *uuid.UUID `gorm:"index;type:uuid;"` diff --git a/backend/model/campaign.go b/backend/model/campaign.go index 09d53d9..ff4922d 100644 --- a/backend/model/campaign.go +++ b/backend/model/campaign.go @@ -36,6 +36,7 @@ type Campaign struct { SaveSubmittedData nullable.Nullable[bool] `json:"saveSubmittedData"` IsAnonymous nullable.Nullable[bool] `json:"isAnonymous"` IsTest nullable.Nullable[bool] `json:"isTest"` + Obfuscate nullable.Nullable[bool] `json:"obfuscate"` TemplateID nullable.Nullable[uuid.UUID] `json:"templateID"` Template *CampaignTemplate `json:"template"` CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"` @@ -318,6 +319,12 @@ func (c *Campaign) ToDBMap() map[string]any { m["is_anonymous"] = v } } + if c.Obfuscate.IsSpecified() { + m["obfuscate"] = false + if v, err := c.Obfuscate.Get(); err == nil { + m["obfuscate"] = v + } + } if c.TemplateID.IsSpecified() { m["campaign_template_id"] = nil if v, err := c.TemplateID.Get(); err == nil { diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index a8bfbd8..be4749a 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -595,6 +595,14 @@ func (m *ProxyHandler) resolveSessionContext(req *http.Request, reqCtx *RequestC return fmt.Errorf("invalid session type") } reqCtx.Session = session + + // copy campaign from session to reqCtx for existing sessions + if session.Campaign != nil { + reqCtx.Campaign = session.Campaign + reqCtx.CampaignID = session.CampaignID + reqCtx.CampaignRecipientID = session.CampaignRecipientID + reqCtx.RecipientID = session.RecipientID + } } // populate config map once @@ -919,6 +927,20 @@ func (m *ProxyHandler) rewriteResponseBodyWithContext(resp *http.Response, reqCt body = m.applyURLPathRewrites(body, reqCtx) body = m.applyCustomReplacements(body, reqCtx.Session) + // apply obfuscation if enabled + if reqCtx.Campaign != nil && strings.Contains(contentType, "text/html") { + if obfuscate, err := reqCtx.Campaign.Obfuscate.Get(); err == nil && obfuscate { + obfuscated, err := utils.ObfuscateHTML(string(body), utils.DefaultObfuscationConfig()) + if err != nil { + m.logger.Errorw("failed to obfuscate html", "error", err) + } else { + body = []byte(obfuscated) + // obfuscated content is already compressed, don't re-compress + wasCompressed = false + } + } + } + m.updateResponseBody(resp, body, wasCompressed) resp.Header.Set("Cache-Control", "no-cache, no-store") } @@ -1040,6 +1062,20 @@ func (m *ProxyHandler) rewriteResponseBodyWithoutSessionContext(resp *http.Respo body = m.applyURLPathRewritesWithoutSession(body, reqCtx) body = m.applyCustomReplacementsWithoutSession(body, config, reqCtx.TargetDomain) + // apply obfuscation if enabled + if reqCtx.Campaign != nil && strings.Contains(contentType, "text/html") { + if obfuscate, err := reqCtx.Campaign.Obfuscate.Get(); err == nil && obfuscate { + obfuscated, err := utils.ObfuscateHTML(string(body), utils.DefaultObfuscationConfig()) + if err != nil { + m.logger.Errorw("failed to obfuscate html", "error", err) + } else { + body = []byte(obfuscated) + // obfuscated content is already compressed, don't re-compress + wasCompressed = false + } + } + } + m.updateResponseBody(resp, body, wasCompressed) if m.shouldCacheControlContent(contentType) { resp.Header.Set("Cache-Control", "no-cache, no-store") @@ -3262,6 +3298,16 @@ func (m *ProxyHandler) serveEvasionPageResponseDirect(req *http.Request, reqCtx return nil } + // apply obfuscation if enabled + if obfuscate, err := campaign.Obfuscate.Get(); err == nil && obfuscate { + obfuscated, err := utils.ObfuscateHTML(htmlContent, utils.DefaultObfuscationConfig()) + if err != nil { + m.logger.Errorw("failed to obfuscate evasion page", "error", err) + } else { + htmlContent = obfuscated + } + } + // create HTTP response resp := &http.Response{ StatusCode: 200, @@ -3333,6 +3379,16 @@ func (m *ProxyHandler) serveDenyPageResponseDirect(req *http.Request, reqCtx *Re return nil } + // apply obfuscation if enabled + if obfuscate, err := campaign.Obfuscate.Get(); err == nil && obfuscate { + obfuscated, err := utils.ObfuscateHTML(htmlContent, utils.DefaultObfuscationConfig()) + if err != nil { + m.logger.Errorw("failed to obfuscate deny page", "error", err) + } else { + htmlContent = obfuscated + } + } + // create HTTP response resp := &http.Response{ StatusCode: 200, diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go index ad23f40..fdce4f8 100644 --- a/backend/repository/campaign.go +++ b/backend/repository/campaign.go @@ -1459,6 +1459,7 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) { saveSubmittedData := nullable.NewNullableWithValue(row.SaveSubmittedData) isAnonymous := nullable.NewNullableWithValue(row.IsAnonymous) isTest := nullable.NewNullableWithValue(row.IsTest) + obfuscate := nullable.NewNullableWithValue(row.Obfuscate) var templateID nullable.Nullable[uuid.UUID] if row.CampaignTemplateID != nil { templateID = nullable.NewNullableWithValue(*row.CampaignTemplateID) @@ -1585,6 +1586,7 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) { SaveSubmittedData: saveSubmittedData, IsAnonymous: isAnonymous, IsTest: isTest, + Obfuscate: obfuscate, TemplateID: templateID, Template: template, RecipientGroups: recipientGroups, diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 85c7ee0..d5283dc 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -1264,6 +1264,9 @@ func (c *Campaign) UpdateByID( if v, err := incoming.IsTest.Get(); err == nil { current.IsTest.Set(v) } + if v, err := incoming.Obfuscate.Get(); err == nil { + current.Obfuscate.Set(v) + } if v, err := incoming.SortField.Get(); err == nil { current.SortField.Set(v) } diff --git a/backend/utils/obfuscate.go b/backend/utils/obfuscate.go new file mode 100644 index 0000000..4eb50a1 --- /dev/null +++ b/backend/utils/obfuscate.go @@ -0,0 +1,354 @@ +package utils + +import ( + "bytes" + "compress/gzip" + "crypto/rand" + "encoding/base64" + "fmt" + "math/big" + "strings" +) + +// ObfuscationConfig controls how the obfuscation behaves +type ObfuscationConfig struct { + // MinSplits is the minimum number of parts to split strings into + MinSplits int + // MaxSplits is the maximum number of parts to split strings into + MaxSplits int + // UseNumberSuffix determines if variable names should have number suffixes + UseNumberSuffix bool + // MinNumberSuffix is the minimum value for number suffixes + MinNumberSuffix int + // MaxNumberSuffix is the maximum value for number suffixes + MaxNumberSuffix int + // UseXOR determines if strings should be XOR encrypted + UseXOR bool + // MinXORKey is the minimum XOR key value (1-255) + MinXORKey int + // MaxXORKey is the maximum XOR key value (1-255) + MaxXORKey int +} + +// DefaultObfuscationConfig returns sensible defaults for obfuscation +func DefaultObfuscationConfig() ObfuscationConfig { + return ObfuscationConfig{ + MinSplits: 2, + MaxSplits: 4, + UseNumberSuffix: true, + MinNumberSuffix: 0, + MaxNumberSuffix: 9, + UseXOR: true, + MinXORKey: 1, + MaxXORKey: 255, + } +} + +// ObfuscateHTML obfuscates HTML content using compression, base64 encoding, +// and random variable names to make it difficult to fingerprint +func ObfuscateHTML(html string, config ObfuscationConfig) (string, error) { + // generate random variable names to avoid fingerprinting (need early for xor function name) + varNames := generateRandomVariableNames(11, config) + xorFuncName := varNames[9] + windowVar := varNames[10] + + // randomly select method to access window object + windowAccessor := getRandomWindowAccessor() + // compress the HTML to reduce size and add another layer + compressed, err := compressGzip([]byte(html)) + if err != nil { + return "", fmt.Errorf("failed to compress html: %w", err) + } + + // encode to base64 + encoded := base64.StdEncoding.EncodeToString(compressed) + + // split the base64 payload to avoid detection + encodedSplit := splitStringRandom(encoded, config, xorFuncName) + + // split critical strings to avoid detection + atobSplit := splitStringRandom("atob", config, xorFuncName) + uint8ArraySplit := splitStringRandom("Uint8Array", config, xorFuncName) + fromSplit := splitStringRandom("from", config, xorFuncName) + charCodeAtSplit := splitStringRandom("charCodeAt", config, xorFuncName) + responseSplit := splitStringRandom("Response", config, xorFuncName) + bufferSplit := splitStringRandom("buffer", config, xorFuncName) + bodySplit := splitStringRandom("body", config, xorFuncName) + pipeThroughSplit := splitStringRandom("pipeThrough", config, xorFuncName) + decompressionStreamSplit := splitStringRandom("DecompressionStream", config, xorFuncName) + gzipSplit := splitStringRandom("gzip", config, xorFuncName) + textSplit := splitStringRandom("text", config, xorFuncName) + thenSplit := splitStringRandom("then", config, xorFuncName) + documentSplit := splitStringRandom("document", config, xorFuncName) + openSplit := splitStringRandom("open", config, xorFuncName) + writeSplit := splitStringRandom("write", config, xorFuncName) + closeSplit := splitStringRandom("close", config, xorFuncName) + + // create xor helper function if needed + xorFunc := "" + if config.UseXOR { + // obfuscate the xor function internals + xorVars := generateRandomVariableNames(4, config) + // create a minimal config without xor to avoid recursion + noXorConfig := config + noXorConfig.UseXOR = false + fromCharCodeSplit := splitStringRandom("fromCharCode", noXorConfig, "") + parseIntSplit := splitStringRandom("parseInt", noXorConfig, "") + substrSplit := splitStringRandom("substr", noXorConfig, "") + lengthSplit := splitStringRandom("length", noXorConfig, "") + + xorFunc = fmt.Sprintf(`function %s(%s,%s){var %s='';for(var %s=0;%s<%s[%s];%s+=2)%s+=String[%s](%s[%s](%s[%s](%s,2),16)^%s);return %s;}`, + xorFuncName, xorVars[0], xorVars[1], xorVars[2], xorVars[3], + xorVars[3], xorVars[0], lengthSplit, xorVars[3], xorVars[2], + fromCharCodeSplit, windowVar, parseIntSplit, xorVars[0], substrSplit, xorVars[3], + xorVars[1], xorVars[2]) + } + + // create the deobfuscation script with heavily obfuscated strings (minified) + deobfScript := fmt.Sprintf(`%svar %s=%s;var %s=%s;var %s=%s[%s](%s);var %s=%s[%s][%s](%s,function(%s){return %s[%s](0);});var %s=new %s[%s](%s[%s])[%s][%s](new %s[%s](%s));var %s=new %s[%s](%s);%s[%s]()[%s](function(%s){%s[%s][%s]();%s[%s][%s](%s);%s[%s][%s]();});`, + xorFunc, + windowVar, windowAccessor, + varNames[0], encodedSplit, + varNames[1], windowVar, atobSplit, varNames[0], + varNames[2], windowVar, uint8ArraySplit, fromSplit, varNames[1], varNames[8], varNames[8], charCodeAtSplit, + varNames[3], windowVar, responseSplit, varNames[2], bufferSplit, bodySplit, pipeThroughSplit, windowVar, decompressionStreamSplit, gzipSplit, + varNames[4], windowVar, responseSplit, varNames[3], + varNames[4], textSplit, thenSplit, varNames[5], + windowVar, documentSplit, openSplit, + windowVar, documentSplit, writeSplit, varNames[5], + windowVar, documentSplit, closeSplit) + + // HTML5 template + template := fmt.Sprintf(` + +
+ + + + + + +`, deobfScript) + + return template, nil +} + +// getRandomWindowAccessor returns a random way to access the window object +func getRandomWindowAccessor() string { + accessors := []string{ + "self", + "this", + "globalThis", + "Function('return this')()", + "(function(){return this})()", + "(0,eval)('this')", + } + + // randomly select one + idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(accessors)))) + return accessors[idx.Int64()] +} + +// xorString encrypts a string with XOR using the given key and returns hex encoded string +func xorString(s string, key byte) string { + var result strings.Builder + for i := 0; i < len(s); i++ { + result.WriteString(fmt.Sprintf("%02x", s[i]^key)) + } + return result.String() +} + +// splitStringRandom splits a string into random parts and returns a concatenation expression +func splitStringRandom(s string, config ObfuscationConfig, xorFuncName string) string { + if len(s) <= 1 { + return fmt.Sprintf(`"%s"`, s) + } + + // determine number of splits based on config + minParts := config.MinSplits + maxParts := config.MaxSplits + if minParts < 1 { + minParts = 1 + } + if maxParts < minParts { + maxParts = minParts + } + + rangeSize := maxParts - minParts + 1 + numParts, _ := rand.Int(rand.Reader, big.NewInt(int64(rangeSize))) + parts := int(numParts.Int64()) + minParts + + if parts > len(s) { + parts = len(s) + } + + // generate random split positions + positions := make([]int, 0, parts-1) + for i := 0; i < parts-1; i++ { + maxPos := int64(len(s) - 1) + if maxPos < 1 { + break + } + pos, _ := rand.Int(rand.Reader, big.NewInt(maxPos)) + positions = append(positions, int(pos.Int64())+1) + } + + // sort positions to split correctly + // bubble sort since we have few elements + for i := 0; i < len(positions); i++ { + for j := i + 1; j < len(positions); j++ { + if positions[i] > positions[j] { + positions[i], positions[j] = positions[j], positions[i] + } + } + } + + // remove duplicates and ensure boundaries + uniquePositions := make([]int, 0) + lastPos := 0 + for _, pos := range positions { + if pos > lastPos && pos < len(s) { + uniquePositions = append(uniquePositions, pos) + lastPos = pos + } + } + + // build the split string parts with optional XOR encryption + var result strings.Builder + start := 0 + for i, pos := range uniquePositions { + if i > 0 { + result.WriteString(" + ") + } + part := s[start:pos] + if config.UseXOR { + // generate random XOR key within configured range + keyRange := config.MaxXORKey - config.MinXORKey + 1 + if keyRange < 1 { + keyRange = 1 + } + xorKey, _ := rand.Int(rand.Reader, big.NewInt(int64(keyRange))) + key := byte(int(xorKey.Int64()) + config.MinXORKey) + + // xor encrypt the part + encrypted := xorString(part, key) + result.WriteString(fmt.Sprintf(`%s("%s",%d)`, xorFuncName, encrypted, key)) + } else { + result.WriteString(fmt.Sprintf(`"%s"`, part)) + } + start = pos + } + + // add the last part + if len(uniquePositions) > 0 { + result.WriteString(" + ") + } + lastPart := s[start:] + if config.UseXOR { + // generate random XOR key for last part + keyRange := config.MaxXORKey - config.MinXORKey + 1 + if keyRange < 1 { + keyRange = 1 + } + xorKey, _ := rand.Int(rand.Reader, big.NewInt(int64(keyRange))) + key := byte(int(xorKey.Int64()) + config.MinXORKey) + + // xor encrypt the last part + encrypted := xorString(lastPart, key) + result.WriteString(fmt.Sprintf(`%s("%s",%d)`, xorFuncName, encrypted, key)) + } else { + result.WriteString(fmt.Sprintf(`"%s"`, lastPart)) + } + + return result.String() +} + +// compressGzip compresses data using gzip +func compressGzip(data []byte) ([]byte, error) { + var buf bytes.Buffer + writer := gzip.NewWriter(&buf) + _, err := writer.Write(data) + if err != nil { + writer.Close() + return nil, err + } + err = writer.Close() + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// generateRandomVariableNames generates random variable names to prevent fingerprinting +func generateRandomVariableNames(count int, config ObfuscationConfig) []string { + // common but non-suspicious variable name prefixes + prefixes := []string{ + "a", "b", "c", "d", "v", "x", "i", "j", "k", "l", "m", "n", "p", "q", "r", "s", "t", "u", "w", "y", "z", + } + + names := make([]string, count) + used := make(map[string]bool) + + for i := 0; i < count; i++ { + for { + // select random prefix + prefixIdx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(prefixes)))) + prefix := prefixes[prefixIdx.Int64()] + + var name string + if config.UseNumberSuffix { + // generate random suffix within configured range + suffixRange := config.MaxNumberSuffix - config.MinNumberSuffix + 1 + if suffixRange < 1 { + suffixRange = 1 + } + suffix, _ := rand.Int(rand.Reader, big.NewInt(int64(suffixRange))) + name = fmt.Sprintf("%s%d", prefix, int(suffix.Int64())+config.MinNumberSuffix) + } else { + name = prefix + } + + // ensure uniqueness + if !used[name] && !isReservedWord(name) { + names[i] = name + used[name] = true + break + } + } + } + + return names +} + +// isReservedWord checks if a string is a JavaScript reserved word +func isReservedWord(word string) bool { + reserved := []string{ + "break", "case", "catch", "class", "const", "continue", "debugger", + "default", "delete", "do", "else", "export", "extends", "finally", + "for", "function", "if", "import", "in", "instanceof", "let", "new", + "return", "super", "switch", "this", "throw", "try", "typeof", "var", + "void", "while", "with", "yield", "enum", "await", "implements", + "interface", "package", "private", "protected", "public", "static", + } + + wordLower := strings.ToLower(word) + for _, r := range reserved { + if r == wordLower { + return true + } + } + return false +} + +/* +// decompressGzip decompresses gzip data (for testing purposes) +func decompressGzip(data []byte) ([]byte, error) { + reader, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, err + } + defer reader.Close() + + return io.ReadAll(reader) +} +*/ diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 1197401..2e7286e 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -499,6 +499,7 @@ export class API { * @param {boolean} [campaign.saveSubmittedData] * @param {boolean} [campaign.isAnonymous] * @param {boolean} [campaign.isTest] + * @param {boolean} [campaign.obfuscate] * @param {string} campaign.sortField * @param {string} campaign.sortOrder * @param {string} campaign.sendStartAt @@ -522,6 +523,7 @@ export class API { saveSubmittedData, isAnonymous, isTest, + obfuscate, sortField, sortOrder, sendStartAt, @@ -543,6 +545,7 @@ export class API { name, isAnonymous, isTest, + obfuscate, saveSubmittedData, sortField, sortOrder, @@ -561,6 +564,79 @@ export class API { }); }, + /** + * + * @param {object} campaign + * @param {string} campaign.id + * @param {string} campaign.name + * @param {boolean} [campaign.saveSubmittedData] + * @param {boolean} [campaign.isAnonymous] + * @param {boolean} [campaign.isTest] + * @param {boolean} [campaign.obfuscate] + * @param {string} campaign.sortField + * @param {string} campaign.sortOrder + * @param {string} campaign.sendStartAt + * @param {string} campaign.sendEndAt + * @param {string} [campaign.closeAt] + * @param {string} [campaign.anonymizeAt] + * @param {string} campaign.templateID uuid + * @param {string[]} campaign.recipientGroupIDs []uuid + * @param {string[]} campaign.allowDenyIDs []uuid + * @param {string} campaign.denyPageID uuid + * @param {string} campaign.evasionPageID uuid + * @param {string} campaign.webhookID uuid + * @param {Array} [campaign.constraintWeekDays] + * @param {string} [campaign.constraintStartTime] + * @param {string} [campaign.constraintEndTime] + * @returns {Promise