Radish alpha
r
rad:z371PVmDHdjJucejRoRYJcDEvD5pp
Radicle website including documentation and guides
Radicle
Git
radicle.xyz _posts disclosure-of-vulnerability-in-signed-references.md.bak
---
title: "Disclosure of Replay Attack Vulnerability in Signed References"
image: radicle-1.png
redirect_from: /2026/03/23/vulnerability-disclosure
---

As announced in the release notes for [Radicle 1.7.0]({% post_url 2026-03-18-radicle-1.7.0 %}),
that version contains a fix for a security vulnerability.
[Radicle 1.7.1]({% post_url 2026-03-20-radicle-1.7.1 %}), and
[Radicle 1.8.0]({% post_url 2026-03-30-radicle-1.8.0 %}),
which were released since, also contain this fix.
We did not disclose the vulnerability at the time of releasing Radicle 1.7.0 in order to give users time to upgrade,
before we disclose the security vulnerability in detail, which is the purpose of this post.

First off, we would like to offer our heartfelt thanks to our community member Felix Bargfeldt, online also known as [Defelo]. 🙌
He made us aware of the vulnerability, and explained the problem in detail.
We stayed in touch with Felix during the process starting from the notification up to the release of the mitigation,
and Felix stayed responsive with valuable feedback throughout the process.

In this, we see confirmation of how valuable our community is and how it shapes Radicle, even when it comes to complex issues at the core of the system.
It is evidence for the power of free software, not just as codebases but as community efforts.

## Timeline

| Date and Time     | Event |
|-------------------|-------|
| 2026-02-14T23:43Z | Felix notifies us about the security vulnerability.
| 2026-02-14T23:51Z | We reply to Felix, acknowledging the issue.
| 2026-02-17T00:20Z | We have implemented an exploit in the form of an E2E test, and inform Felix.
| 2026-03-05T18:47Z | We inform Felix about our plan to implement a mitigation.
| 2026-03-05T23:13Z | Felix provides us with valuable feedback on our mitigation.
| 2026-03-18T16:11Z | We release Radicle 1.7.0.
| 2026-03-20T08:27Z | We release Radicle 1.7.1.
| 2026-03-30TXX:XXZ | We release Radicle 1.8.0.

## Introduction to Signed References

In order to understand the vulnerability, some knowledge about Signed References is required.
However, this knowledge is not required for regular use of Radicle.
Signed References surface via `rad inspect --sigrefs` and `rad sync status`,
but are otherwise internal, and not a concept that users will have to think about much when they use Radicle.
We thus briefly explain the relevant parts of the design.

Whenever users push changes to a repository via `git push`,
and whenever users operate on [Collaborative Objects] (e.g., repository identity, issues, and patches),
associated with a repository, they "change their references".
This means that the target object identifiers (OIDs) of some Git references in their own namespaces change.

For example, consider this invocation of `git push`:

```shell
$ git push
To rad://z4…ji/z6Mk…BU8Vi
   87fa120...145e1e6 cool-feature -> cool-feature
```

Here, the user `z6Mk…BU8Vi` is working on repository `z4…ji` and
intends to advance the branch `cool-feature` from
the target OID with unique prefix `87fa120` to
the target OID with unique prefix `145e1e6`.
When operating on a Collaborative Object, the name of the updated reference is of the shape `refs/cobs/<typename>/<id>`, but the principle is the same.
A reference is updated.
(It is possible to update multiple references "at once", but this is not relevant with regards to the vulnerability.)

This update of a reference is recorded in what we call the Signed References of the user.
In the corresponding bare repo in Radicle storage, i.e. in our example `$RAD_HOME/storage/z4…ji`,
the reference `refs/namespaces/z6Mk…BU8Vi/refs/rad/sigrefs` targets a commit which encodes the current state of the users' references.
Think of this commit as a snapshot of all Git references and all Collaborative Objects at some point in time.
This commit is internal to Radicle.
Its commit message and contents are *not* user-controlled, but computed by Radicle.

