Skip to content

Commit

Permalink
feat(launcher): Enhance file verification
Browse files Browse the repository at this point in the history
  • Loading branch information
cedws committed Nov 25, 2024
1 parent abd6a33 commit da4fc7f
Show file tree
Hide file tree
Showing 5 changed files with 136 additions and 38 deletions.
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ A patch client for Wizard101. It connects to the Wizard101 patch and login serve

## Features

* Super fast patching
* Patch without logging in with `-patch-only`
* Patch to custom directory with `-dir`
* Fully patch with all game files with `-full`
* Super fast patching
* Automatically launch the game with Wine on Linux and macOS

## Options
Expand All @@ -25,4 +25,8 @@ A patch client for Wizard101. It connects to the Wizard101 patch and login serve
- `-patch-only`
- Only patch files without logging in
- `-full`
- Patch all game files
- Patch all game files

## Security

Wizard101 game files are served from an official webserver that is not TLS-secured. The file checksums are also served over a plaintext connection. Although unlikely, this means that an attacker on the network (man-in-the-middle) could theoretically intercept these files as they are downloaded and then execute malicious code on the user's system. The launcher takes a best-effort approach to mitigate this. All file checksums are verified prior to execution, and where possible their [Authenticode](https://learn.microsoft.com/en-us/windows-hardware/drivers/install/authenticode) signature is verified, though many files have an expired signature.
5 changes: 3 additions & 2 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package cmd
import (
"context"
"flag"
"log"
"log/slog"
"os"
"runtime"

Expand Down Expand Up @@ -61,6 +61,7 @@ func Execute() {
}

if err := umbra.Patch(context.Background(), params); err != nil {
log.Fatal(err)
slog.Error("Error during patch", "error", err)
os.Exit(1)
}
}
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@ module github.com/cedws/umbra-launcher
go 1.23.2

require (
github.com/cedws/w101-client-go v0.0.0-20241123173053-55f04ec15974
github.com/cedws/w101-client-go v0.0.0-20241125233420-e29a3ba366fd
github.com/cedws/w101-proto-go v0.0.0-20241123173233-29fff1fc3bae
github.com/saferwall/pe v1.5.5
github.com/spf13/afero v1.11.0
golang.org/x/sync v0.9.0
)

