制作软件
我本质上是一名程序员。我现在拥有了一个很酷的新 Linux 盒子的 root 访问权限,所以现在我必须为它开发软件。
在查看许多 IVI 文件时,我在 /usr/include 中发现了大量与 ccOS 相关的非常酷的 C++ 头文件。
ccOS 是联网汽车操作系统,是由 Nvidia 和现代汽车开发的操作系统,预计从 2022 年起为所有现代汽车提供动力,但我猜一些底层系统已经在以前的现代汽车中使用了相当长一段时间了。一些最古老的头文件有 2016 年的版权注释。
根据头文件,看起来它们提供了一种非常方便的与车辆交互的方式,提供查询里程表和电池电压等功能,以及执行启动发动机或锁定/解锁车门等操作。
错误之路
我想制作一个基本程序,使用 ccOS 头文件来读取门的状态并向其发送锁定或解锁信号。我将 Visual Studio Code 和最新版本的 g++ arm 交叉编译器(g++-arm-linux-gnueabi 包中的arm-linux-gnueabi-g++)安装到我的 Kali VM 上。
对于第一个应用程序,我将制作一个基于控制台的简单应用程序,该应用程序可以读取驾驶员侧门是否打开或关闭。
在名为 HBody.h 的 ccOS 头文件之一中,包含类 HBody。 HBody 是一个单例,包含一个静态方法来获取 HBody 的实例。 HBody 本身包含一个名为 isDoorOpened 的方法,允许您查看特定的门是否打开。
HBody 中的所有查询函数都返回一个 HResult,指示它是否能够成功查询对象,或者是什么错误阻止了它这样做。每个查询方法还接受对某种输出类型的引用以提供实际的查询结果。
isDoorOpened 函数采用 HDoorPosition 枚举来指定您所看到的门打开(左前/右前、左后/右后或尾门)以及对 HTriState 的引用,该 HTriState 指示门是否打开(False、True、无效的)。
这是我想出的代码:
#include <iostream>
#include <vector>
#include <string>
#include "HBody.h"
using namespace std;
const char* HResultToString(ccos::HResult result)
{
switch (result)
{
case ccos::HResult::INVALID:
return "INVALID";
case ccos::HResult::OK:
return "OK";
case ccos::HResult::ERROR:
return "ERROR";
case ccos::HResult::NOT_SUPPORTED:
return "NOT_SUPPORTED";
case ccos::HResult::OUT_OF_RANGE:
return "OUT_OF_RANGE";
case ccos::HResult::CONNECTION_FAIL:
return "CONNECTION_FAIL";
case ccos::HResult::NO_RESPONSE:
return "NO_RESPONSE";
case ccos::HResult::UNAVAILABLE:
return "UNAVAILABLE";
case ccos::HResult::NULLPOINTER:
return "NULLPOINTER";
case ccos::HResult::NOT_INITIALIZED:
return "NOT_INITIALIZED";
case ccos::HResult::TIMEOUT:
return "TIMEOUT";
case ccos::HResult::PERMISSION_DENIED:
return "PERMISSION_DENIED";
case ccos::HResult::ALREADY_EXIST:
return "ALREADY_EXIST";
case ccos::HResult::SOME_UNAVAILABLE:
return "SOME_UNAVAILABLE";
case ccos::HResult::INVALID_RESULT:
return "INVALID_RESULT";
case ccos::HResult::MAX:
return "MAX";
default:
return "Other";
}
}
int main()
{
cout << "Ioniq Test Application";
cout << endl;
ccos::vehicle::general::HBody *body = ccos::vehicle::general::HBody::getInstance();
ccos::vehicle::HTriState doorState;
ccos::HResult doorOpenedResult = body->isDoorOpened(ccos::vehicle::HDoorPosition::FRONT_LEFT, doorState);
if (doorOpenedResult == ccos::HResult::OK) {
cout << "Door Result: " << (doorState == ccos::vehicle::HTriState::TRUE ? "Open" : "Closed");
cout << endl;
} else {
cout << "isDoorOpened did not return OK. Actual return: " << HResultToString(doorOpenedResult);
cout << endl;
}
cout << "Finished door test";
cout << endl;
}
我所要做的就是……
现在我所要做的就是编译它。我使用 bulid 任务设置 VS Code,该任务使用 arm-linux-gnueabi-g++ 编译器,并将系统根目录设置为已安装的 system.img 文件的基础。我运行了该任务,但它给了我一个错误。
哎呀,是的,我不习惯 C++ 开发,忘记链接到 HBody 的库。原来它的名字叫HVehicle。我更新了构建任务以链接它并且……
是的,我对 C++ 还不够了解。经过一番谷歌搜索后,我发现我的编译器附带的 std 库太新,并且不包含 HVehicle 所需的特定版本。我最终制作了一个规格文件以避免包含默认库位置,并手动包含 /usr/lib/ 和 /usr/lib/arm-telechips-linux-gnueabi/4.8.1/ 文件夹。
再次运行构建任务并且……
最后,一个可以工作的(可能)构建!我将生成的二进制文件复制到我的 USB 驱动器并跳进我的车。
我启动了反向 shell,将二进制文件复制到 /tmp/ 目录,将其标记为可执行文件并运行它。
好的,很好。有一些日志垃圾邮件似乎来自 HBody,但它确实正确地报告了门已关闭。我打开门并再次运行,然后……
是的!我漂亮的应用程序正在运行。
现在是时候做一些更复杂的事情了。
#include <iostream>
#include <string>
#include "HBody.h"
#include "HChassis.h"
using namespace std;
namespace ccOSUtils
{
const char *HTriStateToString(ccos::vehicle::HTriState state)
{
switch (state)
{
case ccos::vehicle::HTriState::FALSE:
return "False";
case ccos::vehicle::HTriState::TRUE:
return "True";
case ccos::vehicle::HTriState::INVALID:
return "INVALID";
case ccos::vehicle::HTriState::MAX:
return "MAX";
default:
return "Other";
}
}
const char *HResultToString(ccos::HResult result)
{
switch (result)
{
case ccos::HResult::INVALID:
return "Invalid";
case ccos::HResult::OK:
return "OK";
case ccos::HResult::ERROR:
return "ERROR";
case ccos::HResult::NOT_SUPPORTED:
return "NOT_SUPPORTED";
case ccos::HResult::OUT_OF_RANGE:
return "OUT_OF_RANGE";
case ccos::HResult::CONNECTION_FAIL:
return "CONNECTION_FAIL";
case ccos::HResult::NO_RESPONSE:
return "NO_RESPONSE";
case ccos::HResult::UNAVAILABLE:
return "UNAVAILABLE";
case ccos::HResult::NULLPOINTER:
return "NULLPOINTER";
case ccos::HResult::NOT_INITIALIZED:
return "NOT_INITIALIZED";
case ccos::HResult::TIMEOUT:
return "TIMEOUT";
case ccos::HResult::PERMISSION_DENIED:
return "PERMISSION_DENIED";
case ccos::HResult::ALREADY_EXIST:
return "ALREADY_EXIST";
case ccos::HResult::SOME_UNAVAILABLE:
return "SOME_UNAVAILABLE";
case ccos::HResult::INVALID_RESULT:
return "INVALID_RESULT";
case ccos::HResult::MAX:
return "MAX";
default:
return "Other";
}
}
}
int main(int argc, char *argv[])
{
cout << "Ioniq Advanced Test Application" << endl;
if (argc == 1)
{
cout << "Provide at least 1 argument (doorStatus, doorLock, status, test)" << endl;
return 0;
}
ccos::vehicle::general::HBody *body = ccos::vehicle::general::HBody::getInstance();
string command = argv[1];
if (command == "doorStatus")
{
if (argc != 3)
{
cout << "Expected arguments: doorStatus {fl/fr/rl/rr}" << endl;
return 0;
}
string doorStr = argv[2];
ccos::vehicle::HDoorPosition doorPosition = ccos::vehicle::HDoorPosition::FRONT_LEFT;
if (doorStr == "fl")
{
doorPosition = ccos::vehicle::HDoorPosition::FRONT_LEFT;
}
else if (doorStr == "fr")
{
doorPosition = ccos::vehicle::HDoorPosition::FRONT_RIGHT;
}
else if (doorStr == "rl")
{
doorPosition = ccos::vehicle::HDoorPosition::REAR_LEFT;
}
else if (doorStr == "rr")
{
doorPosition = ccos::vehicle::HDoorPosition::REAR_RIGHT;
}
ccos::vehicle::HTriState doorState;
ccos::HResult doorOpenedResult = body->isDoorOpened(doorPosition, doorState);
if (doorOpenedResult == ccos::HResult::OK)
{
cout << "Door Result: " << (doorState == ccos::vehicle::HTriState::TRUE ? "Open" : "Closed");
cout << endl;
}
else
{
cout << "isDoorOpened did not return OK. Actual return: " << ccOSUtils::HResultToString(doorOpenedResult);
cout << endl;
}
}
else if (command == "doorLock")
{
if (argc != 3)
{
cout << "Expected arguments: doorLock {true/false}" << endl;
return 0;
}
string shouldBeLockedStr = argv[2];
ccos::HBool shouldBeLocked = false;
if (shouldBeLockedStr[0] == 't')
{
shouldBeLocked = true;
}
cout << "Setting Door Locks to: " << (shouldBeLocked ? "Locked" : "Unlocked") << endl;
ccos::HResult doorLockResult = body->requestDoorLock(shouldBeLocked);
if (doorLockResult == ccos::HResult::OK)
{
cout << "Door Lock Success" << endl;
}
else
{
cout << "Door Lock Failure: " << ccOSUtils::HResultToString(doorLockResult) << endl;
}
}
else if (command == "status")
{
ccos::vehicle::general::HChassis *chassis = ccos::vehicle::general::HChassis::getInstance();
ccos::HFloat odometerReading = 0;
chassis->getOdometer(odometerReading);
ccos::HFloat batteryVoltage = 0;
chassis->getBatteryVoltage(batteryVoltage);
ccos::HUInt8 percentBatteryRemaining = 0;
chassis->getRemainBattery(percentBatteryRemaining);
cout << "Vehicle Status:" << endl;
cout << "tOdometer: " << odometerReading << endl;
cout << "tBattery Voltage: " << batteryVoltage << "V" << endl;
cout << "tBattery Remaining: " << percentBatteryRemaining << "%" << endl;
}
else if (command == "test")
{
cout << "Testing methods that might not work" << endl;
ccos::HResult testResult;
cout << "tTesting Wireless Charging Pad State" << endl;
ccos::HUInt8 wirelessChargingPadState = 0;
testResult = body->getWirelessChargingPadState(wirelessChargingPadState);
cout << "tt" << ccOSUtils::HResultToString(testResult) << " - State: " << wirelessChargingPadState << endl;
cout << "tTesting Window State (Driver)" << endl;
ccos::vehicle::HWindowType windowType = ccos::vehicle::HWindowType::DRIVER;
ccos::vehicle::HTriState windowState;
ccos::HUInt8 windowDetail;
body->getWindowOpenState(windowType, windowState, windowDetail);
cout << "tt" << ccOSUtils::HResultToString(testResult) << " - State: " << ccOSUtils::HTriStateToString(windowState) << "Detail?: " << windowDetail << endl;
cout << "Completed testing methods that might not work" << endl;
}
else
{
cout << "Unknown Command" << endl;
}
cout << "Completed" << endl;
return 0;
}
我制作了一个更高级的应用程序,它允许我查询特定的门,锁定或解锁门,读取汽车的一些基本统计数据,并测试一些可能不起作用的方法(它们位于头文件中带有注释的部分下)说:“//未完成”:/)
好吧,回到 VS Code,运行构建任务,然后繁荣起来!
哦,好吧,事情发生了。我再次不知道它出了什么错误,我回到谷歌。这有点麻烦,但最终我发现这些库使用的是旧的 ABI,但幸运的是修复很容易。我所要做的就是将“-DGLIBCXXUSECXX11ABI=0”放入我的编译器参数中。
它终于编译好了,我把它扔到 IVI 上,它成功了!我能够运行门查询、锁定和解锁门以及运行测试。 (其实功能还没有完成)
自从我完成了基本的命令行程序之后,我就该开始学习 GUI 应用程序了。我花了很多时间尝试对某些事情执行变通办法,但这都是非常不必要的。所以我将在这里记录实际有效的方法。
通过我的逆向工程和研究,我知道系统中的所有 GUI 应用程序都是基于 Qt5 并使用 Helix(Wind River Systems 开发的应用程序管理器系统)。如果我想制作一个绝对有效的基于 GUI 的应用程序,我需要像所有其他应用程序一样将其完全合并到 Helix 系统中。
准备好QT
为了能够编译 Qt5 应用程序,我需要首先获得一个可用的 Qt 编译器设置。我花了太多时间试图避免自己编译 Qt,但最终这是最简单、最容易的路径。
为了正确设置 Qt5,我首先安装了 g++,然后抓取了 Qt 5.7.1 并将其解压,我想为 ARM 交叉编译设置 Qt5,所以我还从 Linaro 下载并安装了 GCC 4.9.4。我使用 Qt 5.7.1,因为我发现 IVI 上的本机应用程序使用 Qt 5.7,并使用 GCC 大约版本 4.9 进行编译。我想让我自己的应用程序的编译尽可能无缝,所以我使用了尽可能接近的版本,同时在兼容性破坏之前仍然拥有最新的补丁。
然后我尝试编译 Qt5,事实上有几次,但每次都遇到不同的错误。我发现的第一个错误是 Qt 正在将各种文件安装到我的 IVI 已安装的系统根映像中,但默认情况下该映像没有足够的空间来容纳它们。我使用以下命令使用 dd 将 system.img 的大小增加 1GB,然后调整 system.img 中文件系统的大小,以便能够通过 resize2fs 使用新添加的空间:
dd if=/dev/zero count=4 bs=256M >> system.img
sudo mount system.img system_image
FULL_SYSROOT_DIR=$(realpath system_image)
SYSROOT_MOUNT_DEVICE=$(df | grep $FULL_SYSROOT_DIR | awk '{print $1}')
sudo resize2fs $SYSROOT_MOUNT_DEVICE
我还遇到了一些其他错误,其中一个与缺少 libGLESv2 有关,我可以通过在系统映像中添加符号链接来修复该错误,以便 Qt 可以找到它。
cp system_image/usr/lib/libGLESv2.so.2 system_image/usr/lib/libGLESv2.so
接下来的错误是由于它无法编译 QtQuick,我不完全确定如何修复它,似乎大多数在网上遇到此错误的人只是跳过编译 QtQuick,所以这也是我所做的。最后我还不得不跳过编译 virtualkeyboard 模块,因为它也无法编译。解决这些问题后,我遇到了一个混乱的配置命令:
./configure -device arm-generic-g++ -device-option CROSS_COMPILE=/home/greenluigi1/QtDev/gcc-linaro-4.9.4-2017.01-x86_64_arm-linux-gnueabi/bin/arm-linux-gnueabi- -opensource -confirm-license -sysroot /home/greenluigi1/QtDev/system_image -skip declarative -nomake examples -skip virtualkeyboard
配置完成后,我运行“gmake -j4”并等待 Qt 编译。幸运的是,它有效,我能够运行“gmake install”。
自动化
当我知道一切正常后,我创建了几个脚本来完成大部分复杂的设置。因此,如果我将来需要设置开发环境,我所需要做的就是将脚本提取到一个新文件夹,将未修改的 system.img 文件复制到同一文件夹,然后运行 setupDevelopmentEnvironment.sh。
该脚本将下载并安装正确的交叉编译器 QtCreator,并编译正确版本的 Qt。由于 system.img 的挂载是临时的,因此我还包含了挂载脚本,因此在重新启动后和开发之前可以轻松重新挂载系统映像。
现在我已进入冲刺阶段,我只需要设置 QtCreator 即可使用我的设置。
QtCreator
QtCreator 是用于开发 Qt 应用程序的 IDE。我从 apt 安装了最新版本并开始配置它进行 D-Audio 编译。
在 QtCreator 的设置中,我设置了两个编译器,一个用于 C,一个用于 C++,并将它们指向我之前从 Linario 中提取的 GCC 安装。
然后,我将 Qt 安装添加到“Qt 版本”选项卡中,方法是将其指向 IVI 系统映像根目录中的 qmake 文件。
我通过添加一个名为 D-Audio 2 的新套件并将设置指向使用我的特定 Qt 版本和编译器来完成这一切。
我现在已准备好为 IVI 开发 GUI 应用程序。
还没有
嗯,我差不多准备好了。我仍然需要弄清楚如何将我的应用程序合并到应用程序管理器 Helix 中。所以我又做了一些逆向工程。我决定尝试在系统中找到我能找到的最简单的应用程序,并模仿它的设置方式。
我查看了系统中的所有 GUI 应用程序并寻找最小的应用程序。我选择了在 /usr/share/AMOS/EProfilerApp 中找到的 EProfilerApp。这是一个简单的 GUI 应用程序,根据名称,用于查看/管理 AMOS(内置系统分析工具)。我将 EProfilerApp 导入 IDA 并:
EProfilerApp 仍然保留了其调试信息!因此,对其进行逆向工程相对容易。这是我发现的:
每个 Helix 应用程序的 main() 函数如下所示:
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
MyApplication myApplication = MyApplication();
myApplication.init(app, argc, argv);
return app.exec();
}
QApplication 是 Qt5 中的普通 QApplication 类,并且像普通 Qt5 应用程序一样初始化。然后创建应用程序的 Helix 应用程序的实例,在本例中为 MyApplication。 MyApplication 继承自 Helix 的 ApplicationQt/Application 类。应用程序的 Application 类的唯一职责是创建 Helix 管理的组件。
Helix 中有 3 种不同类型的组件:
-
App Views
应用程序浏览次数
-
Represents a Qt5 screen/window that has controls a user can interact with. This includes full screen windows as well as popups. 表示具有用户可以交互的控件的 Qt5 屏幕/窗口。这包括全屏窗口和弹出窗口。
-
Is in charge of creating, showing, hiding, & destroying a normal Qt5 window. 负责创建、显示、隐藏和销毁普通的 Qt5 窗口。
-
App Services
应用服务
-
Represents a background process. 代表一个后台进程。
-
Event Receivers
事件接收器
-
Represents a handler for the various events which can be emitted throughout the system. 表示可以在整个系统中发出的各种事件的处理程序。
每个组件都有自己的名称,该名称遵循 Java 包样式命名约定(例如:com.mobis.caudio.setupApp.SetupAppView)。 Helix 调用应用程序的 Application 类并传入组件的名称。然后,Application 类检查名称并创建/返回正确的 AppView/AppService/EventReceiver 或 nullptr(如果名称无效)。
HELLO WORLD ,但没有按钮
现在是时候实际制作一个 GUI 应用程序了。可以在此处找到该应用程序的源代码。
该应用程序具有单个AppView,创造性地称为“ExampleGuiAppView”,其组件名称为“com.greenluigi1.guiExample.TestAppView”。此 AppView 创建一个带有 4 个按钮的简单窗口:
-
Lock
-
Locks all of the doors in the vehicle. 锁上车内所有车门。
-
Unlock
开锁
-
Unocks the doors in the vehicle. 打开车门。
-
Acts like the unlock of the key fob. Once press will unlock the driver’s side, two will unlock all doors. 其作用类似于钥匙扣的解锁。一旦按下将解锁驾驶员侧,按下两个将解锁所有车门。
-
Test
测试
-
Prints a test message to the Logcat log. 将测试消息打印到 Logcat 日志。
-
Exit
出口
-
Exits the application using the finish() function. 使用 finish() 函数退出应用程序。
我构建了它并且编译成功。现在是时候让它在真实的硬件上运行了。
不过在此之前我需要做几件事。我们需要“注册”应用程序,以便 Helix 可以看到它。 Helix 的应用程序管理器通过从 /etc/appmanager/appconf 目录读取 ini 文件来工作。每个 ini 文件都会告诉 Helix 应用程序的组件名称,列出每个 AppView、AppService 和 EventReceiver,并说明您的 EventReceiver 监听哪些事件。
标准应用程序配置如下所示:
[Application]
Name=com.company.grouping.appName
Exec=/usr/share/app-appName-1.0.0/appName
[TestAppService]
#ComponentName : com.company.grouping.appName.TestAppService
Type=AppService
[TestAppView]
#ComponentName : com.company.grouping.appName.TestAppView
Type=AppView
[TestEventReceiver]
#ComponentName : com.company.grouping.appName.TestEventReceiver
Type=EventReceiver
Event=com.mobis.caudio.ACTION.POWER_OFF
每个.appconf文件下面都有一个名为:“[Application]”的组,设置了应用程序的基础包名称。这让 Helix 知道,如果应用程序请求创建以该包名称开头的组件,它将被定向到您的应用程序。然后设置Exec,它是可执行文件本身的位置。
在“[应用程序]”组之后,可以跟随任意数量的组。每个组代表一个新组件。组的名称表示组件的名称,例如“[TestAppView]”表示它正在定义一个名为“TestAppView”的组件,或者在本例中更具体地说:“com.company.grouping.appName.TestAppView”。组件组下面是组件的具体设置。每个组件组都有一个 Type,可以是 AppView、AppService 或 EventReceiver。每种类型的组件都可以有自己的设置,例如,EventReceiver 类型具有 Event 属性,该属性是接收器订阅的事件的逗号分隔列表。以“#”开头的行是注释,Helix 会忽略它们。
我只需要制作自己的 .appconf 文件,这样我就可以启动我的应用程序。这就是我想出的:
[Application]
Name=com.greenluigi1.guiExample
Exec=/appdata/guiExample
[TestAppView]
# ComponentName : com.greenluigi1.guiExample.TestAppView
Type=AppView
它定义了一个名为“com.greenluigi1.guiExample”的应用程序,位于 /appdata/guiExample”,其中包含一个名为“com.greenluigi1.guiExample.TestAppView”的 AppView。现在我只需要在我的车上安装该应用程序并运行它。
我将编译的应用程序及其配置文件复制到我的 USB 驱动器上,并加载我的反向 shell。我将根目录安装为读/写,以便我可以修改配置文件夹。然后,我将 GuiExampleApp.appconf 配置文件复制到 /etc/appManager/appconf/ 文件夹中,并将应用程序本身复制到 /appdata/ 文件夹中。
然后我发送重新启动命令并等待 IVI 恢复。
现在我只需启动我的应用程序,但我该怎么做呢?从命令行运行该应用程序本身不会执行任何操作。我们需要告诉 Helix 启动它。
幸运的是,在我之前的调查中,我发现机器上已经安装了一个命令行工具来执行此操作:appctl。 appctl 是一个小程序,它允许您:
-
Start an App View/App Service 启动应用程序视图/应用程序服务
-
Usage: appctl startAppView {componentName} [args…]
-
Usage: appctl startAppService {componentName} [args…]
-
Finish an App View/App Service 完成应用程序视图/应用程序服务
-
Usage: appctl finishAppView {componentName}
-
Usage: appctl finishAppService {componentName}
-
Emit an Event
发出事件
-
Usage: appctl emitEvent {event} [args…]
所以我需要做的就是运行:
appctl startAppView com.greenluigi1.guiExample.TestAppView
答对了!我的应用程序正在运行。这些按钮也可以完美地工作,让我可以锁定或解锁我的门。我还在退出应用程序后转储了日志,并看到我的测试按钮日志和其他调试日志条目已成功写入 Logcat 文件中。
我现在可以完全控制我的汽车的 IVI,这当然是一种很好的感觉。不过,关于这个系统还有更多东西需要了解,当我找到更多信息时,我可能会发布更多关于它的帖子。
免责声明:
本公众号所有文章均为用于技术沟通交流,请勿用于其他用途,否则后果自负。
第二十七条:任何个人和组织不得从事非法侵入他人网络、干扰他人网络正常功能、窃取网络数据等危害网络安全的活动;不得提供专门用于从事侵入网络、干扰网络正常功能及防护措施、窃取网络数据等危害网络安全活动的程序和工具;明知他人从事危害网络安全的活动,不得为其提供技术支持、广告推广、支付结算等帮助
第十二条: 国家保护公民、法人和其他组织依法使用网络的权利,促进网络接入普及,提升网络服务水平,为社会提供安全、便利的网络服务,保障网络信息依法有序自由流动。
任何个人和组织使用网络应当遵守宪法法律,遵守公共秩序,尊重社会公德,不得危害网络安全,不得利用网络从事危害国家安全、荣誉和利益,煽动颠覆国家政权、推翻社会主义制度,煽动分裂国家、破坏国家统一,宣扬恐怖主义、极端主义,宣扬民族仇恨、民族歧视,传播暴力、淫秽色情信息,编造、传播虚假信息扰乱经济秩序和社会秩序,以及侵害他人名誉、隐私、知识产权和其他合法权益等活动。
原文始发于微信公众号(车联网攻防日记):【车联网】现代汽车Ioniq SEL渗透测试(4)