The contents of this commit are as follows:

```
$ git -C $RAD_HOME/storage/… ls-tree refs/namespaces/z6Mk…BU8Vi/refs/rad/sigrefs
100644 blob d5…bc    refs
100644 blob 6c…0a    signature
````

`refs` is a text file.
Its contents are in the same format as the output of [`git show-ref`]
, which lists, line by line in lexicographic order, all references with their respective target OID:

```
e4…f0 refs/cobs/xyz.radicle.id/c9…d9
e1…6a refs/cobs/xyz.radicle.patch/f2…f4
87…20 refs/heads/cool-feature
83…40 refs/heads/main
b1…3a refs/tags/releases/0.9.3
```

`signature` is a binary file which contains an Ed25519 signature over `refs`,
performed with the users' signing key.

As the user intends to change the target for `refs/heads/cool-feature`,
a new commit of the above shape is computed, and the reference `refs/namespaces/z6Mk…BU8Vi/refs/rad/sigrefs`
is advanced to point at it.
The purpose of the new commit is to record the new state of the references of the user,
after applying the user-intended changes to the user-controlled references.

The (shortened) diff for `refs` implied by the above example looks like this:

```diff
-87…20 refs/heads/cool-feature
+14…e6 refs/heads/cool-feature
```

The history of these commits thus makes up all changes the user made to their references,
starting from their earliest change (which might be repository initialization, if they did initialize the repository) to their latest, like advancing `cool-feature`.

During the process of fetching this history, only fast-forward changes to this history are accepted.

## Replay Vulnerability

Versions of Radicle prior to 1.7.0 were vulnerable to replay attacks.
That is, attackers could use `refs` and `signature` from an earlier commit of the victims' references
to forge a new commit, without the victim knowing or agreeing.
The forged commit could be shared with other nodes among the network, and such changes, unintended by the victim,
would be indistinguishable from intended changes.

This was possible since the signature was only made over `refs`,
which does not include any kind of [nonce] or further replay protection.

It was possible to take any previous `refs` file, together with the corresponding `signature` file,
and forge a new commit as a child of the latest, legitimate commit made by the victim.

One possible attack would be to replay the earliest `refs` in a victims' history, whenever they advance their `refs/rad/sigrefs`.
The effect would very likely be that their repository appears empty and stale, as most repositories start out with just an initial commit.
This attack can be performed at scale.

## Mitigation

A widely known measure to prevent replay attacks is to use cryptographic [nonce]s.

There are two immediate questions to answer:
 - Where to "put" the nonce?
 - How to generate or compute the nonce?

### Where?

Within the team, we discussed three proposals mitigate.

##### Nonce in New Blob

One proposal we considered was to add another blob to the aforementioned shape of commits.
This blob would contain the object ID of the `refs` blob, and the nonce.
It would of course also be signed using the signing key of respective user that controls the namespace.
Such design would have introduced more complexity into the verification process.

##### Signed Pushes

We did consider to immediately implement a new component to succeed Signed References based on [signed pushes].
Such change would have meant to introduce a new component,
at the core of Radicle, just like Signed References,
which is a complex task.
We quickly realized that this likely is the better alternative to Signed References in the long term (more on that below),
but that it would be very risky to implement under time pressure,
as we wanted to secure the network as quickly as possible.

##### Nonce in References

Another proposal we considered was to add a new internal reference,
right into the list of references into the refs `refs` blob,
This "internal" reference would not be user-controlled, but always target at the parent commit (if there is a parent that can be targeted).
The drawback of this design is that it conflates two concerns,
or rather, two kinds of data:
User-controlled references and internal references.
Both would appear next to each other in `refs` with no way to distinguish the two cases,
other than the name of the reference, which in case of user-controlled references is, also, user-controlled.
This design is less clean in a sense.

Such decision was however made in the past already.
In commit `989edacd564fa658358f5ccfd08c243c5ebd8cda`,
which was released as part of Radicle 1.1.0,
the reference `refs/rad/root` was introduced.
At that time, the reason was to prevent so called "graft attacks",
which we will not go into detail here.

#### Decision

With new ideas to improve Signed References on a more pervasive way,
and the pre-existing `refs/rad/root` mixup, we decided to implement
the third design proposal, that is, to add an internal reference.

### What?

In our case, to simplify, including a nonce in `refs` ("signing over a nonce") would mean to sign over data that is never re-used.
Using a cryptographically secure source of randomness is often regarded as a sufficient condition for obtaining nonces,
and this way of obtaining nonces is very common.
The chance of reading the same random data twice in that case is vanishingly small.
However, it is not a necessary condition for a nonce to be random in that sense.
The important property is that the nonce is never re-used.
Also, in many other session-based protocols, it is also important that the nonce is not guessable.
In our case, no sessions are involved.

We realized, that realistically, our design space for a nonce in the `refs` blob
is very constrained.

#### Backwards Compatibility

Mitigation of such vulnerability "in a void",
disregarding backwards compatibility with already deployed nodes in the network,
is straight forward.
Implementing a backward incompatible change would have meant that we would
have to implement migration tools, and require action by all users.
It was thus pretty clear to us from the beginning that we would aim
for a mitigation that is backwards compatible.

The crucial design constraint we ran into, was that, in earlier and widely deployed versions of Radicle, fetching fails, if one of
the objects targeted by a reference cannot be fetched.
This ruled out the possibility to abuse the target OID of the newly introduced reference to a random number.

However, we are lucky!
With the exception of the root commit, there always is one object that we can use as a target, and whose value is a hash over all previous history.
It is the OID of the previous commit in history, in Git also called the "parent".
Note that commits can have any positive integer number of parents.
If that number is zero, we call the commit a root commit.
If the number is greater than one, we call the commit a merge commit.

The fact that for the root commit we cannot point at the parent is not problematic, since there also is no prior history that might be replayed.

Thus, in commit `d3bc868e84c334f113806df1737f52cc57c5453d`,
we introduced the new internal reference `refs/rad/sigrefs-parent`.
It may only be present in `refs` if there is some prior history, i.e.,
if the commit is not the root commit.
If it is present, then it's value must match the `parent` header of the commit that contains the `ref` blob.

#### Upsides of this Design

When considering the complexity of the verification mechanism,
using the commit ID of the parent has advantages compared to the use of a random number.
When using a random number, to be sure that it was not used before,
and thus to detect an attack, all random numbers used previously
must be compared.
This translates to a traversal of all prior history in the namespace,
which is expensive.
The cost to do so grows linearly in the length of the history.
On the other hand, the hash of the parent commit is known locally,
with constant overhead, no matter how large the history grows.

#### Downsides of this Design

Earlier versions of Radicle do not know about this new internal reference,
and will thus treat it just like a user-controlled reference.
This might cause confusion, because the user never explicitly creates or modifies the reference.

## Scanning the Network

To assess the impact of this vulnerability, and improve our understanding how widespread attacks might be on the network we developed a dedicated scanning tool (see `rad:z3zzcqqBGP1NdvvMbSDt2jYYp9jSB`).
The scanner operates on Radicle storage of a node.
It traverses the history of Signed References (`refs/rad/sigrefs`), across all repositories and their namespaces in storage.

Specifically, it looks for evidence of replay attacks by detecting sets of commits that all refer to the same state. We want to select, out of one history pointed at by `refs/rad/sigrefs`, all commits that refer to the same `refs` blob.
We call a collection of commits, that all refer to the same `refs` blob a *cluster*.
Naturally such cluster can be identified by the combination of RID and NID,
which narrows down the history it was found in, and the blob ID of the `refs` blob that all commits, that are members of the cluster, share.
Note that empty clusters are not meaningful in our context,
and that clusters of size 1 are of no particular interest to us.
We are interested in clusters with a size of at least two,
which is the smallest kind of cluster that could be an attack.

### False Positives

Also note that this analysis is prone to false positives.
Seeing a cluster is necessary for an attack, but not sufficient.
It is possible for users to legitimately arrive at the same state twice.
For example, assume that `HEAD` is not behind `rad/main` and that there are no other changes to the namespace in parallel, then this simple sequence of pushes would lead to at least one cluster of size at least two:

```
git push HEAD:main
git push HEAD^1:main
git push HEAD:main
```

Further, note that the scanner does not perform any cryptographic verification of the
signature in the `signature` blob.
This makes the scanner much faster, and may only increase the number of false positives.
As Radicle storage is under control of Radicle tooling, which of course does verify
signatures, we chose to trust that the data in the store was not otherwise tampered with.

### Scope of Our Scans

We scanned our two nodes iris.radicle.xyz and rosa.radicle.xyz at 2026-03-26T22:26Z.
These two nodes are very well connected to the rest of the network,
and they have a permissive seeding policy.
Thus, they provide a good view of all public repositories on the main network.

### Results

| Measure                                |  iris |  rosa |
|----------------------------------------|------:|------:|
| Repositories scanned                   | 7 032 | 7 015 |
| Repositories containing duplication    |   199 |   199 |
| Namespaces   scanned                   | 9 140 | 9 101 |
| Namespaces   containing duplication    |   228 |   228 |
| Duplication clusters                   |   477 |   476 |
| Maximum cluster size                   |    24 |    24 |
| Median cluster size                    |     2 |     2 |
| Average cluster size (nearest integer) |     2 |     2 |

A histogram of cluster sizes on iris.radicle.xyz follows.
The data for rosa.radicle.xyz differs, as expected, only marginally (see table above).

| Size  |   2 |  3 | 4 | 5 | 6 | 9 | 10 | 24 |
|-------|----:|---:|--:|--:|--:|--:|---:|---:|
| Count | 421 | 36 | 7 | 3 | 1 | 1 |  1 |  1 |

The larger a cluster, the less likely it is that such a cluster is the
result of legitimate activity.

### Inspecting Duplication Clusters in Your Namespace

In `rad:z3zzcqqBGP1NdvvMbSDt2jYYp9jSB`, you can find the results of our scan.
To find to find out whether one of your namespaces contains a cluster of size
greater than two, consult one of the files `{iris,rosa}-aggregated.json`.

If you need help interpreting the data, please reach out to us [via team@radicle.xyz](mailto:team@radicle.xyz)
or [via Zulip](https://radicle.zulipchat.com/#narrow/channel/369873-Support).

## Vulnerable Versions

All versions of Radicle prior to `d3bc868e84c334f113806df1737f52cc57c5453d`,
which is included in release 1.7.0, are vulnerable.

## Fixed Version

### In Radicle 1.7.0

In Radicle 1.7.0, a major refactor of the signed references logic was written.
The base of the refactor was taking the previous implementation and performing a rewrite that allowed us to focus on the core logic.
This allowed us to quickly iterate on the above ideas,
and then implement the mitigation mechanism of including the `refs/rad/sigrefs-parent` in the `refs` blob.

As mentioned earlier, the inclusion of the `refs/rad/root` in the `refs` blob was previously introduced to prevent graft attacks.
There was a note to make its inclusion a hard error once enough time had passed.
We took the opporunity of this release to make this change as well.

#### Replay Detection

We cannot travel back in time and retroactively add `refs/rad/sigrefs-parent` to
the `refs` blob in the histories of all namespaces.
What is possible, however, is to detect replay attacks in old histories.

It is possible to traverse the history and look for duplicate `refs` blobs
(or `signature` blobs, as the signature is deterministic from the signing key of the user and the `refs` blob).
If the `refs` of the head of `refs/rad/sigrefs` to verify is found in the history,
we can decide to skip this head, and try its parent.
This process can be repeated iteratively, and it is guaranteed to terminate,
since the `refs` blob in the root commit cannot possible be referred to any
earlier commit in the history, simply because there is no earlier commit.

There is one slight oddity, and that is, as alluded to earlier, the fact that the
user may, legitimately, wish to restore an earlier state.
The detection mechanims would skip such update, and effectively drop the update.

### In Radicle 1.7.1

Once Radicle 1.7.0 was released, our community was encouraged to update.
Generally, we would introduce release candidates before releasing a new version.
Unfortunately, due to the vulnerability, we needed to release without the usual ceremony.

When our users update to 1.7.0, they quickly noted that there were some issues.
The first was unrelated to the vulnerability, and revolved around IPv6 parsing.
However, the second was due to the, aforementioned, hard error of the reference `refs/rad/root`
not present in the `refs` blob.

To provide some context, the `refs/rad/root` mechanism was introduced to prevent a graft attack.
Essentially, it ties the commits in `refs/rad/sigrefs` to the repository they are introduced in.
This reference is calculated by Radicle, and there was a code path that introduced a check for its existence,
and if it did not exist then it would be created.
This change was introduced in `989edacd564fa658358f5ccfd08c243c5ebd8cda`.
Then, 10 days later, another change was introduced `09f796234d76f4a25807371bb709c18678ac7bc9`.
This would *always* compute the identity root (by traversing the history of the identity COB to its root).
Since this method would now always result in returning an `Oid`,
our previous condition for a missing `refs/rad/root` would never return true.
This is only a problem for repositories that were created before the initial change,
and did not see an update in this 10 day window.

All that said, there were a significant number of repositories that did not include the `refs/rad/root` reference.
Furthermore, this meant the hard error became a backwards incompatible change.
We relaxed this condition so that nodes could make progress.

These improvements were made and 1.7.1 was released at 2026-03-20T08:27Z.

### In Radicle 1.8.0

With 1.7.1 released, we realised that we had one more piece of the puzzle to implement.
We needed to make sure that downgrade attacks are not possible.
This was achieved by implementing feature detection for signed references.

There are three feature levels for signed references:
- `none`: This is the original behaviour with a `/refs` and `/signature` blob,
  where the `/refs` contain neither `refs/rad/root` nor `refs/rad/sigrefs-parent`.
- `root`: This implies the behaviour of `none` and includes `refs/rad/root`.
- `parent`: This implies the two previous levels and includes `refs/rad/sigrefs-parent`.

The reading and verification of signed references now detects if the feature level is `parent`.
If so, we can verify the signature and confirm we have a cryptographically secure commit.
In the case that it is `root` or `none`, a walk of the commit history must be done.
This walk does further feature detection, and also keeps track of duplicate signatures.

When the walk is complete, the features can be inspected to note if a downgrade attack was attempted.
For example, the head commit could be at a `root` feature level, and its parent be at the `parent` feature level.
In some cases, this could be a legitimate downgrade from a user upgrading to `1.7.0` and back to `1.6.z`, due to the aformentioned compatibility issues.
The solution to this is to always allow the user to create a new commit with the latest feature level.

If no downgrade was attempted, the verification process then takes the first non-replayed commit as the head,
ensures that the commit is verified, and returns those signed references.

With the feature levels in place, we introduced a configuration option for the fetch protocol.
In the Radicle configuration file, `$RAD_HOME/config.json`, a new option was introduced.
The option's key is `node.fetch.signedReferences.featureLevel.minimum` and its value is one of the feature level values, as string: `"none"`, `"root"`, `"parent"`; defaulting to `"none"`.
In the following, we omit double quotes when talking about feture levels.
The value is used during a fetch of a repository from the network.
The fetch will reject any namespaces that have a `rad/sigrefs` head commit that is below the given minimum.
That is to say, if set to `none`, then namespaces will be fetched, but still verified with above rules.
If set to `root`, then the fetched namespaces are protected from graft attacks.
Finally, if set to `parent`, then the fetched namespaces are protected from graft attacks, and replay attacks.

You may notice that this value is a trade-off between security and backwards compatibility.
As more nodes upgrade to 1.8.0, users should update the minimum to `parent`, as soon as possible.
Once that is done, there is one last escape hatch for fetching and cloning.
The `rad sync --fetch` and `rad clone` commands now include a `--signed-refs-feature-level` option.
Its expected value is, once again, one of the feature levels,
and its behaviour is the same as the above.

#### Shields up!

If Radicle 1.7.0 would have rejected all histories that do not end in a commit with `refs/rad/sigrefs-parent` in their `refs`,
this would have also amounted to backwards incompatibility.
It would be impossible to fetch updates from nodes that have not yet updated to Radicle 1.7.0 via the network.
That would have caused lots of disruption.

Instead, we decided to give the user more control.

Firstly, we introduced a new concept, which we call "feature level".
It describes, on an ordinal scale, which features are detected on
the history of Signed References.
For now, all known features are security features, so currently
feature levels translate directly to security level.
Also, these feature levels are strictly monotonic, i.e., higher
ones include all features of the lower ones.

The three feature levels, as of Radicle 1.8.0, are:

    none  <  root  <  parent

The feature level `root` refers to `refs/rad/root`, which was added in
Radicle 1.1.0 (see above), and the feature level `parent` refers to
`refs/rad/sigrefs-parent`, which was added in Radicle 1.7.0.

On top of this concept, we implemented a mechanism for users to choose which
feature level they consider acceptable. This value can be set in the 
configuration file, as `node.fetch.signedReferences.featureLevel.minimum`.

The default value is `none` in order to maximize backwards compatibility.

Roughly speaking, by increasing the feature level to `root`, one can expect
backwards compatibility to 1.1.0, and by setting `parent`, one can expect
backwards compatibility to 1.7.0.

We recommend all users to update to 1.8.0 as soon as possible, so that
more and more nodes in the network can then choose to configure a minimum
feature level of "parent" for improved security, which we strongly recommend everyone to do as early as possible.

## Recommended Actions

Update to Radicle 1.8.0.

### Seed Node Operators

Because, to the best of our knowledge, there are no replay attacks happening on the network,
we think that no immediate action other than updating to Radicle 1.8.0 is necessary.

However, please familiarize yourself with the newly introduced concept of a "feature level" (see above).
Consider to set `node.fetch.signedReferences.featureLevel.minimum = "parent"` after a reasonable period of time to allow others to update.
The Radicle team will do this for seed.radicle.xyz within days, and for iris.radicle.xyz and rosa.radicle.xyz
within the next weeks.
If however, we notice an uptick in suspicious behavior on the network, we will considerably accelerate these timelines.

## The Future of Signed References

Signed References are a design that was conceived some time ago before Radicle reached 1.0.0.
We think that a similar solution, based on [signed pushes] and storing [push certificates] would be more sustainable.
The Linux kernel project, for example, provides transparency logs based on this format,
and it is not specific to Radicle, but defined and maintained by the Git project.

Our goal is to have future versions of Radicle to support both Signed References as well as push certificates,
to allow for a large time-window of cross-compatibility.
Of course, removing support for Signed References means a breaking change down the line.

---

Acknowledgements: We would like to thank maninak and rudolfs for their helpful comments on drafts of this disclosure.

[Defelo]: https://defelo.de/
[nonce]: https://csrc.nist.gov/glossary/term/nonce
[`git show-ref`]: https://git-scm.com/docs/git-show-ref
[Collaborative Objects]: https://radicle.xyz/guides/protocol#collaborative-objects
[signed pushes]: https://people.kernel.org/monsieuricon/signed-git-pushes
[push certificates]: https://git-scm.com/docs/pack-protocol#_push_certificate