Martin's Blog

5. Licensing, Identity, and Trust

When I traced RCS’s trust checks through the source, the broad label split into several narrower mechanisms. The system verified that a licence had the right shape and cryptographic checks. It could bind that licence to a hardware token. It generated a private certificate authority for encrypted service connections. It hashed operator passwords and issued session cookies. It also gave backend components a shared credential that admitted them as servers.

These mechanisms did not form one coherent security boundary. They answered different questions:

MechanismQuestion it tried to answerPrincipal protected interest
Licence signature and integrity fields“Was this entitlement document produced by someone who knows the embedded scheme?”Vendor licensing and feature control
Hardware dongle“Is the expected token present, and what serial/time/counter does it report?”Copy control and metered use
Source and item checks“Have selected constants or database fields changed?”Product integrity and licence enforcement
TLS certificates“Can these services encrypt a channel under the generated local CA?”Transport confidentiality and component setup
Operator password“Does this login know the account secret?”Human access to the console and API
Session cookie and privilege array“What may this already-authenticated client do?”Continuing operator authorization
Server signature“Does this collector or component know the shared backend secret?”Machine-to-machine admission

These mechanisms sat beside one another, but they did not form one boundary. A strong licence check does not make a session revocable. TLS can encrypt the channel without establishing which component is at the other end. Password policy cannot repair a missing authorization check after login. This chapter therefore treats licensing, identity, and authorization as related but separate systems.

As in the rest of this book, the active lab does not run implants or exploits. Its synthetic licence exists only to let the frozen backend and evidence worker operate inside the isolated research boundary. Embedded key values, private keys, session cookies, and password hashes are deliberately omitted from the prose.

A licence was also a feature model

The licence file is a Ruby-symbol-keyed YAML document named rcs.lic. At startup, LicenseManager refuses to continue if the file is missing, fails its cryptographic checks, names the wrong licence version, has expired, or lacks a maintenance date (lib/rcs-db/license.rb:83-129). The examined manager expects licence version 9.2, another direct anchor for the database snapshot.

The document describes much more than an expiry date. It sets limits or flags for:

Defaults in the manager are deliberately narrow: one user, one local collector, one shard, and no non-demo agents, with many optional features disabled (rcs-db/lib/rcs-db/license.rb:44-80). A valid licence replaces those defaults through add_limits. Controllers and model actions then ask LicenseManager#check before creating limited objects or using gated features (rcs-db/lib/rcs-db/license.rb:320-390). The licence is consequently part of the application’s runtime policy, not a banner checked only during installation.

The distinction between capacity and capability is visible in check. For a capacity such as users, collectors, injectors, or shards, the manager compares the current database count with the licensed maximum. For alerting, correlation, deletion, or translation, it returns the corresponding feature flag. For maintenance, it compares the current UTC time with the permitted date. Code elsewhere still has to call the check. A licence flag is not an authorization rule, and an authorization rule is not a licence flag.

That separation becomes important in Chapter 10. Some sensitive controller paths have incomplete human privilege checks even though related product features are licensed. A passing entitlement check says that the installation purchased or enabled the feature. It says nothing about whether this operator should invoke it.

Two checks, keys in the verifier

crypt_check validates the licence in three stages (rcs-db/lib/rcs-db/license.rb:518-533). An optional packed date seed can impose a hidden time limit. The visible licence fields are then authenticated with an HMAC over the Ruby hash representation, excluding the signature and integrity fields. A second value encrypts a SHA-2 digest of the document with AES-128-CBC under a separately derived embedded value.

At first glance, two checks look like defence in depth. Their practical trust model is narrower because everything required to verify—and therefore reproduce—the values is in the same source tree. The verifier contains the HMAC material and the string from which the AES key is derived. Anyone with the leaked source can learn the construction. The checks can detect accidental corruption and reject edits made without knowledge of the implementation, but they cannot preserve vendor-only signing authority after the verifier and its secrets are disclosed.

