Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 12 additions & 0 deletions docs/challengeSolutions.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,16 @@ 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. Open the *Contact Mechanic* flow and observe the request to `/workshop/api/merchant/contact_mechanic`.
3. Add `diagnostic_command` to the request body with a normal value such as `status`.
4. Change `diagnostic_command` to `status; echo crapi-command-injection`.
5. Send the request and confirm that `diagnostic_output` contains `crapi-command-injection`.

## << 2 secret challenges >>
12 changes: 12 additions & 0 deletions docs/challenges.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ 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. 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.

* 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.
23 changes: 21 additions & 2 deletions openapi-spec/crapi-openapi-spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -2408,6 +2408,9 @@
},
"mechanic_code" : {
"type" : "string"
},
"diagnostic_command" : {
"type" : "string"
}
}
},
Expand All @@ -2417,7 +2420,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 +2448,9 @@
},
"report_link" : {
"type" : "string"
},
"diagnostic_output" : {
"type" : "string"
}
}
},
Expand All @@ -2457,7 +2464,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 +2539,14 @@
"type" : "string",
"example" : "0BZCX25UTBJ987271"
}
}, {
"name" : "diagnostic_command",
"in" : "query",
"required" : false,
"schema" : {
"type" : "string",
"example" : "status"
}
} ],
"responses" : {
"200" : {
Expand All @@ -2550,6 +2566,9 @@
"report_link" : {
"type" : "string",
"format" : "url"
},
"diagnostic_output" : {
"type" : "string"
}
}
}
Expand Down
26 changes: 23 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,9 @@
},
"mechanic_code": {
"type": "string"
},
"diagnostic_command": {
"type": "string"
}
}
},
Expand All @@ -3936,7 +3939,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 +3974,9 @@
},
"report_link": {
"type": "string"
},
"diagnostic_output": {
"type": "string"
}
}
},
Expand All @@ -3983,7 +3990,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 +4074,15 @@
"type": "string",
"example": "0BZCX25UTBJ987271"
}
},
{
"name": "diagnostic_command",
"in": "query",
"required": false,
"schema": {
"type": "string",
"example": "status"
}
}
],
"responses": {
Expand All @@ -4090,6 +4107,9 @@
"report_link": {
"type": "string",
"format": "url"
},
"diagnostic_output": {
"type": "string"
}
}
}
Expand Down Expand Up @@ -5302,4 +5322,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
35 changes: 35 additions & 0 deletions services/workshop/crapi/mechanic/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,41 @@ def setUp(self):
updated_on=timezone.now(),
)

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"])

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
37 changes: 32 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 @@ -199,10 +200,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:
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 +421,27 @@ def validate_filename(input: str) -> bool:
return bool(url_encoded_pattern.fullmatch(input))


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 +484,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
38 changes: 37 additions & 1 deletion services/workshop/crapi/merchant/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"""
contains all the test cases related to merchant
"""
from unittest.mock import patch
from unittest.mock import Mock, patch
from utils.mock_methods import (
get_sample_mechanic_data,
mock_jwt_auth_required,
Expand Down Expand Up @@ -164,6 +164,42 @@ def test_repeat_missing_request(self):
)
self.assertEqual(res.status_code, 200)

@patch("crapi.merchant.views.requests.get")
def test_contact_mechanic_forwards_diagnostic_command(self, mocked_get):
"""
diagnostic_command is forwarded to the mechanic api request
:return: None
"""
mechanic_response = Mock()
mechanic_response.status_code = 200
mechanic_response.json.return_value = {
"id": 1,
"sent": True,
"report_link": (
"http://localhost:8888/workshop/api/mechanic/"
"mechanic_report?report_id=1"
),
"diagnostic_output": "Running diagnostic: status\n",
}
mocked_get.return_value = mechanic_response

self.contact_mechanic_request_body["diagnostic_command"] = "status"
res = self.client.post(
"/workshop/api/merchant/contact_mechanic",
self.contact_mechanic_request_body,
**self.user_auth_headers,
content_type="application/json",
)

self.assertEqual(res.status_code, 200)
self.assertEqual(
res.json()["response_from_mechanic_api"]["diagnostic_output"],
"Running diagnostic: status\n",
)
self.assertEqual(
mocked_get.call_args.kwargs["params"]["diagnostic_command"], "status"
)

def test_receive_report_and_get_report(self):
"""
tests receive_report with a valid request
Expand Down