输入验证与注入防护:SQL注入、命令注入、XSS与Intent注入
说实话,在Android安全领域摸爬滚打这么多年,注入漏洞一直是我最头疼的问题之一。为什么?因为它太容易出现了。你想想看,只要应用接收了外部输入,没有做好验证,就可能在不知不觉中把控制权交了出去。
我个人习惯把注入漏洞比作「后门钥匙」。攻击者通过精心构造的输入,绕过你的安全防线,直接操作底层系统。今天我们就来聊聊四种最常见的注入攻击,以及如何从代码层面彻底封死这些后门。
核心观点:一切输入都是不可信的。这是安全开发的铁律,没有例外。
一、SQL注入:数据库的隐形杀手
SQL注入在Web领域是老生常谈了,但在Android上同样致命。我记得有一次做安全审计,发现某个社交应用的本地数据库查询居然直接拼接用户输入。攻击者只要在搜索框里输入 ' OR 1=1 --,就能把整个用户表拖出来。
为什么会这样?说白了,就是代码把用户输入当成了SQL语句的一部分,而不是单纯的数据。
危险代码示例:
// 千万不要这样写!
String query = "SELECT * FROM users WHERE name = '" + userName + "'";
Cursor cursor = db.rawQuery(query, null);
正确的做法是什么?参数化查询。这是最根本的解决方案。
// 安全写法:使用参数化查询
String query = "SELECT * FROM users WHERE name = ?";
Cursor cursor = db.rawQuery(query, new String[]{userName});
如果你用的是Room,那就更省心了。Room在编译时就会生成参数化查询的代码,你只要用 @Query 注解写SQL就行。
我的经验:在ContentProvider中也要注意。如果Provider暴露给外部应用,一定要检查传入的URI参数。我曾经见过一个Provider直接把URI参数拼接到SQL里,结果第三方应用可以随意查询数据库。
二、命令注入:别让用户控制你的终端
命令注入在Android上相对少见,但一旦出现就是灾难。你想想看,如果攻击者能通过你的应用执行 rm -rf / 或者 pm uninstall,那后果不堪设想。
我遇到过最典型的场景:某个文件管理器应用,用户选择文件后调用 Runtime.getRuntime().exec("chmod 777 " + filePath)。攻击者只要把文件名改成 test; rm -rf /sdcard/,就能执行任意命令。
高危操作:
// 绝对不要这样做!
String cmd = "ls -l " + userInput;
Process process = Runtime.getRuntime().exec(cmd);
防护方案其实很简单:
- 能不执行命令就别执行。大部分功能都有Java API替代方案
- 必须执行时,用白名单。只允许预定义的命令和参数
- 对参数做转义。去掉
; & | $ `等特殊字符
// 安全做法:白名单+参数校验
private static final Set<String> ALLOWED_COMMANDS =
new HashSet<>(Arrays.asList("ls", "cat", "echo"));
public void safeExec(String command, String arg) {
if (!ALLOWED_COMMANDS.contains(command)) {
throw new SecurityException("命令不在白名单中");
}
// 对参数做严格校验
if (arg.contains(";") || arg.contains("|")) {
throw new SecurityException("参数包含非法字符");
}
Process process = Runtime.getRuntime().exec(
new String[]{command, arg}
);
}
避坑指南:我曾经用 ProcessBuilder 替代 Runtime.exec(),因为前者支持传入字符串数组,不会经过shell解析,天然防注入。但要注意,如果用了 ProcessBuilder("sh", "-c", cmd),那又回到shell环境了,等于白费功夫。
三、XSS攻击:WebView里的暗箭
XSS(跨站脚本攻击)在Android上主要出现在WebView场景。你的应用加载了网页内容,如果这些内容包含恶意脚本,攻击者就能通过JavaScript偷取用户数据、劫持会话。
我记得有个电商应用,商品详情页用WebView展示HTML描述。攻击者在商品描述里嵌入了 <script>stealCookie()</script>,结果所有浏览该商品的用户cookie都被窃取了。
防护措施分几个层面:
| 防护层面 | 具体措施 | 说明 |
|---|---|---|
| WebView配置 | 禁用JavaScript | 不需要JS交互时,直接关掉 |
| 内容编码 | HTML实体编码 | 将 < 转为 < |
| CSP策略 | Content Security Policy | 限制脚本来源和执行 |
| 输入过滤 | 移除script标签 | 使用白名单HTML解析器 |
// WebView安全配置示例
WebView webView = findViewById(R.id.webview);
WebSettings settings = webView.getSettings();
// 不需要JS就关掉
settings.setJavaScriptEnabled(false);
// 如果必须用JS,至少禁用文件访问
settings.setAllowFileAccess(false);
settings.setAllowContentAccess(false);
// 加载内容时做HTML编码
String safeHtml = TextUtils.htmlEncode(userInput);
webView.loadDataWithBaseURL(null, safeHtml,
"text/html", "UTF-8", null);
特别注意:如果使用了 addJavascriptInterface,一定要在API 17以上用 @JavascriptInterface 注解标记暴露的方法。低于API 17的设备,所有公有方法都会暴露给JS,这是巨大的安全漏洞。
四、Intent注入:组件间的陷阱
Intent注入是Android特有的问题。当你的应用通过Intent接收外部数据,或者发送隐式Intent时,攻击者可能构造恶意的Intent数据来操纵你的应用行为。
我印象最深的一个案例:某个支付应用通过隐式Intent启动支付页面,攻击者构造了一个恶意Intent,把支付金额改成了0.01元,然后自己发起支付请求。虽然金额在后端有校验,但前端显示已经被篡改了。
防护方案其实不复杂:
- 使用显式Intent。明确指定目标组件,不要用action匹配
- 检查Intent来源。通过
getCallingPackage()验证调用者 - 对Intent中的extra数据做校验。类型检查、范围检查
// 安全接收Intent
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = getIntent();
// 检查调用者身份
String callingPackage = getCallingPackage();
if (!"com.trusted.app".equals(callingPackage)) {
// 记录异常,拒绝处理
return;
}
// 对extra数据做严格校验
String action = intent.getStringExtra("action");
if (!isValidAction(action)) {
throw new IllegalArgumentException("非法操作");
}
// 使用显式Intent启动组件
Intent safeIntent = new Intent(this, TargetActivity.class);
safeIntent.putExtra("data", sanitizedData);
startActivity(safeIntent);
}
我的习惯:对于暴露给外部应用的组件,我通常会在AndroidManifest中设置 android:exported="false",除非确实需要外部调用。如果必须暴露,就加上自定义权限,并在代码中做权限检查。
总结:输入验证的黄金法则
说了这么多,其实核心就一句话:永远不要信任外部输入。不管是用户输入的文本、来自其他应用的Intent,还是网络请求返回的数据,都要经过严格的验证和清洗。
我个人习惯在应用的入口层(Activity、Service、BroadcastReceiver、ContentProvider)统一做输入验证,而不是分散在各个业务逻辑里。这样既容易维护,也不容易遗漏。
嗯,最后提醒一点:安全防护不是一劳永逸的。攻击手法在进化,我们的防护也要持续更新。定期做代码审计、关注Android安全公告,这些都是必修课。
公众号:蓝海资料掘金营,微信deep3321