Skip to content

Instantly share code, notes, and snippets.

@Slach
Created June 5, 2026 04:21
Show Gist options
  • Select an option

  • Save Slach/70d7b7e5954bfd51a95281e8523f4530 to your computer and use it in GitHub Desktop.

Select an option

Save Slach/70d7b7e5954bfd51a95281e8523f4530 to your computer and use it in GitHub Desktop.
ClickHouse ReplicatedAccessStorage TOCTOU race: freshly created ROLE evicted from local cache -> CREATE USER/QUOTA ... <role> fails with UNKNOWN_ROLE (Code 511)

ClickHouse ReplicatedAccessStorage TOCTOU race — reproducer

Reproduces a server-side race in ClickHouse when RBAC is stored in Keeper (<user_directories><replicated>). A freshly created ROLE is evicted from the local in-memory cache by the background watch thread, so the very next statement that resolves the role by name fails with:

Code: 511. There is no role `...` in `user directories`. (UNKNOWN_ROLE)

even though CREATE ROLE just succeeded over the same session.

Root cause

src/Access/ZooKeeperReplicator.cpp, refreshEntities(all=false) (run by the background watch thread):

// read the /uuid children list WITHOUT holding the mutex:
const auto entity_uuid_strs = zookeeper->getChildrenWatch(zookeeper_uuids_path, &stat, watch_entities_list);
... parse uuids ...
std::lock_guard lock{mutex};                 // mutex taken only now
memory_storage.removeAllExcept(entity_uuids); // evicts anything not in the STALE snapshot

If the snapshot was taken before a concurrently inserted entity's znode existed, but the watch thread takes the mutex after that entity's synchronous refreshEntity put it into memory, removeAllExcept evicts the just-inserted entity. It reappears on the next refresh — but a statement landing in that window fails with UNKNOWN_ROLE.

This mirrors clickhouse-backup TestRBACcreateRBACObjects.

Layout

docker-compose.yaml
ch-config/keeper.xml             # embedded clickhouse-keeper + <zookeeper>
ch-config/replicated_access.xml  # <user_directories><replicated> (RBAC in Keeper)
ch-config/open_default_user.xml  # let default user connect over the mapped port
go.mod
main.go

Put the three XML files under a ch-config/ directory next to docker-compose.yaml (the gist flattens directories — see the volume mounts).

Run

docker compose up -d
# wait until healthy, then:
go mod tidy
go run .

The reproducer seeds many dummy entities to widen the (otherwise narrow) TOCTOU window, then runs churn + victim goroutines. It typically reproduces within a few thousand iterations (tens of seconds). Tunables via env: SEED_ENTITIES, CHURNERS, VICTIMS, RUN_SECS, CLICKHOUSE_ADDR.