require (
github.com/edsrzf/mmap-go v1.1.0 // indirect
github.com/secDre4mer/pkcs7 v0.0.0-20240322103146-665324a4461d // indirect
github.com/stretchr/testify v1.7.0 // indirect
golang.org/x/crypto v0.16.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.20.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
37 changes: 35 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,23 +1,56 @@
github.com/cedws/w101-client-go v0.0.0-20241123173053-55f04ec15974 h1:Fx2aXEem14v++PJENRDHS1ab1C8rgSxNXT7RF2gQL7Y=
github.com/cedws/w101-client-go v0.0.0-20241123173053-55f04ec15974/go.mod h1:LEEZPLzH2mdzndxQPSwg+7ho+Q1U+raLB6802UUvLzQ=
github.com/cedws/w101-client-go v0.0.0-20241125233420-e29a3ba366fd h1:fJA6YRM2r5N01ijTE5yeXfOFGNy2TloWOCBPTAmPVmI=
github.com/cedws/w101-client-go v0.0.0-20241125233420-e29a3ba366fd/go.mod h1:LEEZPLzH2mdzndxQPSwg+7ho+Q1U+raLB6802UUvLzQ=
github.com/cedws/w101-proto-go v0.0.0-20241123173233-29fff1fc3bae h1:rCpDIzm/BK68zMKEaYCPzcme3e9YXR0PouHjsZ5VzEM=
github.com/cedws/w101-proto-go v0.0.0-20241123173233-29fff1fc3bae/go.mod h1:txaQ4U+DqZHIFvn2q7DLiT1kEtKk6VGy8WmFd/l1lbs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ=
github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/saferwall/pe v1.5.5 h1:GGbzKjXDm7i+1K6riOgtgblyTdRmTbr3r11IzjovAK8=
github.com/saferwall/pe v1.5.5/go.mod h1:mJx+PuptmNpoPFBNhWs/uDMFL/kTHVZIkg0d4OUJFbQ=
github.com/secDre4mer/pkcs7 v0.0.0-20240322103146-665324a4461d h1:RQqyEogx5J6wPdoxqL132b100j8KjcVHO1c0KLRoIhc=
github.com/secDre4mer/pkcs7 v0.0.0-20240322103146-665324a4461d/go.mod h1:PegD7EVqlN88z7TpCqH92hHP+GBpfomGCCnw1PFtNOA=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
118 changes: 87 additions & 31 deletions internal/umbra/umbra.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ import (
"github.com/cedws/w101-client-go/proto"
"github.com/cedws/w101-proto-go/pkg/login"
"github.com/cedws/w101-proto-go/pkg/patch"
"github.com/saferwall/pe"
"github.com/spf13/afero"
"golang.org/x/sync/errgroup"
)

const (
fileTypeExecutable = 4
fileTypeDynamicWAD = 5
)

Expand All @@ -40,6 +42,14 @@ var (
undesiredTables = []string{"_TableList", "About", "PatchClient"}
)

// List of files with a verifiable authenticode signature
// Most other files are signed by an expired certificate, so they have to be ignored :(
var verifiableFiles = []string{
"Bin\\BugReportBuilderCSR.dll",
"Bin\\BugReporter.exe",
"Bin\\WizardGraphicalClient.exe",
}

var makeTableOnce = sync.OnceValue(func() crc32.Table {
polyReversed := bits.Reverse32(0x4C11DB7)
return *crc32.MakeTable(polyReversed)
Expand Down Expand Up @@ -133,6 +143,8 @@ func (p *patchClient) launch(ctx context.Context, params LaunchParams) error {
return err
}

slog.Info("All files OK")

if !params.PatchOnly {
userID, ck2, err := p.requestCK2Token(ctx, params)
if err != nil {
Expand All @@ -153,6 +165,7 @@ type patchFile struct {
Target string
CRC uint32
Size uint32
Type uint32
}

func (p *patchClient) checkBaseFiles(ctx context.Context) error {
Expand Down Expand Up @@ -226,7 +239,7 @@ func (p *patchClient) processRecord(ctx context.Context, urlPrefix string, recor

// Don't patch dynamic WADs unless in full patch mode, they won't
// match the expected CRC since they're loaded in segments at runtime
if !p.LaunchParams.FullPatch && fileType == fileTypeDynamicWAD {
if fileType == fileTypeDynamicWAD && !p.LaunchParams.FullPatch {
slog.Info("Skipping dynamic WAD", "file", target)
return nil
}
Expand All @@ -242,12 +255,21 @@ func (p *patchClient) processRecord(ctx context.Context, urlPrefix string, recor
Target: filepath.Clean(target),
CRC: crc,
Size: size,
Type: fileType,
}

if err := p.checkFile(ctx, patchFile); err != nil {
return err
ok, err := p.verifyFileCRC(patchFile)
if err != nil {
return fmt.Errorf("error verifying file: %w", err)
}
if !ok {
if err := p.downloadFile(ctx, patchFile); err != nil {
return fmt.Errorf("error downloading file: %w", err)
}
}

slog.Info("File OK", "crc", patchFile.CRC, "size", patchFile.Size, "path", patchFile.Target)

return nil
}

Expand Down Expand Up @@ -294,6 +316,9 @@ func (p *patchClient) requestCK2Token(ctx context.Context, params LaunchParams)
r := proto.NewMessageRouter()
login.RegisterLoginService(r, &loginHandler{authenRspCh: authenRspCh})

ctx, cancel := context.WithTimeoutCause(ctx, 10*time.Second, errTimeoutAuthenRsp)
defer cancel()

protoClient, err := proto.Dial(ctx, params.LoginServerAddr, r)
if err != nil {
return 0, "", err
Expand Down Expand Up @@ -324,9 +349,6 @@ func (p *patchClient) requestCK2Token(ctx context.Context, params LaunchParams)
return 0, "", err
}

ctx, cancel := context.WithTimeoutCause(ctx, 5*time.Second, errTimeoutAuthenRsp)
defer cancel()

select {
case rsp := <-authenRspCh:
if rsp.Error != 0 {
Expand All @@ -340,54 +362,87 @@ func (p *patchClient) requestCK2Token(ctx context.Context, params LaunchParams)
}
}

func (p *patchClient) checkFile(ctx context.Context, patchFile patchFile) error {
ok, err := p.verifyFile(patchFile)
if err != nil {
return fmt.Errorf("error verifying file: %w", err)
}
if ok {
slog.Info("File OK", "crc", patchFile.CRC, "size", patchFile.Size, "path", patchFile.Target)
return nil
}

func (p *patchClient) downloadFile(ctx context.Context, patchFile patchFile) error {
dirname := filepath.Dir(patchFile.Target)
if err := p.fs.MkdirAll(dirname, 0o755); err != nil {
return err
}

slog.Info("Downloading file", "url", patchFile.URL)

resp, err := p.request(ctx, patchFile.URL)
file, err := p.fs.Create(patchFile.Target)
if err != nil {
return err
}
defer resp.Close()
defer file.Close()

file, err := p.fs.Create(patchFile.Target)
closeAndRemove := func() {
file.Close()
p.fs.Remove(patchFile.Target)
}

slog.Info("Downloading file", "url", patchFile.URL)

resp, err := p.request(ctx, patchFile.URL)
if err != nil {
return err
}
defer file.Close()
defer resp.Close()

hasher := p.hasherPool.Get().(*reverseHasher)
defer p.hasherPool.Put(hasher)

hasher.Reset()

defer p.hasherPool.Put(hasher)

// Tee into hasher to calculate CRC while writing to file
teeReader := io.TeeReader(resp, hasher)
if _, err := io.Copy(file, teeReader); err != nil {
if _, err := io.CopyN(file, teeReader, int64(patchFile.Size)); err != nil {
return err
}

actualCRC := hasher.Sum32()
if actualCRC != patchFile.CRC {
closeAndRemove()
return fmt.Errorf("crc mismatch for file %s: expected %d, got %d", patchFile.Target, patchFile.CRC, actualCRC)
}

if patchFile.Type == fileTypeExecutable && slices.Contains(verifiableFiles, patchFile.Target) {
if err := p.verifyFileAuthenticode(patchFile); err != nil {
closeAndRemove()
return fmt.Errorf("error verifying file %s: %w", patchFile.Target, err)
}

slog.Info("Authenticode verification passed", "path", patchFile.Target)
}

return nil
}

func (p *patchClient) verifyFileAuthenticode(patchFile patchFile) error {
peBytes, err := afero.ReadFile(p.fs, patchFile.Target)
if err != nil {
return err
}

// Would be preferable to use p.New where it mmaps the given file path
// but building the full path wouldn't be necessarily confined to the afero.Fs
pe, err := pe.NewBytes(peBytes, nil)
if err != nil {
return err
}

if err := pe.Parse(); err != nil {
return err
}

for _, cert := range pe.Certificates.Certificates {
if !cert.SignatureValid || !cert.Verified {
return fmt.Errorf("authenticode verification failed")
}
}

return nil
}

func (p *patchClient) verifyFile(patchFile patchFile) (bool, error) {
func (p *patchClient) verifyFileCRC(patchFile patchFile) (bool, error) {
filePath := patchFile.Target
slog.Info("Verifying file", "path", filePath)

Expand Down Expand Up @@ -415,10 +470,11 @@ func (p *patchClient) verifyFile(patchFile patchFile) (bool, error) {
defer file.Close()

hasher := p.hasherPool.Get().(*reverseHasher)
hasher.Reset()

defer p.hasherPool.Put(hasher)

hasher.Reset()
if _, err := io.Copy(hasher, file); err != nil {
if _, err := io.CopyN(hasher, file, int64(patchFile.Size)); err != nil {
return false, err
}
actualCRC := hasher.Sum32()
Expand All @@ -432,6 +488,9 @@ func (p *patchClient) latestFileList(ctx context.Context) (*patch.LatestFileList
r := proto.NewMessageRouter()
patch.RegisterPatchService(r, &patchHandler{fileListCh: fileListCh})

ctx, cancel := context.WithTimeoutCause(ctx, 10*time.Second, errTimeoutFileList)
defer cancel()

protoClient, err := proto.Dial(ctx, p.LaunchParams.PatchServerAddr, r)
if err != nil {
return nil, err
Expand All @@ -445,9 +504,6 @@ func (p *patchClient) latestFileList(ctx context.Context) (*patch.LatestFileList
return nil, err
}

ctx, cancel := context.WithTimeoutCause(ctx, 5*time.Second, errTimeoutFileList)
defer cancel()

select {
case fileList := <-fileListCh:
return &fileList, nil
Expand Down

0 comments on commit da4fc7f

Please sign in to comment.