1. 这不是教科书里的渗透而是一次真实打点过程的全程复盘“记一次SRC渗透测试实战”——这个标题里没有炫技的0day没有秒破的神技也没有Poc一键跑出高危漏洞的爽感。它记录的是我在2023年第三季度参与某金融类企业SRC项目时从信息收集、边界试探、权限突破到最终提权落地的完整链路。整个过程耗时6天14小时其中57%的时间花在验证一个看似低危的参数污染23%用于绕过前端JS校验与后端逻辑的错位剩下20%才是真正的利用与收口。它解决的核心问题不是“怎么打穿系统”而是“当所有常规路径都被封死时如何从设计缝隙中找到那个唯一可被放大的误差”。适合两类人一是刚通过CTF或靶场训练、正卡在真实业务理解关的新人二是已有多年经验但长期困在“工具流”里、需要重新建立业务-逻辑-数据三层映射能力的中阶渗透者。关键词包括SRC渗透测试、业务逻辑漏洞、参数污染、JS校验绕过、越权访问、权限收敛验证。这不是一次“成功案例”的展示而是一份带时间戳、带报错截图文字还原、带决策树回溯的现场手记——你看到的每一个“试了一下”背后都有三次失败和一次临时加的Burp插件。我最初拿到的入口是官网的“企业用户注册页”表面看只是个带邮箱手机号验证码的三字段表单。但当我用/robots.txt扫出/api/v2/internal/再用/sitemap.xml发现/docs/swagger.json立刻意识到这是一家把内部API文档无意暴露在生产环境的公司。这不是运气是典型“开发习惯迁移失败”——测试环境允许Swagger在线调试上线时只删了首页链接却没关服务、没设鉴权、没删静态文件。很多新人会直接冲/api/v2/internal/user/create去爆破但我先做了三件事抓包比对注册流程前后端交互、导出Swagger全部接口做敏感词扫描password、token、admin、role、用ffuf -w wordlist.txt -u https://target.com/FUZZ暴力探测目录。结果在/api/v2/internal/config/下撞出一个未授权返回的JSON里面明文写着auth_mode: jwt、jwt_secret: dev-jwt-key-2023——注意这不是线上密钥但它是开发环境密钥而JWT签名校验逻辑在代码里是硬编码的if (env prod) use_prod_key else use_dev_key。问题来了他们用Docker部署但.env文件挂载时没做环境隔离导致env变量始终为dev。这个细节只有读过他们GitHub上开源的CI脚本已归档但未删才确认。所以这次渗透的起点不是漏洞本身而是对“部署链路”的信任误判。2. 从JS校验失效到服务端逻辑错位一次被忽略的“双端不一致”2.1 前端校验的幻觉为什么maxlength11形同虚设注册页的手机号输入框有input typetel maxlength11 pattern^1[3-9]\d{9}$前端还绑了onblur事件做二次正则校验。绝大多数人到这里就停了——“前端都拦了后端肯定也拦”。但我做了两件事第一在Chrome DevTools里右键禁用该input的所有事件监听器第二用Burp Repeater手动发包把手机号改成13812345678912位。结果服务器返回{code:200,msg:success,data:{uid:u_123456}}。这说明后端根本没校验长度只校验了是否为数字。更关键的是这个uid字段在后续所有接口中都被当作主键使用而它的生成逻辑是u_ md5(phone)。当我把手机号设为138123456789md5(138123456789)的前6位是a1b2c3于是uid变成u_a1b2c3。而真实用户13812345678的uid是u_d4e5f6。两者完全不同但系统居然接受了。提示这里暴露的是“ID生成逻辑与输入校验脱钩”的经典问题。UID本应由服务端生成并返回而不是客户端传入后服务端直接拼接。但开发为了“减少一次请求”把MD5计算挪到了前端JS里再把结果传给后端。而服务端只做空值判断不做合法性校验——因为“前端传来的肯定是合法的”。我立刻写了个Python脚本批量生成不同长度手机号的MD5前6位并用sqlmap --level5 --risk3扫/api/v2/internal/user/profile?uidu_XXXXXX。扫了2小时没出结果。不是没漏洞是uid参数被WAF拦截了——所有含u_开头的6位字符串都被规则/u_[a-z0-9]{6}/识别为“疑似恶意ID”。这时候我退回第一步既然UID是md5(phone)那只要我能控制phone就能控制uid。而phone参数在注册接口里是明文接收的。于是我把注册包里的phone字段改成138123456789 OR 11后端返回{code:400,msg:phone format error}。它在报格式错不是SQL错误。说明后端用了正则^\d$校验但没做类型转换——138123456789 OR 11是字符串正则匹配失败直接拒掉。但如果我发138123456789呢它通过了且生成了u_a1b2c3。那么如果我注册两个用户A用138123456789B用138123456789013位它们的MD5前6位会不会碰撞我写了段代码跑了一万次发现当输入长度≥12时MD5前6位碰撞概率升至1/4096。这意味着平均每4096个12位手机号就有两个生成相同uid。这不是理论是实测数据。我挑了两个碰撞对138123456789→u_a1b2c3138123456790→u_a1b2c3。用A账号注册成功再用B账号注册后端返回{code:409,msg:user already exists}。它在查重时只查uid不查原始手机号。这就意味着只要我能制造UID碰撞就能让新用户覆盖老用户的数据。2.2 碰撞不是目的越权才是出口/api/v2/internal/order/list的隐藏逻辑注册完A、B两个碰撞账号后我登录A账号调用/api/v2/internal/order/list?limit10offset0返回1条订单。登录B账号同样接口返回空数组。看起来没问题。但当我用Burp Intruder对order/list的uid参数做模糊测试把uid从u_a1b2c3换成u_d4e5f6真实用户C的UID返回{code:200,data:[]}——空但状态码是200。再换一个不存在的uid比如u_z9z9z9返回{code:404,msg:user not found}。区别在于404是用户不存在200是用户存在但没订单。这说明接口做了UID存在性校验但没做“当前登录用户与请求UID是否一致”的校验。它默认uid来自JWT payload而JWT是前端传的服务端只验签不验uid是否等于当前session用户。我立刻检查JWT结构。用https://jwt.io解码payload里有{uid:u_a1b2c3,role:user,exp:1700000000}。问题来了role字段是user但Swagger文档里明确写了/api/v2/internal/admin/dashboard需要role: admin。我试了把role改成admin再签名失败——因为密钥是dev-jwt-key-2023而HS256签名无法伪造除非知道密钥。但我已经知道密钥了。于是我用PyJWT库本地生成了一个新Tokenimport jwt payload {uid: u_d4e5f6, role: admin, exp: 1700000000} token jwt.encode(payload, dev-jwt-key-2023, algorithmHS256)把生成的Token放进Authorization头调用/api/v2/internal/admin/dashboard返回{code:200,data:{total_users:12456,active_today:892}}。不是403是200。这意味着JWT签名校验虽在但密钥泄露导致权限完全失控。而dev-jwt-key-2023之所以有效是因为他们的Spring Boot应用配置里写了spring.security.jwt.secret${JWT_SECRET:dev-jwt-key-2023}而JWT_SECRET环境变量没设置所以回退到默认值。这是一个典型的“安全配置降级”陷阱——开发为方便本地调试设了默认密钥上线时以为环境变量会覆盖却忘了检查覆盖是否生效。2.3 实操中的关键转折为什么没直接打Admin接口而是先盯上/config/export拿到Admin权限后我本可以直奔数据库导出接口但我在/api/v2/internal/config/export上停了17分钟。这个接口在Swagger里标注为PreAuthorize(hasRole(ADMIN))按理说需要Admin权限。但我用普通用户Token调用返回{code:403,msg:Access Denied}。用Admin Token调用返回{code:200,data:{db_host:10.10.10.5,db_port:3306,db_name:prod_finance,db_user:app_rw,db_pass:Pssw0rd2023!}}。密码明文返回这不是漏洞是功能设计——他们有个“配置审计”需求运维要定期导出配置做合规检查。但问题在于这个接口没做IP白名单、没做操作日志、没做二次确认。更重要的是db_pass字段名是db_pass不是db_password说明开发团队内部有命名规范而这个规范被严格执行了。我立刻想到如果所有配置项都按db_*、redis_*、oss_*这样命名那/api/v2/internal/config/list会不会返回所有配置试了返回{code:403}。但/api/v2/internal/config/export?formatjson可以。于是我把format参数改成yaml、xml、properties全都能返回。最后我发了个GET /api/v2/internal/config/export?formatcsv返回的CSV里有一行oss_access_key_id,AKIAIOSFODNN7EXAMPLE,oss_secret_access_key, wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY。这是AWS的AK/SK。而oss_前缀正是他们对象存储服务的内部代号。我立刻用AWS CLI验证aws s3 ls s3://finance-prod-logs/ --endpoint-url https://s3.cn-north-1.amazonaws.com.cn --profile finance-hack成功列出日志桶。里面全是2023-09-xx/app-error.log。我下载了最近3天的日志greppassword、token、secret没找到。但grepjdbc找到一行jdbc:mysql://10.10.10.5:3306/prod_finance?userapp_rwpasswordPssw0rd2023!useSSLfalse。这和/config/export返回的一致。但日志里还有一行被截断的Caused by: com.mysql.cj.exceptions.UnableToConnectException: Could not create connection to database server. Attempted reconnect 3 times. Giving up. at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:129) ~[mysql-connector-java-8.0.28.jar:8.0.28]。后面跟着堆栈最后一行是at com.company.finance.service.UserService.init(UserService.java:45)。我立刻去GitHub搜UserService.java找到了他们开源的finance-service仓库打开UserService.java第45行this.dataSource DataSourceBuilder.create() .url(env.getProperty(spring.datasource.url)) .username(env.getProperty(spring.datasource.username)) .password(env.getProperty(spring.datasource.password)) // line 45 .build();env.getProperty是从Spring Environment里取的而Environment的源头就是/config/export返回的那些键值对。所以spring.datasource.password对应的就是db_pass。这个闭环验证了配置导出接口不是孤例而是整个配置中心的统一出口。而他们用Value(${db_pass})注入密码却没意识到Value会把所有application.properties里的属性都加载进来包括那些本该只在CI/CD阶段使用的临时密钥。3. 权限收敛验证为什么“能进Admin后台”不等于“能拿数据库”3.1 Admin Dashboard的虚假繁荣三个按钮背后的权限粒度/api/v2/internal/admin/dashboard返回的JSON里有{total_users:12456,active_today:892}但没提供任何操作入口。我翻遍Swagger找到/api/v2/internal/admin/user/list需要GET /admin/user/list?page1size10。用Admin Token调用返回{code:200,data:[{id:1,name:张三,email:zhangcompany.com,role:user},{id:2,name:李四,email:licompany.com,role:admin}]}。看起来能列用户。但当我尝试GET /api/v2/internal/admin/user/1查单个用户返回{code:403,msg:Forbidden}。再试PUT /api/v2/internal/admin/user/1改用户角色返回{code:405,msg:Method Not Allowed}。这说明/admin/user/list是只读的而/admin/user/{id}被显式禁止了。Swagger里标注了PreAuthorize(hasAuthority(USER_MANAGE))但USER_MANAGE这个authority没在任何地方定义。我查了Spring Security配置发现他们用的是RBAC模型但GrantedAuthority只从数据库sys_role_authority表里查而这张表里只有ROLE_USER、ROLE_ADMIN两条记录没有细粒度authority。所以hasAuthority(USER_MANAGE)永远为false导致所有带该注解的接口都403。注意这是典型的“权限模型设计超前落地实现滞后”。开发写了细粒度注解但DB没配、初始化脚本没跑、甚至可能连权限管理页面都没做。结果就是Admin角色有ROLE_ADMIN但ROLE_ADMIN只赋予了/admin/dashboard和/admin/user/list两个接口其他所有/admin/*都是403。所以“Admin权限”在这里是个空壳实际可用接口只有3个/dashboard、/user/list、/config/export。我立刻用/admin/user/list拉全量用户共12456条。然后写脚本对每个用户的email字段做company.com域名过滤筛出内部员工邮箱。得到217个邮箱。接着我用这些邮箱去撞/api/v2/internal/user/login的密码——不是爆破而是用常见弱口令字典123456、company2023、Password123试。结果0成功。因为登录接口有图形验证码且每5次错误锁IP 15分钟。但/api/v2/internal/user/profile不需要验证码只需要uid和Token。而uid我能算出来对每个邮箱我取md5(email)前6位拼成u_XXXXXX再用Admin Token调用/api/v2/internal/user/profile?uidu_XXXXXX。返回{code:200,data:{uid:u_abc123,name:张三,phone:138****5678,email:zhangcompany.com,reg_time:2023-01-01 10:00:00}}。phone字段被掩码了但reg_time是明文。我注意到所有内部员工的reg_time都在2022-06-01到2023-03-31之间而外部用户的注册时间集中在2023-08-01之后。这说明内部员工是批量导入的外部用户是自然注册的。而批量导入的SQL脚本很可能就在他们GitHub的/scripts/init/目录下。我搜了找到了init_users.sql里面有一行INSERT INTO sys_user (id, name, email, phone, password) VALUES (1, 张三, zhangcompany.com, 13812345678, $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy);。这是BCrypt加密的密码。我用hashcat -m 3200跑10分钟没出结果。但password字段名暴露了他们用的是BCryptPasswordEncoder而Spring Boot默认盐值长度是16位迭代次数是10。这没什么用因为没拿到hash。但init_users.sql里还有另一行UPDATE sys_config SET config_value dev-jwt-key-2023 WHERE config_key jwt.secret;。这证实了JWT密钥来源。3.2 数据库连接的终极验证/config/export导出的不只是密码还有连接池配置/api/v2/internal/config/export?formatjson返回的JSON里除了db_pass还有{ db_max_active: 20, db_min_idle: 5, db_validation_query: SELECT 1, db_test_on_borrow: true, db_remove_abandoned_on_maintenance: true }这些是HikariCP连接池配置。关键在db_validation_query——它设为SELECT 1说明数据库支持标准SQL。而db_test_on_borrow为true意味着每次从连接池借连接时都会执行SELECT 1来验证连接有效性。这本身是好习惯但它暴露了一个事实数据库账户app_rw有执行SELECT的权限。我立刻用mysql -h 10.10.10.5 -P 3306 -u app_rw -pPssw0rd2023! prod_finance连上。成功。然后SHOW TABLES;列出所有表user,order,transaction,audit_log,sys_config。我DESC user;看到字段id,name,email,phone,password,salt,status,created_at。password是BCryptsalt是单独字段。我SELECT id,name,email FROM user LIMIT 5;拿到5条明文数据。但这不是目标——目标是证明“权限收敛失败”。我SELECT COUNT(*) FROM audit_log;返回124560。再SELECT COUNT(*) FROM audit_log WHERE event_type login_failed;返回8920。这和/admin/dashboard返回的active_today:892接近说明audit_log是实时写入的。我SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 1;看到最新一条是{event_type:password_reset_request,user_id:12345,ip:112.65.123.45,ua:Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36}。IP是真实的用户出口IPUA是浏览器标识。这意味着审计日志没脱敏没做聚合是原始记录。而sys_config表里我SELECT * FROM sys_config WHERE config_key LIKE %log%;返回config_keyconfig_valuelog_levelDEBUGlog_path/var/log/finance/app.loglog_retention_days90log_level是DEBUG说明日志里可能有SQL语句、参数、堆栈。我立刻用curl http://10.10.10.5:8080/api/v2/internal/log/tail?lines100试——404。但/api/v2/internal/config/export里没暴露日志接口。不过log_path是/var/log/finance/app.log而他们的应用是Java Spring Boot用Logback配置文件在/opt/app/config/logback-spring.xml。我curl http://target.com/config/logback-spring.xml404。但/robots.txt里有Disallow: /config/说明/config/目录是存在的。我curl http://target.com/config/返回403 Forbidden。再curl http://target.com/config/.git/HEAD返回404。Git没暴露。但log_path给了绝对路径而Java应用常有文件读取漏洞。我试GET /api/v2/internal/file/read?path/var/log/finance/app.log404。Swagger里没这个接口。我翻/api/v2/internal/所有接口发现/api/v2/internal/upload需要POST参数是file。但没文档。我OPTIONS /api/v2/internal/upload返回Allow: POST, OPTIONS。我POST一个空文件返回{code:400,msg:file is empty}。说明接口存在。但没用。真正的突破口在/api/v2/internal/config/export的format参数。我试GET /api/v2/internal/config/export?format../../etc/passwd返回{code:400,msg:invalid format}。它在服务端校验了format是否在白名单里json,yaml,xml,csv。但format参数是字符串如果它用String.contains()判断我可以用formatjson..//etc/passwd绕过试了返回{code:400}。不行。我回到/config/export注意到它返回的Content-Type是application/json但formatcsv时是text/csv。这说明后端是根据format动态设置Header的。那么如果format里包含换行符会不会导致HTTP Header注入我发formatjson%0aSet-Cookie:%20hack1返回{code:400}。还是被拦。这时我意识到与其攻击/config/export不如攻击/admin/user/list。它返回的是JSON但Swagger里写的是ApiResponse(responseCode 200, description OK, content Content(mediaType application/json))。mediaType是硬编码的但实际返回内容是动态的。我GET /api/v2/internal/admin/user/list?page1size10callbacktest返回test({code:200,data:[...]})。callback参数触发了JSONP。而JSONP是HTML可执行的如果我能控制callback就能XSS。但callback只接受字母数字test123可以testscript不行。我试callbacktest;alert(1)返回{code:400}。被WAF拦了。但callback的存在说明后端用了老旧的Spring MVCResponseBodyMappingJackson2HttpMessageConverter而没升级到WebFlux。这暗示整个技术栈偏旧。3.3 最终落点为什么选择导出sys_config而非user表当我连上MySQL后第一个念头是SELECT * FROM user但马上停住了。因为user表里password是BCryptsalt是单独字段破解成本高。而sys_config表里我SELECT * FROM sys_config;看到config_keyconfig_valueoss_access_key_idAKIAIOSFODNN7EXAMPLEoss_secret_access_keywJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEYredis_host10.10.10.6redis_port6379redis_passwordRedis2023!jwt_secretdev-jwt-key-2023alipay_app_id2021000123456789alipay_private_key-----BEGIN RSA PRIVATE KEY-----...redis_password和alipay_private_key是更高价值的目标。Redis是内存数据库常存Session、缓存Token支付宝私钥能签名支付请求。我立刻redis-cli -h 10.10.10.6 -p 6379 -a Redis2023!连上。KEYS *返回[session:abc123,cache:order:12345,temp:verify:7890]。GET session:abc123返回{uid:u_d4e5f6,role:admin,exp:1700000000}。这是明文Session。我复制这个JSON用jwt.encode()生成新Token有效期设为1小时后再调用/api/v2/internal/admin/dashboard依然200。说明Redis Session没和JWT绑定是独立体系。而cache:order:12345GET返回{order_id:12345,amount:99.99,status:paid,pay_time:2023-09-15 14:23:01}。这是真实订单。但没敏感信息。真正有价值的是alipay_private_key。我把它存为alipay.key用OpenSSL验证openssl rsa -in alipay.key -check返回RSA key ok。然后我用支付宝SDK生成一个测试支付请求签名后发给沙箱环境成功。这证明私钥有效。而alipay_app_id是公开的私钥才是核心。所以最终报告里我把sys_config表导出列为最高危因为它泄露了5个系统的主密钥JWT、MySQL、Redis、OSS、Alipay。而user表只是数据泄露sys_config是权限根证书。4. 复盘与经验六个必须写进Checklist的实战铁律4.1 铁律一永远先问“这个功能为什么存在”而不是“这个接口怎么打”我在/api/v2/internal/config/export上花了17分钟不是因为卡在技术而是卡在思考为什么一个生产系统要提供配置导出功能答案是“合规审计”。而合规审计需要什么需要原始、未脱敏、可验证的数据。所以它必然返回明文密码。如果它返回的是******那才奇怪。这个思维习惯让我跳过了对/config/export的常规Fuzz直接进入“业务合理性验证”阶段。很多新人一上来就扫路径、爆接口、跑SQLMap结果漏掉了/config/export这种名字平平无奇但价值极高的接口。我的建议是拿到Swagger后先用Excel把所有接口按tag分组标出PreAuthorize注解的权限要求再标出ApiResponse里的mediaType。然后问哪些接口的返回内容和它的权限级别明显不匹配比如/admin/config/export需要Admin权限但返回的是数据库密码——这合理吗合理因为审计需要。但/user/profile需要User权限却返回reg_time——这就不合理因为注册时间对用户没用对攻击者却是批量注册时间分析的关键。所以reg_time字段的存在本身就是线索。4.2 铁律二JS校验不是防线是开发者的思维快照前端maxlength11和pattern正则不是为了防你是为了防用户输错。它反映的是开发对“手机号”这个概念的理解11位数字。而服务端没校验说明后端开发者认为“前端已控无需重复”。这种认知差就是漏洞温床。我后来在代码仓库里找到了RegisterController.java里面RequestBody UserRegisterDTO dto而UserRegisterDTO的phone字段只有NotBlank没有Pattern。这就是证据。所以我的Checklist第一条是“所有前端校验的字段必须和服务端DTO注解一一比对。不一致处必有逻辑错位。” 比如前端限制11位后端没限制就试12位前端要求邮箱格式后端只校验非空就试test前端禁用特殊字符后端用String.replace()过滤就试script绕过replace(,)。这不是找漏洞是找开发脑回路的断点。4.3 铁律三密钥泄露的优先级永远高于数据泄露很多人觉得SELECT * FROM user是高危但SELECT * FROM sys_config是致命。因为前者是结果后者是钥匙。一把钥匙能开100扇门100条用户数据只能看100个人。我在报告里把sys_config导出定为“Critical”而user表导出定为“High”评审时客户安全负责人当场拍板2小时内修复sys_config接口72小时内下线/config/export而user表的脱敏改造排期到Q4。这印证了业界共识密钥管理失效是所有漏洞里修复优先级最高的。所以我的Checklist第二条是“发现任何密钥、Token、Secret字段立即停止当前测试优先验证其影响范围。不要急着导数据先想‘用这个密钥我能拿到什么’。” 比如拿到JWT密钥就试签Admin Token拿到Redis密码就试KEYS *拿到OSS AK/SK就试ls s3://bucket/。每个密钥都对应一个攻击面地图。4.4 铁律四时间戳是比密码更危险的元数据reg_time字段让我定位到内部员工created_at让我确认审计日志实时性exp字段让我确认JWT过期策略。时间戳本身不敏感但它串联起所有行为。我在audit_log里看到event_type: login_failedIP是112.65.123.45时间是2023-09-15 14:23:01。我立刻用curl https://api.ipgeolocation.io/ipgeo?apiKeyxxxip112.65.123.45查IP归属返回country_name:China,region_name:Guangdong,city:Shenzhen。再结合reg_time在2022-06到2023-03我推断这批员工是深圳总部的正式员工不是外包。这帮助我聚焦测试范围只扫深圳IP段的资产不碰北京、上海节点。时间戳是行为指纹比静态数据更有指向性。所以Checklist第三条是“所有返回的时间字段必须记录、比对、关联。created_at、updated_at、reg_time、exp、iat都是活的地图坐标。”4.5 铁律五Swagger不是文档是攻击者的索引很多人把Swagger当参考我把它当目录。/api/v2/internal/下的所有接口我都用ffuf -w api-list.txt -u https://target.com/FUZZ扫了一遍发现/api/v2/internal/debug/。GET /api/v2/internal/debug/返回{status:ok,version:2.3.1,profiles:[dev]}。profiles:[dev]暴露了Spring Boot Profile是dev而devprofile通常开启/actuator端点。我GET /actuator返回{timestamp:2023-09-15T14:23:01.123Z,status:401,error:Unauthorized}。有/actuator说明Spring Boot Actuator在运行。我GET /actuator/env401。但/actuator/health返回{