签名:zZhouQing(未成年)
日期:2024年8月23日
女魔头劝解我好好学习,没事多看题,为看雪网络课程做些贡献,好过无聊蛐蛐。(说我小学生,她才小学生呢,标点符号都打不对)
于是我对以下样本进行了研究:
上面俩个软件样本都拥有 Windows 、Android 版本。大黄蜂视频加密 似乎由于经费不足而引起的开发动力不足,更新缓慢,难以对抗市面上的翻录手段。深造视频加密 并没有透露过多的更新内容,与 大黄蜂视频加密 相比,它的防破解强度较高,同时,它的市场份额也更加的大。(盗版的看雪课程均有它份
)接下来的内容,从介绍 深造视频加密 开始。
深造视频加密
软件介绍:
本地视频、在线视频、直播视频加密系统(整套系统永久、全平台、一机一码)
我们承诺随时翻录破解随时全额退款!
我们承诺,翻录一个视频奖励500元,破解一个视频奖励1000元,10万元封顶,支付宝直接转账!!!
样本版本(Windows):
版本:24.07.524 大小:41.7MB 发布日期:2024年07月05日
(2)、播放器会检测采集卡翻录、软件翻录、破解调试、虚拟机翻录、隐藏进程、AI行为识别翻录、数据分析识别翻录、手机摄像机拍摄翻 录等,请尊重视频著作权!对于恶意破坏者收回权限处理!情节严重,我们会对涉及侵权等违法行为搜集资料并起诉;
(3)、播放过程中不要外接U盘和移动硬盘,不要更换电脑CPU、主板、硬盘、显示器等,否则会导致电脑机器码超限;
样本版本(Android):
大小:104MB 版本:24.06.111 发布日期:2024年06月29日
(1)、深造播放器是一款逐帧加密的混合式视频加密播放器,用于保护视频版权;需要android 9.0以上系统,或者鸿蒙系统;
(2)、安卓手机要关闭开发者模式、关闭USB调试、播放过程中禁止手机连接电脑;
(3)、安卓播放器禁止:虚拟机录屏、软件录屏、投屏录屏、外接采集卡录屏、root环境、模拟器环境;
受害人(坛友 hackflame 及网友 战天明):
Android 端分析
Windows
端没意思,考虑到我从未学习过 Android
开发或逆向知识,就从这里开始。
基本原理
准备如下环境:
- root 手机
- mt 管理器
- GameGuardian
- 算法助手
- 浏览器
算法助手配置
配置如下,需要注意的是,如下配置并无法完全绕过反调试。如感兴趣,请看下去。
通过 mt 管理器 对 深造播放器 进行解析。
- com.supermedia.mediaplayer
- 版本号 58
- 360加固
我们知道,程序加壳了,要想 完美逆向 便需要进行 脱壳。但这是 360加固 ,要想将其脱掉,对于 初学者 来说也许得花点时间去学习下 Android 程序加载原理 了。
在我查看了几篇 安卓逆向文档 后,发现 Android 程序 的运行,一般分为俩层。分别是 java 层,native 层。 java 层的代码是可以进行反编译的,即可以将其还原成源代码查看。
我迫不及待地去学习新的知识,发现 java 层的代码存储于一个后缀名为 .dex 的文件里。我发现一种内存修改工具,名为 GameGuardian (游戏保护)。可以通过它获取进程内存中的数据。即通过它,获取内存中 .dex 的信息。
DumpDex
深造播放器 dex 版本:
dex 039
64 65 78 0A 30 33 39 00
dex 035
64 65 78 0A 30 33 35 00
上面的十六进制数据便是 .dex 文件的特征码。game guardian 附加上 目标进程 ,勾选内存范围 jh (java heap),搜索如上十六进制数据 dump 下来便是 360加固 在运行后解密的 .dex 文件啦。(搜索失败,将内存范围全部勾上后,测试成功,看样子 java 层代码和 java heap 并无关系)
不过我在事后发现了 dump dex
的脚本,脚本内容如下(可能是我不会用吧,我 dump 失败了,有空重试):
--[[
https://gitee.com/rlyun/dump-dex
]] --local dumpDex = {}function dumpDex:getTargetPackage()
return gg.getTargetPackage()endfunction dumpDex:getPackageName()
return self.targetPackageendfunction dumpDex:setPackageName(targetPackage)
self.targetPackage = targetPackageendfunction dumpDex:getRootDir()
return string.format('%s/dumpDex', gg.EXT_STORAGE)endfunction dumpDex:getDumpDir()
return string.format('%s/%s', self:getRootDir(), self:getPackageName())endfunction dumpDex:getRangeMap()
local rangeMap = self.rangeMap if rangeMap then
return rangeMap end
rangeMap = {
Jh = gg.REGION_JAVA_HEAP,
Ch = gg.REGION_C_HEAP,
Ca = gg.REGION_C_ALLOC,
Cd = gg.REGION_C_DATA,
Cb = gg.REGION_C_BSS,
PS = gg.REGION_PPSSPP,
A = gg.REGION_ANONYMOUS,
J = gg.REGION_JAVA,
S = gg.REGION_STACK,
As = gg.REGION_ASHMEM,
V = gg.REGION_VIDEO,
O = gg.REGION_OTHER,
B = gg.REGION_BAD,
Xa = gg.REGION_CODE_APP,
Xs = gg.REGION_CODE_SYS }
self.rangeMap = rangeMap return rangeMapendfunction dumpDex:getRangeText()
local rangeMap = self:getRangeMap()
local rangeValue = gg.getRanges()
local ranges = {}
for k, v in pairs(rangeMap) do
if (rangeValue & v) == v then
ranges[#ranges + 1] = k
rangeValue = rangeValue & ~v end
end
return table.concat(ranges, ' | ')endfunction dumpDex:getRangeNames()
local map = self:getRangeMap()
local list = {}
for k, v in pairs(map) do
list[#list + 1] = k end
return listendfunction dumpDex:selectRange()
local names = self:getRangeNames()
local selection = {}
local rangeMsg = self:getRangeText()
local curRangName = {}
for name in string.gmatch(rangeMsg, '%w+') do
curRangName[name] = true
end
for i, name in ipairs(names) do
selection[i] = (curRangName[name] == true)
end
local inputs = gg.multiChoice(names, selection, string.format('当前的选择:%s', rangeMsg))
if not inputs then
return
end
local map = self:getRangeMap()
local rangeValue = 0
for i, v in pairs(inputs) do
if v then
local name = names[i]
rangeValue = rangeValue | map[name]
end
end
gg.setRanges(rangeValue)endfunction dumpDex:stringToHex(s)
return (string.gsub(s, '.', function(s)
return string.format('%02x', string.byte(s))
end))endfunction dumpDex:getDexHeader()
return 'dex\x0A035\x00'endfunction dumpDex:getResultsStep()
return #self:getDexHeader()endfunction dumpDex:newDex(address)
local dex = {}
setmetatable(dex, {
__index = self })
function dex:getAddress()
return address end
function dex:getName()
local dexName local index = self.index if type(index) ~= 'number' then
error('需要 dex.index = number', 2)
end
if index == 1 then
dexName = 'classes.dex'
else
dexName = string.format('classes%d.dex', index)
end
return dexName end
function dex:getRangesInfo()
local rangesInfo = self.rangesInfo if rangesInfo then
return rangesInfo end
local address = self:getAddress()
local rangesInfo local RangesList = gg.getRangesList()
for _, info in ipairs(RangesList) do
local startAddr = info.start local endAddr = info['end']
-- 找到 dex 在内存中的信息
if startAddr <= address and address <= endAddr then
-- 筛选出结束地址最大的唯一信息
if not rangesInfo or rangesInfo['end'] < info['end'] then
rangesInfo = info end
end
end
if not rangesInfo then
error(string.format('%s没有找到 rangesInfo', self:getName()), 2)
end
self.rangesInfo = rangesInfo return rangesInfo end
function dex:getStartAddr()
return self:getRangesInfo().start end
function dex:getEndAddr()
return self:getRangesInfo()['end']
end
function dex:getDexPosition()
return self:getAddress() - self:getStartAddr()
end
function dex:assertProcess()
local targetPackage = self:getTargetPackage()
local packageName = self:getPackageName()
if targetPackage ~= packageName then
error(string.format('进程发生了改变 %s>%s', packageName, targetPackage), 2)
end
end
function dex:getDumpMemoryPath(startAddr, endAddr, dumpDir, packageName)
if not packageName then
packageName = gg.getTargetPackage()
end
return string.format('%s/%s-%x-%x.bin', dumpDir, packageName, startAddr, endAddr)
end
function dex:dumpMemory(startAddr, endAddr, dumpDir)
local res = gg.dumpMemory(startAddr, endAddr - 1, dumpDir)
if res ~= true then
return false, res end
return self:getDumpMemoryPath(startAddr, endAddr, dumpDir, self:getPackageName())
end
function dex:dump()
self:assertProcess()
local startAddr = self:getStartAddr()
local endAddr = self:getEndAddr()
local dumpDir = self:getDumpDir()
local path, err = self:dumpMemory(startAddr, endAddr, dumpDir)
if not path then
error(string.format('无法导出内存\n%s', err), 2)
end
return path end
function dex:getSize()
local dexSize = self.dexSize if dexSize then
return dexSize end
local offset = 32
local value = {
address = self:getAddress() + offset,
flags = gg.TYPE_DWORD }
local vlaues = {value}
vlaues = gg.getValues(vlaues)
dexSize = vlaues[1].value
self.dexSize = dexSize return dexSize end
function dex:getDexOutPath()
return string.format('%s/%s', self:getDumpDir(), self:getName())
end
function dex:getDexOutFile(path)
return assert(io.open(path, 'w'))
end
function dex:getDexInputFile(path)
local f = assert(io.open(path, 'r'))
f:seek('cur', self:getDexPosition())
return f end
function dex:checkSize()
-- 正常情况应该 DEX大小是正数,并且小于内存的最大范围
return dex:getSize() > 0 and dex:getSize() < (dex:getEndAddr() - dex:getStartAddr())
end
function dex:out(outPath)
local binPath = self:dump()
local inputf = self:getDexInputFile(binPath)
local dexSize = self:getSize()
local readSize = 1024 * 1024 * 1
if dexSize < readSize then
readSize = dexSize end
if not outPath then
outPath = self:getDexOutPath()
end
local outf = self:getDexOutFile(outPath)
local readLength = 0
-- 从内存导出的bin文件中提取出 DEX
local function copy()
while true do
local tmp = inputf:read(readSize)
outf:write(tmp)
readLength = readLength + readSize if readLength >= dexSize then
inputf:close()
outf:close()
break
end
end
end
local ok, err = pcall(copy)
-- 删除导出的内存文件
os.remove(binPath)
if not ok then
-- 复制 DEX 出错了,删除出错的文件
os.remove(outPath)
return false, err end
-- 返回导出后的 DEX 文件路径
return outPath end
return dexendfunction dumpDex:results2dexs(results)
local dexs = {}
for i = 1, #results, self:getResultsStep() do
local value = results[i]
local address = value.address local dex = self:newDex(address)
if dex and dex:checkSize() then
local index = #dexs + 1
dex.index = index
dexs[index] = dex end
end
return dexsendfunction dumpDex:getDexs()
-- 清空当前搜索结果
gg.clearResults()
gg.toast('正在搜索DEX文件...')
-- DEX 文件头
local dexhead = self:getDexHeader()
-- h 表示以 HEX (十六进制)的方式搜索,后面跟随需要搜索的十六进制
local text = 'h ' .. self:stringToHex(dexhead)
-- 搜索以十六进制格式的 DEX 头信息
local res = gg.searchNumber(text)
if res ~= true then
error(string.format('搜索DEX失败\n%s', res), 2)
end
-- 获取全部搜索结果
local results = gg.getResults(gg.getResultsCount())
-- 把搜索到的结果解析成 DEX 对象
local dexs = self:results2dexs(results)
-- 清空搜索
gg.clearResults()
return dexsendfunction dumpDex:getLogPath()
return string.format('%s/dump.log', self:getDumpDir())endfunction dumpDex:getLogFile()
return assert(io.open(self:getLogPath(), 'w'))endfunction dumpDex:start()
local function dumpAll(dexs)
local log local ok, err = pcall(function()
log = self:getLogFile()
end)
-- if not ok then
-- if gg.alert(string.format('无法创建LOG文件\n%s', err), '继续', '退出') ~= 1 then
-- return
-- end
-- end
local function outLog(msg)
msg = tostring(msg)
if log then
log:write(msg .. '\n')
end
if msg:find('%S') then
gg.toast(msg)
end
end
local msg = string.format('导出包名:%s', self:getTargetPackage())
outLog(msg)
local successCount = 0
local failCount = 0
for i, dex in ipairs(dexs) do
local name = dex:getName()
local msg = string.format('正在导出:%s', name)
outLog(msg)
local path, err = dex:out()
if not path then
failCount = failCount + 1
local msg = string.format('导出失败:%s', err)
outLog(msg)
else
successCount = successCount + 1
local msg = string.format('导出成功:%s', path)
outLog(msg)
local msg = string.format('文件大小:%s', dex:getSize())
outLog(msg)
end
outLog('\n')
end
local msg = string.format('成功导出 %s 个DEX', successCount)
if failCount > 0 then
msg = msg .. '\n' .. string.format('导出失败 %s 个DEX', failCount)
end
msg = msg .. '\n' .. string.format('保存路径 %s', self:getDumpDir())
outLog(msg)
gg.alert(msg)
-- 关闭LOG文件
if log then
log:close()
end
end
-- 开始计时
local startTime = os.clock()
-- 缓存包名,避免进程被切换了导致包名不一致
self:setPackageName(self:getTargetPackage())
-- 获取 DEX 对象集
local dexs = self:getDexs()
-- 得到解析DEX的耗时时间
local consuming = os.clock() - startTime local dexCount = #dexs local msg = string.format('耗时 %.2fs 在内存中搜索到%d个dex文件', consuming, dexCount)
local operationNames = {'一键导出全部'}
local operationFuns = {}
operationFuns[1] = function()
return dumpAll(dexs)
end
for i, dex in ipairs(dexs) do
local name = dex:getName()
local text = name .. '\n' .. dex:getSize() .. '字节'
local i = #operationNames + 1
operationNames[i] = text
operationFuns[i] = function()
local path, err = dex:out()
if not path then
gg.alert(string.format('%s导出失败!\n\n%s', name, err))
return
end
gg.alert(string.format('%s导出成功!\n\n%s', name, path))
end
end
while true do
if gg.isVisible() then
local input = gg.choice(operationNames, nil, msg)
if not input then
local input2 = gg.alert('您确定要退出吗?', '确定', '最小化')
if input2 == 1 then
return
else
gg.setVisible(false)
end
else
local func = operationFuns[input]
self:trySelfCall(func)
end
else
gg.sleep(100)
end
endendfunction dumpDex:try(err)
gg.alert(string.format('try error:\n%s', err))
return errendfunction dumpDex:trySelfCall(func, ...)
local ok, err = pcall(func, self, ...)
if not ok then
self:try(err)
endendfunction dumpDex:main()
local function getMsg()
local process = string.format('进程:%s', self:getTargetPackage())
local range = string.format('内存:%s', self:getRangeText())
local msg = process .. '\n' .. range return msg end
while true do
if gg.isVisible() then
local input = gg.choice({'选择内存', '开始导出', '退出程序'}, nil, getMsg())
if not input then
gg.setVisible(false)
elseif input == 1 then
self:trySelfCall(self.selectRange)
elseif input == 2 then
self:trySelfCall(self.start)
elseif input == 3 then
return
end
else
gg.sleep(100)
end
endenddumpDex:main()
DumpDex所存在的问题
可以看到,.dex 的属性似乎有些奇怪,它的大小太大了。
除了大小太大的问题,MT 管理器在解析的时候,也未看到与程序有相关的信息。
验证 DumpDex 的准确性
试探
回顾下刚才查文档所学的知识点:java 层的代码存储于一个后缀名为 .dex 的文件里。
设想一下,java 层的代码都存了些什么?(我也不清楚)自然包含程序的界面相关信息吧。
打开深造播放器,随便输入 Serial
,发现下图所示提示:
返回 GameGuardian 搜索 UTF8:请联系
,发现内存属性不是 javaHeap
,寻求其他办法。
奇技淫巧
考虑到能力有限,在万能的互联网上找到了 深造播放器 v1.8.9 最新版本,下载下来。
apk 基本信息如图所示:
未加固,这可是个好消息,如图所示常量,皆会在 java heap
中出现。
实验出真知,发现还是不在 java heap
内存,不过这 Anonymous
内存倒也显特殊。将这块内存导出,最终发现,相关信息其的确存在于 DumpDex 文件中。
SplitDex
这下子,我敢猜测,这份 .bin
中存在着多份 .dex
文件。(其实我压根没测试,上面的验证是为了开阔下思路,通过老版本验证新版本,好玩吧~ hiahia)
搜索 .dex 特征码,提取 .bin
中所存在的 .dex
,需要注意的是,这份代码所提取的 .dex
文件是不全的,但是正好能覆盖程序主要逻辑。如果需要提取全部 .dex
需要略微变动下代码。
f = open(r"C:\Users\zZhouQing\Documents\MuMu共享文件夹\ShareDex\origin.bin","rb")bytes = f.read()sig = b"\x64\x65\x78\x0a\x30"list = bytes.split(sig)count = 0for v in list:
path = r"C:\Users\zZhouQing\Documents\MuMu共享文件夹\ShareDex"+"\\"+str(count) + ".dex"
f = open(path,"wb")
f.write(sig+v)
count += 1
反调试 - 第一部分
查看 io.antiengine 相关代码,这是 深造播放器 防破解的源头之一。(还存在着定时器)
该类含有以下方法:
- _init
- info0
- info1
- info2
- isSafe
为什么方法名要用 info 呢?我猜测,是因为其防破解原理主要是通过 获取系统相关信息 来完成的。
io.antiengine.info0:
调用 info1
调用 isSafe
获取 pageName list
获取 传感器数量
获取 显示屏数量
获取 Usb 信息
获取 Adb 信息
获取 su 信息
获取 emulator 信息
获取 virtual circumstance
呵呵,相对于 pdd 等大厂软件获取的系统信息,sz 算是温柔地了。
发现 info1 是 native 层的函数,我通过查阅文档发现其是 .so export method (so 是 Android 系统下的动态链接库)。
_init:
str soName = "eagle_sdk";loadLibrary(soName);
我们将 data/app/.../lib/ 文件夹下的 eagle_sdk.so 提取出来,拖入 ida 中进行反编译。.so 文件是未加壳的,呵呵,看样子 深造视频加密 从 2021 年开始推行的 深造播放器 Android 并没有多安全。
我觉得 Android 逆向还是挺有意思的,所以用 IDA 过一下 eagle_sdk.so
文件。
check su:
// su__int64 sub_14CA0(){
strcpy(v19, "/system/bin/su");
strcpy(v21, "/product/bin/su");
strcpy((char *)v22, "$/data/local/bin");
strcpy(v24, "/system/xbin/su");
strcpy((char *)v25, "*/system/xbin/which/su");
strcpy((char *)v26, "$/data/local/xin");
strcpy((char *)v27, "$/data/local/bin");
strcpy((char *)v29, "/system/bin/failsafe/su");}
check magisk and more circumstance:
__int64 sub_151E0(){
strcpy(v17, "lsposed");
strcpy(v22, "magisk");
strcpy(v26, "Magisk");
strcpy(v28, "/appwidget");
if ( (unsigned __int8)sub_146E0("/proc/mounts") )
{
strcpy((char *)v8 + 1, "/proc/mounts->");
}
else if ( (unsigned __int8)sub_146E0("/proc/self/mountstats") )
{
strcpy(*(char **)&v8[1], "/proc/self/mountstats->");
}
else if ( (unsigned __int8)sub_146E0("/proc/self/mountinfo") )
{
strcpy((char *)v8, ",/proc/self/mountinfo->");
}
else
{
if ( !(unsigned __int8)sub_146E0("/proc/self/maps") )
strcpy((char *)v8, "\"/proc/self/maps");
}
}
check adb:
__int64 sub_157F0(){
strcpy(v52, "lsposed");
strcpy(v57, "magisk");
strcpy(v61, "Magisk");
strcpy(&v62[1], "/appwidget");
strcpy((char *)&dest[1] + 1, "s/");
strcpy((char *)dest, "$/data/adb/modul");
if ( !(unsigned __int8)sub_14840(dest, v43) )
{
v40 = 0;
ptr = 0LL;
v38 = 20;
strcpy(v39, "/data/adb/");
v0 = sub_14840(&v38, v43);
if ( (v38 & 1) != 0 )
operator delete(ptr);
}
}
反反调试
方法有二,请看下去。
反反调试 - 法一 - 0day
该漏洞危害很大,直戳 反调试命脉 。不知道各位是否记得 io.eagle_sdk._init 函数?不记得没关系,我会把内容复制下来,方便阅读。
_init:
str soName = "eagle_sdk";loadLibrary(soName);
我们直接将 data/app/../lib 中的 eagle_sdk.so 删去,程序是可以正常运行的。此时,深造播放器 Android 便只有 java 层的反调试了,而 java 层的反调试之一(info0、1)早已被我们略过。
反调试 - 第二部分(定时器)
做 hook 进行反反调试,需要注意 hook 的点位齐全,稍有不慎,可能被封号。程序在登录到主界面后,会开启一个定时器进行反调试。出于本篇随笔的长度考虑,这一部分,交由读者自行实验。
反反调试(防封) - 法二 - hook
使用算法助手做 hook,经过分析,这个点位既能实现反反调试,又能实现防封。堪称绝佳点位。
截图黑屏
算法助手开启了这个设置,仍然截图失败。
猜测原因,第一种可能,算法助手的这个选项是对程序的 java
层代码进行了 hook,而深造播放器的反截图是在 native
层上运行的,故而功能失效。
猜测原因,第二种可能,算法助手 hook 的点位并不完全。
破解黑屏
Github 上随便找了个 xposed
插件进行测试,测试结果如下:
为了保护 看雪论坛
的网络课程,广告我就随便抹去了。
水印
分为俩种,一种是通过读取内存中的 username
在屏幕中显示,直接 hook 掉相关代码即可。(不能在内存中扫,username 是动态生成的)
另一种是 bmp
水印,即图片,通过劫持相关网络层包即可,当然,可以在内存中扫一遍,擦除掉。
视频提取
这部分内容我跟着 xdbg
师傅学习的,和 梁山 beta
一样的难度,需要一个正版号获取对应帧的 key
进行解密。当然,这部分我是懒得写了。
炫技
小菜之前沉迷逛 飘云阁
论坛,羡慕论坛里大牛们的破解图,这是论坛的文化。在此,我也想炫耀一下。播放器是基于 rust
语言编写的。
Windows端翻录
HOOK DWM
即可进行录屏,声音的获取需要逆向音频管理器了。(直接获取声音可能会被检测)
HDMI 信号泄露
通过硬件设备的缺陷进行翻录,基于此,鹏程万里
也难以抵抗。项目的链接放在附录当中了,我本想加入论坛的 翻译小组
,从事翻译外文的工作,奈何实力不行,只好凭兴趣翻译了。
How does it works? (and how to cite our work or data)You can find a detailed technical explanation of how deep-tempest works in our article. If you found our work or data useful for your research, please consider citing it as follows:@misc{fernández2024deeptempestusingdeeplearning,
title={Deep-TEMPEST: Using Deep Learning to Eavesdrop on HDMI from its Unintended Electromagnetic Emanations},
author={Santiago Fernández and Emilio Martínez and Gabriel Varela and Pablo Musé and Federico Larroca},
year={2024},
eprint={2407.09717},
archivePrefix={arXiv},
primaryClass={cs.CR},
url={https://arxiv.org/abs/2407.09717},
note={Submitted}}Data
相关法律普及
倒卖课程立案的标准是违法所得数额达到十万元以上,或者未销售的货物价值达到三十万元以上。
根据相关法律规定,以营利为目的销售侵权复制品,涉嫌违法所得数额十万元以上或未销售的货物价值三十万元以上的情况,应予立案追诉。这些规定适用于各种形式的侵权复制品,包括但不限于课程、软件、音乐、电影等。具体来说:
违法所得数额十万元以上;
未销售的货物价值三十万元以上。
在此呼吁大家不要购买盗版课程,共建良好的绿色网络生态环境。
同时,也希望 深造播放器
的开发者能够注意自己产品中的内容,看雪论坛的课程资源应该是拥有版权保护的,应该吧。。
虽然我一时半会没话讲,但本人是 尚未成年 的文科生,法律相关知识,迟早会来的。
附录
MT管理器
GameGuardian 101.1
算法助手
Xposed-Disable-FLAG_SECURE
Deep-tempest: Using Deep Learning to Eavesdrop on HDMI from its Unintended Electromagnetic Emanations
TempestSDR.jl
闲话
本文旨在为看雪论坛做出一些贡献,这是我的第一帖,我要学习的东西还有很多很多。如果有错误,我多半也不会改。
最后于 1小时前
被zZhouQing编辑
,原因: 主语用错了。