HIGH replay attackginhmac signatures

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.

Frequently Asked Questions

What additional measures can help prevent replay attacks in Gin APIs using HMAC?
Use short-lived timestamps with strict skew tolerance, persist nonces with TTL, enforce one-time nonces, and always verify the signature over a canonical representation that includes method, path, timestamp, nonce, and body.
Can HMAC alone prevent replay attacks?
No. HMAC ensures integrity and authenticity but does not prevent reuse of a valid signature. You must add freshness checks (timestamps) and uniqueness checks (nonces) to prevent replay.