Web Development12 min read

BrunnerCTF 2026 - Welcome Aboard (Web)

The "Welcome Aboard" challenge from BrunnerCTF 2026 presented a multi-tenant onboarding gateway built on a modern Node.js and TypeScript stack. On the surface, ...

Listen to Article

Click play to listen to audio narration

BrunnerCTF 2026 - Welcome Aboard (Web)

Introduction

The “Welcome Aboard” challenge from BrunnerCTF 2026 presented a multi-tenant onboarding gateway built on a modern Node.js and TypeScript stack. On the surface, it looked like a standard SaaS provisioning pipeline: register, verify email, configure tenant settings, and unlock the dashboard. Underneath, the application relied on a distributed state machine to track user progression and a recursive configuration merger to apply tenant-specific defaults.

When we audited the pipeline during the competition, we found the vulnerability wasn’t in a forgotten API endpoint or an exposed database query. It lived in the intersection of object deserialization and state transition logic. A carefully crafted configuration payload triggered prototype pollution, which silently altered the evaluation path of the onboarding state machine. The result was a privilege escalation that bypassed the CONFIGURING gate and dropped a fully provisioned admin session directly into the dashboard.

This post breaks down the architecture, walks through the exploit chain, and translates the findings into production-grade defensive patterns for web platforms that handle tenant onboarding, dynamic configuration, and session state.

Why This Matters

SaaS applications continuously process untrusted configuration payloads during onboarding. Every time a tenant submits preferences, branding assets, or feature toggles, the backend must merge that input with a base schema, validate state transitions, and emit a session token. When these steps are decoupled or rely on client-controlled flags, the attack surface expands rapidly.

In our production environments, we’ve seen this exact pattern cause silent RBAC bypasses, tenant cross-contamination, and session fixation. The “Welcome Aboard” challenge distilled a real-world architectural risk into a single pipeline: untrusted object merging combined with mutable state evaluation. Fixing it requires treating onboarding flows as security-critical state machines, not just sequential API calls.

How It Works

The application routes all onboarding traffic through an API gateway that delegates to three core services: authentication, profile configuration, and state management. When a user completes email verification, the gateway issues a temporary provisioning token. The client then submits a JSON payload containing tenant preferences, which the backend merges into a base configuration object. Once merged, the state machine evaluates whether the payload meets minimum requirements before transitioning the session from CONFIGURING to ACTIVE.

The flaw emerged in the configuration merger. The backend used a naive recursive merge that failed to strip JavaScript prototype properties. By injecting __proto__ or constructor.prototype keys, an attacker could attach a isVerified or isAdmin flag to the base object’s prototype chain. When the state machine evaluated config.isVerified, JavaScript resolved it up the prototype chain, returning true without the backend ever writing the flag to the database or validating the email verification step.

flowchart TD
  Client[Web Client] --> Gateway[API Gateway]
  Gateway --> AuthService[Auth Service]
  Gateway --> ConfigService[Config Service]
  Gateway --> StateMachine[Onboarding State Machine]
  AuthService --> Redis[(Redis Session Store)]
  ConfigService --> DB[(PostgreSQL)]
  StateMachine -->|Evaluates Transition| StateMachine
  ConfigService -->|Merges Payload| ConfigEngine[Config Engine]
  ConfigEngine -->|Returns Merged Object| StateMachine
  StateMachine -->|Emits Session| Redis
  Client -->|Crafted JSON Payload| ConfigService
  ConfigService -->|Prototype Pollution| ConfigEngine
  ConfigEngine -->|Polluted Prototype| StateMachine
  StateMachine -->|Bypasses Gate| Redis

The diagram illustrates the legitimate flow and the attack path. The client submits a payload to the Config Service. Instead of sanitizing the input, the Config Engine merges it directly into a mutable base object. The polluted prototype propagates to the State Machine, which reads the injected flag during transition evaluation. The state machine then emits a fully provisioned session to Redis, skipping the verification gate entirely.

Core Concepts

  • State Machine Design: Onboarding pipelines should model progression as explicit states with guarded transitions. States must be immutable during evaluation, and transitions should require cryptographic proof rather than client flags.
  • Prototype Pollution: JavaScript’s prototype chain resolves property lookups upward. If untrusted input reaches a recursive merge function without prototype stripping, attackers can attach properties to Object.prototype, affecting all subsequent object evaluations.
  • Session Binding: Provisioning tokens must bind to cryptographic nonces and server-side state records. Client-side flags should never dictate session privileges.
  • Defense in Depth: Validation, merging, state evaluation, and session emission must operate in isolated stages with strict schema enforcement at each boundary.