This is not a weakness in HMAC or AES. It is a key-distribution problem. Symmetric verification requires the verifier to possess the same secret authority needed to generate an accepted value. An offline commercial product often accepts that tradeoff because the vendor expects code obfuscation, hardware binding, legal controls, and release secrecy to make extraction costly. Once the complete verifier is public, the security assumption changes.

The use of Ruby’s hash string representation introduces a second historical constraint. Field selection, insertion order, symbol serialization, string encoding, and exact bytes all participate in the computed values. One embedded key line contains a private-use Unicode character that is visually easy to lose when copied. A generator that retypes the displayed string can produce a licence that appears identical to a reviewer yet fails verification.

The lab avoids that ambiguity. Its helper reads the key-bearing lines from the copied verifier at runtime and extracts the exact source bytes. It builds the document in the order expected by the Ruby code, inserts the check field before signing, computes both verifier values, and writes the result as YAML (docker-lab/rcs-db/make_license.rb:13-85). The book does not reproduce the key literals. The method—not possession of the strings—is the relevant finding.

The resulting synthetic licence has broad capacity so the backend UI, evidence pipeline, alerts, intelligence, and system views can be tested. Broad entitlement does not broaden the project scope. The console’s build-installer paths remain render-only, compliance tests reject build tasks, and no exploit or implant is built or executed. Licensing answers what the backend would permit as a product; the research boundary answers what this project permits as an experiment.

Hardware binding and the clock problem

RCS could operate with serial: off, in which case licence dates use the host’s UTC clock. Otherwise it asks the dongle layer for a serial number and time, then rejects a mismatch (rcs-db/lib/rcs-db/license.rb:110-150). One-shot licences can also depend on storage and counter operations in the token.

The Dongle implementation wraps a Windows DLL through FFI. It sends a random initialization vector, decrypts the returned fixed-size structure with a pre-shared AES key, checks a protocol version, and extracts the token serial, real-time clock, one-shot counter, and error state (lib/rcs-db/dongle.rb:26-111). Decrementing the token is a separate DLL operation. The surviving source explicitly says HASP support is Windows-only; its macOS branch returns a synthetic “off” result, while ordinary non-Windows execution lacks the attached functions.

Using a dongle clock addresses a real licensing problem: a customer can move a host clock backward to evade a simple expiry date. It also creates a critical availability dependency. A missing token, serial mismatch, communication failure, exhausted counter, or unusable token clock can prevent startup or licensed work. The code sometimes falls back to the local clock when the token clock cannot be read, logging a replacement warning (dongle.rb:121-128).

The Linux reconstruction deliberately uses an unbound reusable licence with serial: off. It neither emulates the HASP DLL nor pretends to validate token behavior. Claims about the dongle path in this chapter are SOURCE claims. The lab demonstrates only the no-dongle branch.

This is another fidelity boundary with a useful security lesson. Hardware binding can make unauthorized copying harder without improving the protection of collected evidence. The token authenticates an installation to the licensing policy. It does not authenticate a human analyst, authorize a target, or provide cryptographic provenance for evidence records.

Enforcement continues after startup

Licence acceptance is not a one-time event. The database heartbeat registers a pre-hook that performs a periodic licence check, checks firewall state, verifies anti-tamper constants, and examines shard and component status (lib/rcs-db/heartbeat.rb:12-32). The default heartbeat interval is 15 seconds, with a configured minimum of ten seconds (lib/rcs-db/config.rb:28-39,74-101).

During periodic_check, the manager reloads the licence file, stores its limits in MongoDB for other components, and compares current database state with the permitted limits (rcs-db/lib/rcs-db/license.rb:403-515). Exceeding a limit can cause the application to change operational state rather than merely log a warning. The source can disable the most recently updated excess user, remove excess collector configuration, queue excess agents, and disable alerts when that feature is no longer licensed.

