Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions deploy/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ services:
- DB_HOST=postgresdb
- DB_PORT=5432
- SERVER_PORT=${WORKSHOP_SERVER_PORT:-8000}
- ENABLE_SHELL_INJECTION=${ENABLE_SHELL_INJECTION:-false}
- MONGO_DB_HOST=mongodb
- MONGO_DB_PORT=27017
- MONGO_DB_USER=admin
Expand Down
1 change: 1 addition & 0 deletions deploy/helm/templates/workshop/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ data:
MONGO_DB_PASSWORD: {{ .Values.mongodb.config.mongoPassword }}
MONGO_DB_NAME: {{ .Values.mongodb.config.mongoDbName }}
SERVER_PORT: {{ .Values.workshop.port | quote }}
ENABLE_SHELL_INJECTION: {{ .Values.enableShellInjection | quote }}
API_GATEWAY_URL: {{ if .Values.apiGatewayServiceInstall }}"https://{{ .Values.apiGatewayService.service.name }}"{{ else }}{{ .Values.apiGatewayServiceUrl }}{{ end }}
TLS_ENABLED: {{ .Values.tlsEnabled | quote }}
FILES_LIMIT: {{ .Values.workshop.config.filesLimit | quote }}
Expand Down
1 change: 1 addition & 0 deletions deploy/k8s/base/workshop/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ data:
MONGO_DB_PASSWORD: crapisecretpassword
MONGO_DB_NAME: crapi
SERVER_PORT: "8000"
ENABLE_SHELL_INJECTION: "false"
API_GATEWAY_URL: "https://api.mypremiumdealership.com"
# Gunicorn configuration for better performance under load
GUNICORN_WORKERS: "4"
Expand Down
13 changes: 13 additions & 0 deletions docs/challengeSolutions.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,17 @@ The above challenge was completed using Burp Suite Community Edition.
- `AA==` is the Base64 encoded form of Hex null byte `00`
- This JWT will be accepted as a valid JWT Token by crAPI

## Command Injection

### [Challenge 19 - Execute an additional command through the Workshop diagnostic flow](challenges.md#challenge-19---execute-an-additional-command-through-the-workshop-diagnostic-flow)

#### Detailed solution

1. Login to crAPI and add a vehicle.
2. Start the Workshop service with `ENABLE_SHELL_INJECTION=true` in the lab environment.
3. Open the *Contact Mechanic* flow and observe the request to `/workshop/api/merchant/contact_mechanic`.
4. Add `diagnostic_command` to the request body with a normal value such as `status`.
5. Change `diagnostic_command` to `status; echo crapi-command-injection`.
6. Send the request and confirm that `diagnostic_output` contains `crapi-command-injection`.

## << 2 secret challenges >>
14 changes: 14 additions & 0 deletions docs/challenges.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,20 @@ Extract the credentials of another user and check their orders.

Use the chatbot to perform an action like placing order on behalf on another user.

## Command Injection

### Challenge 19 - Execute an additional command through the Workshop diagnostic flow

crAPI lets vehicle owners contact a mechanic and send vehicle problem details to the Workshop service. When `ENABLE_SHELL_INJECTION=true` is set for the Workshop service, the report submission flow also accepts a diagnostic command that is passed to a shell command in the Workshop container.

* Analyze the request sent by the contact mechanic flow.

* Enable the lab-only shell injection behavior with `ENABLE_SHELL_INJECTION=true`.

* Add a command separator to the diagnostic command.

* Confirm that the response includes output from the additional command.

## << 3 secret challenges >>

