java审计-基础语法
1. 模式卡片(最重要)
每学一个漏洞类型,记一张”模式卡”:
1 | 【SQL 注入 - JDBC 拼接】 |
2. 危险函数清单
记一张表,不断往里加:
1 | | 漏洞类型 | 危险函数(Sink) | 项目中哪个文件出现过 | |
3. 审计 checklist
审计一个新文件时,养成固定习惯:
1 | □ 1. 这个文件有几个 @GetMapping / @PostMapping?(有多少接口入口) |
基础语法
A.文件骨架
项目数据流:
浏览器发起请求
↓
Controller(接收请求,比如 IDOR1.java)
↓
Mapper(去数据库查数据,比如 UserMapper.java)
↓
数据库
1、package:
1 | package com.best.hello.controller.IDOR; |
Java 要求:文件的物理路径必须和 package 声明一致。
审计用途:拿到一个项目后,看到 package 就能快速定位文件在哪。controller 包 = 接收请求的地方。
2、import
1 | import com.alibaba.fastjson.JSON; |
引用说明
审计用途:import 里如果有 com.alibaba.fastjson,要警觉——这个库历史上有反序列化漏洞。
3、注释
注释 // 和 /* */ 语法和c语言差不多。
审计用途:
- 废弃代码泄露:程序员把以前老的、有漏洞的登录接口注释掉了,但代码还在。
- 敏感信息泄露:程序员写着 // TODO: 这里测试用,默认密码是 admin123,或者 // 暂时把鉴权关掉,明天再开。在源码里全局搜索 TODO、test、password 等注释内容,往往有奇效。
4、class 类声明与文件名的死规矩
1 | public class IDOR1 { ... } |
审计用途:
如果类前面加了 public,那么这个类的名字,必须和这个文件的物理名字一模一样!
- 类叫 public class IDOR1,文件就必须叫 IDOR1.java。
- 审计用途:当在路由表或者其他配置文件里看到报错或者跳转指向了 IDOR1 类,在源码目录里直接搜索 IDOR1.java 这个文件就行,绝对不会叫别的名字。这让找文件极快。
5、{} 花括号 与 () 圆括号
语法:
- {}:圈定地盘。类有多大、方法有多长、if 逻辑管几行代码,全看花括号。
- ():装参数的
审计用途:
在查**逻辑漏洞(特别是越权、权限绕过)**时,{} 就是命门
很多水平差的程序员,写 if (isAdmin) 的时候,花括号 {} 圈错了位置,导致安全校验只管了上面一句话,下面真正干活的危险代码漏在花括号外面了,任何人都可以直接执行。
审计的时候可用鼠标点击以下{},看落在哪里。
B. 变量与数据
1. 基本数据类型 与 类型转换
语法点:
- Java 是强类型语言,数字就是 int,文本就是 String。
- Web 请求传进来的参数,永远都是 String(文本)!
- 类型转换:把文本变成真正的数字,需要用到强转语法,最常见的是 Integer.parseInt(文本)。
long(长整型数字):
- 是什么:就是超大号的 int。int 最多只能存 21 亿左右,如果是个时间戳,或者全球用户的唯一 ID,int 装不下,就会用 long。
- 审计视点:和 int 一样,只要强转成了 long,它自己就是安全的
- ⚠️ 注意边界:它保护的只是它自己。同一个 SQL 里如果还拼了别的没强转的参数,那些参数照样能注入。
double(双精度浮点数 / 小数):
- 是什么:用来存带小数点的数字(比如 3.14)。
- 审计视点(业务逻辑漏洞):在审“商城支付、充值”代码时,如果看到 double price,要极度警惕精度丢失或者负数金额导致的逻辑漏洞(比如黑客买东西传入价格为 -10.0,不但没扣钱,余额反而增加了)。
boolean(布尔型 / 真假):
- 是什么:全世界最简单的类型,它的值只有两个:true(真) 或 false(假)。
- 审计视点(越权漏洞核心):在看权限校验时,经常看到 boolean isAdmin = …。黑客的目标就是想尽一切办法(比如修改网页传参),让这个变量变成 true。
char(字符):
- 是什么:只能存单个字符,并且用单引号圈起来(比如 ‘A’、’1’)。它和 String(双引号 “ABC”)不一样,String 是一长串,char 只有一个字。审计中了解即可。
审计用途:
危险写法:
1 | // 黑客输入 1 OR 1=1,id 变成了文本 "1 OR 1=1" |
安全写法:
1 | String id = request.getParameter("id"); |
这种写法这里,如果输入 1 OR 1=1,到了 Integer.parseInt 这一步,Java 发现这根本不是纯数字,会当场抛出异常,这个请求直接失败,根本走不到下面执行 SQL 的地方。
(补一句:抛异常 ≠ 程序崩溃。程序本身不会死,死的只是这一次请求。但如果外面用 try-catch 把异常信息回显到了页面上,反而会变成信息泄露。)
看到参数被强转成了 int、long,说明这个变量自己是安全的,可以直接划掉看下一个。
但要记住边界:被保护的只是「这一个变量」。如果同一个 SQL 里还拼了别的没强转的参数,那些参数照样能注入 —— 这类「只净化了一半」的代码,比完全没做校验的更难发现,因为审计的人看到一行强转就放心了。
2. null 与空判断
语法:null 意思是这个变量是个空壳,里面什么都没有。
审计用途:不写空判断会引发崩溃,或者被用来绕过逻辑。
如果后端没写if对null的处理逻辑,直接就调用user.getName()然后抛nullPointerException(空指针异常), 结合try-catch,如果系统因此崩溃,说不定就会把底层路径和错误信息吐在网页上(信息泄露)。
3. 数组 []
语法:String[] ips = {“192.168.1.1”, “10.0.0.1”};
和 List 一样,是一长串数据的集合
4. static 和 final
语法:
- static(静态的):属于类本身的。调用时不需要 new,直接 类名.方法名()。
- final(最终的):被它修饰的变量,一旦赋值,这辈子都不能再改。
因为 static 是全局共享的,所以调用它的时候,不需要去 new 具体的实体,直接用类名去调用。
审计用途:
在代码里看到:public static final String SECRET_KEY = “abc123xxx”;
马上注意。这种叫**硬编码凭证(Hardcoded Credentials)**漏洞。密钥被写死在代码里了,以后只要源码泄露,所有用户的加密数据全废。
C. 方法与调用
1、构造方法
伴随 new 关键字一起执行的“出厂设置”代码。
语法:
- 方法名必须和类名一模一样!
- 绝不能写返回值(连 void 都不能写)
【有点类似c++】
示例:
1 | public class User { |
审计用途:
在 JSON 库反序列化(Fastjson、Jackson)里,黑客会利用目标类的构造方法或 setter 里的危险操作(比如一 new 出来就去连接恶意服务器)来搞事情。
(⚠️ 这里别记混:原生 Java 反序列化走的是 readObject(),它恰恰不调用构造方法 —— 这正是它反直觉的地方,也是它危险的原因之一。构造方法会被触发的是 JSON 库那条路线。阶段 5 会展开。)
2、方法重载 Overload
语法:
和override名称类似但不同。
- 重写 (Override):子类觉得父类的方法不好,同名同参数,推翻重做。(多态的基础)
- 重载 (Overload):同一个类里面,可以有名字一模一样,但参数不同的好几个方法。(方便程序员调用)【类似c++】
示例:
1 | public class Tool { |
D. 流程控制
这里很多之前在c++里学过,快速过一下。
1、三元运算符
语法:
条件?正确执行:错误执行
1 | String role; |
2、switch 与 break/continue
1 | switch(roleId) { |
3、while循环
语法略。
审计用途:如果在代码里看到 while,一定要去检查它有没有可能永远停不下来。如果黑客能构造一个永远停不下来的条件(死循环),服务器的 CPU 瞬间就会飙到 100%,导致网站瘫痪( ReDoS 或逻辑拒绝服务攻击)。
4、throw 和 throws
catch 是抓住异常,而 throw 是程序**主动“扔出(制造)”**异常。
1 | // 方法声明上加 throws,告诉别人:“我这方法可能会扔炸弹,调用我的人自己小心点” |
5、instanceof
判断对象是不是某个特定类型的类
1 | Object obj = 接收黑客传来的数据(); |
6、List和try-catch
示例代码:
1 | // 1. 列表 (List) |
payloads.add("admin' OR 1=1--"); payloads.add("1/0");
解读:调用对象 payloads 里的 add 方法添加 admin’ OR 1=1– 和 1/0
for (String data : payloads)
解读:每轮取出payloads里面的数据,命名为data用了循环
try-catch
**try { … }**尝试,把可能出问题、会崩溃的代码关在这个大括号里。Java 会小心翼翼地运行它
**catch (Exception e) { … }**抓住
- Exception:异常(所有的报错、崩溃在 Java 里都叫 Exception)。
- e:给抓住的这个报错信息起个代号
如果 try 里面的代码真崩溃了,程序不会直接闪退,而是会立刻跳进 catch 的大括号里,让程序员有个机会去处理报错
【很多开发人员在写 catch 的时候,为了调试方便,会直接把底层的报错信息(e.getMessage() 或者更可怕的 e.printStackTrace())直接通过网页返回给用户】
练习:
1 | // 这个方法接收用户传来的文件名清单 |
为了试探这台服务器系统内部路径,可以在fileName里面放一个假文件名,等待错误信息抛出。比如,”../../../non_existent_file.txt”
报错可能为:
1 | java.io.FileNotFoundException: /usr/local/tomcat/webapps/ROOT/../../../non_existent_file.txt (No such file or directory) |
7、finally(绝对执行)
跟在try和catch后面,无论代码是否报错/崩溃,finally里面的代码都会执行。
示例:
1 | try { |
审计:
先记住一个前提:关闭资源有三种写法,只有一种是危险的。 别看错方向。
| 写法 | 长什么样 | 安全吗 |
|---|---|---|
| try-with-resources | try (InputStream in = ...) { } |
✅ 安全,圆括号里的东西会自动关闭 |
| 手动 close 写在 try 末尾 | rs.close(); conn.close(); |
❌ 危险 |
| close 写在 finally 里 | finally { conn.close(); } |
✅ 安全 |
危险的是第二种:close 写在 try 块的最后几行。因为只要它上面任何一行抛异常,后面的 close 就一行都执行不到。
靶场里的 JDBC.java 就是第二种,真实行号:
1 | 66: ResultSet rs = stmt.executeQuery(sql); // 报错注入在这里抛异常 |
所以攻击方式是:疯狂发送会让第 66 行报错的请求(报错注入最合适)。每次报错都跳过了 close,连接只借不还,服务器的连接数很快就被耗尽,网站瘫痪(DoS)。
而 Traversal.java:40 是第一种写法,没有 finally,但完全安全:
1 | try (InputStream inputStream = new BufferedInputStream(Files.newInputStream(Paths.get(filePath)))) { |
【所以审计时:看到打开文件 / 连接数据库的对象,先看它有没有写在 try ( ... ) 的圆括号里(自动关,安全);如果没有,再看 close() 是写在 try 末尾(← 危险)还是 finally 里(← 安全)】
E. 面向对象
1、接口(Interface)与实现类(Implements)
定义接口:
1 | public interface UserService { |
解读:只要实现这个接口的实现类的都要有void deleteUser(int userId)功能
实现类:
1 | public class UserServiceImpl implements UserService { |
public class UserServiceImpl 普通的类,通常名为接口名+Impl
implements 关键字,实现、履行
UserService接口的名字
@Override 注解,以 @ 开头的叫注解(给编译器看的提示标签),覆盖、重写。告诉 Java:“我正在把菜单里规定的 deleteUser 方法,写出具体的逻辑来覆盖掉空壳”。
示例代码:
1 | // ==================== 角色1:前台窗口 (WebController 类) ==================== |
解读: UserService userService = new UserServiceImpl();
UserService接口名,
userService变量名,对应UserServiceImpl();这个实现类
这里顺便给这个写法起个正式名字:多态。
UserService userService = new UserServiceImpl(); 这一行,左边是接口类型,右边是具体实现类 —— 这就是多态:接口类型的变量,装的是实现类的对象,调方法时实际跑的是实现类的那一版。
审计意义:看到 XxxService xxxService = ... 时,光看左边不知道真正干活的是谁,必须看右边 new 的是哪个类。如果它是 @Autowired 注入的(没有 new),那就得手动去找谁实现了这个接口。
2、继承 extends(父与子)
示例:
1 | // 1. 父类(干爹):把通用的功能写好 |
类似C++的继承
权限:
- public(公开的):谁都能用,儿子当然也能用。
- private(私有的):父类的私房钱,就算是亲儿子(子类)也绝对不准碰,儿子甚至都不知道有这个东西。
- protected(受保护的/家族内部的):专为继承设计的权限外人不能碰,但是亲儿子(子类)可以随便用、随便改
但java不支持多继承,支持多重继承。【不能一个子拥有两个父,但是可以儿子有爸爸,爸爸有爷爷】
- 子类拥有父类非 private 的属性、方法。
- 子类可以拥有自己的属性和方法,即子类可以对父类进行扩展。
- 子类可以用自己的方式实现父类的方法。
但是 implements 可以让 java 变相拥有多继承特性 —— 一个类可以实现多个接口。
1 | public interface A { |
super 和 this 的引用:
this:指”当前这个对象自己”super:指”父类那一部分”,用来调用父类的方法或字段
final 的两层含义(只写”final 无法被继承”容易混,因为 final 也能修饰变量和方法):
final class X→ 这个类不能被继承final void m()→ 这个方法不能被重写final int a = 1→ 这个变量不能再被赋值
审计视点:记住这个句型 —— 父类里调用了一个方法,而这个方法被子类重写了,那实际跑的就是子类的那一版。
反序列化的 gadget 链,本质就是一串这样的”父类喊、子类答”接起来的。阶段 5 会展开。
3、abstract 抽象类
“半成品图纸”,绝对不能直接被 new 出来,只能留给儿子去继承(extends)并补全
1 | public abstract class Animal { |
**审计:**看到 abstract class 知道它是半成品,审计时必须直接去找它的子类(找谁 extends 了它)
4、匿名内部类
不想麻烦去专门写一个正规的类文件,直接就地捏一个没名字的一次性类。
示例:
1 | // 正常你要 new 一个接口的实现类,要先写个文件,这里不用: |
审计:
在 JNDI 注入(Log4j2 漏洞)或者反序列化的 Payload 里,经常用这种奇怪的语法,把恶意代码就地封装在一个没有名字的类里绕过检查。看到这种 new 接口名() { … } 知道是在原地造类。
5、enum 枚举
就像网页上的下拉选择框,只能从里面挑,不能乱填
1 | public enum Role { |
审计:
看到 enum,知道这是一个限定死的固定选项,黑客传别的东西进不去。
F. 集合
List在前面和trycatch一起写了,此处略。
1、键值对(Key-Value)
1 | // 1. 准备一本字典 |
Map<String, String> userParams = new HashMap<>();
Map也是接口,逻辑类似上文。只不过多了一个<String, String>
第一个 String 是 Key(键名),第二个 String 是 Value(键值)
userParams.put("username", "admin");
在userParams中新增目录为username的记录,对应内容:admin
String currentPass = userParams.get("password");
把目录名(Key)是 password 的具体内容查出来拿走。
练习:
1 | public void doLogin(Map<String, String> requestData) { |
当这个role等于admin的时候,会被触发这个高危语句
.equals(“admin”):在 Java 中,判断两段文本内容是否一样,不能直接用两个等号 ==,而是用 .equals()(意思是等于)
2、Set(去重清单)
也是清单,前面有List(单列清单)和 Map(双列字典)
这个清单也是单列清单,但是:绝对不允许有重复的东西
示例:
1 | Set<String> blacklistedIPs = new HashSet<>(); |
【开发人员喜欢用set存放黑名单ip或者用户的权限角色,审计时看到就知道不用另外去找有没有去重逻辑】
3、stream() (流式处理)
Java 8 之后程序员特别喜欢用的一种炫技版 for 循环
老写法:
1 | List<String> userList = Arrays.asList("guest", "admin_bob", "user1", "admin_alice"); |
新写法:
1 | List<String> userList = Arrays.asList("guest", "admin_bob", "user1", "admin_alice"); |
- .stream():把这个清单放到履带流水线上,准备开动。
- .filter(…):流水线上的安检机器(相当于 if)。
- 括号里的 name -> name.contains(“admin”) 叫做 Lambda 表达式(箭头语法)。
- 意思非常直白:“拿到一个 name ➡️ 判断它是否包含 admin,包含就放行,不包含就扔掉”。
- .forEach(…):流水线的终点打包员(相当于 for 循环体里面的代码,前面有filter时相当于循环内if里的语句,没有时就单纯循环体代码。)。
- 意思:“拿到顺利通过安检的 name ➡️ 把它打印出来”。
如果循环里不仅有 if,在 if 的前面或后面还有其他代码(比如先打个日志,再做判断),那流水线该怎么写?
放弃 .filter ,直接把整个包含** if 的复杂逻辑,原封不动地塞进 .forEach 的花括号 {} 里
1 | for (String name : userList) { |
1 | userList.stream().forEach(name -> { |
练习:
1 | package com.best.hello.controller; |
- 假设这个靶场网站的域名是 http://127.0.0.1:8080。作为黑客,如果你想触发这个带有漏洞的 jdbcVuln 方法,你应该在浏览器地址栏里输入的**完整 URL(包含传参)**是什么?
- 假设你想利用单引号 ‘ 闭合产生 SQL 注入。请把你构造的包含恶意测试内容(比如 admin’)的完整 URL 写出来,并说出程序控制台最终打印出来的 SQL 语句是什么样子的?
http://127.0.0.1:8080/sqli/jdbc/vuln?username=admin' or 1=1 -- -
SELECT * FROM users WHERE user = 'admin' or 1=1 -- -'
两个提醒:
- URL 里的空格和单引号严格来说要编码成
%20和%27。浏览器会自动帮你转,但以后用 Burp 或 curl 手工发请求时得自己写。 -- -这种注释写法是 MySQL 专用的(--后面必须跟一个空格)。换到别的数据库就不灵了,别记死。
基础知识二:
反射:是 Java 提供的一个“上帝模式”。它允许程序在运行的时候,仅仅凭借一段普通的文本字符串(比如 “java.lang.Runtime”),就能把内存里的某个类抓出来,哪怕方法是私有的(private),也能强行扒开调用
示例:
1 | // ================= 真正能跑通的反射弹计算器代码 ================= |
Class<?> clazz = Class.forName(hackerClassName);
**<?>**意思是java的占位符,未知。【因为 Class.forName(hackerClassName) 是通过传来的字符串去动态找类的。在代码运行之前,Java 根本不知道具体是什么类型】
forName(…),根据字符串的名字,把类加载进内存里面
注意:看到要警惕,如果括号内的东西是黑客可控的,那他就可以加载任意危险的类。
getMethod(…):根据字符串名字,从刚刚的类里面找出具体的方法/动作。
- ⚠️ 注意:
getMethod()只能拿到 public 方法。私有方法要用getDeclaredMethod()。 - 想调用私有方法,还得再加一句
method.setAccessible(true);—— 这才是前面说的”强行扒开”。这个动作在反射漏洞里非常关键。
.invoke(…):调用/激活,上面找了半天类和方法,到了 invoke 这里,代码才真正被执行
method.invoke(Runtime.getRuntime(), hackerCommand);
invoke(参数1:具体由哪个对象来干活?, 参数2:干活需要的原材料是什么?)
练习:
1 | public void testAPI(Map<String, String> userInput) { |
前面两步是获取具体的类模板名和方法名。
重点是这句的链式调用:==整行从左往右看,但遇到括号先算括号里面的==
targetClass.getMethod(methodName).invoke(targetClass.newInstance());
这句话的理解顺序:从左边开始,`targetClass类模板,调用getMethod()获取methodName这个方法,然后调用invoke执行这个方法,但在invoke这里的时候,先执行括号内的targetClass.newInstance()造出一个用来执行前面methodName的实体。类模板里的方法,需要对应的类里的实体才能执行。
这里有一个需要注意的点:
.invoke(…) 的第一个参数永远永远都是干活的实体,有两个参数的情况是,第一个参数为实体,第二个实体为干活需要的原材
【如果执行的是 static 静态方法,连工人都不需要,第一个参数直接填 null,这在高级漏洞利用里会经常出现】