Examples & Code Walkthrough

Below is the vulnerable configuration merger we extracted from the challenge backend, followed by the hardened implementation we deployed in our production audit.

Vulnerable Implementation

import { Request, Response, NextFunction } from 'express';

type TenantConfig = Record<string, unknown>;

const baseConfig: TenantConfig = {
  theme: 'default',
  notifications: true,
  features: ['dashboard', 'reports'],
  isVerified: false,
  role: 'viewer'
};

// Naive recursive merge without prototype stripping
function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
  const output = { ...target };
  for (const key of Object.keys(source)) {
    if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
      output[key] = deepMerge(output[key] || {}, source[key]);
    } else {
      output[key] = source[key];
    }
  }
  return output;
}

export const applyTenantConfig = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const payload = req.body as Record<string, unknown>;
    const merged = deepMerge(baseConfig, payload);
    
    // State machine evaluates merged object directly
    if (merged.isVerified && merged.role === 'admin') {
      res.json({ status: 'provisioned', token: generateSessionToken(merged) });
    } else {
      res.status(403).json({ error: 'Pending verification' });
    }
  } catch (err) {
    next(err);
  }
};

The vulnerability lives in deepMerge. When source contains __proto__: { isVerified: true }, the function assigns to output.__proto__, which modifies Object.prototype. Every subsequent object in the process inherits isVerified: true. The state machine reads merged.isVerified, resolves it up the prototype chain, and emits a provisioned session.

Hardened Implementation

import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { createHash } from 'crypto';

const TenantConfigSchema = z.object({
  theme: z.enum(['default', 'dark', 'high-contrast']).optional(),
  notifications: z.boolean().optional(),
  features: z.array(z.string()).max(10).optional(),
  // Explicitly exclude role and verification flags from client input
});

function safeMerge(base: Record<string, unknown>, payload: Record<string, unknown>): Record<string, unknown> {
  // Freeze base object to prevent prototype modification
  const frozenBase = Object.freeze({ ...base });
  const sanitized = Object.keys(payload).reduce((acc, key) => {
    if (key.startsWith('__') || key === 'constructor' || key === 'prototype') {
      return acc; // Drop dangerous keys
    }
    acc[key] = payload[key];
    return acc;
  }, {} as Record<string, unknown>);

  return Object.freeze({ ...frozenBase, ...sanitized });
}

export const applyTenantConfigSecure = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const parsed = TenantConfigSchema.safeParse(req.body);
    if (!parsed.success) {
      return res.status(400).json({ error: 'Invalid configuration payload', details: parsed.error.flatten() });
    }

    const baseConfig = {
      theme: 'default',
      notifications: true,
      features: ['dashboard', 'reports'],
      isVerified: false,
      role: 'viewer'
    };

    const merged = safeMerge(baseConfig, parsed.data);

    // Verification and role elevation must come from server-side records
    const verificationRecord = await db.verifySession(req.sessionId);
    if (!verificationRecord.confirmed) {
      return res.status(403).json({ error: 'Email verification pending' });
    }

    const sessionNonce = createHash('sha256').update(`${req.sessionId}:${Date.now()}`).digest('hex');
    res.json({ status: 'provisioned', token: generateSessionToken({ ...merged, nonce: sessionNonce }) });
  } catch (err) {
    next(err);
  }
};

The hardened version introduces three safeguards:

  1. Schema Validation: zod rejects unknown keys. Role and verification flags are never accepted from the client.
  2. Prototype Stripping: safeMerge drops __proto__, constructor, and prototype keys. The base object is frozen to prevent prototype chain mutation.
  3. Server-Side State Resolution: Verification and role elevation are pulled from a persistent database record, not the merged config object. A cryptographic nonce binds the session to the server’s evaluation context.

Best Practices

  • Validate all configuration payloads against strict schemas before merging.
Tags:#brunnerctf#2026#welcome#web development
L

Written by Lead Frontend & Web Architect

Editorial staff persona leading coverage on modern web architectures, state management, web performance optimization, and client-side framework engineering.

View Profile
Recommended For You

Related Articles

Quick:
Navigate Select
Loading search index...