These actions show how commercial policy reached into stored investigative state. Replacing a licence with a lower-capacity one was not just an accounting change. On the next check, it could alter which users remained enabled or which components and agents remained active. A forensic timeline therefore needs licence-file changes, heartbeat traces, user and item update times, and audit records before attributing a sudden disablement to an administrator.

The same loop recalculates checksums over selected operation, target, factory, and agent fields. Each item stores cs, an AES-encrypted SHA-1 digest of a fixed field list under material embedded in the source (db_objects/item.rb:51-58,720-733). A mismatch causes the process to report a tampered item and exit. Separate Unicode constants in the licence and dongle classes are compared against literals in the heartbeat; changing them also causes an immediate exit (rcs-db/lib/rcs-db/heartbeat.rb:34-39).

These are product self-defence mechanisms, but they are not tamper-evident audit in the modern cryptographic sense. A party able to read and alter the entire source can discover the algorithms and embedded values. A database-only editor may trip the item check if it changes protected fields without updating cs, while other fields are outside the checksum list. The mechanism raises the cost of casual unsupported modification; it does not establish an independent root of trust.

RCS web-port Monitor view with component health and licence capacity
WEB PORT · SYNTHETIC LABThe Monitor view puts component health and licence capacity beside one another. It reproduces the operator-facing contract; the displayed records belong to the isolated lab.

A private CA created on first boot

RCS will not start its database listeners without its configured certificate, private key, and collector PEM bundle (lib/rcs-db/config.rb:49-71). On first boot, the lab invokes the original configuration tool to create them. The generation routine makes a local certificate authority, a database key and certificate signing request, and a collector key and request. It signs both certificates, appends the CA certificate to the database certificate, and creates a collector PEM containing the collector certificate, private key, and CA certificate (rcs-db/lib/rcs-db/config.rb:310-366).

The defaults are distinctive and dated:

These values are useful defensive indicators when combined, as Chapter 12 explains. Individually they are not proof of RCS: generic subjects, long-lived private CAs, SHA-1, and 1,024-bit RSA all appeared in unrelated legacy products. Operators could also replace the defaults.

The certificate design provides encryption but leaves several trust questions to deployment. The database’s EventMachine server presents its generated certificate on HTTPS and WSS. Collectors receive a bundle that includes their private key. Possession and distribution of that bundle therefore matter as much as the certificate fields. A long validity period reduces operational renewal burden but increases the useful life of a copied key. A private CA avoids dependence on a public issuer but places the entire trust root on the RCS installation.

The lab persists the certificate directory in a named volume. Recreating only the application container retains the same keys; wiping the configuration volume creates a new CA and service identity. For packet captures and reproduction records, certificate fingerprints must therefore be associated with a particular volume generation rather than described as universal RCS fingerprints.

The first administrator

After connecting to MongoDB and creating indexes, the backend ensures that at least one enabled full administrator exists. If none does, it deletes any disabled account named admin, creates a new one, grants the complete PRIVS array, and attaches it to an administrators group (lib/rcs-db/db_layer.rb:254-285).

The password comes from a one-use admin_pass file when present; the backend reads and deletes that file. Without the override, it uses a fixed, source-embedded default (db_layer.rb:263-280). The value is not reproduced here. It satisfies the product’s own minimum length and character-class rule, but its predictability nevertheless makes first-boot exposure a security event. The default is safe only when commissioning controls prevent untrusted access and the password is changed before exposure.

The lab retains a documented disposable credential for reproducibility and binds the web console to loopback. That is not a recommendation for any other deployment. The database’s two published host ports also require an isolated host or firewall, as Chapter 4 noted.

There is a second recovery path. /auth/reset bypasses normal authentication but checks that the request peer is exactly 127.0.0.1; it can recreate the administrator if absent and set the named user’s password (rest/auth.rb:66-97). This is a local administrative trust decision. Its security depends on the request peer reflecting a genuine local caller rather than an untrusted proxy arrangement. The lab does not expose or use the reset route as a remote management feature.

