Command Injection in Sinatra with Hmac Signatures
Command Injection in Sinatra with Hmac Signatures — how this specific combination creates or exposes the vulnerability
Command injection occurs when an attacker can cause an application to execute arbitrary operating system commands. In Sinatra, this risk can emerge when HMAC signatures are used to validate incoming requests but the application uses the signature data in an unsafe way before verification or in constructing shell commands. A common pattern is to include user-controlled values in a signature to ensure integrity, then using those values in a system call without proper sanitization. If the server-side code builds a shell command by string concatenation—for example, passing a user-supplied parameter directly into system, exec, or backticks—attackers can escape the expected argument boundaries and inject additional shell commands.
Consider a Sinatra endpoint that expects an HMAC signature in a header and a parameter filename that identifies a file to process. If the server recomputes the HMAC over the raw parameter and compares it to the client-supplied signature, the signature itself may be valid even when the parameter is malicious. The vulnerability is not in the HMAC algorithm but in what the server does after verification. If the code then runs something like system("tar -xvf #{filename}"), an attacker can supply file.tar; rm -rf / to execute arbitrary commands. Because the signature covers the attacker-controlled input, the server may treat the request as legitimate, bypassing any naive allow-list assumptions about the parameter format.
HMAC signatures do not prevent command injection; they only prove that the message has not been altered. Attackers might also probe for endpoints that accept signed but unverified inputs, especially if the application logs or reflects the parameter values. In a black-box scan, such endpoints can be identified by sending crafted HMACs with shell metacharacters and observing command execution behavior, error messages, or side effects. This is why it is essential to treat all inputs as untrusted even when protected by a signature and to apply strict input validation and output encoding before using any data in a shell context.
Hmac Signatures-Specific Remediation in Sinatra — concrete code fixes
To mitigate command injection while using HMAC signatures in Sinatra, ensure that user-controlled data is never used directly in shell commands. Instead, use strict allow-listing, avoid shell metacharacters, and prefer safe APIs that do not invoke a shell. Below are two concrete patterns: one vulnerable and one secure.
Vulnerable pattern
require 'sinatra'
require 'openssl'
SECRET = 'shared-secret'
post '/process' do
filename = params['filename']
received_hmac = request.env['HTTP_X_HMAC']
computed_hmac = OpenSSL::HMAC.hexdigest('SHA256', SECRET, filename)
if timing_safe_compare(computed_hmac, received_hmac)
# Dangerous: filename is used in a shell command
result = `tar -xvf #{filename}`
result
else
halt 401, 'Invalid signature'
end
end
def timing_safe_compare(a, b)
return false unless a.bytesize == b.bytesize
l = a.unpack 'C*'
r = 0
b.each_byte { |c| r |= c ^ l.shift }
r == 0
end
In the vulnerable example, the filename is interpolated into a backtick command after a valid HMAC is confirmed. An attacker can supply a filename such as archive.tar; id to execute arbitrary commands.
Secure remediation
require 'sinatra'
require 'openssl'
SECRET = 'shared-secret'
ALLOWED_BASENAMES = ['report.tar', 'data.tar', 'logs.tar'].freeze
def safe_filename(input)
# Strict allow-list: only exact matches from a pre-approved set
ALLOWED_BASENAMES.include?(input) ? input : nil
end
post '/process' do
filename = params['filename']
received_hmac = request.env['HTTP_X_HMAC']
computed_hmac = OpenSSL::HMAC.hexdigest('SHA256', SECRET, filename)
unless timing_safe_compare(computed_hmac, received_hmac)
halt 403, 'Invalid signature'
end
safe = safe_filename(filename)
halt 400, 'Filename not allowed' unless safe
# Safe: no shell interpolation, explicit arguments
result = `tar -xvf #{safe.shellescape}`
result
end
def timing_safe_compare(a, b)
return false unless a.bytesize == b.bytesize
l = a.unpack 'C*'
r = 0
b.each_byte { |c| r |= c ^ l.shift }
r == 0
end
Key improvements:
- Allow-list validation: Only pre-approved filenames are accepted, preventing unexpected or malicious inputs.
- No shell interpolation: Even with a valid HMAC, the code uses backticks with a shell-escaped argument. In this example, explicit allow-listing makes
shellescapea defense-in-depth measure; ideally, you would avoid a shell entirely by using a Ruby tar library (e.g.,tar-win32orruby-tar) to avoid shell involvement altogether. - Explicit error handling: Clear 400 and 403 responses help clients understand rejection reasons without leaking sensitive details.
If you must construct shell commands, always use built-in escaping (e.g., shellescape from shellwords) and avoid interpolating untrusted data. Better yet, use language-native libraries for archive extraction to eliminate the shell surface area entirely.
Related CWEs: inputValidation
| CWE ID | Name | Severity |
|---|---|---|
| CWE-20 | Improper Input Validation | HIGH |
| CWE-22 | Path Traversal | HIGH |
| CWE-74 | Injection | CRITICAL |
| CWE-77 | Command Injection | CRITICAL |
| CWE-78 | OS Command Injection | CRITICAL |
| CWE-79 | Cross-site Scripting (XSS) | HIGH |
| CWE-89 | SQL Injection | CRITICAL |
| CWE-90 | LDAP Injection | HIGH |
| CWE-91 | XML Injection | HIGH |
| CWE-94 | Code Injection | CRITICAL |