There are two more secret challenges in crAPI, that are pretty complex, and for now we don’t share details about them, except the fact they are really cool.
27 changes: 25 additions & 2 deletions openapi-spec/crapi-openapi-spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -2408,6 +2408,10 @@
},
"mechanic_code" : {
"type" : "string"
},
"diagnostic_command" : {
"type" : "string",
"description" : "Lab-only diagnostic command. Executed only when ENABLE_SHELL_INJECTION=true is set for the Workshop service."
}
}
},
Expand All @@ -2417,7 +2421,8 @@
"number_of_repeats" : 1,
"repeat_request_if_failed" : false,
"problem_details" : "Hi Jhon",
"vin" : "8UOLV89RGKL908077"
"vin" : "8UOLV89RGKL908077",
"diagnostic_command" : "status"
}
}
}
Expand All @@ -2444,6 +2449,10 @@
},
"report_link" : {
"type" : "string"
},
"diagnostic_output" : {
"type" : "string",
"description" : "Returned only when ENABLE_SHELL_INJECTION=true is set for the Workshop service."
}
}
},
Expand All @@ -2457,7 +2466,8 @@
"response_from_mechanic_api" : {
"id" : 17,
"sent" : true,
"report_link" : "http://localhost:8888/workshop/api/mechanic/mechanic_report?report_id=17"
"report_link" : "http://localhost:8888/workshop/api/mechanic/mechanic_report?report_id=17",
"diagnostic_output" : "Running diagnostic: status\n"
},
"status" : 200
}
Expand Down Expand Up @@ -2531,6 +2541,15 @@
"type" : "string",
"example" : "0BZCX25UTBJ987271"
}
}, {
"name" : "diagnostic_command",
"in" : "query",
"required" : false,
"schema" : {
"type" : "string",
"example" : "status",
"description" : "Lab-only diagnostic command. Executed only when ENABLE_SHELL_INJECTION=true is set for the Workshop service."
}
} ],
"responses" : {
"200" : {
Expand All @@ -2550,6 +2569,10 @@
"report_link" : {
"type" : "string",
"format" : "url"
},
"diagnostic_output" : {
"type" : "string",
"description" : "Returned only when ENABLE_SHELL_INJECTION=true is set for the Workshop service."
}
}
}
Expand Down
30 changes: 27 additions & 3 deletions services/chatbot/src/resources/crapi-openapi-spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -3927,6 +3927,10 @@
},
"mechanic_code": {
"type": "string"
},
"diagnostic_command": {
"type": "string",
"description": "Lab-only diagnostic command. Executed only when ENABLE_SHELL_INJECTION=true is set for the Workshop service."
}
}
},
Expand All @@ -3936,7 +3940,8 @@
"number_of_repeats": 1,
"repeat_request_if_failed": false,
"problem_details": "Hi Jhon",
"vin": "8UOLV89RGKL908077"
"vin": "8UOLV89RGKL908077",
"diagnostic_command": "status"
}
}
}
Expand Down Expand Up @@ -3970,6 +3975,10 @@
},
"report_link": {
"type": "string"
},
"diagnostic_output": {
"type": "string",
"description": "Returned only when ENABLE_SHELL_INJECTION=true is set for the Workshop service."
}
}
},
Expand All @@ -3983,7 +3992,8 @@
"response_from_mechanic_api": {
"id": 17,
"sent": true,
"report_link": "http://localhost:8888/workshop/api/mechanic/mechanic_report?report_id=17"
"report_link": "http://localhost:8888/workshop/api/mechanic/mechanic_report?report_id=17",
"diagnostic_output": "Running diagnostic: status\n"
},
"status": 200
}
Expand Down Expand Up @@ -4066,6 +4076,16 @@
"type": "string",
"example": "0BZCX25UTBJ987271"
}
},
{
"name": "diagnostic_command",
"in": "query",
"required": false,
"schema": {
"type": "string",
"example": "status",
"description": "Lab-only diagnostic command. Executed only when ENABLE_SHELL_INJECTION=true is set for the Workshop service."
}
}
],
"responses": {
Expand All @@ -4090,6 +4110,10 @@
"report_link": {
"type": "string",
"format": "url"
},
"diagnostic_output": {
"type": "string",
"description": "Returned only when ENABLE_SHELL_INJECTION=true is set for the Workshop service."
}
}
}
Expand Down Expand Up @@ -5302,4 +5326,4 @@
}
}
}
}
}
1 change: 1 addition & 0 deletions services/workshop/crapi/mechanic/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class ReceiveReportSerializer(serializers.Serializer):
problem_details = serializers.CharField()
vin = serializers.CharField()
owner_id = serializers.CharField(required=False)
diagnostic_command = serializers.CharField(required=False)


class SignUpSerializer(serializers.Serializer):
Expand Down
58 changes: 57 additions & 1 deletion services/workshop/crapi/mechanic/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
"""
contains all the test cases related to mechanic
"""
from django.utils import timezone
import os
from unittest.mock import patch

from django.utils import timezone
from utils.mock_methods import (
get_sample_mechanic_data,
mock_jwt_auth_required,
Expand Down Expand Up @@ -254,6 +256,60 @@ def setUp(self):
updated_on=timezone.now(),
)

@patch.dict(os.environ, {"ENABLE_SHELL_INJECTION": "false"})
def test_receive_report_diagnostic_disabled_without_flag(self):
"""
diagnostic commands are ignored unless shell injection is enabled
:return: None
"""
payload = dict(self.contact_mechanic_request_body)
payload["diagnostic_command"] = "status; echo crapi-command-injection"
res = self.client.get(
"/workshop/api/mechanic/receive_report",
payload,
**self.user_auth_headers,
content_type="application/json",
)
self.assertEqual(res.status_code, 200)
self.assertNotIn("diagnostic_output", res.json())

