Introdução
Hoje vamos falar sobre race conditions em Golang e como um detalhe aparentemente inocente no código pode se transformar em uma vulnerabilidade real em uma API web.
Para fugir da teoria pura, este post utiliza como exemplo prático um desafio do HackingClub chamado Toc-toc, nome que faz referência direta à vulnerabilidade TOCTOU (Time-of-Check to Time-of-Use).
A aplicação analisada consiste em um servidor escrito em Go, com o código-fonte exposto publicamente por uma rota acessível, cujo objetivo é encontrar uma forma de ler a flag armazenada no servidor. A partir dessa aplicação, foi possível identificar uma condição de corrida em um endpoint de auditoria de logs e explorá-la para escapar do diretório esperado e acessar arquivos sensíveis do sistema.
Primeiro Contato
Ao acessar o IP da aplicação, percebemos que o código-fonte estava disponível diretamente na rota raiz. Isso já nos dá uma excelente superfície de análise. Vamos, então, examiná-lo com mais atenção.
A seguir está o código completo da aplicação:
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gorilla/mux"
)
type AuditResponse struct {
Log string `json:"log"`
Content string `json:"content"`
}
type ErrorResponse struct {
Message string `json:"message"`
}
func sanitizeFileName(name string) string {
return filepath.Base(name)
}
func checkPathTraversal(name string) bool {
if strings.Contains(name, "..") || strings.Contains(name, "/") {
return true
}
return false
}
func main() {
err := os.MkdirAll("logs", 0755)
if err != nil {
fmt.Println("Error to create logs directory:", err)
return
}
filePath := ""
r := mux.NewRouter()
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
content, err := os.ReadFile("main.go")
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Error reading source code"))
return
}
w.Write(content)
}).Methods("GET")
r.HandleFunc("/audit", func(w http.ResponseWriter, r *http.Request) {
logParam := r.URL.Query().Get("log")
if logParam != "" {
filePath = logParam
pathTraversal := checkPathTraversal(filePath)
if pathTraversal {
filePath = sanitizeFileName(filePath)
}
} else {
filePath = "last-activity.txt"
}
filePath = filepath.Join("logs", filePath)
content, err := os.ReadFile(filePath)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
errorResponse := ErrorResponse{
Message: "Log not found",
}
json.NewEncoder(w).Encode(errorResponse)
return
}
response := AuditResponse{
Log: logParam,
Content: string(content),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}).Methods("GET")
r.HandleFunc("/audit/list", func(w http.ResponseWriter, r *http.Request) {
files, err := os.ReadDir("logs")
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
errorResponse := ErrorResponse{
Message: "Could not list logs",
}
json.NewEncoder(w).Encode(errorResponse)
return
}
var logFiles []string
for _, file := range files {
if !file.IsDir() {
logFiles = append(logFiles, file.Name())
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(logFiles)
}).Methods("GET")
// r.HandleFunc("/get-flag", func(w http.ResponseWriter, r *http.Request) {
// w.Header().Set("Content-Type", "text/plain")
// content, err := os.ReadFile("./flag.txt")
// if err != nil {
// w.WriteHeader(http.StatusInternalServerError)
// w.Write([]byte("Error reading flag"))
// return
// }
// w.Write(content)
// }).Methods("GET")
fmt.Println("Server running on port 8000...")
http.ListenAndServe(":8000", r)
}
Analisando o Código
Durante a análise do código, um detalhe aparentemente inofensivo chama a atenção logo no início da função main. É o tipo de coisa que facilmente passa em um code review apressado e exatamente por isso merece cuidado. A variável: filePath := "", que está declarada no escopo do main(), fora de qualquer handler HTTP. Isso faz com que ela se torne uma variável global compartilhada entre todas as requisições processadas pelo servidor.
No pacote net/http, cada requisição recebida é tratada em sua própria goroutine. Isso significa que múltiplas requisições podem acessar e modificar essa variável simultaneamente. Como não há nenhum mecanismo de sincronização (como mutex), cria-se um cenário clássico de race condition.
Essa variável compartilhada é, de fato, o coração da vulnerabilidade.
func main() {
err := os.MkdirAll("logs", 0755)
if err != nil {
fmt.Println("Error to create logs directory:", err)
return
}
--->filePath := ""<-------------AQUI!----------------------
r := mux.NewRouter()
Mas por que isso é tão crítico?
Entendendo a Vulnerabilidade
Antes de falar da exploração em si, vale dar um passo atrás e entender por que esse tipo de bug existe — e por que ele é tão perigoso em sistemas concorrentes.
A vulnerabilidade explorada aqui pertence à classe TOCTOU (Time-of-Check to Time-of-Use), uma forma clássica de race condition. Ela ocorre quando uma verificação de segurança é feita em um determinado momento (check), mas o recurso verificado é utilizado apenas posteriormente (use). Entre esses dois pontos existe uma janela crítica, na qual o estado do sistema pode mudar.
No contexto dessa aplicação, o problema fica evidente porque:
- O valor de
filePathé validado em um momento - Esse mesmo valor é utilizado posteriormente para leitura de arquivo
- Entre esses dois momentos, outra requisição pode alterar completamente o conteúdo da variável
Como cada handler roda em sua própria goroutine, múltiplas requisições competem simultaneamente por essa variável compartilhada, criando o cenário perfeito para exploração.
Indo para a Exploração
Vamos analisar o fluxo crítico do endpoint /audit:
- A aplicação lê o parâmetro
logda query string - Esse valor é atribuído à variável global
filePath - A função
checkPathTraversalverifica se o valor contém..ou/ - Caso algo suspeito seja detectado, o valor é sanitizado com
sanitizeFileName - Em seguida, o código executa:
filePath = filepath.Join("logs", filePath) - Por fim, chama
os.ReadFile(filePath)
O problema central é que a validação e o uso do valor não são atômicos.
Imagine a seguinte sequência:
-
Requisição A chega com
?log=safe.txt filePath = "safe.txt"- Passa na verificação
- Antes de
os.ReadFileser executado, chega a Requisição B com?log=../flag.txt -
filePathé sobrescrito para"../flag.txt" - A Requisição A continua sua execução
- Executa
filepath.Join("logs", "../flag.txt") - O caminho é resolvido para fora do diretório logs
-
os.ReadFilelê o arquivo sensível da flag
Mesmo que filepath.Join normalize o caminho, ele não oferece nenhuma proteção contra condições de corrida quando a variável de entrada pode ser alterada entre a validação e o uso.
Exploração Prática
A exploração foi realizada utilizando o Caido, explorando requisições concorrentes.
O endpoint /audit/list permitiu identificar nomes de arquivos válidos dentro do diretório logs, o que ajudou a construir payloads que passavam facilmente pela verificação inicial.
Ao disparar múltiplas requisições simultâneas, algumas com parâmetros aparentemente legítimos e outras com caminhos maliciosos. Após algumas tentativas, a resposta retornou o conteúdo da flag.
Entendendo a Criticidade
Essa vulnerabilidade é particularmente crítica porque:
- Não exige autenticação
- Pode ser explorada remotamente
- Permite leitura arbitrária de arquivos
- Pode levar ao vazamento de credenciais, chaves, tokens ou configurações sensíveis
Além disso, falhas de race condition são notoriamente difíceis de detectar em code reviews e quase sempre passam despercebidas por scanners automáticos, já que dependem de comportamento concorrente e timing preciso.
Como Perceber e Corrigir
Mais do que corrigir esse bug específico, o ponto aqui é entender o padrão de falha. Vulnerabilidades como essa não surgem do nada. Elas são resultado de decisões de design que não consideraram concorrência como uma superfície de ataque.
Mas bem, a primeira lição aqui é simples: handlers HTTP nunca devem compartilhar estado mutável sem sincronização.
A correção mais adequada neste caso é eliminar completamente o estado global. A variável filePath deve ser local ao handler, garantindo que cada requisição trabalhe com seu próprio contexto.
Uma correção simples e eficaz seria: filePath := filepath.Join("logs", sanitizeFileName(logParam))
Além disso:
- Evite validações separadas do uso do recurso
- Prefira operações atômicas
- Mutexes podem mitigar o problema, mas não são a melhor solução nesse cenário
- O ideal é não compartilhar estado entre requisições
Além disso, há uma ferramenta extremamente útil para identificar esse tipo de problema em aplicações Go: o Data Race Detector nativo da linguagem.
O Go fornece suporte integrado para detecção de data races em tempo de execução, permitindo identificar acessos concorrentes não sincronizados à memória compartilhada. Essa ferramenta é especialmente eficaz para encontrar falhas que dificilmente seriam percebidas apenas com testes funcionais ou code review.
Durante o desenvolvimento e testes, é altamente recomendado executar a aplicação ou os testes automatizados com a flag:
go run -race main.go
Conclusão
Este desafio mostra como uma única variável mal posicionada pode comprometer completamente a segurança de uma aplicação concorrente. Em linguagens como Go, onde concorrência é simples e eficiente, erros desse tipo são fáceis de introduzir e perigosos de ignorar.
Race conditions e vulnerabilidades TOCTOU não são apenas problemas teóricos. Elas existem, são exploráveis e podem ter impactos severos em ambientes reais.
Referências
- MITRE. CWE-367: Time-of-check Time-of-use (TOCTOU) Race Condition.
- Portswigger. Race Condition.
- Go Documentation. net/http Package.
- Go Blog. Concurrency is not Parallelism.
- OWASP. Path Traversal.
- Go Documentation. filepath Package.
- Go Wiki. Race Detector.
- The Go Programming Language. The Go Memory Model.


Top comments (0)