Summary

Gitea Actions Artifacts V4 signed URL HMAC ambiguity allows cross-repository artifact read and cross-task upload-state write

Advisory details

Summary

Gitea Actions Artifacts V4 signed upload/download URLs can be rewritten to access a different running task and repository context while preserving the original HMAC signature. An attacker with permission to run a Gitea Actions job can turn a signed URL for an attacker-controlled artifact into a URL that reads artifacts from another task context, or writes attacker-controlled data into another task's artifact upload staging context, including in a private repository.

This is one vulnerability with two exploit paths:

  • DownloadArtifact: cross-task/cross-repository artifact read, giving C:H.
  • UploadArtifact: cross-task artifact staging write and metadata mutation, giving I:H.

Details

The root cause is that the V4 artifact signed URL signature is built from raw concatenated fields without delimiters or length-prefixing:

func (r *artifactV4Routes) buildSignature(endpoint, expires, artifactName string, taskID, artifactID int64) []byte {
        mac := hmac.New(sha256.New, setting.GetGeneralTokenSigningSecret())
        mac.Write([]byte(endpoint))
        mac.Write([]byte(expires))
        mac.Write([]byte(artifactName))
        _, _ = fmt.Fprint(mac, taskID)
        _, _ = fmt.Fprint(mac, artifactID)
        return mac.Sum(nil)
}

Affected code: routers/api/actions/artifactsv4.go:164-171.

Because artifactName, taskID, and artifactID are concatenated without boundaries, two different URL tuples can produce the same HMAC input. For example:

signed tuple:
artifactName = "artifact-795-153"
taskID       = 48
artifactID   = <attacker artifact id>

forged tuple:
artifactName = "artifact-795-1"
taskID       = 53
artifactID   = 48<attacker artifact id>

The final HMAC input suffix is identical:

artifact-795-15348<attacker artifact id>

The attacker does not need to know the target artifact's database artifactID. The forged URL's artifactID only needs to carry digits that preserve the original HMAC input. After verification, the actual target artifact is looked up by target task/run/attempt and artifactName, not by the signed artifactID.

The signed URL handlers are unauthenticated and use ArtifactV4Contexter() only:

m.Group("", func() {
        m.Put("UploadArtifact", r.uploadArtifact)
        m.Get("DownloadArtifact", r.downloadArtifact)
}, ArtifactV4Contexter())

Affected code: routers/api/actions/artifactsv4.go:156-159.

After verifying the HMAC, verifySignature() trusts the URL-controlled taskID, loads that task, checks that it is running, and returns the URL-controlled artifactName. It parses artifactID for the HMAC, but does not load or bind the artifact by that signed artifact ID:

task, err := actions_model.GetTaskByID(ctx, taskID)
...
if task.Status != actions_model.StatusRunning {
        ...
}
...
return task, artifactName, true

Affected code: routers/api/actions/artifactsv4.go:224-267.

The artifact lookup then uses the URL-selected task's run/attempt plus URL-selected artifact name:

has, err := db.GetEngine(ctx).Where(builder.Eq{
        "run_id": runID,
        "run_attempt_id": runAttemptID,
        "artifact_name": name,
}, builder.Like{"content_encoding", "%/%"}).Get(&art)

Affected code: routers/api/actions/artifactsv4.go:270-278.

For download, the forged URL reaches downloadArtifact(), which verifies the signature, resolves the artifact by the forged task/run/name context, and serves it:

task, artifactName, ok := r.verifySignature(ctx, "DownloadArtifact")
...
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
...
err = actions.DownloadArtifactV4ReadStorage(ctx.Base, artifact)

Affected code: routers/api/actions/artifactsv4.go:674-693.

For upload, the forged URL reaches uploadArtifact(), which verifies the signature, resolves the target artifact by the forged task/run/name context, appends attacker-controlled data, and updates target artifact metadata:

task, artifactName, ok := r.verifySignature(ctx, "UploadArtifact")
...
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
...
uploadedLength, err := appendUploadChunkV3(r.fs, ctx, artifact, artifact.RunID, artifact.FileSize)
...
artifact.FileCompressedSize += uploadedLength
artifact.FileSize += uploadedLength
...
actions_model.UpdateArtifactByID(ctx, artifact.ID, artifact)

Affected code: routers/api/actions/artifactsv4.go:382-422.

The strengthened dynamic PoC also opens the storage object created by the forged upload and verifies that the attacker-controlled bytes were written under the target run and target artifact ID staging path. This demonstrates an unauthorized write primitive into the target artifact upload context. The current PoC does not claim that a finalized artifact download already serves modified bytes; for the integrity path, the confirmed impact is cross-context staging write plus target artifact metadata mutation. In normal artifact upload flow, data in this staging area is what FinalizeArtifact later consumes.

V4 artifact creation also appears to omit the existing artifact-name validation:

artifactName := req.Name
...
artifact, err := actions_model.CreateArtifact(ctx, ctx.ActionTask, artifactName, fileName, retentionDays)

Affected code: routers/api/actions/artifactsv4.go:309-337.

This is a hardening issue, but it is not required for the demonstrated tuple-boundary collision. The crafted artifact names in the PoC use ordinary characters such as letters, digits, and hyphens that would normally be valid. The root cause is the ambiguous signed payload plus the post-verification lookup by URL-selected task/run/name context.

The target task must be running, but that is the normal validity window of these signed URLs. The exploit itself is a deterministic parameter rewrite against the active artifact URL flow, so AC:L is appropriate.

Even if the integrity impact is scored conservatively, the DownloadArtifact path independently demonstrates cross-repository private artifact disclosure.

Practical constraints:

  • the target task must be in running state;
  • the target task ID and artifact name must be known or predictable;
  • for DownloadArtifact on newer branches, the target artifact must already be UploadConfirmed while the target task is still running. Older V4 implementations differ slightly in artifact lookup/status handling; the PoC validates the branch-specific condition used by each tested release;
  • the forged decimal artifactID must parse as int64;
  • the exact storage effect depends on the configured artifact storage backend. The local PoC uses the default non-Azure storage path. The root cause still exists before storage backend handling because the signed URL context is confused before upload/download dispatch.
  • the demonstrated exploit applies when Gitea issues its own V4 UploadArtifact/DownloadArtifact signed URLs. Storage backends or configurations that return direct backend URLs should be assessed separately, because they may bypass these Gitea signed URL handlers.

PoC

Tested against Gitea main checkout:

6a270662690439cabe8582e92c22b04d1f8a3fe9

Verified affected versions tested locally:

main       6a27066269  reproduced dynamically
v1.26.1    afdbd9b7c5  reproduced dynamically
v1.25.5    f913d90ab6  reproduced dynamically
v1.24.7    99053ce4fa  reproduced dynamically
v1.23.8    cccd54999a  reproduced dynamically
v1.22.6    8eefa1f6de  reproduced dynamically with Go 1.22.12 test toolchain
v1.21.11   V4 artifactsv4.go route not present in routers/api/actions; not affected by this V4 signed URL path as tested

Unless otherwise noted, the affected code snippets and line numbers above refer to the tested main checkout 6a27066269. Older release branches contain the same vulnerable signed URL construction and post-verification context confusion, but line

References