Password policy and storage

User passwords are stored as BCrypt hashes. A Mongoid pre-save hook detects a new plaintext password, hashes it with BCrypt::Password.create, and records the change time (db_objects/user.rb:72-117). The REST user listing explicitly removes the password hash and password-change fields before returning account records (rest/user.rb:12-26).

The password rule requires ten characters containing at least one lowercase letter, one uppercase letter, and one digit. A separate validator rejects a password containing the username. Unless PASSWORDS_NEVER_EXPIRE is enabled, new-style accounts expire after 90 days and receive a warning during the last 15 days (rcs-db/lib/rcs-db/db_objects/user.rb:119-151; rcs-db/lib/rcs-db/websocket.rb:68-75). Accounts without the newer password-change fields are treated as migrated users whose passwords never expire.

RCS stores an MD5 checksum derived from the user identifier and password-change timestamp and considers a mismatch to mean the timestamp was changed directly in MongoDB (rcs-db/lib/rcs-db/db_objects/user.rb:95-125). Because the construction is unkeyed and its inputs are available with database access, it is an accidental-change detector, not strong protection against a knowledgeable database editor. It protects licensing-style consistency more than it provides an independent account audit.

Password quality is only one part of account security. The account document also contains the complete privilege array, enabled state, group memberships, dashboard state, and recent items. Chapter 10 identifies authorization flaws in how those fields can be updated and used. BCrypt correctly protecting a password at rest does not compensate for a controller that lets an authenticated user change an authority-bearing field.

One login creates two connected channels

The console first posts a username, password, and client version to /auth/login. The controller tries component authentication and then human authentication. A valid human login creates a random UUID session cookie, stores a session document with address, time, version, user relationship, and a copied privilege array, and returns the user for compatibility with the console (rcs-db/lib/rcs-db/rest/auth.rb:15-63; rcs-db/lib/rcs-db/sessions.rb:25-40).

RCS web-port login screen with user, password, and server fields
WEB PORT · LABThe reconstructed login preserves the original console's user, password, server, language, and client-version flow. No credential shown in the image belongs to a historical deployment.

The HTTP response gives the cookie a seven-day expiry and path=/. In this snapshot the emitted cookie string does not add Secure, HttpOnly, or SameSite attributes (rest/auth.rb:41-47). The original AIR console did not have the same browser threat model as a modern web application. The reconstructed console talks through a loopback same-origin proxy, but the cookie behavior remains a source-level security property worth recording.

After the REST login, the console opens WSS and sends an auth message containing the cookie. The WebSocket manager looks up the existing session, grants or denies the connection, and associates the socket with that cookie for pushes (rcs-db/lib/rcs-db/websocket.rb:23-79). The server sends heartbeat pings every 60 seconds. A client pong updates the session’s last-contact time (rcs-db/lib/rcs-db/websocket.rb:81-105).

The apparent seven-day browser expiry is therefore not the application session lifetime. Non-server sessions time out after 900 seconds since the last update, and the update normally comes from WebSocket pong traffic (rcs-db/lib/rcs-db/sessions.rb:99-135). A browser holding an unexpired cookie after that point still lacks a valid database session. Conversely, an active WebSocket can keep the session alive indefinitely while pongs continue.

Authorization uses the level array copied into the session at login. require_auth_level intersects the route’s permitted levels with @session[:level] (rcs-db/lib/rcs-db/rest.rb:245-250). Changing an account’s password, enabled state, or privileges does not update or destroy that session in the user update path. A demoted or disabled operator can therefore retain the old session authority until logout, timeout, administrative disconnection, or a new login replaces it. Chapter 10 treats that as a validated revocation flaw, not merely a user-interface oddity.

The same-user login trap

Before creating a new human session, AuthManager searches for an existing session for the username. If it finds one, it writes a forced-logout audit event, queues a logout push addressed to the user’s identifier, destroys the old WebSocket and session, and creates the new session (lib/rcs-db/auth.rb:82-96). This implements a one-session-per-user policy.

The decisive sequence is short enough to inspect directly:

# rcs-db/lib/rcs-db/auth.rb:82-96
sess = SessionManager.instance.get_by_user(username)
unless sess.nil?
  PushManager.instance.notify('logout', {
    rcpt: sess.user[:_id],
    text: "Your account has been used on another machine"
  })
  SessionManager.instance.delete(sess[:cookie])
end

return SessionManager.instance.create(user, auth_level, peer, version)

The push recipient is the user identifier, whereas the deleted object is the old session cookie. That difference creates the race described below.

The intention is understandable: prevent silent account sharing and tell the previous operator that the account was used elsewhere. The implementation creates a race because the queued push is addressed to the user, not uniquely to the old cookie. Push delivery is asynchronous. If a script logs in as admin without opening a WebSocket, then a human logs in as the same account before the queue is dispatched, the new browser can receive the old session’s logout message. Even a fresh browser login can sometimes authenticate its WSS connection in time to receive the notification generated while replacing its predecessor.

This behavior was observed repeatedly in the lab and explains apparently spontaneous “used on another machine” messages. It is original server behavior, not a defect introduced by the web port. Restarting rcs-db drops live WebSocket connections and in-process caches, and gives the dispatcher a chance to consume queued messages before a browser reconnects. It does not delete human session documents from MongoDB; a later login can still replace one and queue another logout. Restart is therefore a disruptive timing change, not a reliable session purge or health-check technique.

The test architecture avoids the trap by creating a unique full-privilege user for each live test. It also uses a separate HTTP request context for helper API calls; sharing page cookies would overwrite the UI session. Automated scripts must never use the default administrator while a human is operating the console.

This small design choice has wider incident-response consequences. A login is not passive observation. It changes the session collection, writes audit events, terminates a connection, and can send a visible message to an operator. Investigators should acquire volatile and database state before attempting an interactive login whenever authority and conditions permit.

Component identity is broader than human identity

The same login endpoint accepts backend components. auth_server fetches the first signature whose scope is server and compares the supplied password directly with that stored value. A successful component receives a session whose level is simply server; collectors also create or update their component record (rcs-db/lib/rcs-db/auth.rb:19-49). Previous sessions for the same component name are deleted before the new one is created.

This is a global shared-secret model, not a distinct certificate or credential per component. The username contributes an instance and declared component type, but the credential check uses the same server signature. A party that obtains it can authenticate under another component identity and receive broad server authority. Chapter 10 follows that trust path through archive setup and the plaintext backend-to-frontend channel.

TLS does not remove this weakness. Certificates encrypt and sometimes identify the channel endpoint; the application-level server signature admits the component. If many peers share one credential, compromise of one peer can become compromise of the machine-to-machine trust domain. Revocation also becomes coarse: rotating the shared signature affects every component that depends on it.

Human and component sessions are treated differently by timeout. The periodic session cleanup expressly excludes sessions whose level is server (rcs-db/lib/rcs-db/sessions.rb:99-108). Long-lived machine sessions make operational sense, but they raise the value of copied component credentials and stale sessions.

Revocation is a collection of unrelated events

RCS has no single operation meaning “this identity is no longer trusted.” Each identity system responds to change in its own way:

ChangeImmediate server behaviorWhat can remain
Licence file disappears or fails validationPeriodic check exits the database processMongoDB state and other durable files
Licence capacity is reducedHeartbeat may disable, delete, queue, or deactivate excess stateExisting audit and evidence records; effects depend on category
Bound dongle is missing or has the wrong serialLicence loading fails closedInstallation state on disk
Human password is changedNew logins require the new passwordExisting session cookie and copied privilege level
Human account is disabled or demotedNew login is denied or receives new privilegesExisting session authority until another termination event
Same username logs in againFirst matching old session is deleted and a logout push is queuedRace-prone notification in the asynchronous push queue
Human explicitly logs outWebSocket closes, session document is deleted, browser cookie is expiredAudit and other historical records
Human stops answering WSS heartbeatSession is removed after 900 secondsBrowser cookie can remain until its later client-side expiry
Component logs in under the same component namePrevious session for that name is deletedOther sessions authenticated with the same global secret
Shared server signature is exposedNo automatic per-component isolation or revocation followsEvery component that continues to accept the shared value

This matrix explains why credential rotation, account disablement, and session termination must not be used as synonyms. Changing a password affects the next proof of identity. It does not necessarily revoke an already-issued authorization object. Disabling an account affects auth_user, while normal REST authorization consults the session found by cookie and its copied level. Rotating a global component value can affect many peers at once but does not identify which peer was compromised.

The session manager has two forms of state. Session documents live in MongoDB, while the manager and WebSocket manager also maintain in-process caches and socket maps (rcs-db/lib/rcs-db/sessions.rb:17-76; rcs-db/lib/rcs-db/websocket.rb:15-21,77-79). A database record, a cached lookup, and a live socket can therefore briefly disagree during login, logout, restart, or asynchronous push delivery. Restarting the database process clears in-memory state and connections, but it is not a substitute for understanding the durable session collection.

For incident response, the order of operations follows from this design. Preserve live sockets and process memory where authorized and practical; acquire the session and push collections; correlate account changes and audit events; then revoke or restart. Acting first can remove precisely the volatile state needed to explain which identity was active.

Authentication produces evidence, but not an independent audit

The authentication manager writes an audit record for a missing user, a disabled user, an expired password, an invalid password, a successful login, and a forced logout (rcs-db/lib/rcs-db/auth.rb:51-101). Explicit logout, timeout, password reset, and administrative session destruction also write audit events. This is useful coverage: failed attempts and lifecycle transitions are not left only in transient standard output.

Several limitations keep those records from being a complete security history. They live in the same MongoDB trust domain as the application data. Chapter 7 found no cryptographic chaining or automatic retention policy for the audit collection in the reviewed snapshot. A licence heartbeat can change users and items independently of a human console action. A process failure can occur after an audit write but before all related cleanup, or before the audit write for an intended action. Asynchronous logout delivery can reach a different socket from the one described by the preceding event.

The login record identifies a username and peer address, not the person at the keyboard. A successful password check establishes possession of the account secret at that moment. Who typed it, whether the workstation was compromised, and whether the account was shared remain open. A component login likewise establishes possession of the global server signature, not the identity named in the username.

Defenders should therefore correlate RCS audit events with session documents, WebSocket traces, reverse-proxy or network records, user and group history, licence changes, process restarts, and host authentication evidence. The audit log is an application witness. It is not an external observer.

Whose trust was strongest?

The examined design invested heavily in preserving product entitlement. It checked licence structure twice, optionally consulted a hardware token and its clock, reloaded limits every heartbeat, repaired over-limit state, checked selected database fields, and terminated when anti-tamper constants changed.

Operator and component trust had useful controls too: BCrypt password storage, a nontrivial password policy, private TLS, random session cookies, WebSocket authentication, session timeouts, audit events, and one-session-per-user behavior. But the seams between those controls are where risk accumulated:

The conclusion is not that RCS had no security. It is that its trust model was asymmetric. Controls defending licensing and vendor intellectual property were continuous and fail-closed. Controls limiting a previously authenticated human or component were coarser and sometimes stale. That asymmetry prepares the central security question of this book: a platform built to compromise other systems also concentrated evidence, credentials, and operational control in a backend whose own identities were not always isolated from one another.

Sources and evidence

↑ HackingTeam's RCS: Bringing a Commercial Spyware Platform Back to Life