From 33174cbb80a3147fb7bca9b1ac7f462350e7e0c7 Mon Sep 17 00:00:00 2001 From: Vincent Ambo Date: Sat, 11 Feb 2017 12:26:52 +0100 Subject: [PATCH 01/11] Initial commit From 98e81c2c0edd3d9bb483000d598e07e6dd9da6b0 Mon Sep 17 00:00:00 2001 From: Vincent Ambo Date: Sat, 11 Feb 2017 12:27:12 +0100 Subject: [PATCH 02/11] feat: Initial working implementation --- main.go | 90 ++++++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++ urls.go | 19 +++++++++++ 3 files changed, 205 insertions(+) create mode 100644 main.go create mode 100644 main_test.go create mode 100644 urls.go diff --git a/main.go b/main.go new file mode 100644 index 000000000..11f27260e --- /dev/null +++ b/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "bufio" + "encoding/xml" + "fmt" + "net/http" + "os" + "strings" +) + +// The XML response returned by the WatchGuard server +type Resp struct { + Action string `xml:"action"` + LogonStatus int `xml:"logon_status"` + LogonId int `xml:"logon_id"` + Error string `xml:"errStr"` + Challenge string `xml:"chaStr"` +} + +func main() { + args := os.Args[1:] + + if len(args) != 3 { + fmt.Fprintf(os.Stderr, "Usage: watchblob \n") + os.Exit(1) + } + + host := args[0] + username := args[1] + password := args[2] + + challenge, err := triggerChallengeResponse(&host, &username, &password) + + if err != nil || challenge.LogonStatus != 4 { + fmt.Fprintln(os.Stderr, "Did not receive challenge from server") + fmt.Fprintf(os.Stderr, "Response: %v\nError: %v\n", challenge, err) + os.Exit(1) + } + + token := getToken(&challenge) + err = logon(&host, &challenge, &token) + + if err != nil { + fmt.Fprintf(os.Stderr, "Logon failed: %v\n", err) + os.Exit(1) + } + + fmt.Println("Login succeeded, you may now (quickly) authenticate OpenVPN with %s as your password", token) +} + +func triggerChallengeResponse(host *string, username *string, password *string) (r Resp, err error) { + return request(templateUrl(host, templateChallengeTriggerUri(username, password))) +} + +func getToken(challenge *Resp) string { + fmt.Println(challenge.Challenge) + + reader := bufio.NewReader(os.Stdin) + token, _ := reader.ReadString('\n') + + return strings.TrimSpace(token) +} + +func logon(host *string, challenge *Resp, token *string) (err error) { + resp, err := request(templateUrl(host, templateResponseUri(challenge.LogonId, token))) + if err != nil { + return + } + + if resp.LogonStatus != 1 { + err = fmt.Errorf("Challenge/response authentication failed: %v", resp) + } + + return +} + +func request(url string) (r Resp, err error) { + fmt.Println(url) + resp, err := http.Get(url) + if err != nil { + return + } + + defer resp.Body.Close() + decoder := xml.NewDecoder(resp.Body) + + err = decoder.Decode(&r) + return +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 000000000..5c171c404 --- /dev/null +++ b/main_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "encoding/xml" + "reflect" + "testing" +) + +func TestUnmarhshalChallengeRespones(t *testing.T) { + var testXml string = ` + + + sslvpn_logon + 4 + + + RADIUS + + + 441 + Enter Your 6 Digit Passcode +` + + var r Resp + xml.Unmarshal([]byte(testXml), &r) + + expected := Resp{ + Action: "sslvpn_logon", + LogonStatus: 4, + LogonId: 441, + Challenge: "Enter Your 6 Digit Passcode ", + } + + assertEqual(t, expected, r) +} + +func TestUnmarshalLoginError(t *testing.T) { + var testXml string = ` + + + sslvpn_logon + 2 + + + RADIUS + + + 501 +` + + var r Resp + xml.Unmarshal([]byte(testXml), &r) + + expected := Resp{ + Action: "sslvpn_logon", + LogonStatus: 2, + Error: "501", + } + + assertEqual(t, expected, r) +} + +func TestUnmarshalLoginSuccess(t *testing.T) { + var testXml string = ` + + + sslvpn_logon + 1 + + + RADIUS + + + +` + var r Resp + xml.Unmarshal([]byte(testXml), &r) + + expected := Resp{ + Action: "sslvpn_logon", + LogonStatus: 1, + } + + assertEqual(t, expected, r) +} + +func assertEqual(t *testing.T, expected interface{}, result interface{}) { + if !reflect.DeepEqual(expected, result) { + t.Errorf( + "Unmarshaled values did not match.\nExpected: %v\nResult: %v\n", + expected, result, + ) + + t.Fail() + } +} diff --git a/urls.go b/urls.go new file mode 100644 index 000000000..a1fd825f5 --- /dev/null +++ b/urls.go @@ -0,0 +1,19 @@ +package main + +import "fmt" + +const urlFormat string = "https://%s%s" +const triggerChallengeUri = "/?action=sslvpn_logon&fw_username=%s&fw_password=%s&style=fw_logon_progress.xsl&fw_logon_type=logon&fw_domain=Firebox-DB" +const responseUri = "/?action=sslvpn_logon&style=fw_logon_progress.xsl&fw_logon_type=response&response=%s&fw_logon_id=%d" + +func templateChallengeTriggerUri(username *string, password *string) string { + return fmt.Sprintf(triggerChallengeUri, *username, *password) +} + +func templateResponseUri(logonId int, token *string) string { + return fmt.Sprintf(responseUri, *token, logonId) +} + +func templateUrl(baseUrl *string, uri string) string { + return fmt.Sprintf("https://%s%s", *baseUrl, uri) +} From 01ad38d5320e0b6d2f27d6f0c7b44f82be1887d6 Mon Sep 17 00:00:00 2001 From: Vincent Ambo Date: Sat, 11 Feb 2017 13:17:55 +0100 Subject: [PATCH 03/11] docs: Add README --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 000000000..95e53115d --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +Watchblob - WatchGuard VPN on Linux +=================================== + +This tiny helper tool makes it possible to use WatchGuard / Firebox / <> VPNs that use multi-factor authentication on Linux. + +Rather than using OpenVPN's built-in dynamic challenge/response protocol, WatchGuard +has opted for a separate implementation negotiating credentials outside of the +OpenVPN protocol, which makes it impossible to start those connections solely by +using the `openvpn` CLI and configuration files. + +What this application does has been reverse-engineered from the "WatchGuard Mobile VPN +with SSL" application on OS X. A writeup of the protocol and the security implications +will be linked here in the future. + +## Installation + +Make sure you have Go installed and `GOPATH` configured, then simply +`go get github.com/tazjin/watchblob`. + +## Usage + +Right now the usage is very simple. Make sure you have the correct OpenVPN client +config ready (this is normally supplied by the WatchGuard UI) simply run: + +``` +watchblob vpnserver.somedomain.org username p4ssw0rd +``` + +The server responds with a challenge which is displayed to the user, wait until you +receive the SMS code or whatever and enter it. `watchblob` then completes the +credential negotiation and you may proceed to log in with OpenVPN using your username +and *the OTP token* (**not** your password) as credentials. From e6c3212018b6bcbe4a83c3e7c557fc43b0e7571d Mon Sep 17 00:00:00 2001 From: Vincent Ambo Date: Sat, 11 Feb 2017 16:19:24 +0100 Subject: [PATCH 04/11] chore: Don't print URLs --- .gitignore | 1 + main.go | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..62c893550 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ \ No newline at end of file diff --git a/main.go b/main.go index 11f27260e..b556d606d 100644 --- a/main.go +++ b/main.go @@ -46,7 +46,7 @@ func main() { os.Exit(1) } - fmt.Println("Login succeeded, you may now (quickly) authenticate OpenVPN with %s as your password", token) + fmt.Printf("Login succeeded, you may now (quickly) authenticate OpenVPN with %d as your password\n", token) } func triggerChallengeResponse(host *string, username *string, password *string) (r Resp, err error) { @@ -76,7 +76,6 @@ func logon(host *string, challenge *Resp, token *string) (err error) { } func request(url string) (r Resp, err error) { - fmt.Println(url) resp, err := http.Get(url) if err != nil { return From 393cff48479e3cb065c00fc542c353c5ac29f0af Mon Sep 17 00:00:00 2001 From: Vincent Ambo Date: Sat, 11 Feb 2017 16:30:23 +0100 Subject: [PATCH 05/11] feat: Don't echo password while inputting --- README.md | 2 +- main.go | 28 +++++++++++++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 95e53115d..1846a8bea 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ will be linked here in the future. ## Installation Make sure you have Go installed and `GOPATH` configured, then simply -`go get github.com/tazjin/watchblob`. +`go get github.com/tazjin/watchblob/...`. ## Usage diff --git a/main.go b/main.go index b556d606d..93a85324f 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "strings" + "golang.org/x/crypto/ssh/terminal" ) // The XML response returned by the WatchGuard server @@ -21,16 +22,20 @@ type Resp struct { func main() { args := os.Args[1:] - if len(args) != 3 { - fmt.Fprintf(os.Stderr, "Usage: watchblob \n") + if len(args) != 1 { + fmt.Fprintln(os.Stderr, "Usage: watchblob ") os.Exit(1) } host := args[0] - username := args[1] - password := args[2] - challenge, err := triggerChallengeResponse(&host, &username, &password) + username, password, err := readCredentials() + if err != nil { + fmt.Fprintf(os.Stderr, "Could not read credentials: %v\n", err) + } + + fmt.Println("Requesting challenge from %s as user %s\n", host, *username) + challenge, err := triggerChallengeResponse(&host, username, password) if err != nil || challenge.LogonStatus != 4 { fmt.Fprintln(os.Stderr, "Did not receive challenge from server") @@ -49,6 +54,19 @@ func main() { fmt.Printf("Login succeeded, you may now (quickly) authenticate OpenVPN with %d as your password\n", token) } +func readCredentials() (*string, *string, error) { + fmt.Printf("Username: ") + reader := bufio.NewReader(os.Stdin) + username, err := reader.ReadString('\n') + + fmt.Printf("Password: ") + passwordBytes, err := terminal.ReadPassword(1) + password := string(passwordBytes) + + // If an error occured, I don't care about which one it is. + return &username, &password, err +} + func triggerChallengeResponse(host *string, username *string, password *string) (r Resp, err error) { return request(templateUrl(host, templateChallengeTriggerUri(username, password))) } From 4a85116b4a04765e13a0aa7c8b8a37778ef8dcbd Mon Sep 17 00:00:00 2001 From: Vincent Ambo Date: Sat, 11 Feb 2017 16:32:36 +0100 Subject: [PATCH 06/11] fix: Remove trailing newlines from input --- main.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/main.go b/main.go index 93a85324f..9ac5598b1 100644 --- a/main.go +++ b/main.go @@ -34,8 +34,8 @@ func main() { fmt.Fprintf(os.Stderr, "Could not read credentials: %v\n", err) } - fmt.Println("Requesting challenge from %s as user %s\n", host, *username) - challenge, err := triggerChallengeResponse(&host, username, password) + fmt.Println("Requesting challenge from %s as user %s\n", host, username) + challenge, err := triggerChallengeResponse(&host, &username, &password) if err != nil || challenge.LogonStatus != 4 { fmt.Fprintln(os.Stderr, "Did not receive challenge from server") @@ -54,17 +54,16 @@ func main() { fmt.Printf("Login succeeded, you may now (quickly) authenticate OpenVPN with %d as your password\n", token) } -func readCredentials() (*string, *string, error) { +func readCredentials() (string, string, error) { fmt.Printf("Username: ") reader := bufio.NewReader(os.Stdin) username, err := reader.ReadString('\n') fmt.Printf("Password: ") - passwordBytes, err := terminal.ReadPassword(1) - password := string(passwordBytes) + password, err := terminal.ReadPassword(1) // If an error occured, I don't care about which one it is. - return &username, &password, err + return strings.TrimSpace(username), strings.TrimSpace(string(password)), err } func triggerChallengeResponse(host *string, username *string, password *string) (r Resp, err error) { From e4ee5a452622de946e4b9a62aeedb6eb3a6851dd Mon Sep 17 00:00:00 2001 From: Vincent Ambo Date: Sat, 11 Feb 2017 16:33:16 +0100 Subject: [PATCH 07/11] fix: Portability of stdin --- main.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 9ac5598b1..ec0f55f42 100644 --- a/main.go +++ b/main.go @@ -4,10 +4,11 @@ import ( "bufio" "encoding/xml" "fmt" + "golang.org/x/crypto/ssh/terminal" "net/http" "os" "strings" - "golang.org/x/crypto/ssh/terminal" + "syscall" ) // The XML response returned by the WatchGuard server @@ -60,7 +61,7 @@ func readCredentials() (string, string, error) { username, err := reader.ReadString('\n') fmt.Printf("Password: ") - password, err := terminal.ReadPassword(1) + password, err := terminal.ReadPassword(syscall.Stdin) // If an error occured, I don't care about which one it is. return strings.TrimSpace(username), strings.TrimSpace(string(password)), err From dd1e6c3b36d4b055af879fc1a0f6febecae35591 Mon Sep 17 00:00:00 2001 From: Vincent Ambo Date: Sat, 11 Feb 2017 16:38:23 +0100 Subject: [PATCH 08/11] fix: Two minor, silly fixes --- main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index ec0f55f42..fd74e8b45 100644 --- a/main.go +++ b/main.go @@ -35,7 +35,7 @@ func main() { fmt.Fprintf(os.Stderr, "Could not read credentials: %v\n", err) } - fmt.Println("Requesting challenge from %s as user %s\n", host, username) + fmt.Printf("Requesting challenge from %s as user %s\n", host, username) challenge, err := triggerChallengeResponse(&host, &username, &password) if err != nil || challenge.LogonStatus != 4 { @@ -52,7 +52,7 @@ func main() { os.Exit(1) } - fmt.Printf("Login succeeded, you may now (quickly) authenticate OpenVPN with %d as your password\n", token) + fmt.Printf("Login succeeded, you may now (quickly) authenticate OpenVPN with %s as your password\n", token) } func readCredentials() (string, string, error) { From 7824e0e7e34a4b5245f817fb70da53d4bcd707c7 Mon Sep 17 00:00:00 2001 From: Vincent Ambo Date: Sat, 11 Feb 2017 17:34:09 +0100 Subject: [PATCH 09/11] docs: Add blog post to README --- README.md | 6 ++++-- main_test.go | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1846a8bea..712c96cd9 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,10 @@ OpenVPN protocol, which makes it impossible to start those connections solely by using the `openvpn` CLI and configuration files. What this application does has been reverse-engineered from the "WatchGuard Mobile VPN -with SSL" application on OS X. A writeup of the protocol and the security implications -will be linked here in the future. +with SSL" application on OS X. + +I've published a [blog post](https://www.tazj.in/en/1486830338) describing the process +and what is actually going on in this protocol. ## Installation diff --git a/main_test.go b/main_test.go index 5c171c404..1af52d0cd 100644 --- a/main_test.go +++ b/main_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -func TestUnmarhshalChallengeRespones(t *testing.T) { +func TestUnmarshalChallengeRespones(t *testing.T) { var testXml string = ` From 6dcb0f4b2bddc583212995397d6c6e1ec14adc58 Mon Sep 17 00:00:00 2001 From: Vincent Ambo Date: Mon, 13 Feb 2017 09:55:24 +0100 Subject: [PATCH 10/11] fix urls: Escape values in URLs For usernames and passwords containing special characters the URL parameters must be escaped. Because the entire URI is just query parameters I've opted for using net/url.Values for the entire URI. Fixes #1 --- main.go | 1 + urls.go | 30 ++++++++++++++++++++++++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/main.go b/main.go index fd74e8b45..a7ab65d3d 100644 --- a/main.go +++ b/main.go @@ -62,6 +62,7 @@ func readCredentials() (string, string, error) { fmt.Printf("Password: ") password, err := terminal.ReadPassword(syscall.Stdin) + fmt.Println() // If an error occured, I don't care about which one it is. return strings.TrimSpace(username), strings.TrimSpace(string(password)), err diff --git a/urls.go b/urls.go index a1fd825f5..37f65e0fa 100644 --- a/urls.go +++ b/urls.go @@ -1,19 +1,37 @@ package main -import "fmt" +import ( + "fmt" + "net/url" + "strconv" +) const urlFormat string = "https://%s%s" -const triggerChallengeUri = "/?action=sslvpn_logon&fw_username=%s&fw_password=%s&style=fw_logon_progress.xsl&fw_logon_type=logon&fw_domain=Firebox-DB" -const responseUri = "/?action=sslvpn_logon&style=fw_logon_progress.xsl&fw_logon_type=response&response=%s&fw_logon_id=%d" +const uriFormat = "/?%s" func templateChallengeTriggerUri(username *string, password *string) string { - return fmt.Sprintf(triggerChallengeUri, *username, *password) + v := url.Values{} + v.Set("action", "sslvpn_logon") + v.Set("style", "fw_logon_progress.xsl") + v.Set("fw_logon_type", "logon") + v.Set("fw_domain", "Firebox-DB") + v.Set("fw_username", *username) + v.Set("fw_password", *password) + + return fmt.Sprintf(uriFormat, v.Encode()) } func templateResponseUri(logonId int, token *string) string { - return fmt.Sprintf(responseUri, *token, logonId) + v := url.Values{} + v.Set("action", "sslvpn_logon") + v.Set("style", "fw_logon_progress.xsl") + v.Set("fw_logon_type", "response") + v.Set("response", *token) + v.Set("fw_logon_id", strconv.Itoa(logonId)) + + return fmt.Sprintf(uriFormat, v.Encode()) } func templateUrl(baseUrl *string, uri string) string { - return fmt.Sprintf("https://%s%s", *baseUrl, uri) + return fmt.Sprintf(urlFormat, *baseUrl, uri) } From 24b075bdeba5457a96965ceb705ffc8d57f03388 Mon Sep 17 00:00:00 2001 From: Vincent Ambo Date: Sat, 21 Dec 2019 01:11:29 +0000 Subject: [PATCH 11/11] chore(watchblob): Prepare for depot merge --- .gitignore | 1 - README.md => fun/watchblob/README.md | 0 main.go => fun/watchblob/main.go | 0 main_test.go => fun/watchblob/main_test.go | 0 urls.go => fun/watchblob/urls.go | 0 5 files changed, 1 deletion(-) delete mode 100644 .gitignore rename README.md => fun/watchblob/README.md (100%) rename main.go => fun/watchblob/main.go (100%) rename main_test.go => fun/watchblob/main_test.go (100%) rename urls.go => fun/watchblob/urls.go (100%) diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 62c893550..000000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.idea/ \ No newline at end of file diff --git a/README.md b/fun/watchblob/README.md similarity index 100% rename from README.md rename to fun/watchblob/README.md diff --git a/main.go b/fun/watchblob/main.go similarity index 100% rename from main.go rename to fun/watchblob/main.go diff --git a/main_test.go b/fun/watchblob/main_test.go similarity index 100% rename from main_test.go rename to fun/watchblob/main_test.go diff --git a/urls.go b/fun/watchblob/urls.go similarity index 100% rename from urls.go rename to fun/watchblob/urls.go