Credential phishing: Suspicious e-sign agreement document notification

Detects phishing attempts disguised as e-signature requests, characterized by common document sharing phrases, unusual HTML padding, and suspicious link text.

Sublime rule (View on GitHub)

  1name: "Credential phishing: Suspicious e-sign agreement document notification"
  2description: "Detects phishing attempts disguised as e-signature requests, characterized by common document sharing phrases, unusual HTML padding, and suspicious link text."
  3type: "rule"
  4severity: "medium"
  5source: |
  6  type.inbound
  7  and (
  8    any([subject.subject, sender.display_name],
  9        regex.icontains(strings.replace_confusables(.),
 10                        "D[0o]cuLink",
 11                        "Agreement",
 12                        "Access.&.Appr[0o]ved",
 13                        "Agreement.{0,5}Review",
 14                        "Attend.and.Review",
 15                        "action.re?quired",
 16                        "AuthentiSign",
 17                        "Completed.File",
 18                        "D[0o]chsared",
 19                        "D[0o]cshared",
 20                        "D[0o]csPoint",
 21                        "D[0o]cument.Shared",
 22                        "D[0o]cuCentre",
 23                        "D[0o]cuCenter",
 24                        "D[0o]cCenter",
 25                        "D[0o]csOnline",
 26                        "D[0o]cSend",
 27                        "D[0o]cu?Send",
 28                        "d[0o]csign",
 29                        "D[0o]cu-eSin",
 30                        "D[0o]cu-management",
 31                        "\\beSign",
 32                        "e\\.sign",
 33                        "esign.[0o]nline",
 34                        "[SsZz][lL][GgSs][Nn].*D[0o]c",
 35                        "e-d[0o]c",
 36                        "e-signature",
 37                        "e-Verify Doc",
 38                        "eSignature",
 39                        "eSign&Return",
 40                        "eSign[0o]nline",
 41                        "Fileshare",
 42                        "Review.and.C[0o]mplete",
 43                        "Review.&.Sign",
 44                        "Sign[0o]nline",
 45                        "Signature.Request",
 46                        "Shared.C[0o]mpleted",
 47                        "Sign.and.Seal",
 48                        "viaSign",
 49                        "D[0o]cuSign",
 50                        "D[0o]csID",
 51                        "Complete.{0,10}D[0o]cuSign",
 52                        "Enroll & Sign",
 53                        "Review and Sign",
 54                        "Sign(?:Report|Now)",
 55                        "SignD[0o]c",
 56                        "D[0o]cxxx",
 57                        "d[0o]cufile",
 58                        'E\x{00AD}-\x{00AD}S\x{00AD}i\x{00AD}g\x{00AD}n\x{00AD}&Return',
 59                        "d[0o]cument.signature",
 60                        "Electr[0o]nic.?Signature",
 61                        "Complete: ",
 62                        "Please (?:Review|Sign)",
 63                        "^REVIEW$",
 64                        "requests your signature",
 65                        "signature on.*contract",
 66                        "Independent Contract",
 67                        "Contract.*signature",
 68                        "add your signature",
 69                        "signature needed",
 70                        "attn_task",
 71                        "DocReq\\b",
 72                        "noreply_edocx-task[0-9a-f-]{36}",
 73                        "securesign delivery",
 74                        "kindly review",
 75                        "^i shared.*with you"
 76        )
 77        or (
 78          regex.icontains(strings.replace_confusables(.), "action.re?quired")
 79          and not (
 80            sender.email.domain.root_domain == "sharepointonline.com"
 81            and headers.auth_summary.dmarc.pass
 82            and strings.icontains(subject.subject, "asked to edit")
 83          )
 84        )
 85    )
 86    // lure CTA injected into the body (not subject/display) above a forwarded thread
 87    or (
 88      regex.icontains(strings.replace_confusables(body.current_thread.text),
 89                      "requests? your signature",
 90                      "review, sign, and return",
 91                      "signature on the attached",
 92                      "sign for execution"
 93      )
 94      // legit "please sign & return" mail attaches the actual document; this
 95      // campaign references an "attached" doc but delivers a fake one via an
 96      // off-domain link, with no real document attached
 97      and not any(attachments,
 98                  .file_extension in~ ("pdf", "doc", "docx", "xls", "xlsx", "rtf")
 99      )
100      // legit notification platforms (e.g. ParentSquare, Panopto) self-host their
101      // "review/sign/view" CTA on the sender's own root domain; this campaign points
102      // the CTA at an off-domain credential page. require a suspicious link whose
103      // display text is a document CTA AND that leaves the sender domain, so
104      // self-hosted legit notifications (and incidental links like the aka.ms
105      // first-contact banner, which carries no CTA verb) don't match.
106      and any(body.links,
107              .href_url.domain.root_domain is not null
108              and .href_url.domain.root_domain != sender.email.domain.root_domain
109              and regex.icontains(strings.replace_confusables(.display_text),
110                                  "view",
111                                  "review",
112                                  "sign",
113                                  "document",
114                                  "download",
115                                  "open",
116                                  "complete"
117              )
118      )
119    )
120  )
121  and (
122    // unusual repeated patterns in HTML
123    regex.icontains(body.html.raw, '((<br\s*/?>\s*){20,}|\n{20,})')
124    or regex.icontains(body.html.raw, '(<p[^>]*>\s*<br\s*/?>\s*</p>\s*){30,}')
125    or regex.icontains(body.html.raw,
126                       '(<p class=".*?"><span style=".*?"><o:p>&nbsp;</o:p></span></p>\s*){30,}'
127    )
128    or regex.icontains(body.html.raw, '(<p>&nbsp;</p>\s*){7,}')
129    or regex.icontains(body.html.raw, '(<p[^>]*>\s*&nbsp;<br>\s*</p>\s*){5,}')
130    or regex.icontains(body.html.raw, '(<p[^>]*>&nbsp;</p>\s*){7,}')
131    or strings.count(body.html.raw, "&nbsp;\u{200C}&nbsp;\u{200C}&nbsp") > 50
132    or regex.count(body.html.raw,
133                   '<span\s*class\s*=\s*"[^\"]+"\s*>\s*[a-z]\s*<\/span><span\s*class\s*=\s*"[^\"]+"\s*>\s*[a-z]+\s*<\/span>'
134    ) > 50
135    // lookalike docusign
136    or regex.icontains(body.html.raw, '>Docus[1l]gn<')
137    or strings.icontains(body.current_thread.text, 'completed by all parties')
138    or (
139      regex.icontains(body.html.inner_text, 'Document')
140      and length(body.html.inner_text) < 500
141    )
142    // common greetings via email.local_part
143    or any(recipients.to,
144           // use count to ensure the email address is not part of a disclaimer
145           strings.icount(body.current_thread.text, .email.local_part) > 
146           // sum allows us to add more logic as needed
147           sum([
148                 strings.icount(body.current_thread.text,
149                                strings.concat('was sent to ', .email.email)
150                 ),
151                 strings.icount(body.current_thread.text,
152                                strings.concat('intended for ', .email.email)
153                 )
154               ]
155           )
156    )
157    // common greetings via mailbox display name
158    or strings.icount(body.current_thread.text, mailbox.display_name) > 
159    // sum allows us to add more logic as needed
160    sum([
161          strings.icount(body.current_thread.text,
162                         strings.concat('was sent to ', mailbox.display_name)
163          ),
164          strings.icount(body.current_thread.text,
165                         strings.concat('intended for ', mailbox.display_name)
166          )
167        ]
168    )
169    // Abnormally high count of mailto links in raw html
170    or regex.count(body.html.raw,
171                   'mailto:[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'
172    ) > 50
173  
174    // High count of empty elements (padding)
175    or regex.count(body.html.raw,
176                   '<(?:p|div|span|td)[^>]*>\s*(?:&nbsp;|\s)*\s*</(?:p|div|span|td)>'
177    ) > 30
178  
179    // HR impersonation
180    or strings.ilike(sender.display_name, "HR", "H?R", "*Human Resources*")
181  
182    // Sender display name contains a phone number
183    or regex.icontains(sender.display_name,
184                       '\+?([ilo0-9]{1}.)?\(?[ilo0-9]{3}?\)?.[ilo0-9]{3}.?[ilo0-9]{4}'
185    )
186  
187    // short CTA grafted above a forwarded legitimate thread to borrow its credibility
188    or (
189      length(body.previous_threads) > 0 and length(body.current_thread.text) < 600
190    )
191    or (
192      1 < length(body.current_thread.links) < 5
193      and any(body.current_thread.links,
194              any(.href_url.query_params_decoded['url'],
195                  strings.parse_url(.).domain.root_domain != ..href_url.domain.root_domain
196              )
197      )
198    )
199  )
200  and (
201    any(body.links,
202  
203        // suspicious content within link display_text
204        regex.icontains(strings.replace_confusables(.display_text),
205                        "activate",
206                        "re-auth",
207                        "verify",
208                        "acknowledg",
209                        "(keep|change).{0,20}(active|password|access)",
210                        '((verify|view|click|download|goto|keep|Vιew|release).{0,15}(attachment|current|download|fax|file|document|message|same)s?)',
211                        'use.same.pass',
212                        'validate.{0,15}account',
213                        'recover.{0,15}messages',
214                        '(retry|update).{0,10}payment',
215                        'check activity',
216                        '(listen|play).{0,10}(vm|voice)',
217                        'clarify.{0,20}(deposit|wallet|funds)',
218                        'enter.{0,15}teams',
219                        'Review and sign',
220                        'REVIEW.*DOCUMENT',
221                        'Open Document',
222                        'Sign Now',
223                        'complete tasks?'
224        )
225        // check that the display_text is all lowercase
226        or (
227          regex.contains(.display_text,
228                         "\\bVIEW",
229                         "DOWNLOAD",
230                         "CHECK",
231                         "KEEP.(SAME|MY)",
232                         "VERIFY",
233                         "ACCESS\\b",
234                         "SIGN\\b",
235                         "ENABLE\\b",
236                         "RETAIN",
237                         "PLAY",
238                         "LISTEN",
239          )
240          and regex.match(.display_text, "^[^a-z]*[A-Z][^a-z]*$")
241        )
242  
243        // the display text is _exactly_
244        or .display_text in~ ("Open")
245  
246        // URL fragment containing recipient's address
247        or .href_url.fragment in map(recipients.to, .email.email)
248    )
249    // one hyperlinked image that's not a tracking pixel
250    or (
251      length(html.xpath(body.html,
252                        "//a//img[(number(@width) > 5 or not(@width)) and (number(@height) > 5 or not(@height))]"
253             ).nodes
254      ) == 1
255      and length(body.current_thread.text) < 500
256    )
257    or (
258      length(attachments) > 0
259      and any(attachments,
260              (
261                regex.icontains(beta.ocr(.).text,
262                                "activate",
263                                "re-auth",
264                                "verify",
265                                "acknowledg",
266                                "(keep|change).{0,20}(active|password|access)",
267                                '((verify|view|click|download|goto|keep|Vιew|release).{0,15}(attachment|current|download|fax|file|document|message|same)s?)',
268                                'use.same.pass',
269                                'validate.{0,15}account',
270                                'recover.{0,15}messages',
271                                '(retry|update).{0,10}payment',
272                                'check activity',
273                                '(listen|play).{0,10}(vm|voice)',
274                                'clarify.{0,20}(deposit|wallet|funds)',
275                                'enter.{0,15}teams',
276                                'Review and sign'
277                )
278              )
279              or (
280                any(file.explode(.),
281                    regex.icontains(.scan.ocr.raw,
282                                    "activate",
283                                    "re-auth",
284                                    "verify",
285                                    "acknowledg",
286                                    "(keep|change).{0,20}(active|password|access)",
287                                    '((verify|view|click|download|goto|keep|Vιew|release).{0,15}(attachment|current|download|fax|file|document|message|same)s?)',
288                                    'use.same.pass',
289                                    'validate.{0,15}account',
290                                    'recover.{0,15}messages',
291                                    '(retry|update).{0,10}payment',
292                                    'check activity',
293                                    '(listen|play).{0,10}(vm|voice)',
294                                    'clarify.{0,20}(deposit|wallet|funds)',
295                                    'enter.{0,15}teams',
296                                    'Review and sign'
297                    )
298                )
299              )
300      )
301    )
302  )
303  // the message is unsolicited and no false positives
304  and (
305    not profile.by_sender_email().solicited
306    or profile.by_sender_email().prevalence in ("new", "uncommon")
307    or (
308      profile.by_sender_email().any_messages_malicious_or_spam
309      and not profile.by_sender_email().any_messages_benign
310    )
311    or (
312      profile.by_sender_email().any_messages_malicious_or_spam
313      and profile.by_sender_email().any_messages_benign
314      and (
315        not headers.auth_summary.dmarc.pass or not headers.auth_summary.spf.pass
316      )
317    )
318  )
319  
320  // negate replies/fowards containing legitimate docs
321  and not (length(headers.references) > 0 or headers.in_reply_to is not null)
322  
323  // negate highly trusted sender domains unless they fail DMARC authentication
324  and not (
325    sender.email.domain.root_domain in $high_trust_sender_root_domains
326    and coalesce(headers.auth_summary.dmarc.pass, false)
327  )  
328attack_types:
329  - "Credential Phishing"
330tactics_and_techniques:
331  - "Social engineering"
332detection_methods:
333  - "Content analysis"
334  - "Header analysis"
335  - "HTML analysis"
336  - "URL analysis"
337  - "Sender analysis"
338id: "9b68c2d8-951e-5e04-9fa3-2ca67d9226a6"
to-top