Replay Attack in Gin with Hmac Signatures
Replay Attack in Gin with Hmac Signatures — how this specific combination creates or exposes the vulnerability
A replay attack in the context of an API built with Gin and HMAC signatures occurs when a valid request signed by a client is captured and maliciously resent by an attacker. Because HMAC verifies integrity and authenticity of the signed data, but does not inherently prevent reuse of a valid signature, an attacker can replay the exact HTTP request—including the signature header—within the validity window and have it accepted by the server.
In Gin, this typically happens when the server-side HMAC verification logic checks the signature against a shared secret and the request payload or canonical string, but does not incorporate a nonce or timestamp, or does not enforce strict one-time-use or freshness constraints. For example, if the signature is computed over a concatenation of method, path, and body without a nonce, two identical requests will produce the same signature, making replay trivial. Attackers can capture requests over insecure channels or via logs and then replay them to perform unauthorized actions such as transferring funds or changing user settings, which are common abuse patterns in OAuth-style flows or payment integrations.
Another vector specific to Gin arises when middleware or handlers parse and verify the signature after some request transformations, or when body buffering is not handled consistently, leading to signature mismatches or bypasses. Without server-side protections like a nonce cache or timestamp skew tolerance, the API’s authentication boundary relies solely on signature correctness, which is insufficient to prevent replay. This exposes endpoints that do not enforce request uniqueness, even when HMAC is used to ensure the request has not been tampered with in transit.
Hmac Signatures-Specific Remediation in Gin — concrete code fixes
To mitigate replay attacks in Gin with HMAC signatures, you must include freshness and uniqueness mechanisms in the verification logic. Below is a complete, syntactically correct example that includes a timestamp, a nonce, and server-side checks to prevent replays.
// main.go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const (
sharedSecret = "super-secret-shared-key"
// Max allowed clock skew in seconds
maxClockSkew = 30
)
// In-memory store for used nonces; in production use a distributed cache with TTL.
var nonceStore = make(map[string]bool)
func verifyHMAC(c *gin.Context) {
timestampStr := c.GetHeader("X-Timestamp")
nonce := c.GetHeader("X-Nonce")
signature := c.GetHeader("X-Signature")
if timestampStr == "" || nonce == "" || signature == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "missing headers"})
return
}
// Check timestamp freshness
timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "invalid timestamp"})
return
}
reqTime := time.Unix(timestamp, 0)
if duration := time.Since(reqTime); duration > maxClockSkew*time.Second || duration < -maxClockSkew*time.Second {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "request expired"})
return
}
// Reject replayed nonces
if _, seen := nonceStore[nonce]; seen {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "replay detected"})
return
}
nonceStore[nonce] = true
// Reconstruct the canonical string exactly as the client did
bodyBytes, _ := c.GetRawData() // reads and buffers body for verification and reuse
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
// Sort headers to ensure canonical order
headers := []string{"X-Timestamp", "X-Nonce", "X-Request-Method", "X-Request-Path"}
values := map[string]string{
"X-Timestamp": timestampStr,
"X-Nonce": nonce,
"X-Request-Method": c.Request.Method,
"X-Request-Path": c.Request.URL.Path,
}
var parts []string
for _, h := range headers {
parts = append(parts, h+"="+values[h])
}
canonical := strings.Join(parts, "&")
mac := hmac.New(sha256.New, []byte(sharedSecret))
mac.Write([]byte(canonical))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(signature)) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid signature"})
return
}
c.Next()
}
func submitHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func main() {
r := gin.Default()
r.Use(verifyHMAC)
r.POST("/submit", submitHandler)
r.Run()
}On the client side, the request must be signed with the same canonical form. Example in Go:
// client.go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"sort"
"strings"
"time"
)
func signRequest(method, path string, body []byte, secret string) (string, string, string, error) {
timestamp := time.Now().Unix()
nonce := "unique-nonce-12345" // generate a unique nonce per request
headers := []string{"X-Timestamp", "X-Nonce", "X-Request-Method", "X-Request-Path"}
values := map[string]string{
"X-Timestamp": fmt.Sprintf("%d", timestamp),
"X-Nonce": nonce,
"X-Request-Method": method,
"X-Request-Path": path,
}
var parts []string
for _, h := range headers {
parts = append(parts, h+"="+values[h])
}
canonical := strings.Join(parts, "&")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(canonical))
signature := hex.EncodeToString(mac.Sum(nil))
return signature, values["X-Timestamp"], nonce, nil
}
func main() {
body := []byte(`{"account":"123","amount":100}`)
sig, ts, nc, _ := signRequest("POST", "/submit", body, "super-secret-shared-key")
req, _ := http.NewRequest("POST", "http://localhost:8080/submit", strings.NewReader(string(body)))
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Nonce", nc)
req.Header.Set("X-Signature", sig)
client := &http.Client{}
resp, _ := client.Do(req)
defer resp.Body.Close()
fmt.Println(resp.StatusCode)
}These examples ensure each request includes a timestamp and a nonce, and the server tracks used nonces and checks timestamp skew. This approach directly counters replay by making each signed request unique and time-bound, and it integrates naturally into Gin’s middleware pipeline.