1811 字
9 分钟
关于口语100
2026-09-30

关于#

各位知道的,口语100是个上古时期的文物,而它有一个网页版,经过一番体验,可以知道网页版也是同样的难用。

那么,这样的网站,它安全吗?

不难发现,大部分接口都没有身份验证,所以我们可以仅用学生账号来干一些奇妙的事情。

获取答案#

经验证,查询答案接口并不会验证当前账号是不是老师,所以任何人都可以获取听力答案。下面以“10月4日初三听力”为例进行演示。

发送请求#

首先我们需要知道这场考试的定义 ID,即 examDefind.id(服务端自己少打了一个 e),可以通过地址直接查看,如 https://016.kouyu100.com/jshawgy/gotoExam.action?examDefind.id=3563,问号后就是,所以这场考试的定义 ID 为 3563。

考试页面

然后,我们就可以向 findListenExamListChangeMock.action 接口发送请求,具体为:

GET /jshawgy/findListenExamListChangeMock.action?examId=3563&examType=2&isMockMode=1

将其中的 examId 改为刚刚获取到的 3563 并发送。

然后就得到了一大坨 JSON,而我们只关心其中 examList 数组的第 0 项,找到里面的 id 并记下它的值 315899,这就是这场考试使用的试卷 ID。至于其他的,如 bookName 是试卷标题,我们用不到。

而有了试卷 ID,我们就可以获取试卷题目:

GET /jshawgy/getListenGroupsByExamId.action?examId=315899

将 examId 改为 315899,响应中就包含了试卷的题目以及答案。为防止答案泄露,故在此不做演示。

分析返回结果#

至于如何从响应里找到答案,下面简单演示一下。

响应剥掉外层后的结构:

{
"groupList": [
{
"chooseList": [
{
"title": "What time is it now?",
"answers": "C",
"score": 1,
"item1": "...",
"item2": "...",
"item3": "...",
"id": 1993221
}
]
}
]
}

answers 和 title 属于同一个对象,title 为本题题目,answers 为答案。题目文本和标准答案被打包在同一个数组元素里一起发了下来。

限制?#

考试开始前、进行中、结束后各发一次同样的请求,返回内容一致,answers 字段始终存在。

也就是说,理论上可以在开考前拿到整张卷子的答案。

可能的修复#

只需要将评分放在服务端,提交答案后只返回对错即可,最起码也要在考试结束前拒绝发送答案,而我们的口语100显然一种都没做。

获取成绩#

答案本身不该给,成绩这条路的性质不太一样:数据确实该给,但给错了人。

另一个参数#

GET /jshawgy/findExamStudentScores.action?examScoreForm.examId=3562&examScoreForm.orgId=10613&examScoreForm.orgType=1

成绩获取的越权原理

orgId 这名字看着挺抽象,实际就是班级 ID,而且是连号的,10604 到 10622 正好是九年级的十九个班,换一个数字换一个班。

服务端拿到这个参数之后什么也没做,它不检查当前账号属不属于这个班,也不检查是不是老师,直接拿去数据库里查,然后原样返回。

如:

  • orgId=10604,拿到九年级1班,65条记录
  • orgId=10613,拿到九年级10班,74条记录
  • 换成别的班级 ID,拿到对应班级的记录

所以,一个学生账号可以看到全校任何一个班的成绩。

返回了什么#

每条记录有59个字段,挑几个:

字段含义示例
realname姓名同学 A
studentId账号100001
stuScore得分20
startTime开始作答22:15:38
endTime交卷22:37:39

班级 ID 连号,考试 ID 也连号,两层循环套起来,整所学校的成绩就到手了。

还有两个 Excel 接口#

GET /jshawgy/findStudentScoreByOrg.action?examScoreForm.examId=3562&examScoreForm.orgId=10613&orderByType=1
GET /jshawgy/findAllStudentScore.action?examScoreForm.examId=3562&orderByType=1

第一个是班级成绩单,22 KB。第二个是全校成绩单,363 KB,一次请求就够了。

响应是二进制流,开头是 d0 cf 11 e0,OLE2 复合文档的魔数,也就是老式 .xls。文件元信息里甚至还留着生成工具的名字:Java Excel API v2.6.1。

比 JSON 接口还省事,不用分页不用拼接,下载下来用表格软件打开,全校数据就有了。

一个坑#

这个接口对不存在的考试 ID 不返回空也不报错,而是回落到某个默认数据集,照样返回74条记录,只是 examName 和截止时间变成了 null。

不注意很容易把假数据当真。判断方法是看第一条记录的 examName 是不是 null。

根因#

答案和成绩表面上是两类问题,往下看是同一个:服务端没有自己判断你是谁,而是听客户端复述。

信任边界画在了客户端

三个地方犯的是同一个错。

跨站单点登录令牌是明文。叫 wwwToken,值是一段 base64:

eyJ1aWQiOiI1NDk3NTAiLCJkbSI6ImpzaGF3Z3kiLCJ1cmwiOiIwMTYua291eXUxMDAuY29tIn0=

解码完是:

{"uid":"100000","dm":"example","url":"example.com"}

只有用户 ID、域名、目标主机三个字段,没有签名,没有有效期,也没有防篡改手段。改掉 uid 重新编码,就是另一个人的身份。

媒体端身份是客户端拼的 JSON。上传视频时返回的地址里带了一串 base64 参数,解开是一段完整的身份描述,包含用户 ID、域名、姓名、学校、机构 ID。服务端直接把这些值写进了返回页面的 JavaScript 变量:

var userId = "100000";
var userName = "示例用户";

构造同样结构的 JSON,服务端就会认。

登录成功与否,客户端自己判断。密码错误返回 HTTP 200,账号不存在也返回 HTTP 200,只有正文里一个是 ERROR2 一个是 ERROR3。只看状态码的客户端会认为三次都登录成功了。

三件事的共同点是校验责任被推到了客户端。

其他#

家长端子域。jiazhang 子域有个接口不带 Cookie 就能调,传用户 ID 返回真实姓名、班级和完整手机号。用户 ID 连号,等于一个全校通讯录查询器。

教师页面直达。普通学生账号访问 gotoUserManage、gotoChangeExam、gotoExamMonitor、gotoExamTeacherMark 全部返回 200,页面正常渲染。敏感的写操作接口另有一层校验,但可读内容已经足够多了。

失败响应没有区分度。“没登录”和“登录了但没权限”在响应上完全一样,都是 HTTP 200 加空响应体。探测时无法区分这两种情况,可以一直用匿名身份试所有接口。

最后#

这个站点没有复杂的加密绕过,没有内存破坏,也不需要零日漏洞。

问题可以概括成:该在服务端做的判断被放到了客户端。

答案不该跟题目一起下发,这是数据问题。成绩该按班级过滤,这是权限问题。身份该由服务端签发并验证,这是信任问题。三个问题也是一个都没做对。

判断一段返回数据的代码有没有问题,只要问一句:这个请求凭什么能看到这些数据。如果答案是“因为它带了个 Cookie”或者“因为参数里写了这个 ID”,那就已经出问题了。


仅用于学习目的,禁止泄露任何答案及他人隐私数据,违者自行承担相应责任。

部分图片由 AI 生成。

关于口语100
https://fuwari.vercel.app/posts/kouyu100/
作者
Juicy233
发布于
2026-09-30
许可协议
CC BY-NC-SA 4.0