Tag: phishing

  • SPF, DKIM and DMARC, and why p=none isn’t enough

    SPF, DKIM and DMARC, and why p=none isn’t enough

    Email was designed in an era when every host on the network trusted every other host. SMTP lets any server claim to send mail from any address, and the From: header that a person actually reads is just text the sender typed. Three standards were bolted on afterwards to fix that: SPF, DKIM and DMARC. Most domains I look at have all three records published. A surprising number of them are still not protected, because the DMARC policy says p=none.

    SPF: which servers may send

    Flowchart of how a receiving mail server evaluates SPF, DKIM, alignment and the DMARC policy

    Sender Policy Framework (RFC 7208) lets a domain publish, in DNS, the list of servers allowed to send mail for it. A receiving server takes the domain from the SMTP envelope sender (the MAIL FROM, also called the return path), looks up its SPF record, and checks whether the connecting IP is on the list.

    example.com.  IN TXT  "v=spf1 ip4:198.51.100.0/24 include:_spf.google.com include:sendgrid.net -all"

    SPF has two well-known weaknesses. It checks the envelope sender, not the visible From: header, so on its own it does nothing to stop someone spoofing your domain in the address the recipient sees. And it breaks on forwarding, because a forwarding server isn’t in your list.

    There’s also a hard limit that bites growing companies: evaluating an SPF record may trigger at most 10 DNS lookups (include, a, mx, ptr, exists and redirect each count). Every SaaS tool you add with an include: eats into that budget, and when you exceed it the result is a permerror, which many receivers treat as a failure. I’ve seen marketing teams take down a company’s outbound mail by adding one more vendor.

    DKIM: a signature on the message

    DomainKeys Identified Mail (RFC 6376) has the sending server sign selected headers and the body with a private key. The signature goes in a DKIM-Signature header naming the signing domain (d=) and a selector (s=). The receiver fetches the public key from DNS at selector._domainkey.domain and verifies.

    s1._domainkey.example.com.  IN TXT  "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..."

    DKIM survives plain forwarding because the signature travels with the message, though it breaks if a mailing list rewrites the subject or appends a footer. Use 2048-bit RSA keys, and rotate them. In 2012 a mathematician found that Google was signing with a 512-bit key that he could factor, which led to a quick round of key upgrades across large providers.

    The catch, again, is that DKIM proves a message was signed by d= some domain. It doesn’t, by itself, say that domain has anything to do with the From: address.

    DMARC: tying it to the From header

    DMARC (RFC 7489) is what makes the first two useful against spoofing. It adds the concept of alignment: a message passes DMARC only if SPF passes for a domain that matches the From: domain, or DKIM passes with a d= that matches the From: domain. Relaxed alignment (the default) accepts the same organisational domain, so mail.example.com aligns with example.com. Strict alignment requires an exact match.

    The DMARC record then tells receivers what to do with mail that fails, and where to send reports:

    _dmarc.example.com.  IN TXT  "v=DMARC1; p=reject; rua=mailto:[email protected]; adkim=r; aspf=r"

    The p= tag is the policy: none, quarantine or reject.

    Why p=none isn’t protection

    A p=none policy tells receivers: evaluate DMARC, send me reports, but deliver failing mail exactly as you would have anyway. It’s a monitoring mode. Anyone can still send mail with your domain in the From: header to your customers, your suppliers or your own finance team, and DMARC will do nothing to stop it.

    Monitoring is the right first step, and that’s why p=none exists. The problem is that a lot of organisations treat publishing it as finishing the job. Google and Yahoo’s bulk sender requirements, which took effect in February 2024, made a DMARC record mandatory for anyone sending more than 5,000 messages a day to their users, but the minimum they ask for is p=none. That pushed a lot of domains into publishing a record, and many of them stopped there.

    Business email compromise is where this hurts. A convincing invoice from [email protected] that passes every visual check is a lot easier to pull off when your domain doesn’t ask receivers to reject spoofs. With p=reject, major mailbox providers will drop exact-domain spoofs outright. Attackers then have to fall back on lookalike domains, which is still a problem, but a much more detectable one.

    Getting from none to reject

    The path is boring and it works.

    First, publish p=none with an rua address and actually read the aggregate reports. They’re XML, so use a parser or a reporting service rather than reading them by hand. Within a couple of weeks you’ll see every source sending mail as your domain: your mail platform, the CRM, the ticketing system, the HR tool somebody bought on a credit card, and the spoofers.

    Second, fix each legitimate source. Ideally each one signs with DKIM using your domain as d=, which most SaaS senders support through a CNAME to their key. Where you can only get SPF alignment, make sure the vendor uses a return path on your domain. Retire sources nobody can explain.

    Third, move to p=quarantine, then to p=reject. RFC 7489 provides the pct= tag to apply the policy to a percentage of failing mail, which lets you ramp up gradually. Be aware that the DMARCbis revision in the IETF drops pct in favour of a simpler testing flag, so don’t build long-term tooling around it.

    Don’t forget domains that send no mail at all. Parked and unused domains are attractive for spoofing precisely because nobody watches them. Lock them down completely:

    parked-example.com.          IN TXT  "v=spf1 -all"
    _dmarc.parked-example.com.   IN TXT  "v=DMARC1; p=reject;"

    Also check the sp= tag, which sets the policy for subdomains. If you leave it out, subdomains inherit p=, which is usually what you want.

    What DMARC doesn’t do

    DMARC protects the exact domain. It does nothing about examp1e.com or example-billing.com, nor display-name tricks where the friendly name says “Example Payroll” over an unrelated address. Those need receiver-side defences, user training and monitoring for newly registered lookalikes. MTA-STS (RFC 8461) and TLS reporting are separate pieces that protect mail in transit, and BIMI only becomes available once you’re at quarantine or reject.

    If you run a domain and its DMARC record still says p=none after a few months, you’ve built the monitoring and switched off the alarm. Read the reports, fix your senders, and finish the rollout.

  • MFA that survives phishing: TOTP, push and FIDO2 passkeys compared

    MFA that survives phishing: TOTP, push and FIDO2 passkeys compared

    Part 2 of 4 in the Authentication done right series. Part 1: Password storage in 2026.

    In the first part of this series I covered how to store passwords so a database leak doesn’t hand attackers working credentials. This part assumes the password is already gone, because sooner or later it will be. Phished, reused, scraped off an infostealer log. The question is what your second factor does when someone is actively trying to get past it, and the honest answer for most deployed MFA is “less than you’d think”.

    Not all second factors are the same control

    Two sequence diagrams side by side: a phishing proxy relays a password and TOTP code to the real site and steals the session cookie, while with WebAuthn the origin-bound credential is not offered on the fake domain and the login fails

    People talk about MFA as if it were one thing. In practice the methods fall into two groups. Shared secret and approval based factors (SMS codes, TOTP apps, push notifications) prove the user has a device, but the proof can be relayed by whoever the user is talking to. Public key factors bound to an origin (FIDO2/WebAuthn security keys and passkeys) produce proof that only works for the site that asked for it. That distinction is what CISA means by phishing resistant MFA in its 2022 guidance, and it’s what OMB memo M-22-09 required US federal agencies to adopt.

    SMS is the weakest of the first group. SIM swaps and number porting fraud move the phone number to an attacker, and NIST SP 800-63B has long classed SMS and voice as a restricted authenticator. I’d still take SMS over no second factor, but I wouldn’t let it protect anything I cared about.

    TOTP and the relay problem

    TOTP (RFC 6238, built on HOTP from RFC 4226) derives a six digit code from a shared secret and the current 30 second time step. It’s offline, cheap and works everywhere. It also has nothing in it that identifies which website the user is typing it into.

    That’s the core weakness. An adversary-in-the-middle kit like Evilginx sits between the victim and the real login page as a reverse proxy. The victim sees what looks like the real site, types their password and their TOTP code, and the proxy forwards both in real time and keeps the resulting session cookie. The code was valid. It just went to the wrong party.

    The 2022 “0ktapus” campaign worked this way at scale. SMS messages pointed employees of Twilio and many other companies to lookalike Okta pages that captured credentials and one time codes as they were typed. Cloudflare was targeted by the same campaign, and some of its employees did enter their credentials. The attack still failed there, because Cloudflare required FIDO2 hardware keys and the fake domain couldn’t get a valid assertion from them. Cloudflare’s own write-up of that incident is one of the clearest arguments for WebAuthn I know of.

    If you do run TOTP, at least implement it properly. Accept a small clock skew window and reject codes that have already been used.

    from datetime import datetime, timezone
    import pyotp
    
    def check_totp(user, code):
        totp = pyotp.TOTP(user.totp_secret)
        # accept the current step and one on either side for clock drift
        if not totp.verify(code, valid_window=1):
            return False
        step = totp.timecode(datetime.now(timezone.utc))
        if user.last_totp_step is not None and step <= user.last_totp_step:
            return False  # replayed code
        user.last_totp_step = step
        user.save()
        return True

    Rate limit verification attempts too. A six digit code has a million values, which is not a lot if an endpoint lets someone try thousands of codes per account.

    Push approval and MFA fatigue

    Push MFA looked like an upgrade because the user doesn’t type anything. It created a different failure. If an attacker has the password, they can trigger approval prompts over and over until the user taps Approve to make it stop, or until someone calls pretending to be IT and asks them to accept.

    That’s what happened at Uber in September 2022. According to Uber’s own statement, the attacker had a contractor’s password, sent repeated push requests, and the contractor eventually accepted one. Cisco’s breach that year, disclosed in August, combined voice phishing with the same kind of repeated push prompts. In both cases the MFA product behaved exactly as designed.

    The fixes are real but partial. Number matching shows a code on the login screen that the user has to enter in the authenticator app, so a blind tap no longer works; Microsoft made it mandatory for Authenticator push in May 2023. Showing the location and application in the prompt helps a little. Limiting how many prompts can be sent per hour and alerting on bursts helps more. None of it stops a real-time phishing proxy, because the user is looking at a page that displays the number too.

    Why WebAuthn is different

    With WebAuthn, the browser builds a clientDataJSON structure that includes the challenge and the origin the user is actually on, and the authenticator signs it along with the relying party ID hash. Credentials are scoped to the relying party ID, so an authenticator won’t even offer a credential for example.com to a page on examp1e-login.com. There’s no code for the user to copy, so there is nothing to relay.

    The server side still has to do its part. Every serious library makes you pass the expected origin and RP ID, and you should never loosen those to make a test environment work.

    from webauthn import verify_authentication_response
    
    verification = verify_authentication_response(
        credential=assertion_from_browser,
        expected_challenge=session.pop("webauthn_challenge"),
        expected_rp_id="example.com",
        expected_origin="https://example.com",
        credential_public_key=stored.public_key,
        credential_current_sign_count=stored.sign_count,
        require_user_verification=True,
    )
    stored.sign_count = verification.new_sign_count

    The challenge must be random, single use and tied to the session that requested it. I’ve reviewed implementations that reused a static challenge, which turns a signature scheme back into something replayable.

    Passkeys, synced and device bound

    Passkeys are WebAuthn credentials, usually discoverable ones, and most are synced through a platform account such as iCloud Keychain or Google Password Manager. That’s great for adoption, because users who lose a phone don’t lose their credentials. It also means the security of the passkey now depends on the security of that cloud account and its recovery process.

    For most consumer apps, synced passkeys are a large improvement over passwords plus TOTP. For administrators and production access I still want device bound credentials on hardware keys, and I want attestation checked so I know what kind of authenticator registered.

    Recovery is part of your MFA

    Attackers go where the controls are weakest, and that’s usually account recovery. A strong factor with a “lost your key? we’ll send an SMS” fallback is an SMS factor with extra steps. Help desk resets are the same problem with a human in the loop. The MGM Resorts intrusion in 2023 was widely reported to have started with a call to the help desk.

    So register at least two strong authenticators per user, make recovery require something stronger than knowledge of public facts, and log and alert on every factor reset. Treat a new factor enrolled right after a password reset as a high priority event.

    The next part moves from authenticating a person to delegating access between systems, which means OAuth 2.0, OpenID Connect and the token mistakes that come with them.

    Other parts: Part 1: Password storage in 2026, Part 3: OAuth 2.0 vs OpenID Connect, Part 4: Session management.

  • New SPAM

    New SPAM

    I get a lot of emails and a lot of SPAM. A lot of SPAM. Most of it is some sort of scam or phishing attempt. Once in a while there is one that I find amusing. This one made me snort: