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:
| Mechanism | Question it tried to answer | Principal 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:
- enabled human users;
- total, desktop, and mobile agents;
- build and demonstration entitlement by platform;
- local collectors and remote anonymizers;
- shards and network injectors;
- alerting, correlation, intelligence, connectors, OCR, and translation;
- deletion, modification, archive, and exploit features; and
- reusable versus one-shot licence behavior.
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.

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:
- the CA subject is
CN=Root Certification Authority, O=ACME Corp; - the database common name comes from configuration and defaults to
127.0.0.1in the lab; - the collector certificate uses common name
collector; - certificates are issued for 3,650 days;
- the OpenSSL configuration uses SHA-1 as its default digest; and
- request keys default to 1,024 bits
(
config/certs/openssl.cnf:5-39).
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).

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:
| Change | Immediate server behavior | What can remain |
|---|---|---|
| Licence file disappears or fails validation | Periodic check exits the database process | MongoDB state and other durable files |
| Licence capacity is reduced | Heartbeat may disable, delete, queue, or deactivate excess state | Existing audit and evidence records; effects depend on category |
| Bound dongle is missing or has the wrong serial | Licence loading fails closed | Installation state on disk |
| Human password is changed | New logins require the new password | Existing session cookie and copied privilege level |
| Human account is disabled or demoted | New login is denied or receives new privileges | Existing session authority until another termination event |
| Same username logs in again | First matching old session is deleted and a logout push is queued | Race-prone notification in the asynchronous push queue |
| Human explicitly logs out | WebSocket closes, session document is deleted, browser cookie is expired | Audit and other historical records |
| Human stops answering WSS heartbeat | Session is removed after 900 seconds | Browser cookie can remain until its later client-side expiry |
| Component logs in under the same component name | Previous session for that name is deleted | Other sessions authenticated with the same global secret |
| Shared server signature is exposed | No automatic per-component isolation or revocation follows | Every 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:
- a predictable bootstrap administrator remained possible;
- local symmetric secrets lost authority when the verifier leaked;
- long-lived generated keys depended on installation security;
- privileges were copied into sessions without immediate revocation;
- same-user logout notifications were addressed too broadly;
- browser cookie attributes reflected an AIR-era client model; and
- backend components shared one application credential and one broad role.
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
- SOURCE:
rcs-db/lib/rcs-db/license.rb,config.rb,auth.rb,sessions.rb,websocket.rb,db_layer.rb, and the user/signature models at commit6cff59d28634d718cac9fdd17cb629fd59a3cf3f. - SOURCE: shared cryptographic and status behavior from
rcs-commoncommit38290d4eab2b2c295bea021429848a3666647827, subject to Chapter 3’s skew limitations. - LAB:
docker-lab/rcs-db/make_license.rb, first-boot configuration, session observations, and console mock/live authentication contracts. - MANUAL: RCS 9 Administrator and System Administrator guides for licence, account, certificate, and component-administration workflows.
- LIMITATION: hardware-token behavior remains source/manual evidence; embedded keys, credential values, and private certificate material are not reproduced.
↑ HackingTeam's RCS: Bringing a Commercial Spyware Platform Back to Life