services:
clickhouse:
image: clickhouse/clickhouse-server:latest
container_name: ch-rbac-race
ulimits:
nofile:
soft: 262144
hard: 262144
ports:
- "8123:8123"
- "9000:9000"
- "9181:9181"
volumes:
- ./ch-config/keeper.xml:/etc/clickhouse-server/config.d/keeper.xml:ro
- ./ch-config/replicated_access.xml:/etc/clickhouse-server/config.d/replicated_access.xml:ro
- ./ch-config/open_default_user.xml:/etc/clickhouse-server/users.d/open_default_user.xml:ro
healthcheck:
test: ["CMD", "clickhouse-client", "--query", "SELECT 1"]
interval: 2s
timeout: 2s
retries: 30
module ch-rbac-race
go 1.22
require github.com/ClickHouse/clickhouse-go/v2 v2.30.0
require (
github.com/ClickHouse/ch-go v0.61.5 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.17.7 // indirect
github.com/paulmach/orb v0.11.1 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
go.opentelemetry.io/otel v1.26.0 // indirect
go.opentelemetry.io/otel/trace v1.26.0 // indirect
golang.org/x/sys v0.26.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
<clickhouse>
<!-- Run an embedded clickhouse-keeper so a single server can use replicated access storage -->
<keeper_server>
<tcp_port>9181</tcp_port>
<server_id>1</server_id>
<log_storage_path>/var/lib/clickhouse/coordination/log</log_storage_path>
<snapshot_storage_path>/var/lib/clickhouse/coordination/snapshots</snapshot_storage_path>
<coordination_settings>
<operation_timeout_ms>10000</operation_timeout_ms>
<session_timeout_ms>30000</session_timeout_ms>
<raft_logs_level>warning</raft_logs_level>
</coordination_settings>
<raft_configuration>
<server>
<id>1</id>
<hostname>localhost</hostname>
<port>9234</port>
</server>
</raft_configuration>
</keeper_server>
<zookeeper>
<node>
<host>localhost</host>
<port>9181</port>
</node>
</zookeeper>
</clickhouse>
// Reproducer for a TOCTOU race in ClickHouse ReplicatedAccessStorage.
//
// When RBAC is stored in Keeper (<user_directories><replicated>), creating a
// ROLE and then immediately referencing it by name in CREATE QUOTA ... TO <role>
// (or CREATE ROW POLICY ... TO <role>) can fail with:
//
// Code: 511. There is no role `...` in `user directories`. (UNKNOWN_ROLE)
//
// even though CREATE ROLE just succeeded over the same session and CREATE USER
// ... DEFAULT ROLE resolved the same role.
//
// Root cause (src/Access/ZooKeeperReplicator.cpp, refreshEntities(all=false)):
// the background watch thread reads getChildrenWatch("/uuid") WITHOUT the mutex,
// then later takes the mutex and calls memory_storage.removeAllExcept(snapshot).
// If the snapshot was taken before the freshly inserted role's znode existed,
// the role is evicted from the local in-memory cache.
//
// This mirrors clickhouse-backup TestRBAC -> createRBACObjects. To widen the
// narrow TOCTOU window we (1) bloat the /uuid list with many entities so the
// lock-free getChildrenWatch + parse takes longer, and (2) run dedicated churn
// goroutines so a stale-snapshot refresh is almost always in flight.
package main
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
)
func envInt(name string, def int) int {
if v := os.Getenv(name); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func addr() string {
if a := os.Getenv("CLICKHOUSE_ADDR"); a != "" {
return a
}
return "127.0.0.1:9000"
}
func open() (clickhouse.Conn, error) {
return clickhouse.Open(&clickhouse.Options{
Addr: []string{addr()},
Auth: clickhouse.Auth{Database: "default", Username: "default"},
MaxOpenConns: 1,
MaxIdleConns: 0,
DialTimeout: 10 * time.Second,
})
}
func isUnknownRole(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "UNKNOWN_ROLE") ||
strings.Contains(s, "code: 511") ||
strings.Contains(s, "There is no role")
}
var (
iters atomic.Int64
hit atomic.Bool
once sync.Once
stop = make(chan struct{})
)
func stopped() bool {
select {
case <-stop:
return true
default:
return false
}
}
// victim mirrors test/integration/rbac_test.go:createRBACObjects: create a
// SETTINGS PROFILE, ROLE, USER, then reference the role by name in CREATE QUOTA
// / CREATE ROW POLICY (TO <role>), dropping first to recreate each iteration.
func victim(ctx context.Context, conn clickhouse.Conn, name string) (failedStmt string, err error) {
q := func(query string) bool {
if err = conn.Exec(ctx, query); err != nil {
failedStmt = query
return false
}
return true
}
for _, drop := range []string{
fmt.Sprintf("DROP SETTINGS PROFILE IF EXISTS `%s`", name),
fmt.Sprintf("DROP QUOTA IF EXISTS `%s`", name),
fmt.Sprintf("DROP ROW POLICY IF EXISTS `%s` ON test_rbac.test_rbac", name),
fmt.Sprintf("DROP ROLE IF EXISTS `%s`", name),
fmt.Sprintf("DROP USER IF EXISTS `%s`", name),
} {
if !q(drop) {
return
}
}
if !q(fmt.Sprintf("CREATE SETTINGS PROFILE `%s` SETTINGS max_execution_time=60", name)) {
return
}
if !q(fmt.Sprintf("CREATE ROLE `%s` SETTINGS PROFILE `%s`", name, name)) {
return
}
if !q(fmt.Sprintf("CREATE USER `%s` IDENTIFIED BY 'test_rbac_password' DEFAULT ROLE `%s`", name, name)) {
return
}
// The statement that fails with UNKNOWN_ROLE in CI (RolesOrUsersSet::init).
if !q(fmt.Sprintf("CREATE QUOTA `%s` KEYED BY user_name FOR INTERVAL 1 hour NO LIMITS TO `%s`", name, name)) {
return
}
if !q(fmt.Sprintf("CREATE ROW POLICY `%s` ON test_rbac.test_rbac USING v>=0 AS RESTRICTIVE TO `%s`", name, name)) {
return
}
return
}
func main() {
ctx := context.Background()
seed := envInt("SEED_ENTITIES", 6000)
churners := envInt("CHURNERS", 24)
victims := envInt("VICTIMS", 16)
runSecs := envInt("RUN_SECS", 240)
setup, err := open()
if err != nil {
panic(err)
}
for _, q := range []string{
"CREATE DATABASE IF NOT EXISTS test_rbac",
"CREATE TABLE IF NOT EXISTS test_rbac.test_rbac (v UInt64) ENGINE=MergeTree() ORDER BY tuple()",
} {
if err := setup.Exec(ctx, q); err != nil {
panic(err)
}
}
// Bloat the /uuid children list so the lock-free getChildrenWatch + parse in
// refreshEntities is slow, widening the TOCTOU window.
fmt.Printf("seeding %d dummy settings profiles to bloat /uuid ...\n", seed)
for i := 0; i < seed; i++ {
if err := setup.Exec(ctx, fmt.Sprintf("CREATE SETTINGS PROFILE IF NOT EXISTS `seed-%d` SETTINGS max_execution_time=%d", i, i%100+1)); err != nil {
panic(err)
}
if i%1000 == 999 {
fmt.Printf(" seeded %d\n", i+1)
}
}
_ = setup.Close()
fmt.Println("seed done; starting churn + victims")
report := func(workerName, stmt string, err error) {
once.Do(func() {
hit.Store(true)
fmt.Println("\n========================================================")
fmt.Println("REPRODUCED: ReplicatedAccessStorage TOCTOU race")
fmt.Println("========================================================")
fmt.Printf("victim entity : %s\n", workerName)
fmt.Printf("total iters : %d\n", iters.Load())
fmt.Printf("failed stmt : %s\n", stmt)
fmt.Printf("server error : %v\n", err)
fmt.Println("--------------------------------------------------------")
fmt.Println("CREATE ROLE succeeded and CREATE USER ... DEFAULT ROLE resolved")
fmt.Println("the same role, yet the next TO `<role>` failed: the role was")
fmt.Println("evicted from the local cache by the background watch thread.")
close(stop)
})
}
var wg sync.WaitGroup
// Churn goroutines: rapid CREATE/DROP of unique profiles to keep the /uuid
// children list volatile, so a stale-snapshot watch refresh is always in flight.
for c := 0; c < churners; c++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
conn, err := open()
if err != nil {
return
}
defer conn.Close()
n := 0
for !stopped() {
name := fmt.Sprintf("churn-%d-%d", id, n%4)
_ = conn.Exec(ctx, fmt.Sprintf("CREATE SETTINGS PROFILE IF NOT EXISTS `%s` SETTINGS max_execution_time=%d", name, n%50+1))
_ = conn.Exec(ctx, fmt.Sprintf("DROP SETTINGS PROFILE IF EXISTS `%s`", name))
n++
}
}(c)
}
// Victim goroutines: the role -> quota sequence that exposes the eviction.
for w := 0; w < victims; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
conn, err := open()
if err != nil {
return
}
defer conn.Close()
name := fmt.Sprintf("test.rbac-name-%d", id)
for !stopped() {
stmt, err := victim(ctx, conn, name)
iters.Add(1)
if isUnknownRole(err) {
report(name, stmt, err)
return
}
}
}(w)
}
// Progress + timeout watchdog.
go func() {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
deadline := time.Now().Add(time.Duration(runSecs) * time.Second)
for {
select {
case <-stop:
return
case <-t.C:
fmt.Printf("... %d victim iterations, no race yet\n", iters.Load())
if time.Now().After(deadline) {
fmt.Println("timeout: race not reproduced in this run")
once.Do(func() { close(stop) })
return
}
}
}
}()
wg.Wait()
if !hit.Load() {
os.Exit(2)
}
}
<clickhouse>
<!-- Throwaway test env: allow the default user from any network so the Go
reproducer can connect over the mapped port. -->
<users>
<default replace="replace">
<password></password>
<networks>
<ip>::/0</ip>
</networks>
<profile>default</profile>
<quota>default</quota>
<access_management>1</access_management>
</default>
</users>
</clickhouse>
<clickhouse>
<!-- Store RBAC entities in Keeper via ReplicatedAccessStorage, exactly like
clickhouse-backup TestRBAC (test/integration/configs/dynamic_settings.sh). -->
<user_directories replace="replace">
<users_xml>
<path>users.xml</path>
</users_xml>
<replicated>
<zookeeper_path>/clickhouse/access</zookeeper_path>
</replicated>
</user_directories>
</clickhouse>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment