Who Is Atsign?
Atsign is the company behind NoPorts, a remote-access product designed to let users connect to devices and systems without exposing inbound listening ports. Instead of relying on network location as the primary trust boundary, Atsign builds its access model around cryptographic identity and end-to-end encryption. The idea is straightforward: verify the identity of the user requesting access, establish an encrypted session, and avoid exposing a traditional public attack surface.
A typical production environment helps explain why that model is appealing. Imagine a server sitting in a data center behind restrictive firewall rules with no inbound SSH access, no VPN termination, and no management services exposed to the internet. The NoPorts daemon runs on that server and establishes an outbound connection to its atServer, where it maintains a long-lived monitor connection.
When an administrator needs to SSH into the server, the client sends an encrypted notification through the atProtocol infrastructure. That notification is delivered to the device's atServer and eventually reaches the daemon. The daemon then establishes an outbound connection to a rendezvous server, allowing the client and device to meet there and carry the SSH session across the relay.
From the perspective of the data center firewall, nothing needs to change. The server never exposes an inbound SSH listener to the internet, and administrators do not need to add temporary firewall rules just to reach the system. This is the core appeal of NoPorts: remote access based on cryptographic identity without directly exposing the destination service.
The Atsign Foundation is the nonprofit open-source organization behind the atProtocol and much of the platform code. It publishes the protocol specification along with open-source implementations of NoPorts, which makes the repository directly relevant to the security properties Atsign promotes for the product.
That architecture is what made the project interesting to review. Whenever a product claims that one of its primary security benefits is the removal of public attack surface, the natural question is where the access-control boundary has moved. Removing an exposed port does not eliminate the need to decide who is allowed to connect; it simply moves that decision somewhere else. In NoPorts, a significant portion of that responsibility belongs to the daemon.
The Promise of NoPorts
The NoPorts model is based on the idea that remote access should be identity-first rather than network-first. Instead of assuming that someone should be trusted because they can reach a particular IP address or TCP port, the platform relies on cryptographic identities, encrypted messages, and trust decisions made by the software.
The open-source implementation includes a device daemon, commonly referred to as sshnpd, along with the supporting components required to establish connections. Because the protected service does not need to be exposed directly, the daemon becomes a major part of the security boundary. It is responsible not only for receiving authenticated requests but also for deciding whether the identity behind each request is actually authorized to perform the requested action.
That distinction between authentication and authorization is where the vulnerability begins.
The High-Level Problem
The core issue I found is that the C implementation successfully authenticates incoming requests without consistently enforcing whether the authenticated identity is authorized to make them.
Authentication and authorization are related, but they are not interchangeable. Authentication establishes who sent a message, while authorization determines whether that identity is permitted to perform a specific action. A system can authenticate a user perfectly and still have a serious security problem if it fails to enforce what that user is allowed to do.
In the C implementation of sshnpd, incoming messages are cryptographically verified, which means the daemon can determine which atSign sent a request. The problem is that the request-dispatch path does not compare that identity against the configured manager list before allowing several sensitive handlers to execute.
This matters because the protected service may still appear completely inaccessible to a traditional network scan. There may be no externally reachable SSH listener and no obvious management port to attack, but the access-control boundary has moved into the daemon's message-processing logic. If authorization is not enforced correctly there, an attacker can reach functionality that was intended only for trusted managers without ever connecting directly to an exposed service.
The Two Flaws
The vulnerability chain is built from two separate implementation problems. The first allows an unauthorized atSign to reach functionality intended for trusted managers. The second breaks validation in the code responsible for accepting SSH public keys.
The first issue exists independently of public-key sharing and affects the daemon's session-request path. The second becomes relevant when the daemon is started with the -s option, which enables SSH public-key sharing. When the two conditions are combined, an unauthorized identity can potentially establish both network reachability to the protected SSH service and its own method of authenticating to that service.
Flaw 1: Missing Manager Authorization
The first issue is that the C daemon does not properly enforce the configured manager list before processing certain requests.
The product already has the concept of trusted managers. The --manager argument is parsed at startup and stored in params.manager_list, and the Dart implementation explicitly checks whether the requesting atSign belongs to that list. In the C request-dispatch path, however, the equivalent authorization check is missing.
As a result, an unrelated atSign can reach handlers that should have been restricted to authorized managers. The sender still has a valid cryptographic identity, but having a valid identity is not the same thing as being trusted by the device.
Flaw 2: Broken SSH Public-Key Validation
The second issue appears when the daemon is configured with the -s option to accept SSH public keys. Before writing a submitted key into authorized_keys, the handler attempts to verify that the supplied value begins with one of the supported SSH key prefixes.
The C implementation contains several errors in that validation logic. It calculates the wrong comparison length, performs a comparison that does not test the intended prefix correctly, and then treats a non-zero strncmp() result as a successful match. Since strncmp() returns zero when strings match, that final condition is inverted.
The result is that the validation does not provide the security check it appears to provide. When this flaw is combined with the missing manager authorization check, an unauthorized identity can reach the public-key handler and submit attacker-controlled key material.
What the Attack Chain Looks Like
There are really two related attack paths here. The missing manager authorization check affects session requests regardless of whether public-key sharing is enabled. The -s option is only required to extend that access into the SSH public-key injection path.
With public-key sharing enabled, the full chain looks like this:
An attacker identifies a device using the C implementation of
sshnpdand learns the device's atSign and NoPorts device name.The attacker uses their own valid atSign to communicate with the target.
Because the daemon does not enforce the configured manager list in the request-dispatch path, the attacker can request a relay session even though their atSign is not an authorized manager.
If the daemon is also running with the
-soption, the attacker can send ansshpublickeynotification and reach the public-key handler through the same missing authorization check.The defective public-key validation allows the attacker-controlled key to proceed to the code responsible for modifying
authorized_keys.The attacker requests a relay session to the device's SSH service, such as
localhost:22.The relay provides connectivity to the target's SSH daemon, where the attacker can authenticate using the newly installed key.
The distinction around -s is important because the key-injection bug is not required to demonstrate the authorization failure. Even without public-key sharing enabled, an unauthorized atSign can request connectivity to the protected SSH service. Enabling -s makes the issue substantially more severe because the same unauthorized identity may also be able to establish its own SSH authentication material.
What makes the chain particularly interesting is that it does not depend on memory corruption, unsafe pointer handling, or a complicated protocol exploit. The attacker is interacting with the application's intended control flow. The vulnerability exists because a critical access-control decision is missing and a second validation decision is implemented incorrectly.
In-Depth Technical Analysis
The full details are covered in the disclosure report, but the two core bugs are small enough to walk through directly. The relevant files are located under:
packages/c/sshnpd/src/
Flaw 1: The Daemon Never Checks Who Is Allowed to Request Sessions
The daemon maintains a long-lived monitor connection and receives notifications through it. Each notification is classified according to its key name and then dispatched to the corresponding handler.
The relevant section of daemon.c looks like this:
switch (notification_key) {
case NK_SSHPUBLICKEY:
handle_sshpublickey(
¶ms,
&message,
authkeys_file,
authkeys_filename
);
break;
case NK_SSH_REQUEST:
permitopen.requested_host = "localhost";
permitopen.requested_port = params.local_sshd_port;
if (!should_permitopen(&permitopen)) {
break;
}
handle_ssh_request(
&worker,
¶ms,
&is_child_process,
&message,
signingkey
);
break;
case NK_NPT_REQUEST:
handle_npt_request(
&worker,
¶ms,
&is_child_process,
&message,
signingkey
);
break;
}
The important part of this code is what it does not contain. There is no authorization check against params.manager_list before the request is dispatched to the SSH public-key, SSH session, or NoPorts tunnel handlers.
The daemon parses the --manager argument into params.manager_list during startup, so the concept of an authorized manager clearly exists in the C implementation. However, that list is not consulted in this portion of the request-processing path before sensitive actions are performed.
There is a separate security check inside handler_commons.c. The function verify_envelope_signature_from() retrieves the requesting atSign's public key and verifies the signature attached to the envelope:
int verify_envelope_signature_from(
cJSON *envelope,
char *requesting_atsign,
atclient *atclient
) {
// ...
char *buffer = NULL;
res = atclient_get_public_key(
atclient,
&atkey,
&buffer,
NULL
);
// ...
res = verify_envelope_signature(
&requesting_atsign_publickey,
(const unsigned char *)payloadstr,
(unsigned char *)buffer,
hashing_algo_str,
signing_algo_str
);
// ...
}
This function provides authentication. It establishes that the message was signed by the atSign that claims to have sent it, which is an important security property. What it does not determine is whether that atSign is permitted to request a session, modify SSH authorization data, or invoke another privileged action.
That second decision is authorization, and there is no corresponding comparison against params.manager_list in the dispatch path shown above. The daemon therefore knows who sent the request but does not consistently verify whether that sender is allowed to make it.
The Dart Implementation Shows the Intended Behavior
The missing authorization check becomes much clearer when the C implementation is compared with the Dart implementation. The Dart daemon contains an explicit manager check in sshnpd_impl.dart:
if (managerAtsigns.contains(clientAtsign)) {
This check verifies that the identity making the request belongs to the configured set of trusted managers before allowing the protected logic to continue.
That distinction is important because it shows that the authorization model already exists in the project's own codebase. The system was designed around the idea that only configured managers should be permitted to perform these actions, and the Dart implementation explicitly enforces that relationship.
The corresponding authorization gate was not carried into the C dispatch path. Because of that omission, the C daemon can authenticate who sent a request without verifying that the sender belongs to the set of identities authorized to make it. This is not a case where a new authorization model needed to be designed after the vulnerability was discovered; the expected behavior was already implemented elsewhere in the project.
Secondary Impact: Unauthorized Device Information Disclosure
The same missing authorization boundary can also expose information about the target through less destructive request types. An unauthorized atSign can interact with the daemon's ping functionality and receive information about the device, including its NoPorts device name, daemon version, and configured permitopen services.
For example, the response can expose version information such as:
"version": "1.0.19"
This is not the primary vulnerability, but it reinforces the same underlying problem. The daemon is willing to return information about the protected device to an identity that it can authenticate without first establishing that the identity is an authorized manager.
The disclosed information may also be useful for fingerprinting. Knowing the daemon version, device name, and available permitopen services gives an unauthorized party additional information about the target environment and which internal services NoPorts is configured to expose through its relay mechanism.
Flaw 2: The Public-Key Validation Is Inverted
The second issue appears in the SSH public-key handling code.
When the daemon runs with the -s option, clients can provide an SSH public key that the daemon will add to the target user's authorized_keys file. Before doing that, the code attempts to verify that the supplied value begins with one of several supported SSH public-key prefixes.
The validation loop in handle_sshpublickey.c is:
bool is_valid_prefix = false;
for (int i = 1; i < SUPPORTED_KEY_PREFIX_LEN; i++) {
char *prefix = supported_key_prefix_map[i];
size_t prefix_len =
strlen(message->notification->decrypted_value); // (a)
if (prefix_len < strlen(ssh_key)) { // (b)
continue;
}
if (strncmp(ssh_key, prefix, prefix_len)) { // (c)
is_valid_prefix = true; // (d)
break;
}
}
There are four problems stacked on top of each other in this small block of code.
First, prefix_len is populated using the length of message->notification->decrypted_value, which represents the supplied key. The variable is supposed to describe the length of the supported prefix being tested, so the comparison should instead be based on strlen(prefix).
Second, the guard immediately below it is effectively dead:
if (prefix_len < strlen(ssh_key)) {
continue;
}
Because prefix_len was derived from the supplied key itself, this comparison does not perform the intended bounds check.
Third, the same incorrect length is passed to strncmp():
strncmp(ssh_key, prefix, prefix_len)
Instead of comparing the beginning of the supplied key against the supported prefix for the length of that prefix, the function is given the length of the entire submitted value. As a result, the comparison is not testing the property the developer intended to validate.
Finally, the result of strncmp() is interpreted backwards:
if (strncmp(ssh_key, prefix, prefix_len)) {
is_valid_prefix = true;
break;
}
A successful strncmp() comparison returns 0, while a non-zero result indicates that the strings differ. The code therefore sets is_valid_prefix to true when the comparison reports a mismatch.
Taken together, these mistakes mean the public-key validation does not function as intended. Once the value passes through this handler, it can be provided to authorize_ssh_public_key() in file_utils.c, which is responsible for adding it to the user's authorized_keys file.
The Dart Client Shows the Expected Validation
The Dart codebase performs corresponding validation before the client sends the public key. In sshnpd_channel.dart, the value must begin with one of the supported SSH public-key formats:
if (!publicKeyContents.startsWith(
RegExp(
r'^(ecdsa-sha2-nistp)|(rsa-sha2-)|(ssh-rsa)|(ssh-ed25519)'
),
)) {
throw SshnpError(
'SSH Public Key does not look like a public key file'
);
}
It is important to be precise about where this check occurs. This validation is performed by the Dart client when sending the key rather than directly before a daemon-side authorized_keys write, so it is not an exact one-to-one comparison with the C handler.
However, it clearly demonstrates the expected format validation within the reference implementation. The C daemon attempts to perform its own validation when receiving the key, but the incorrect length calculation and inverted strncmp() condition cause that check to fail open.
Relying on client-side validation would not be sufficient by itself, since an attacker can control the client. The receiving daemon still needs to validate untrusted input independently, particularly when that input is about to modify an SSH authorization file.
Why the Two Bugs Matter Together
The impact becomes substantially more serious when the two bugs are combined. The missing authorization check allows an identity that is not a configured manager to reach functionality intended for trusted managers, while the broken public-key validation allows attacker-controlled input through a security-sensitive handler when public-key sharing is enabled.
The resulting chain looks like this:
Valid atSign
↓
Cryptographically signed request
↓
Sender successfully authenticated
↓
Manager authorization not enforced
↓
SSH public-key handler reached
↓
Public-key validation fails open
↓
Attacker-controlled key added to authorized_keys
↓
Relay session established to the target SSH service
↓
sshd authenticates the attacker using the newly installed key
The important distinction is that the cryptographic authentication itself is not the part that fails. The daemon can determine which identity sent the request. The failure occurs immediately afterward because the daemon does not consistently answer the next question: whether that authenticated identity is authorized to perform the requested action.
The missing manager authorization is therefore the foundational issue, while the public-key validation bug increases its impact. Even without -s, the authorization flaw can allow an untrusted identity to request connectivity to services exposed through NoPorts. With -s enabled, that same identity may also be able to establish SSH credentials that can be used once the relay reaches sshd.
Where Is the Authorization in the Dispatch?
This is the central issue in the vulnerability. The daemon receives a notification, determines whether it represents an sshpublickey, ssh_request, npt_request, ping, or another supported message type, and then dispatches it to the corresponding handler.
What is missing between classification and execution is an authorization gate.
The --manager option exists, the resulting manager list is stored by the daemon, and the Dart implementation demonstrates that requests are supposed to be restricted according to that list. However, the C dispatch path does not consult the manager list before processing these sensitive request types.
The authorization model therefore exists at the product level and is visible in the reference implementation, but the C daemon fails to enforce that model in a critical portion of the request path.
Why It Matters
From an attacker's perspective, the concerning part of this vulnerability is how little additional exploitation is required once the authorization boundary fails. The missing manager check alone can provide an unauthorized identity with relay access to a service that was intentionally hidden from direct network exposure. If the target is also running the affected C implementation with public-key sharing enabled, the attacker may not need existing SSH credentials because the product's own control path can potentially be used to add attacker-controlled authentication material.
From a defender's perspective, the more important issue is what this means for the security boundary NoPorts is designed to provide. NoPorts removes direct inbound exposure, but eliminating an exposed port does not eliminate the need for authorization. Instead, the authorization decision moves into the application's control plane. If that control plane accepts a request from an authenticated but unauthorized identity, an attacker may still be able to reach the protected service even though that service was never directly exposed to the network.
The security boundary has therefore changed rather than disappeared. Instead of depending primarily on a firewall rule or exposed SSH listener to restrict access, NoPorts depends on the daemon to correctly enforce which identities are permitted to request connections and modify access-related state. When that authorization logic fails, the absence of an exposed inbound port is no longer sufficient to prevent unauthorized access.
Simple Implementation Errors With Critical Impact
What makes these vulnerabilities interesting is the contrast between their implementation complexity and their impact. The underlying mistakes are easy to describe: a configured manager list is not consulted in the sensitive request path, and a public-key validation routine contains several basic comparison errors.
Neither flaw requires a complicated cryptographic attack, an unusual memory-corruption primitive, or manipulation of an obscure protocol state. Their significance comes from where those mistakes occur: directly inside the access-control path of a remote-access product.
The first flaw allows the software to correctly determine who sent a request while failing to enforce whether that identity should be allowed to perform the requested operation. The second flaw weakens a handler that can modify SSH authentication state. When those conditions overlap, relatively small implementation mistakes can undermine a much larger security boundary.
This is also why identity-first architectures still depend on traditional authorization principles. Cryptographically proving who sent a request is valuable, but identity alone does not determine what that person should be allowed to do. The policy still has to be enforced at every security-sensitive boundary where that identity can cause an action to occur.
Remediation
The authorization issue should be addressed by enforcing the configured manager list before the C daemon processes sensitive session-related or state-changing requests. The C implementation should contain an authorization gate equivalent to the manager check already present in the Dart implementation, ensuring that a successfully authenticated sender is also verified as an authorized manager before the request reaches the corresponding handler.
The SSH public-key validation should also be corrected so that each submitted value is compared against the supported key prefixes using the length of the expected prefix. The result of strncmp() should only be treated as a successful match when it returns 0, and malformed values should be rejected before they reach the code responsible for modifying authorized_keys.
The daemon should also consider which informational requests require authorization. Responses that expose version information, device names, or configured services may appear relatively low risk compared with session establishment or key modification, but they still provide useful fingerprinting information to an unauthorized party.
More broadly, this type of issue highlights the importance of negative authorization testing when porting security-sensitive software between languages. Testing that an authorized manager can successfully request a connection is useful, but the test suite should also verify that an authenticated identity outside the manager list cannot request sessions, modify SSH authorization data, enumerate protected services, or reach other privileged handlers.
These vulnerabilities would also be difficult to identify through traditional network scanning alone because the interesting attack surface is not an exposed service. The relevant security boundary exists inside the daemon's control flow and policy enforcement, which makes source review and authorization-focused testing especially important for this type of architecture.
My Take
I originally became interested in Atsign because the architecture presents an appealing security model: no directly exposed inbound ports, identity-first remote access, and an open-source implementation that can be reviewed to understand how those security properties are actually enforced.
What I found was not a failure of Atsign's cryptography or an unusually complicated exploitation technique. In fact, the authentication layer was doing its job: the daemon could determine which identity had sent a request. The failure came immediately afterward, when the C implementation did not consistently enforce whether that authenticated identity was permitted to perform the requested operation.
That distinction is what makes this vulnerability interesting to me. Moving remote access away from exposed listening ports can meaningfully reduce traditional network attack surface, but it also makes the authorization logic inside the control plane part of the security perimeter. If that logic fails, the service does not need to be publicly listening for an unauthorized user to reach it.
Authentication proves who you are. Authorization determines whether you should be there. A secure remote-access system needs both.
Disclosure Timeline
| Date | Event |
|---|---|
| August 21, 2026 | Vulnerabilities reported to Atsign with technical details and proof-of-concept video |
| August 21, 2026 | Atsign confirms receipt and begins engineering investigation |
| August 22, 2026 | Incident manager assigned |
| August 24, 2026 | Atsign confirms fixes were developed over the weekend and begins CVE process |
| August 24, 2026 | Final remediation pull request remains in progress before release |
| August 25, 2026 | Patched release completed |
| August 25, 2026 | CVE request submitted through GitHub Security Advisory; identifier pending |
Overall, Atsign handled the report professionally and moved quickly once the issue was disclosed. Engineering began investigating immediately, communication remained open throughout the process, and a patched release was completed within four days of the initial report.