@patch.dict(os.environ, {"ENABLE_SHELL_INJECTION": "true"})
def test_receive_report_diagnostic_normal_input(self):
"""
normal diagnostic input is echoed in the report response
:return: None
"""
payload = dict(self.contact_mechanic_request_body)
payload["diagnostic_command"] = "status"
res = self.client.get(
"/workshop/api/mechanic/receive_report",
payload,
**self.user_auth_headers,
content_type="application/json",
)
self.assertEqual(res.status_code, 200)
self.assertIn("diagnostic_output", res.json())
self.assertIn("Running diagnostic: status", res.json()["diagnostic_output"])

@patch.dict(os.environ, {"ENABLE_SHELL_INJECTION": "true"})
def test_receive_report_diagnostic_command_injection(self):
"""
a command separator executes the injected diagnostic command
:return: None
"""
payload = dict(self.contact_mechanic_request_body)
payload["diagnostic_command"] = "status; echo crapi-command-injection"
res = self.client.get(
"/workshop/api/mechanic/receive_report",
payload,
**self.user_auth_headers,
content_type="application/json",
)
self.assertEqual(res.status_code, 200)
output_lines = res.json()["diagnostic_output"].splitlines()
self.assertIn("Running diagnostic: status", output_lines)
self.assertIn("crapi-command-injection", output_lines)

def test_create_comment(self):
"""
creates a dummy service request
Expand Down
42 changes: 37 additions & 5 deletions services/workshop/crapi/mechanic/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import os
import bcrypt
import re
import subprocess
from urllib.parse import unquote
from django.template.loader import get_template
from xhtml2pdf import pisa
Expand Down Expand Up @@ -46,6 +47,7 @@
)
from rest_framework.pagination import LimitOffsetPagination


class SignUpView(APIView):
"""
Used to add a new mechanic
Expand Down Expand Up @@ -199,10 +201,15 @@ def get(self, request):
reverse("get-mechanic-report"), service_request.id
)
report_link = request.build_absolute_uri(report_link)
return Response(
{"id": service_request.id, "sent": True, "report_link": report_link},
status=status.HTTP_200_OK,
)
response_data = {
"id": service_request.id,
"sent": True,
"report_link": report_link,
}
diagnostic_command = report_details.get("diagnostic_command")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RCE should be only enabled when the ENABLE_SHELL_INJECTION env is injected. So its safe when users deploy with public access which should be default behavior

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review. I pushed an update that gates the diagnostic shell execution behind ENABLE_SHELL_INJECTION, with the default set to false for public deployments.

I also wired the flag into the Workshop deployment config, added coverage for the default-disabled and enabled challenge paths, and updated the docs/OpenAPI to make the lab-only behavior explicit.

Validated locally with the Workshop test suite and the full Postman/Newman collection.

if diagnostic_command and is_shell_injection_enabled():
response_data["diagnostic_output"] = run_diagnostic(diagnostic_command)
return Response(response_data, status=status.HTTP_200_OK)


class GetReportView(APIView):
Expand Down Expand Up @@ -415,6 +422,31 @@ def validate_filename(input: str) -> bool:
return bool(url_encoded_pattern.fullmatch(input))


def is_shell_injection_enabled() -> bool:
return os.environ.get("ENABLE_SHELL_INJECTION", "").lower() == "true"


def run_diagnostic(command: str) -> str:
"""
Shells out with user input on purpose for the command injection challenge.
"""
try:
completed_process = subprocess.run(
f"echo Running diagnostic: {command}",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=True,
text=True,
timeout=5,
)
return completed_process.stdout
except subprocess.TimeoutExpired as exception:
output = exception.stdout or ""
if isinstance(output, bytes):
output = output.decode(errors="replace")
return f"{output}\nDiagnostic command timed out."


def service_report_pdf(response_data, report_id):
"""
Generates service report's PDF file from a template and saves it to the disk.
Expand Down Expand Up @@ -457,4 +489,4 @@ def manage_reports_directory():
os.remove(oldest_file)

except (OSError, FileNotFoundError) as e:
print(f"Error during report directory management: {e}")
print(f"Error during report directory management: {e}")
1 change: 1 addition & 0 deletions services/workshop/crapi/merchant/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class ContactMechanicSerializer(serializers.Serializer):
mechanic_api = serializers.CharField()
repeat_request_if_failed = serializers.BooleanField(required=False)
number_of_repeats = serializers.IntegerField(required=False)
diagnostic_command = serializers.CharField(required=False)


class MechanicPublicSerializer(serializers.ModelSerializer):
Expand Down
Loading