├── .gitignore ├── LICENSE ├── README.md ├── Steganographier.py ├── cover_video └── Rick Astley - Never Gonna Give You Up (Official Music Video) [dQw4w9WgXcQ].mp4 ├── modules ├── favicon.ico ├── favicon_captcha_generator.ico └── favicon_hash_modifier.ico ├── requirements.txt └── tools ├── captcha_generator.exe ├── hash_modifier.exe ├── mkvextract.exe ├── mkvinfo.exe └── mkvmerge.exe /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 CL 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SteganographierGUI 2 | 将文件隐写进MP4/MKV文件中 3 | 4 | 5 | ## 更新 6 | 7 | ### 隐写者 GUI&CLI两用的MP4/MKV隐写程序 8 | 9 | 作者: 层林尽染 10 | 11 | 12 | 13 | #### 使用说明 14 | 15 | 本程序可以将文件或文件夹隐写到视频文件中,或从视频文件中提取隐写的文件或文件夹。程序支持命令行界面 (CLI) 和图形用户界面 (GUI) 两种模式。 16 | 17 | 1. **GUI** 模式: 【推荐】双击直接运行程序,不带任何参数。关于GUI的用法详见[演示视频](https://youtu.be/ztjKF8FPIM0?si=rI4QANcmoU2cQEHn)。 18 | 19 | 2. **CLI** 模式: 使用以下参数运行程序: 20 | 21 | ``` 22 | -i, --input 指定输入文件或文件夹的路径。如果不使用任何参数标签,程序会将第一个未知参数视为输入路径。 23 | -o, --output 1. 指定输出文件名(包含后缀名) [或] 2. 指定输出路径(默认为原文件名+"_hidden.mp4/mkv")。 24 | -p, --password 设置密码 (不指定则无密码)。 25 | -t, --type 设置输出文件类型 (默认为mp4),支持mp4和mkv两种格式。 26 | -c, --cover 指定外壳MP4视频(如果不指定,程序会按照以下顺序搜索: 27 | - 程序同目录下的cover_video文件夹下 28 | - 程序所在目录下 29 | - 输入文件或目录的所在目录下) 30 | -r, --reveal 执行解除隐写 (如果输入文件不是隐写文件则不进行任何操作) 31 | ``` 32 | 33 | 34 | 35 | #### 使用示例 36 | 37 | 1. 隐写文件: 38 | 39 | ``` 40 | python Steganographier.py -i "input.txt" -o "output.mp4" -p "password" -t "mp4" -c "cover.mp4" 41 | python Steganographier.py -i "input.txt" -o "outputFolder" -p "password" -t "mp4" -c "cover.mp4" 42 | ``` 43 | 44 | 2. 隐写文件夹: 45 | 46 | ``` 47 | python Steganographier.py -i "inputFolder" -o "outputFolder" -p "password" -t "mp4" 48 | python Steganographier.py -i "inputFolder" -o "output.mp4" -p "password" -t "mp4" 49 | ``` 50 | 51 | 3. 解除隐写提取文件: 52 | 53 | ``` 54 | python Steganographier.py -i "input.mp4" -r -p "password" 55 | ``` 56 | 57 | 4. 若仅指定输入文件,则使用默认设置: 58 | 59 | ``` 60 | python Steganographier.py "input.txt" 61 | ``` 62 | 63 | 64 | 65 | 66 | 67 | #### 注意事项 68 | 69 | 1. 如果没有指定输出文件路径,程序会在输入文件同目录下创建默认的输出文件,文件名为原文件名 + `_hidden.mp4/mkv`。 70 | 2. 如果指定了输出路径但没有指定文件名,程序会在指定输出路径下创建一个默认的输出文件,文件名为原文件名 + `_hidden.mp4/mkv`。 71 | 3. 如果输入路径是一个文件夹,程序将隐写整个文件夹。 72 | 4. 如果没有指定外壳MP4视频,程序会按照以下顺序搜索: 73 | - 程序同路径下的 `cover_video` 文件夹 74 | - 程序所在目录 75 | - 输入文件或目录的父目录 76 | 5. 程序会在程序同路径下查找 `cover_video` 文件夹。如果该文件夹存在,程序会在其中搜索 .mp4 文件。如果该文件夹不存在,程序会跳过这一步,继续在其他位置搜索。 77 | 6. 解除隐写时,如果输入文件不是隐写文件则不进行任何操作。 78 | 79 | **Full Changelog**: https://github.com/cenglin123/SteganographierGUI/compare/v1.1.0...v1.1.1 80 | 81 | * * * 82 | 83 | **v1.2.4 更新** 84 | 85 | 20241027 更新 v1.2.4 版本 86 | 87 | - 去除:去除密码掩蔽,便于查看密码内容; 88 | - 增加:现在程序在关闭时会自动记录使用的配置并保存在程序同目录的 config.json 文件中,下次启动时读取; 89 | - 修复:修复 #4 ,现在程序可以正常解除无密码的隐写。 90 | 91 | **v1.2.1 更新** 92 | 93 | 20240828-v1.2.1 版本:解除隐写逻辑新增密码本功能 94 | 95 | **v1.2.0 更新** 96 | 97 | 20240730-v1.2.0 版本:新增集成隐写者到右键菜单的功能 98 | 99 | **v1.1.8 更新** 100 | 101 | 新增验证码生成器扩展工具 102 | 103 | **v1.1.7 更新** 104 | 105 | 新增文件哈希值修改工具 106 | 107 | **v1.1.6 更新** 108 | 109 | 修改解除隐写的逻辑,提升效率 110 | 111 | **v1.1.5 更新** 112 | 113 | 改善压缩zip文件部分的进度显示逻辑,并且程序增加详细日志窗口,便于问题定位 114 | 115 | **v1.1.4 更新** 116 | 117 | 优化程序在嵌入moov box时的运行逻辑,修正一些bug 118 | 119 | **v1.1.3 更新** 120 | 121 | 修改MP4隐写模式,现在会把zip文件嵌入到外壳MP4文件的 moov box 中而不是贴在MP4文件后面 122 | 123 | **v1.1.2 更新** 124 | 125 | 新增隐写大小-外壳时长不合理提醒 126 | 127 | **v1.1.1 更新** 128 | 129 | 修复BUG 130 | 131 | 132 | **v1.1.0 版本进位** 133 | 134 | 新增命令行调用的 CLI 模式, 现在程序可以作为第三方工具被其他程序调用 135 | 136 | **v1.0.10 改进:** 137 | ``` 138 | 1. 隐写时会随机插入压缩文件的特征码,进一步增加混淆度 139 | 2. 输出文件名可以选择随机文件名 140 | 3. 外壳文件新增名称排序、随机排序、时长排序选项。 141 | ``` 142 | 143 | **v1.0.9 修改**:参数栏添加【输出名】选项,现在可以选择外壳MP4文件名作为输出文件名 144 | 145 | **v1.0.8 改进**:新增外壳MP4文件夹选择功能,现在可以点击【选择文件夹】按钮以自行选择外壳MP4文件夹。 146 | 147 | **v1.0.7 改进**:外壳文件菜单中的文件现在会按照时长降序排列 148 | 149 | **v1.0.6 修改**:密码获取的逻辑变更,现在可以不指定密码 150 | 151 | **v1.0.5 改进**:隐写时文件末尾增加随机字节,以使得每次生成的文件哈希值不同 152 | 153 | **v1.0.4 改进**:新增外壳文件选择菜单 154 | 155 | **v1.0.3 改进** 156 | ``` 157 | 1. 隐写文件夹时现在会在压缩包内生成同名的文件夹 158 | 2. 修改tools的判断逻辑,如果不使用mkv模式则不会弹出警告 159 | 3. 新增mkv文件隐写大小警告(单个mkv文件不能隐写总量超过2GB的资源) 160 | 4. 修正一些其他bug 161 | ``` 162 | 163 | **v1.0.2 新增** 隐写为mkv文件的逻辑,引入第三方工具用于处理mkv文件 164 | 165 | ``` 166 | .\tools\mkvextract.exe 167 | .\tools\mkvinfo.exe 168 | .\tools\mkvmerge.exe 169 | ``` 170 | 171 | **v1.0.1** 修复了无法隐写ZIP格式文件的bug 172 | 173 | 174 | ## 摘要 175 | 为了探索秒传链接失效之后国内网盘资源分享的安全问题,本文推荐了一种以 MP4 文件为外壳的文件隐写方法,并进行了 [压力测试[3]](https://cangku.moe/archives/211696),初步证明了方法的有效性。同时分享了对使用隐写技术时的一些 [经验和使用建议[4]](https://cangku.moe/archives/211707)。通过进一步 [控制变量测试[5]](https://cangku.moe/archives/211857),探讨了评论区地毯式炸链的机制,得出其 [原因是资源倒卖者的举报[6]](https://cangku.moe/archives/211893),证明了 [隐写文件的优势:可申诉性[7]](https://cangku.moe/archives/211944)。然后讨论了 [百度网盘的审核机制以及举报的原理[8]](https://cangku.moe/archives/212002)。提出了应对倒卖者举报的 [一些策略](https://cangku.moe/archives/212735) [10]。 176 | 177 | 本程序的代码已经在 [GitHub](https://github.com/cenglin123/SteganographierGUI) 上开源,大家有任何建议欢迎提 issue。 178 | 179 | [New] 20240730-v1.2.0 版本:新增 **集成隐写者到右键菜单** 的功能,详见: [GUI 说明部分](#a) 180 | 181 | [New] 20240801-v1.2.0.1 版本:进一步 **集成哈希修改器功能** 182 | 183 | [collapse title = "其余重要更新"] 184 | 185 | 20240716-v1.1.8 版本:新增工具 **验证码生成器**,可以将提取码处理为验证码图片,用于反爬,详见 GUI 说明部分。 186 | 187 | 20240708-v1.1.7 版本:新增拓展工具 **哈希值修改器**,可以原位批量修改常见资源文件的 MD5 值 (jpg、png、mp3、mp4、mkv、flac、zip、rar、7z 等),详见 GUI 说明部分。 188 | 189 | 20240622-v1.1.5 版本:改善压缩 zip 文件部分的进度显示逻辑,并且程序增加详细日志窗口,便于问题定位 190 | 191 | 20240609-v1.1.3 版本:修改 MP4 隐写模式,现在会把 zip 文件嵌入到外壳 MP4 文件的 moov box 中而不是贴在 MP4 文件后面 192 | 193 | **2024.05.26 更新:1.1.0 版本更新新增了 CLI 操作模式,今后本程序可以作为第三方应用被其他程序调用。详见演示视频和 Github。** 194 | 195 | [/collapse] 196 | 197 | **文章目录** 198 | 199 | ![58f1a856c26a24542636716ce2d3df2d.webp](https://file.cangku.moe/images/58f1a856c26a24542636716ce2d3df2d.webp) 200 | 201 | ## 1. 背景 202 | 如今,国内各大网盘审查政策日趋严格,分享链接炸链的可能性越来愈大。 203 | 204 | 传统来说,我们采用 **带密码的多层压缩包** 来应对审查问题。这样的做法非常麻烦,并且当层数多、文件大时,频繁解压对于硬盘的损耗也是难以忽视的。围绕这个问题,过去产生了多种利用网盘特性进行 **秒传** 的解决方案,但是随着网盘政策的收紧,这些方案大多已经失效或名存实亡 。 205 | 206 | 秒传方案失效以后,基于国内网盘的资源分享重新回到了多层加密压缩包的形式,分享者开始不可避免地要和网盘的分享及审核系统打交道,加之举报分享链接的 **资源倒卖者** 横行,炸链又开始频频出现,有时候甚至 [地毯式发生](https://cangku.moe/archives/211857),分享环境日趋恶劣 。 207 | 208 | 综上,这使得研究和开发更加隐蔽、安全的数据传输方法变得尤为重要,在这样的艰难情况下,本文介绍的文件隐写技术有望成为后秒传时代的安全分享新方案。  209 | 210 | ## 2. 方法介绍:把文件隐写到 MP4 文件中 211 | 本程序受到 [仓库文章](https://cangku.moe/archives/211591)(以下简称文章 [1])的启发,利用文件隐写技术来隐藏数据从而绕过常规审查。 212 | 213 | 隐写技术通过将数据嵌入到其他媒体文件中,使数据的存在对于普通观察者而言不可见,从而实现在不引起注意的情况下进行信息传输。 214 | 215 | 隐写技术已经有很多先例,传统做法主要有 [图种](https://cangku.moe/archives/204982) [X7] ,即把数据嵌入图片中,表面上看起来是一张图片,但修改后缀名后可以解压然后得到隐藏的数据。 216 | 217 | 图种的原理如下: 218 | 219 | ```sh 220 | copy /b "图片.jpg" + "压缩包.zip" "生成目标.jpg" 221 | ``` 222 | 223 | 但是这样的做法容易引起怀疑,毕竟一张清晰度分辨率都并不算高的图片居然有几个 G,并且还有非常高的下载转存记录,这实在太可疑了 [[1]](https://cangku.moe/archives/211591)。 224 | 225 | 因此,考虑 **伪装的有效性**,使用 MP4 文件作为隐写的外壳文件更为合理一些,大视频引起怀疑的可能性显然低于大图片。 226 | 227 | 我们的目标是通过隐写伪装来 **降低可疑度**,从而尽可能以 **最低的成本** 实现安全分享。因为假如被频繁举报,即使压缩包层数再多,密码再复杂,**在已经被强烈怀疑的情况下大概也回天乏术**。因为对于网盘而言,无法解密又被大量举报的文件最省事的方式就是一刀切判定违规([参考这个试验[5]](https://cangku.moe/archives/211857))。 228 | 229 | **自然界最好的防御不是叠甲而是伪装**。 230 | 231 | 具体实现方面:将 ZIP 格式的压缩包嵌入到比如海绵宝宝这样的普通 MP4 视频文件中,当文件以 MP4 格式被打开时,只能看到海绵宝宝的视频,看不到 ZIP 部分;而当文件名改为 ZIP 以后,解压软件(如 WinRAR)可以寻找到 ZIP 部分进行正常解压。如此实现文件的低成本安全分享。 232 | 233 | [collapse title = "解除隐写方法(根据自己所用的解压软件对号入座)" show = "true" ] 234 | 235 | **1.** [WinRAR6](https://pan.baidu.com/s/1J8_amt4s_cIubbOnyE-LJw?pwd=1s25) **(注意不能是 WinRAR5):** 236 | 237 |     1.1. 对于 MP4 隐写文件,直接改后缀名为 **.zip** 即可解压(必须是 **zip**,不是 rar 也不是 7z 或者别的) 238 | 239 |     1.2. 对于 MKV 隐写文件,改后缀名后,【工具-修复压缩文件】,然后再解压。 240 | 241 | ![117c966dac71fd2b968b0b31bd6f2551.webp](https://file.cangku.moe/images/117c966dac71fd2b968b0b31bd6f2551.webp) 242 | 243 | **2.** [7-zip](https://www.7-zip.org/) **:** 244 | 245 |     2.1. MP4 隐写文件先修改后缀名,然后右键点击文件,用 **#号模式** 打开压缩包,即可解压,如下图: 246 | 247 | ![fac85bc467296f3e462e8389413be4ec.webp](https://file.cangku.moe/images/fac85bc467296f3e462e8389413be4ec.webp) 248 | 249 |     2.2. MKV 隐写文件同上,用 **#号模式** 打开压缩包,即可解压。 250 | 251 |     2.3 使用命令行解压(-i!*.zip 表示仅解压.zip 部分,通过此法解压出的压缩包文件名为 "2.zip") 252 | 253 | ``` 254 | 7z x -t# "D:\TEMP\测试_hidden.mp4" -o "D:\TEMP" -i!*.zip 255 | ``` 256 | 257 | **3.** **Bandizip**:7.0 以上版本改后缀名为 .zip 即可解压 258 | 259 | **4. 手机**: 260 | 261 | **4.1. 【安卓】RAR**:用法和 WinRAR 相同,[下载地址](https://apkpure.com/rar-extractor-manager/com.rarlab.rar/download/7.01.build123) 262 | 263 | **4.2.** **【苹果】解压专家**:用法和 WinRAR 相同:[下载地址](https://apps.apple.com/cn/app/%E8%A7%A3%E5%8E%8B%E4%B8%93%E5%AE%B6-dzip-zip-rar-7z-%E5%BF%AB%E9%80%9F%E8%A7%A3%E5%8E%8B%E5%92%8C%E5%8E%8B%E7%BC%A9/id1400133654) 264 | 265 | **5.** 其余解压软件正在测试中,暂时未找到 100%稳定的方法 266 | 267 | 268 | [/collapse] 269 | 270 | ## 3. 程序功能简介 271 | 虽然文章 [[1]](https://cangku.moe/archives/211591)提供了一个有效的代码实现用于文件隐写,但该方法缺乏一个简单易用的操作界面,这限制了其推广与普及。 272 | 273 | 本程序在文章 [[1]](https://cangku.moe/archives/211591)的基础上进行简化,开发了一个包含图形用户界面(GUI)的隐写程序,使用户能够通过简单的拖放和点击操作完成文件的隐写和解隐写。 274 | 275 | 2024.4.24 新增:根据文章 [[X1]](https://cangku.moe/archives/199992)提出的方法,也可以把文件以附件的形式嵌入到 MKV 文件中,在 v1.0.2 版本中新增了此逻辑。 276 | 277 | ## 4. GUI 设计与功能介绍 278 | [nav title1 = "演示视频" title2 = "GUI 界面" title3 = "哈希修改器" title4 = "验证码生成器" title5 = "右键菜单集成" type = "tab"] [content target = "title1"] [/content] [content target = "title2"] 279 | 280 | ![89a2d5d315fe293ee4dd8f22b4e1dd67.webp](https://file.cangku.moe/images/89a2d5d315fe293ee4dd8f22b4e1dd67.webp) 281 | 282 |  [/content] 283 | 284 | [content target = "title3"] 285 | 286 | 本程序的适用场景为 **传火、补档** 或者传和谐文件到网盘 前处理工作,上传文件之前需要修改哈希值以 **防止炸链牵连到原分享文件**。 287 | 288 | 哈希修改的原理类似于隐写,在文件的后面贴 **1** 个随机字节,使得文件的 **MD5** 值发生变化。 289 | 290 | 注意隐写者本身会自动进行哈希随机化处理,哪怕原文件是同一个,每次生成的隐写文件哈希值都不一样,正常隐写文件时 **不必考虑** 哈希值问题。 291 | 292 |  ![3e78f26da4d708e61049a599cde829e3.webp](https://file.cangku.moe/images/3e78f26da4d708e61049a599cde829e3.webp) 293 | 294 | [/content]  295 | 296 | [content target = "title4"] 297 | 298 | 使用验证码生成器处理提取码,可以防止被爬虫爬取到链接 299 | 300 | ![d72d3b79e10f0e412d7bd101a3058db1.webp](https://file.cangku.moe/images/d72d3b79e10f0e412d7bd101a3058db1.webp) 301 | 302 | 示例如下: 303 | 304 | ![d55ef7164a6e579b01534ea1dd88eb7f.webp](https://file.cangku.moe/images/d55ef7164a6e579b01534ea1dd88eb7f.webp) 305 | 306 | [/content]  307 | 308 | [content target = "title5"] 309 | 310 | 在 v1.2.0 版中,新增了可以 **集成软件到右键菜单** 的安装脚本和卸载脚本,双击执行即可安装/卸载。功能基于 CLI 模式(默认选择 cover_video 文件下第一个视频)。目前暂时不能处理密码、选择视频等详细操作,如果需要进行这类操作,可以选择右键-打开隐写者 GUI 311 | 312 | ![d36f4b4f035cdf963cc481991cfa44e8.webp](https://file.cangku.moe/images/d36f4b4f035cdf963cc481991cfa44e8.webp) 313 | 314 | 20240801-v1.2.0.1 版本,进一步 **集成哈希修改器** 功能 315 | 316 | ![ef0d856f69b62f7baf724a774cc83bc4.webp](https://file.cangku.moe/images/ef0d856f69b62f7baf724a774cc83bc4.webp) 317 | 318 | 以下为详细操作演示 319 | 320 | ![fa9b0e5590c07a7c7bc88a016b48eb54.gif](https://file.cangku.moe/images/fa9b0e5590c07a7c7bc88a016b48eb54.gif) 321 | 322 | [/content] 323 | 324 | [/nav] 325 | 326 | 本程序允许通过 **输入密码** 和 **拖入文件** 的方式来直接进行文件的隐写和解隐写。 327 | 328 | 程序具有以下特点: 329 | 330 | **(1) 一体化**:既可以进行 **隐写**,也可以在同一个界面进行 **解除隐写** 操作,提升了程序的整体效率和便利性。 331 | 332 | **(2) 拖放功能**:支持拖放文件或文件夹到指定区域,简化了文件选择的过程。 333 | 334 | **(3) 通用性**:产生的隐写 MP4 文件可以 **手动** 修改后缀名解压,**并不强制要求使用本程序**。 335 | 336 | **(4) 密码保护**:**必须** 输入密码才能进行隐写或解隐写操作。 v1.0.6 版后允许不指定密码。 337 | 338 | **(5) CLI 调用:** 可在终端窗口中使用指令操作,或被其他应用作为 **第三方程序** 调用(1.1.0 版本更新) 339 | 340 | **(6) 右键菜单集成:可以集成到鼠标右键菜单,以类似常见压缩软件的逻辑进行操作(** 1.2.0 版本更新 **)** 341 | 342 | [collapse title = "文件路径结构" show = "false"] 343 | 344 | ![bf5bdfdf99d7b6388d2f156de054bc55.webp](https://file.cangku.moe/images/bf5bdfdf99d7b6388d2f156de054bc55.webp) 345 | 346 | └─Steganographier-文件隐写程序 347 | │ ├─ 隐写者.exe 11.88 MB --> 主程序 348 | │ ├─cover_video --> 放置伪装外壳 MP4 文件 349 | │ │ ├─SpongeBob SquarePants S01E01A Help Wanted.mp4 44.91 MB 350 | │ │ ├─SpongeBob SquarePants S01E01B Reef Blower.mp4 21.21 MB 351 | │ │ └─SpongeBob SquarePants S01E01C Tea at the Treedome.mp4 64.50 MB 352 | │ ├─modules 353 | │ │ └─favicon.ico 24.93 KB --> icon 354 | │ ├─tools 355 | 356 | [/collapse] 357 | 358 | **隐写外壳视频下载工具**(B 站视频下载工具):[https://github.com/leiurayer/downkyi](https://github.com/leiurayer/downkyi) 359 | 360 | ## 5. 经验与技巧分享 361 | 362 | ### 5.1 资源分享的几个安全级别 363 | 364 | (1) **直接上传&分享**:真的勇士,总是敢于直面惨淡的人生和淋漓的鲜血,以及炸链、封号等一系列挫折。 365 | 366 | (2) **单层/多层压缩包**:有密码并加密文件名就能防止网盘扫描压缩包中的内容,一定程度上可以抵抗审查,但是无法防止在线解压(手机端可以在线解压包括 7z 在内的压缩包格式,不过大于 12GB 的压缩包目前无法在线解压),如果没有密码,那么参考 (1) 367 | 368 | (3)-1 **分卷压缩包**:由于分卷压缩包无法在线解压,安全性较 2 提高了很多(是否改后缀名,或者有没有混淆文件并无太多影响) 。 369 | 370 | (3)-2 **自解压格式压缩包**:格式为 exe 的压缩包,不需要有解压软件直接执行就可以解压,可以设置密码加密文件名。自解压文件也不能在线解压,安全性与分卷压缩包为同一级别。 371 | 372 | (4) **其他专有格式的加密文件**:包括但不限于 Cryptomator 、VeraCrypt 等专有格式加密文件。相比于较为通用的压缩包,网盘方不太可能搭载能够解密这些专有格式的功能,所以安全性高于前者。不过无法应对大量举报造成的强制违规。 373 | 374 | (5) **隐写文件**:这里特指 MP4/MKV 隐写文件,从加密技术层面来看,隐写文件属于 3 这个级别(隐写文件也不能在线解压,会提示压缩包损坏)。由于其伪装能力强的特性,可以较好混淆审查。不怕举报造成的强制违规,可以申诉(详见 5.3 节)。因此安全性高于上述所有。 375 | 376 | (6) **磁链、IPFS、自建网盘**:去中心化分享由于无法被举报,所以安全性是顶级,自建网盘也是同理。关于举报相关的内容,会在 5.3、5.6 节详细讨论。 377 | 378 | 总的来说,根据 【**1. 能否加密**】 【**2. 能否在线解压**】 【**3. 能否被举报**】这三个点,可以把分享方式大致划分出 3 个大的安全级别。 379 | 380 | 关于网盘分享的安全级别排名,感兴趣可以进一步看 [这篇文章](https://cangku.moe/archives/212002) [8]。 381 | 382 | ### 5.2 隐写文件安全性来源:低可疑度 383 | 384 | 在选择文件和隐写内容时,需要根据分享资源的大小选择合适的外壳文件,**看上去要合理,不至于让人怀疑**。 385 | 386 | 比如你的资源大小有 3 个 GB,此时最好就不要选 1、2 分钟的短视频,因为这不太合理,容易让人怀疑;最好选择一个时长 1 到 2 小时的长视频。可以选择低清晰度的电影或者 b 站上的网课类长视频,这类视频的 360P 大小通常在 300MB 以内。 387 | 388 | 我也在程序中提供了几个供参考的长视频,大家可以按需选用。**我认为,使用少量的额外流量换取安全性还是比较划算的**。 389 | 390 | 下面是个人推荐的 **资源大小-外壳时长** 参考表。 391 | 392 | | 资源大小 | 视频时长推荐 | 393 | |----------------|-------------------| 394 | | 0-200MB | 1-3分钟 | 395 | | 200-400MB | 3-15分钟 | 396 | | 400-500MB | 15-30分钟 | 397 | | 500MB-1GB | 30分钟-1小时 | 398 | | 1GB-3GB | 1小时 | 399 | | 3GB-4GB | 2小时 | 400 | | 4GB以上 | 2小时以上 | 401 | 402 | 403 | 对于过大的资源,可以采用分文件夹或分卷处理的方式,嵌入的外壳视频可以为按顺序分集的动画,这样可疑度会更低一些。目前程序在版本 1.1.2 之后新增了隐写不合理的提醒。 404 | 405 | ### 5.3 基于隐写的申诉补档技巧 406 | 407 | 隐写文件的区别于其他网盘分享方式的主要特点为:**不容易炸链,且** **违规可申诉**。 408 | 409 | 所谓不容易炸链,是指隐写虽然被举报到一定数量(这里用“**少量**”指代 ),会 **短暂地进入审核状态,表现为【暂时冻结】或【正在审核】,但是过 5-10 分钟可自行恢复正常**,**免疫** 少量及以下的举报。  410 | 411 | 关于此特性的证明,参考 [这个试验[5]](https://cangku.moe/archives/211857) 412 | 413 | 这是以往的任何加密方法都做不到的,如上文所述,网盘对于无法解密又被大量举报的文件会倾向于直接判违规。 414 | 415 | 不过,隐写文件虽然不容易炸链,但是被 **大量** 或 **海量** 举报以后还是有可能炸链的(分别会提示“此文件禁止分享”或者“文件违规根据相关法律法规予以屏蔽”)。 416 | 417 | 当一个分享炸链以后,我们如果要对其进行补档,通常需要重新压缩以后再上传,之所以不能直接重新分享而要这样做,是因为 **违规文件的哈希值已经被网盘记录**,再次上传或者分享网盘都认得这个文件(文件哈希值可以类比人类的指纹),这无疑费时费力,尤其是文件很大的时候,更是一场噩梦。 418 | 419 | 隐写文件的 **可申诉性** 给了我们另一种比较方便的解决办法:**我们可以直接对违规文件进行申诉,然后重新分享即可,并不需要一次次地重新压缩上传进行补档**。假如最近一直被人盯着举报,可以选择等待一段时间避过风头之后再申诉让文件“活过来”然后继续分享。 420 | 421 | 关于此技巧的更多细节,详见 [这篇文章[10]](https://cangku.moe/archives/212735) 和 [这篇文章[9]](https://cangku.moe/archives/212105) 。 422 | 423 | ### 5.4 适度的擦边也是伪装 424 | 425 | 另一方面,**内容完全没有问题可能也不太好**。 426 | 427 | 如果被大量举报,容易引起人工复查,一个视频明明看着完全没有问题,但却总是被举报色情,这也很可疑。 428 | 429 | 对此的一个建议是,**假如你的资源可能面临** [大量举报](https://cangku.moe/archives/212002) **的风险**,可以使用各类性学相关或者能过审的哲 ♂ 学银梦等擦边但不至于被封的视频。 430 | 431 | 这样在面对大量举报时,可以最大程度降低暴露的风险,假如不幸被封,也可以 **合理化申诉理由**。 432 | 433 | ![6b3f6289b08797becab8518029722d52.webp](https://file.cangku.moe/images/6b3f6289b08797becab8518029722d52.webp) 434 | 435 | 具体大家可以发挥自己的想象力,**这里** **只是示例,视网盘方审核人员的好恶也有可能申诉失败**。总之: 436 | 437 | **伪装的目的并不是让人挑不出毛病,而是让对方误判,大事化小小事化了**。 438 | 439 | ### 5.5 个人隐私安全 440 | 441 | 尽管隐写可以增加文件的安全性,但分享时仍应考虑个人的网络安全和匿名性。例如,在上传期间使用 VPN 或代理;**不要使用包含个人信息的视频**,也 **不要总是使用同一个视频作为隐写外壳** 等,以降低被追踪和识别的风险。 442 | 443 | ### 5.6 资源倒卖者的举报 444 | 445 | 某些被认为有价值的资源有可能会被 **资源倒卖者** 盯上,所谓资源倒卖者,就是把免费分享的资源拿去贩卖以牟取利益的人,俗称“**倒狗**”。 446 | 447 | 为了保证利益的 **垄断**,资源倒卖者会举报其他的分享文件使之炸链,从而维持其来源的唯一性(白话:吃独食)。其结果表现就是投稿及其下方传火链接地毯式炸链。(例子:[[A](https://cangku.moe/archives/210844)]、[[B](https://cangku.moe/archives/204477)]、[[C](https://cangku.moe/archives/188146)]、[[D](https://cangku.moe/archives/205342)]、[[E](https://cangku.moe/archives/210908)]、[[F](https://cangku.moe/archives/209500)]) 448 | 449 | 具体来说,资源倒卖者会使用自动化的脚本对分享文件进行举报。注意 **举报的是文件不是链接**,倒卖者会转存想要使之违规的文件,用自己的号分享,然后运行举报脚本持续举报直到文件违规。 450 | 451 | 这种规模的举报不是隐写文件能够应对的,不仅隐写文件不行,任何正常文件都不行,哪怕是正常文件也会被网盘强制判定违规,也就是说这种违规与文件实际上存不存在违规内容无关,而属于近似于 DDos 的一种 **攻击行为**(这也就意味着哪怕秒传也不行),虽然隐写文件可以申诉,但是 **人的精力有限没必要和 24 小时持续不断举报的脚本抗衡。** 452 | 453 | 因此假如资源已经被资源倒卖者盯上(**判断标准是隐写文件分享后很快炸链,并且申诉成功解封以后再次快速炸链**)此时不建议再继续用常见网盘分享,而建议改用 **IPFS** [[X3](https://cangku.moe/archives/212530), [X10](https://cangku.moe/archives/212812)][](https://modsfire.com) **磁链** [[X4](https://cangku.moe/archives/212031), [X9](https://cangku.moe/archives/92314)]**、** [自建网盘](https://cangku.moe/archives/209596) [X5] **、** 等不会因举报而和谐的分享方式。 454 | 455 | 关于安全分享以及倒卖者举报的原理分析,参考 [这篇文章](https://cangku.moe/archives/212002) **[** 8],关于网盘违规以及倒卖者的手法证明,参考 [这个试验](https://cangku.moe/archives/211857) [5]。关于倒卖者的应对策略,可以参考****[这篇文章[10]](https://cangku.moe/archives/212735)。 456 | 457 | ### 5.7 法律问题 458 | 459 | 估计能看到这里的朋友都明白,但还是容我多嘴强调一点。 460 | 461 | 在使用本程序时,请大家遵守必要的相关法律和道德规范。自己的爱好可以分享资源,但是务必不要让这些小圈子里的东西上了台面,也不要去任何官方可以看到的地方跳脸。历史经验无数次告诉我们,小圈子破圈的后果往往是一地鸡毛。(**你!不要让大家都用不了隐写!**) 462 | 463 | 我们不鼓励使用隐写技术进行非法、涉政的活动,而是希望通过技术增强个人数据保护和隐私安全。 464 | 465 | ## 6. 使用案例分享 466 | 467 | 接下来,分享几个我认为比较有效的隐写案例: 468 | 469 | 1. **海绵宝宝法**:即采用成系列的视频来隐写分卷后的资源。比如海绵宝宝,每个视频时长 10min 左右,一集可以用来隐写 400MB 左右的内容。此法适合用来隐写需要分卷处理的大文件。同理,外壳文件也可以采用葫芦娃、喜羊羊等等其他儿童向视频。 470 | 471 | 2. **RickRoll 法**:使用歌曲 《Never Gonna Give You Up》 的 mv 作为隐写外壳,原视频时长 3min32s。 此法适合小于 200MB 的资源,个人比较推荐,因为容易被当成恶作剧进而被审核人员放过。 472 | 473 | 3. **电影法**:采用一个长电影作为外壳文件,可以用来隐写不适合分卷处理的大文件。 474 | 475 | 程序也支持自行选择 MP4 文件存放的文件夹,隐写时外壳 MP4 文件选取顺序可以 **按照** **名称** 或者 **按照时长** 顺序降序选择,亦或者 **随机选择**。 476 | 477 | ![56c1d783f4ea58f7601cd7b84bf88cfb.webp](https://file.cangku.moe/images/56c1d783f4ea58f7601cd7b84bf88cfb.webp) 478 | 479 | 如此可以更方便地进行【分卷压缩然后逐个隐写】的操作。如果使用如 B 站多 P 的视频,可以使得隐写结果看起来非常自然。 如下图 480 | 481 | ![f90d0cb2ed3374e7de4c386a9b1b7b2c.webp](https://file.cangku.moe/images/f90d0cb2ed3374e7de4c386a9b1b7b2c.webp) 482 | 483 | 关于分享的链接的命名问题,原则上以不引起怀疑为宗旨,但是也需要具体问题具体分析。比如采用了 RickRoll 法,那么文件名可以直接为原资源名(某些敏感词可能需要修改),如此可以给审查者一种分享者在搞恶作剧的感觉。 484 | 485 | ## 7. 不足与展望 486 | 目前的程序仍然存在一些问题,比如合并方法是简单地将 ZIP 文件附加到视频文件的末尾、嵌入 MP4 文件的 moov box 或者 MKV 文件的附件中。这种方法虽然易于实现但也容易被检测到。 487 | 488 | 后续也许可以考虑使用一种更加隐蔽的方式,例如将 ZIP 文件的内容嵌入到视频文件的某些不太关键的部分,在每个 I 帧后插入一小段数据等。这类做法需要分析视频文件的编码细节,可能需要用到其他库如 FFmpeg 等,具体留待后续研究。 489 | 490 | 尽管如此,根据文章 [[1]](https://cangku.moe/archives/211591)的测试结果,以及 [本测试的结果](https://cangku.moe/archives/211857),隐写具有:**① 不易违规 ② 即使违规也** [可申诉](https://cangku.moe/archives/212105) **③ 补档方便** 等优势。**在不被特意针对性举报的情况下,这样的隐写方法已经足以认为是一个可以推广的解决方案了**。 491 | 492 | 今后随着技术的进一步完善,此类隐写方法或许能成为替代秒传链接的一个有效手段。 493 | 494 | ## 8. 总结 495 | 本文介绍了资源分享手法到秒传链接为止的历史,证明了倒卖者是引起炸链的主要原因,提出采用隐写技术作为新时代的资源安全分享解决方案,同时分享了一些使用隐写技术时的一些经验和建议,希望能帮助资源分享者更好地活用隐写技术。 496 | 497 | 随着技术的进步和数字媒体的普及,隐写技术可能会有新的突破,除了之前提到过的插帧法隐写,还可能有深度学习 AI 驱动的隐写系统,这些都可能为资源分享安全问题提供新的解决方案。 498 | 499 | **矛与盾的对抗永不停息,新的时代在呼唤新的解决方案**。 500 | 501 | 欢迎大家参与到隐写技术的测试和研究中来,共同推动其发展。如果大家对于本程序有什么进一步的改善要求和建议,欢迎在评论区提出,或者在 GitHub 上提 issue。 502 | 503 | ## 9. 源代码: 504 | [https://github.com/cenglin123/SteganographierGUI](https://github.com/cenglin123/SteganographierGUI) 505 | 506 | ## 10. Release: 507 | 508 | ## 11. Virustotal 查毒报告 509 | [collapse title = "Virustotal 查毒报告"] 510 | 511 | https://www.virustotal.com/gui/file/720da1de9d83aa8714c69576a5e96d43cd63974806f78c5fa1253699bd74ff5b?nocache = 1 512 | 513 | [/collapse] 514 | 515 | ## 附录:MP4 隐写技术相关系列文章(时间顺序) 516 | ### 主要内容 517 | [1] [[技巧] 用文件隐写来规避网盘和谐](https://cangku.moe/archives/211591) 518 | 519 | [2] [[工具分享] 隐写者:把资源嵌入 MP4 文件的隐写工具 [资源防炸链解决方案倡议&规避网盘审查技巧探讨]](https://cangku.moe/archives/211602) 520 | 521 | [3] [[技巧分享] 隐写分享压力测试阶段性报告 [资源防炸链解决方案倡议]](https://cangku.moe/archives/211696) 522 | 523 | [4] [[技巧分享] 后秒传时代如何避免资源分享炸链? [资源防炸链解决方案倡议]](https://cangku.moe/archives/211707) 524 | 525 | [5] [[技巧分享] 关于评论区地毯式炸链现象的一些测试及初步猜想 [资源防炸链解决方案倡议]](https://cangku.moe/archives/211857) 526 | 527 | [6] [[技巧分享] 关于此评论区地毯式炸链的真实原因及补档相关说明 [资源防止炸链解决方案倡议]](https://cangku.moe/archives/211893) 528 | 529 | [7] [[技巧分享] 后秒传时代的安全分享路在何方?为什么我们需要隐写? [资源分享传火呼吁&隐写用法释疑]](https://cangku.moe/archives/211944) 530 | 531 | [8] [[技巧分享] 网盘资源分享的几种安全级别、审核与举报,分享建议 [资源防炸链解决方案倡议]](https://cangku.moe/archives/212002) 532 | 533 | [9] [[技巧分享] 隐写文件误区及申诉补档建议、百度云批量申诉工具 [资源防炸链解决方案倡议]](https://cangku.moe/archives/212105) 534 | 535 | [10] [[技巧分享] 如何应对资源倒卖者(倒狗)的举报 - 百度云篇 [资源防炸链解决方案倡议]](https://cangku.moe/archives/212735) 536 | 537 | ### 延伸阅读 538 | [X1] [[技术分享] 如何在.mkv 格式视频里夹带隐藏文件,附带 mkvtoolnix,MkvEdit 和 gMKVExtractGUI 工具](https://cangku.moe/archives/199992) 539 | 540 | [X2] [[杂谈] 给新司机的一个简单的科普](https://cangku.moe/archives/186292) (笔者注:此文是关于安全分享的科普) 541 | 542 | [X3] [[技巧分享] IPFS 分享资源快速上手及其适用场景浅议 [资源防炸链解决方案]](https://cangku.moe/archives/212530) 543 | 544 | [X4] [[技巧] 利用网盘离线下载分享规避审查](https://cangku.moe/archives/212031) 545 | 546 | [X5] [[技巧分享] [自建网盘] 自建网盘 cloudreve+离线下载](https://cangku.moe/archives/209596) 547 | 548 | [X6] [[高阶文章] 关于新时代文件分享机制的思考](https://cangku.moe/archives/178593) (笔者注:此文介绍了除网盘外的其他分享方案) 549 | 550 | [X7] [[技巧分享] 图种的制作与使用](https://cangku.moe/archives/204982)  551 | 552 | [X8] [[技巧分享] 防炸教程](https://cangku.moe/archives/179329) (笔者注:本文介绍了网盘常用的分享方案,不过作者有可能要吃电脑屏幕了) 553 | 554 | [X9] [[教程] BitTorrent (种子文件) 扫盲 [绅士仓库 tracker 更新] [2020 Rev]](https://cangku.moe/archives/92314) (笔者注:本文是磁力做种的教程) 555 | 556 | [X10] [[技巧分享] [IPFS] 无法被举报的文件分享神器 CRUST IPFS 操作指南 PART.I](https://cangku.moe/archives/212812) ( IPFS 托管平台教程) 557 | 558 | ## 免责声明: 559 | 本程序仅用于保护个人信息安全,请勿用于任何违法犯罪活动 560 | 561 | 否则 [后果](https://mps.gjzwfw.gov.cn/) 自负,开发者对此不承担任何责任 562 | -------------------------------------------------------------------------------- /Steganographier.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Created on Mon Apr 22 14:56:41 2024 4 | 5 | 隐写者源代码 6 | 7 | pip install tkinterdnd2 8 | pip install pyzipper 9 | pip install hachoir 10 | pip install natsort 11 | 12 | @author: Cr 13 | """ 14 | 15 | import os 16 | import io 17 | import re 18 | import sys 19 | import signal 20 | import json 21 | import random 22 | import datetime 23 | import tkinter as tk 24 | from tkinter import messagebox, ttk, filedialog 25 | from tkinterdnd2 import DND_FILES, TkinterDnD 26 | import pyzipper 27 | import zipfile 28 | import threading 29 | import subprocess 30 | import string 31 | from hachoir.parser import createParser 32 | from hachoir.metadata import extractMetadata 33 | from natsort import ns, natsorted # windows风格的按名称排序专用包 34 | import time 35 | import argparse 36 | import hashlib 37 | 38 | 39 | def generate_random_filename(length=16): 40 | """生成指定长度的随机文件名, 不带扩展名""" 41 | chars = string.ascii_letters + string.digits 42 | return ''.join(random.choice(chars) for _ in range(length)) 43 | 44 | def format_duration(seconds): 45 | if seconds < 60: 46 | return f"{seconds}s" 47 | elif seconds < 3600: 48 | minutes = seconds // 60 49 | seconds = seconds % 60 50 | return f"{minutes}m:{seconds:02d}s" 51 | else: 52 | hours = seconds // 3600 53 | minutes = (seconds % 3600) // 60 54 | seconds = seconds % 60 55 | return f"{hours}h:{minutes:02d}m:{seconds:02d}s" 56 | 57 | def get_video_duration(filepath): 58 | parser = createParser(filepath) 59 | if not parser: 60 | return None 61 | try: 62 | metadata = extractMetadata(parser) 63 | if not metadata: 64 | return None 65 | duration = metadata.get('duration') 66 | return int(duration.seconds) if duration else None 67 | finally: 68 | if parser.stream: 69 | parser.stream._input.close() 70 | 71 | def get_cover_video_files_info(folder_path, sort_by_duration=False): 72 | try: 73 | videos = [] 74 | for filename in os.listdir(folder_path): 75 | if filename.endswith(".mp4"): 76 | filepath = os.path.join(folder_path, filename) 77 | duration_seconds = get_video_duration(filepath) 78 | if duration_seconds is None: 79 | formatted_duration = "Unknown" 80 | else: 81 | formatted_duration = format_duration(duration_seconds) 82 | size = get_file_or_folder_size(filepath) # 获取文件/文件夹大小 83 | videos.append({ 84 | "filename": filename, 85 | "duration": formatted_duration, 86 | "duration_seconds": duration_seconds or 0, # 时长未知则为0 87 | "size": format_size(size) 88 | }) 89 | 90 | # 先按Windows显示风格排序 91 | videos = list(natsorted(videos, key=lambda x: x['filename'], alg=ns.PATH)) 92 | 93 | # 如果需要,再按总时长降序排列 94 | if sort_by_duration: 95 | videos.sort(key=lambda x: x['duration_seconds'], reverse=True) 96 | 97 | # 格式化显示 98 | formatted_videos = [f"{video['filename']} - {video['duration']} - {video['size']}" for video in videos] 99 | return formatted_videos 100 | 101 | except Exception as e: 102 | # 如果出现任何错误,返回空列表并可以打印错误信息进行调试 103 | print(f"An error occurred: {e}") 104 | return [] 105 | 106 | def get_file_or_folder_size(path): 107 | total_size = 0 108 | if os.path.isfile(path): 109 | total_size = os.path.getsize(path) 110 | elif os.path.isdir(path): 111 | for dirpath, dirnames, filenames in os.walk(path): 112 | for f in filenames: 113 | fp = os.path.join(dirpath, f) 114 | total_size += os.path.getsize(fp) 115 | return total_size 116 | 117 | def format_size(size): 118 | for unit in ['B', 'KB', 'MB', 'GB', 'TB']: 119 | if size < 1024: 120 | return f"{size:.2f} {unit}" 121 | size /= 1024 122 | 123 | def check_size_and_duration(size, duration_seconds): 124 | duration_minutes = duration_seconds / 60 125 | # 根据参考标准判断 126 | if size <= 200 * 1024*1024 and duration_minutes < 1: 127 | return False 128 | elif 200 * 1024*1024 < size <= 400 * 1024*1024 and duration_minutes < 3: 129 | return False 130 | elif 400 * 1024*1024 < size <= 500 * 1024*1024 and duration_minutes < 15: 131 | return False 132 | elif 500 * 1024*1024 < size <= 1 * 1024*1024*1024 and duration_minutes < 30: 133 | return False 134 | elif 1 * 1024*1024*1024 < size <= 3 * 1024*1024*1024 and duration_minutes < 60: 135 | return False 136 | elif 3 * 1024*1024*1024 < size <= 4 * 1024*1024*1024 and duration_minutes < 120: 137 | return False 138 | elif size > 4 * 1024*1024*1024 and duration_minutes <= 120: 139 | return False 140 | return True 141 | 142 | def add_empty_mdat_box(file): 143 | mdat_size = 8 # Minimum box size 144 | mdat_box = mdat_size.to_bytes(4, byteorder='big') + b'mdat' 145 | file.write(mdat_box) 146 | 147 | class SteganographierGUI: 148 | '''GUI: 隐写者程序表示层''' 149 | def __init__(self): 150 | self.root = TkinterDnD.Tk() 151 | self.video_folder_path = os.path.join(application_path, "cover_video") # 定义实例变量 默认外壳MP4文件存储路径 152 | self.output_option = "外壳文件名" # 设置输出模式的默认值 153 | self.type_option_var = tk.StringVar(value="mp4") 154 | self.output_cover_video_name_mode_var = tk.StringVar(value="") 155 | self.mkvmerge_exe = os.path.join(application_path,'tools','mkvmerge.exe') 156 | self.mkvextract_exe = os.path.join(application_path,'tools','mkvextract.exe') 157 | self.mkvinfo_exe = os.path.join(application_path,'tools','mkvinfo.exe') 158 | self._7z_exe = os.path.join(application_path,'tools','7z.exe') 159 | self.hash_modifier_exe = os.path.join(application_path,'tools','hash_modifier.exe') 160 | self.captcha_generator_exe = os.path.join(application_path,'tools','captcha_generator.exe') 161 | self.title = "隐写者 Ver.1.2.5 GUI 作者: 层林尽染" 162 | self.total_file_size = None # 被隐写文件总大小 163 | self.password = None # 密码 164 | self.password_modified = False # 追踪密码是否被用户修改过 165 | self.check_file_size_and_duration_warned = False # 追踪是否警告过文件大小-外壳时长匹配问题 166 | self.cover_video_options = [] # 外壳MP4文件列表 167 | 168 | self.hash_modifier_process = None 169 | self.hash_modifier_thread = None 170 | self.captcha_generator_process = None 171 | self.captcha_generator_thread = None 172 | self.root.protocol("WM_DELETE_WINDOW", self.on_closing) 173 | 174 | self.config_file = os.path.join(application_path, "config.json") # 设置配置文件路径 175 | self.load_config() # 程序启动时加载配置文件 176 | print(f"Loaded password from config: '{self.password}'") # Debug output 177 | 178 | self.steganographier = Steganographier(self.video_folder_path, gui_enabled=True) # 创建一个Steganographier类的实例 传递self.video_folder_path 179 | self.steganographier.set_progress_callback(self.update_progress) # GUI进度条回调显示函数, 把GUI的进度条函数传给逻辑层, 从而把逻辑层的进度传回GUI 180 | self.steganographier.set_cover_video_duration_callback(self.on_cover_video_duration) # 外壳文件时长回调函数, 把当前外壳视频时长传回GUI 181 | self.steganographier.set_log_callback(self.log) # log回调函数 182 | 183 | self.create_widgets() # GUI实现部分 184 | 185 | # 1. 窗口控件初始化方法 186 | def create_widgets(self): 187 | # 1.1 参数设定部分 188 | params_frame = tk.Frame(self.root) 189 | params_frame.pack(pady=5) 190 | 191 | self.root.title(self.title) 192 | try: 193 | self.root.iconbitmap(os.path.join(application_path,'modules','favicon.ico')) # 设置窗口图标 194 | except tk.TclError: 195 | print("无法找到图标文件, 继续运行程序...") 196 | 197 | # 密码输入框 198 | self.password_label = tk.Label(params_frame, text="密码:") 199 | self.password_label.pack(side=tk.LEFT, padx=5) 200 | self.password_entry = tk.Entry(params_frame, width=13, fg="grey") 201 | 202 | def clear_default_password(event): 203 | if self.password_entry.get() == "留空则无密码": 204 | self.password_entry.delete(0, tk.END) 205 | self.password_entry.configure(fg="black") #, show="*" 206 | self.password_modified = True 207 | 208 | def restore_default_password(event): 209 | if not self.password_entry.get(): 210 | self.password_entry.insert(0, "留空则无密码") 211 | self.password_entry.configure(fg="grey", show="") 212 | self.password_modified = False 213 | else: 214 | self.password_modified = True 215 | 216 | if self.password: 217 | self.password_entry.insert(0, self.password) 218 | self.password_entry.configure(fg="black") # , show="*" 219 | self.password_modified = True 220 | else: 221 | self.password_entry.insert(0, "留空则无密码") 222 | 223 | self.password_entry.pack(side=tk.LEFT, padx=10) 224 | self.password_entry.bind("", clear_default_password) 225 | self.password_entry.bind("", restore_default_password) 226 | 227 | # 工作模式选择 228 | self.type_option_label = tk.Label(params_frame, text="工作模式:") 229 | self.type_option_label.pack(side=tk.LEFT, padx=5, pady=5) 230 | self.type_option_var = tk.StringVar(value=self.type_option_var.get()) # 使用加载的配置 231 | self.type_option = tk.OptionMenu(params_frame, self.type_option_var, "mp4", "mkv") 232 | self.type_option.config(width=4) 233 | self.type_option.pack(side=tk.LEFT, padx=5, pady=5) 234 | 235 | # 输出选项 236 | self.output_option_label = tk.Label(params_frame, text="输出名:") 237 | self.output_option_label.pack(side=tk.LEFT, padx=5, pady=5) 238 | self.output_option_var = tk.StringVar(value=self.output_option) # 使用加载的配置 239 | self.output_option = tk.OptionMenu(params_frame, self.output_option_var, "原文件名", "外壳文件名", "随机文件名") 240 | self.output_option.config(width=8) 241 | self.output_option.pack(side=tk.LEFT, padx=5, pady=5) 242 | 243 | # 1.2 隐写/解隐写文件拖入窗口 244 | self.hide_frame = tk.Frame(self.root, bd=2, relief=tk.GROOVE) 245 | self.hide_frame.pack(pady=10) 246 | self.hide_label = tk.Label(self.hide_frame, text="在此窗口中批量输入/拖入需要隐写的文件/文件夹:") 247 | self.hide_label.pack() 248 | self.hide_text = tk.Text(self.hide_frame, width=65, height=5) 249 | self.hide_text.pack() 250 | self.hide_text.drop_target_register(DND_FILES) 251 | self.hide_text.dnd_bind("<>", self.hide_files_dropped) 252 | 253 | self.reveal_frame = tk.Frame(self.root, bd=2, relief=tk.GROOVE) 254 | self.reveal_frame.pack(pady=10) 255 | self.reveal_label = tk.Label(self.reveal_frame, text="在此窗口中批量输入/拖入需要解除隐写的MP4/MKV文件:") 256 | self.reveal_label.pack() 257 | self.reveal_text = tk.Text(self.reveal_frame, width=65, height=5) 258 | self.reveal_text.pack() 259 | self.reveal_text.drop_target_register(DND_FILES) 260 | self.reveal_text.dnd_bind("<>", self.reveal_files_dropped) 261 | 262 | # 1.3 外壳MP4文件选择相关逻辑 263 | video_folder_frame = tk.Frame(self.root) 264 | video_folder_frame.pack(pady=5) 265 | 266 | video_folder_label = tk.Label(video_folder_frame, text="外壳MP4文件存放:") 267 | video_folder_label.pack(side=tk.LEFT, padx=5) 268 | 269 | self.video_folder_entry = tk.Entry(video_folder_frame, width=35) 270 | self.video_folder_entry.insert(0, self.video_folder_path) 271 | self.video_folder_entry.pack(side=tk.LEFT, padx=5) 272 | 273 | self.video_folder_button = tk.Button(video_folder_frame, text="选择文件夹", command=self.select_video_folder) 274 | self.video_folder_button.pack(side=tk.LEFT, padx=5) 275 | 276 | if os.path.exists(self.video_folder_path): 277 | self.cover_video_options = get_cover_video_files_info(self.video_folder_path) # 获取外壳MP4视频文件列表和时长-大小信息 278 | else: 279 | self.cover_video_options = [] # 如果文件夹不存在, 提供一个默认的空列表 280 | 281 | self.output_cover_video_name_mode_var = tk.StringVar() 282 | if self.cover_video_options: 283 | self.output_cover_video_name_mode_var.set(self.cover_video_options[0]) # 默认选择菜单中的第一个视频文件 284 | # self.output_cover_video_name_mode_var.set('===============名称顺序模式===============') # 默认选择模式 285 | else: 286 | self.output_cover_video_name_mode_var.set("No videos found") 287 | 288 | self.video_option_menu = tk.OptionMenu(self.root, 289 | self.output_cover_video_name_mode_var, # 默认选择 290 | *self.cover_video_options, # 其余选择 291 | '===============随机选择模式===============', 292 | '===============时长顺序模式===============', 293 | '===============名称顺序模式===============') 294 | self.video_option_menu.pack() 295 | 296 | # 1.4 log文本框和滚动条 297 | log_frame = tk.Frame(self.root) 298 | log_frame.pack(pady=5, padx=10, fill=tk.BOTH, expand=True) # 调整布局管理器为 pack,支持扩展 299 | 300 | log_scrollbar_y = tk.Scrollbar(log_frame) 301 | log_scrollbar_y.pack(side=tk.RIGHT, fill=tk.Y) # 垂直滚动条设置为填充Y方向 302 | 303 | log_scrollbar_x = tk.Scrollbar(log_frame, orient=tk.HORIZONTAL) 304 | log_scrollbar_x.pack(side=tk.BOTTOM, fill=tk.X) # 水平滚动条设置为填充X方向 305 | 306 | self.log_text = tk.Text(log_frame, wrap=tk.NONE, 307 | yscrollcommand=log_scrollbar_y.set, 308 | xscrollcommand=log_scrollbar_x.set, 309 | width=65, height=10, state=tk.NORMAL) 310 | self.log_text.insert(tk.END, "【免责声明】:\n--本程序仅用于保护个人信息安全, 请勿用于任何违法犯罪活动--\n--否则后果自负, 开发者对此不承担任何责任--\nConsole output goes here...\n\n") 311 | self.log_text.configure(state=tk.DISABLED, fg="grey") 312 | self.log_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # 文本框设置为填充BOTH方向,并支持扩展 313 | 314 | log_scrollbar_y.config(command=self.log_text.yview) # 设置垂直滚动条与文本框的联动 315 | log_scrollbar_x.config(command=self.log_text.xview) # 设置水平滚动条与文本框的联动 316 | 317 | # 1.5 控制按钮部分 318 | button_frame = tk.Frame(self.root) 319 | button_frame.pack(pady=10) 320 | 321 | self.start_button = tk.Button(button_frame, text="开始执行", command=self.start_thread, width=12, height=2) 322 | self.start_button.pack(side=tk.LEFT, padx=5) 323 | 324 | self.clear_button = tk.Button(button_frame, text="清除窗口", command=self.clear, width=12, height=2) 325 | self.clear_button.pack(side=tk.LEFT, padx=5) 326 | 327 | self.start_hash_modifier_button = tk.Button(button_frame, text="哈希修改器", command=self.start_hash_modifier, width=12, height=2) 328 | self.start_hash_modifier_button.pack(side=tk.LEFT, padx=5) 329 | 330 | self.start_captcha_generator_button = tk.Button(button_frame, text="验证码生成器", command=self.start_captcha_generator, width=12, height=2) 331 | self.start_captcha_generator_button.pack(side=tk.LEFT, padx=5) 332 | 333 | # 1.6 进度条 334 | self.progress = ttk.Progressbar(self.root, length=500, mode='determinate') 335 | self.progress.pack(pady=10) 336 | 337 | self.root.mainloop() 338 | 339 | # 2. 被隐写文件的拖入方法 340 | def hide_files_dropped(self, event): 341 | file_paths = self.root.tk.splitlist(event.data) 342 | self.hide_text.insert(tk.END, "\n".join(file_paths) + "\n") 343 | for idx, file_path in enumerate(file_paths): 344 | size = get_file_or_folder_size(file_path) 345 | print(f"Target size: {size}") 346 | print(f"self.type_option_var.get(): {self.type_option_var.get()}") 347 | # 检查是否为 mkv 文件并且大小是否超过 2GB 348 | if self.type_option_var.get() == "mkv" and size > 2 * 1024 * 1024 * 1024: 349 | messagebox.showerror("文件大小错误", "mkv 文件不能隐写超过 2GB 的文件") 350 | self.clear() 351 | return 352 | # 2.1 由逻辑层传入当前正在使用的外壳MP4文件, 用来进行大小-时长检查 353 | cover_video_path = self.steganographier.choose_cover_video_file( 354 | processed_files=idx, 355 | output_cover_video_name_mode=self.output_cover_video_name_mode_var.get(), 356 | video_folder_path=self.video_folder_path, 357 | ) 358 | duration_seconds = get_video_duration(cover_video_path) 359 | self.check_file_size_and_duration(file_path, duration_seconds, idx) 360 | self.log(f"输入[{idx+1}]: {os.path.split(file_path)[1]}, 文件/文件夹大小 {format_size(size)}, 预定外壳时长 {format_duration(duration_seconds)}") 361 | 362 | # 2.a 检查输入大小-外壳时长, 并给出建议 363 | def check_file_size_and_duration(self, file_path, duration_seconds, idx=0): 364 | size = get_file_or_folder_size(file_path) 365 | if not check_size_and_duration(size, duration_seconds) and self.check_file_size_and_duration_warned == False: 366 | messagebox.showinfo("===========【文件隐写不合理提醒】===========", self.get_warning_text(size, duration_seconds, idx)) 367 | self.check_file_size_and_duration_warned = True 368 | 369 | # 2.b 给出建议的text文本 370 | def get_warning_text(self, size, duration_seconds, idx): 371 | return f'''本弹窗仅为提醒, 并非报错 372 | 373 | 输入 [{idx+1}] 体积为 {format_size(size)}, 预定外壳 [{idx+1}] 时长: {format_duration(duration_seconds)} 374 | 375 | 【体积较大但时长较短】的文件容易【引起怀疑】, 建议【分卷压缩】后再进行隐写, 或者选取较长的外壳视频, 本警告程序重启前仅提醒 1 次, 可参照下列建议值检查其他文件是否存在问题. 376 | 377 | 建议值: 378 | 文件大小 外壳视频时长 379 | -------------------------------- 380 | 0-200MB 1-3分钟 381 | 200-400MB 3-15分钟 382 | 400-500MB 15-30分钟 383 | 500MB-1GB 30分钟-1小时 384 | 1GB-3GB 1小时 385 | 3GB-4GB 2小时 386 | >4GB 2小时以上 387 | 388 | 本弹窗仅为提醒, 并非报错, 可以坚持执行. 389 | ''' 390 | 391 | # 3. 解除隐写文件的拖入方法 392 | def reveal_files_dropped(self, event): 393 | file_paths = self.root.tk.splitlist(event.data) 394 | self.reveal_text.insert(tk.END, "\n".join(file_paths) + "\n") 395 | for idx, file_path in enumerate(file_paths): 396 | size = get_file_or_folder_size(file_path) 397 | self.log(f"输入[{idx+1}]: {os.path.split(file_path)[1]} 大小: {format_size(size)}") 398 | 399 | # 4. 外壳MP4文件路径传参更新函数 400 | def update_video_folder_path(self, new_path): 401 | self.video_folder_path = new_path 402 | self.steganographier.video_folder_path = new_path # 更新Steganographier实例的video_folder_path 403 | 404 | # 4.a 选择外壳MP4文件夹函数 405 | def select_video_folder(self): 406 | folder_path = filedialog.askdirectory() 407 | if folder_path: 408 | self.video_folder_path = folder_path 409 | self.video_folder_entry.delete(0, tk.END) 410 | self.video_folder_entry.insert(0, folder_path) 411 | self.update_video_folder_path(folder_path) # 调用方法更新video_folder_path 412 | 413 | # 4.a.1 更新外壳MP4视频文件列表和信息 414 | self.cover_video_options = get_cover_video_files_info(self.video_folder_path) 415 | self.cover_video_options += ['===============随机选择模式===============', 416 | '===============时长顺序模式===============', 417 | '===============名称顺序模式==============='] 418 | if not [item for item in os.listdir(self.video_folder_path) if item.lower().endswith('.mp4')]: 419 | messagebox.showwarning("Warning", "文件夹下没有MP4文件, 请添加文件后继续.") 420 | self.output_cover_video_name_mode_var.set("No videos found") 421 | self.video_option_menu['menu'].delete(0, 'end') 422 | return 423 | 424 | self.output_cover_video_name_mode_var.set(self.cover_video_options[0]) # 默认选择第一个文件 425 | # self.output_cover_video_name_mode_var.set('===============名称顺序模式===============') 426 | self.video_option_menu['menu'].delete(0, 'end') 427 | for option in self.cover_video_options: 428 | self.video_option_menu['menu'].add_command(label=option, 429 | command=tk._setit(self.output_cover_video_name_mode_var, option)) 430 | 431 | # 检查mkv工具是否缺失 432 | def check_mkvtools_existence(self): 433 | missing_tools = [] 434 | for tool in [self.mkvmerge_exe, self.mkvinfo_exe, self.mkvextract_exe]: 435 | if not os.path.exists(tool): 436 | missing_tools.append(os.path.basename(tool)) 437 | 438 | if missing_tools: 439 | messagebox.showwarning("Warning", "以下工具文件缺失, 请在tools文件夹中添加后继续: " + ", ".join(missing_tools)) 440 | return False 441 | return True 442 | 443 | # 检查7zip工具是否缺失 444 | def check_7zip_existence(self): 445 | missing_tools = [] 446 | for tool in [self._7z_exe]: 447 | if not os.path.exists(tool): 448 | missing_tools.append(os.path.basename(tool)) 449 | 450 | if missing_tools: 451 | messagebox.showwarning("Warning", "以下工具文件缺失, 请在tools文件夹中添加后继续: " + ", ".join(missing_tools)) 452 | return False 453 | return True 454 | 455 | def log(self, message): 456 | self.log_text.configure(state=tk.NORMAL, fg="grey") 457 | self.log_text.insert(tk.END, message + "\n") 458 | self.log_text.configure(state=tk.DISABLED, fg="grey") 459 | self.log_text.see(tk.END) 460 | self.log_text.update_idletasks() 461 | 462 | def start_thread(self): 463 | # 在启动线程前, 先将焦点转移到主窗口上, 触发密码输入框的FocusOut事件 464 | self.root.focus_set() 465 | threading.Thread(target=self.start).start() 466 | 467 | def start(self): 468 | # 1. 开始后禁用start和clear按钮 469 | self.start_button.configure(state=tk.DISABLED) 470 | self.clear_button.configure(state=tk.DISABLED) 471 | 472 | self.progress['value'] = 0 # 初始化进度条 473 | 474 | # 2. 获取密码的逻辑 475 | def get_password(): 476 | if not self.password_modified: 477 | return "" # 如果密码未修改过, 返回空字符串 478 | return self.password_entry.get() 479 | self.password = get_password() 480 | 481 | if self.type_option_var.get() == 'mkv': # MKV模式检查工具是否存在 482 | if not self.check_mkvtools_existence(): 483 | # 结束后恢复按钮 484 | self.start_button.configure(state=tk.NORMAL) 485 | self.clear_button.configure(state=tk.NORMAL) 486 | return 487 | 488 | # 3. 输入文件检查 489 | hide_file_paths = self.hide_text.get("1.0", tk.END).strip().split("\n") 490 | reveal_file_paths = self.reveal_text.get("1.0", tk.END).strip().split("\n") 491 | if not any(hide_file_paths) and not any(reveal_file_paths): 492 | messagebox.showwarning("Warning", "请输入或拖入文件.") 493 | # 结束后恢复按钮 494 | self.start_button.configure(state=tk.NORMAL) 495 | self.clear_button.configure(state=tk.NORMAL) 496 | return 497 | 498 | # 4. 外壳MP4文件检查 499 | if not [item for item in os.listdir(self.video_folder_path) if item.lower().endswith('.mp4')]: 500 | messagebox.showwarning("Warning", "文件夹下没有MP4文件, 请添加文件后继续.") 501 | self.output_cover_video_name_mode_var.set("No videos found") 502 | self.video_option_menu['menu'].delete(0, 'end') 503 | # 结束后恢复按钮 504 | self.start_button.configure(state=tk.NORMAL) 505 | self.clear_button.configure(state=tk.NORMAL) 506 | return 507 | 508 | total_files = len(hide_file_paths) + len(reveal_file_paths) 509 | 510 | # 5. 隐写流程 511 | processed_files = 0 512 | for input_file_path in hide_file_paths: 513 | if input_file_path: 514 | self.steganographier.hide_file(input_file_path=input_file_path, 515 | password=self.password, 516 | processed_files=processed_files, 517 | output_option=self.output_option_var.get(), 518 | output_cover_video_name_mode=self.output_cover_video_name_mode_var.get(), 519 | type_option_var=self.type_option_var.get(), 520 | video_folder_path=self.video_folder_path) 521 | processed_files += 1 522 | self.update_progress(processed_files, total_files) 523 | 524 | # 6. 解除隐写流程 525 | processed_files = 0 526 | for input_file_path in reveal_file_paths: 527 | if input_file_path: 528 | self.steganographier.reveal_file(input_file_path=input_file_path, 529 | password=self.password, 530 | type_option_var=self.type_option_var.get()) 531 | processed_files += 1 532 | self.update_progress(processed_files, total_files) 533 | 534 | messagebox.showinfo("Success", "所有操作已完成!") 535 | # 结束后恢复按钮 536 | self.start_button.configure(state=tk.NORMAL) 537 | self.clear_button.configure(state=tk.NORMAL) 538 | 539 | def update_progress(self, processed_size, total_size): # 进度条回调函数, 接收逻辑层的处理进度然后显示在GUI中 540 | progress = (processed_size+1) / total_size 541 | self.progress['value'] = progress * 100 542 | self.root.update_idletasks() 543 | 544 | def on_cover_video_duration(self, duration_seconds): # 回调函数, 用于接收来自逻辑层的外壳文件时长信息 545 | self.cover_video_duration = duration_seconds 546 | 547 | def clear(self): 548 | self.hide_text.delete("1.0", tk.END) 549 | self.reveal_text.delete("1.0", tk.END) 550 | 551 | self.log_text.configure(state=tk.NORMAL, fg="grey") 552 | self.log_text.delete("1.0", tk.END) 553 | self.log_text.insert(tk.END, "【免责声明】:\n--本程序仅用于保护个人信息安全, 请勿用于任何违法犯罪活动--\n--否则后果自负, 开发者对此不承担任何责任--\nConsole output goes here...\n\n") 554 | self.log_text.configure(state=tk.DISABLED, fg="grey") 555 | # self.check_file_size_and_duration_warned = False # 禁用此则程序重启前大小检测只提醒一次, 启用后每次程序复位都会恢复它 556 | 557 | def start_hash_modifier(self): 558 | self.start_hash_modifier_button.config(state=tk.DISABLED) 559 | 560 | def run_and_wait(): 561 | try: 562 | # 使用 subprocess.DETACHED_PROCESS 标志启动进程 563 | self.hash_modifier_process = subprocess.Popen( 564 | [self.hash_modifier_exe], 565 | creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP 566 | ) 567 | self.hash_modifier_process.wait() 568 | finally: 569 | self.start_hash_modifier_button.config(state=tk.NORMAL) 570 | self.hash_modifier_process = None 571 | self.hash_modifier_thread = None 572 | 573 | self.hash_modifier_thread = threading.Thread(target=run_and_wait) 574 | self.hash_modifier_thread.start() 575 | 576 | def start_captcha_generator(self): 577 | self.start_captcha_generator_button.config(state=tk.DISABLED) 578 | 579 | def run_and_wait(): 580 | try: 581 | # 使用 subprocess.DETACHED_PROCESS 标志启动进程 582 | self.captcha_generator_process = subprocess.Popen( 583 | [self.captcha_generator_exe], 584 | creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP 585 | ) 586 | self.captcha_generator_process.wait() 587 | finally: 588 | self.start_captcha_generator_button.config(state=tk.NORMAL) 589 | self.captcha_generator_process = None 590 | self.captcha_generator_thread = None 591 | 592 | self.captcha_generator_thread = threading.Thread(target=run_and_wait) 593 | self.captcha_generator_thread.start() 594 | 595 | def load_config(self): 596 | if os.path.exists(self.config_file): 597 | with open(self.config_file, 'r') as f: 598 | config = json.load(f) 599 | # 从配置文件获取 video_folder_path,若不存在则使用默认值 600 | self.video_folder_path = config.get('video_folder_path', self.video_folder_path) 601 | # 如果 video_folder_path 不存在,使用默认路径 602 | if not os.path.exists(self.video_folder_path): 603 | # 在这里设置默认的路径 604 | self.video_folder_path = os.path.join(application_path, "cover_video") 605 | 606 | self.password = config.get('password', '') 607 | self.output_option = config.get('output_option', self.output_option) 608 | self.type_option_var = tk.StringVar(value=config.get('type_option', 'mp4')) 609 | self.output_cover_video_name_mode_var = tk.StringVar(value=config.get('output_cover_video_name_mode', '')) 610 | 611 | def save_config(self): 612 | config = { 613 | 'video_folder_path': self.video_folder_path, 614 | 'password': self.password_entry.get() if self.password_modified else '', 615 | 'output_option': self.output_option_var.get(), 616 | 'type_option': self.type_option_var.get(), 617 | 'output_cover_video_name_mode': self.output_cover_video_name_mode_var.get() 618 | } 619 | with open(self.config_file, 'w') as f: 620 | json.dump(config, f, indent=4) 621 | 622 | def on_closing(self): 623 | # 程序关闭时保存配置 624 | self.save_config() 625 | # 程序退出时关闭已打开的 hash_generator 626 | if self.hash_modifier_process: 627 | # 使用 taskkill 命令强制结束进程树 628 | subprocess.call(['taskkill', '/F', '/T', '/PID', str(self.hash_modifier_process.pid)]) 629 | 630 | if self.hash_modifier_thread and self.hash_modifier_thread.is_alive(): 631 | self.hash_modifier_thread.join(timeout=1) 632 | 633 | # 程序退出时关闭已打开的 captcha_generator 634 | if self.captcha_generator_process: 635 | # 使用 taskkill 命令强制结束进程树 636 | subprocess.call(['taskkill', '/F', '/T', '/PID', str(self.captcha_generator_process.pid)]) 637 | 638 | if self.captcha_generator_thread and self.captcha_generator_thread.is_alive(): 639 | self.captcha_generator_thread.join(timeout=1) 640 | 641 | self.root.destroy() 642 | # 强制结束整个 Python 进程 643 | os._exit(0) 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 | class Steganographier: 655 | '''隐写的具体功能由此类实现''' 656 | def __init__(self, video_folder_path=None, gui_enabled=False, password_file=None): 657 | self.mkvmerge_exe = os.path.join(application_path,'tools','mkvmerge.exe') 658 | self.mkvextract_exe = os.path.join(application_path,'tools','mkvextract.exe') 659 | self.mkvinfo_exe = os.path.join(application_path,'tools','mkvinfo.exe') 660 | self._7z_exe = os.path.join(application_path,'tools','7z.exe') 661 | self.password_file = password_file or os.path.join(application_path,'modules',"PW.txt") 662 | self.passwords = self.load_passwords() 663 | if video_folder_path: 664 | self.video_folder_path = video_folder_path 665 | else: 666 | self.video_folder_path = os.path.join(application_path, "cover_video") # 默认外壳文件存放路径 667 | print(f"外壳文件夹路径:{self.video_folder_path}") 668 | self.total_file_size = None # 被隐写文件/文件夹的总大小 669 | self.password = None # 密码 670 | self.remaining_cover_video_files = [] # 随机选择模式时的剩余外壳文件列表 671 | self.progress_callback = None # 进度条回调参数 672 | self.cover_video_path = None # 包含完整路径的外壳文件 673 | self.gui_enabled = gui_enabled # 是否是GUI模式 674 | 675 | def initialize_cover_video_files(self): 676 | """随机选择模式-初始化剩余可用的外壳文件列表""" 677 | cover_video_files = [f for f in os.listdir(self.video_folder_path) if f.endswith(".mp4")] 678 | random.shuffle(cover_video_files) # 随机排序 679 | self.remaining_cover_video_files = cover_video_files 680 | 681 | # GUI回调函数部分 682 | def set_progress_callback(self, callback): # GUI进度条回调函数, callback代表自GUI传入的进度条函数 683 | self.progress_callback = callback 684 | 685 | def set_cover_video_duration_callback(self, callback): # 外壳MP4文件回调函数 686 | self.cover_video_duration_callback = callback 687 | 688 | def set_log_callback(self, callback): # GUI log方法回调函数, 把GUI的self.log方法(这里用callback指代)传给逻辑层, 逻辑层再借self.log_callback把log信息传回GUI 689 | self.log_callback = callback 690 | 691 | def read_in_chunks(self, file_object, chunk_size=1024*1024): 692 | while True: 693 | data = file_object.read(chunk_size) 694 | if not data: 695 | break 696 | yield data 697 | 698 | def log(self, message): 699 | if self.gui_enabled == False: # CLI模式专属log方法 700 | print(message) 701 | else: 702 | self.log_callback(message) 703 | 704 | def choose_cover_video_file(self, cover_video_CLI=None, 705 | processed_files=None, 706 | output_cover_video_name_mode=None, 707 | video_folder_path=None): 708 | # 外壳文件选择: CLI模式 709 | if cover_video_CLI: # 如果指定了外壳文件名就使用之(CLI模式)绝对路径 710 | return cover_video_CLI 711 | 712 | # 外壳文件选择:GUI模式 713 | # 1. 检查cover_video中是否存在用来作为外壳的MP4文件(比如海绵宝宝之类, 数量任意, 每次随机选择) 714 | cover_video_files = [f for f in os.listdir(video_folder_path) if f.endswith(".mp4")] # 按默认排序选择 715 | cover_video_files = natsorted(cover_video_files, alg=ns.PATH) 716 | if not cover_video_files: 717 | raise Exception(f"{video_folder_path} 文件夹下没有文件, 请添加文件后继续.") 718 | 719 | # 2. 否则在cover_video中选择 720 | if output_cover_video_name_mode == '===============随机选择模式===============': 721 | # 2-a. 随机选择一个外壳MP4文件用来隐写, 尽量不重复 722 | if not self.remaining_cover_video_files: 723 | self.initialize_cover_video_files() 724 | cover_video = self.remaining_cover_video_files.pop() 725 | print(output_cover_video_name_mode, cover_video) 726 | 727 | elif output_cover_video_name_mode == '===============时长顺序模式===============': 728 | # 2-b. 按时长顺序选择一个外壳MP4文件用来隐写 729 | cover_video_files = get_cover_video_files_info(video_folder_path, sort_by_duration=True) # 按时长顺序选择 730 | cover_video = cover_video_files[processed_files % len(cover_video_files)] 731 | cover_video = cover_video[:cover_video.rfind('.mp4')] + '.mp4' # 按最后一个.mp4切分, 以去除后续可能存在的时长大小等内容 732 | print(output_cover_video_name_mode, cover_video) 733 | 734 | elif output_cover_video_name_mode == '===============名称顺序模式===============': 735 | # 2-c. 按名称顺序选择一个外壳MP4文件用来隐写 736 | cover_video = cover_video_files[processed_files % len(cover_video_files)] 737 | print(output_cover_video_name_mode, cover_video) 738 | 739 | else: 740 | # 2-d. 根据下拉菜单选择外壳MP4文件 741 | cover_video = output_cover_video_name_mode 742 | cover_video = cover_video[:cover_video.rfind('.mp4')] + '.mp4' # 按最后一个.mp4切分, 以去除后续可能存在的时长大小等内容 743 | print(f'下拉菜单模式, 视频信息: {output_cover_video_name_mode}') 744 | 745 | cover_video_path = os.path.join(video_folder_path, cover_video) 746 | print(f'cover_video_path: {cover_video_path}') 747 | return cover_video_path 748 | 749 | def compress_files(self, zip_file_path, input_file_path, processed_size=0, password=None): 750 | # 计算文件或文件夹的大小 751 | def get_file_or_folder_size(path): 752 | total_size = 0 753 | if os.path.isfile(path): 754 | total_size = os.path.getsize(path) 755 | elif os.path.isdir(path): 756 | for dirpath, dirnames, filenames in os.walk(path): 757 | for f in filenames: 758 | fp = os.path.join(dirpath, f) 759 | total_size += os.path.getsize(fp) 760 | return total_size 761 | 762 | # 计算文件或文件夹的 SHA-256 哈希值 763 | def compute_sha256(path): 764 | sha256_hash = hashlib.sha256() 765 | if os.path.isfile(path): 766 | with open(path, 'rb') as f: 767 | for byte_block in iter(lambda: f.read(4096), b''): 768 | sha256_hash.update(byte_block) 769 | elif os.path.isdir(path): 770 | # 对于文件夹,按照文件名排序,递归计算所有文件的哈希值并更新 771 | for root, dirs, files in os.walk(path): 772 | for names in sorted(files): 773 | filepath = os.path.join(root, names) 774 | sha256_hash.update(names.encode()) # 更新文件名到哈希 775 | with open(filepath, 'rb') as f: 776 | for byte_block in iter(lambda: f.read(4096), b''): 777 | sha256_hash.update(byte_block) 778 | return sha256_hash.hexdigest() 779 | 780 | # 计算输入文件或文件夹的总大小 781 | self.total_file_size = get_file_or_folder_size(input_file_path) 782 | 783 | # 计算 SHA-256 哈希值 784 | sha256_value = compute_sha256(input_file_path) 785 | 786 | # 计算时间戳及其哈希值 787 | readable_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time())) 788 | time_hash = hashlib.sha256(readable_time.encode()).hexdigest() 789 | 790 | # 准备要添加到 ZIP 注释中的信息 791 | zip_comment = f"SHA-256 Hash of '{os.path.basename(input_file_path)}':\n{sha256_value}\nTimestamp '{readable_time}'\nTimehash '{time_hash}'" 792 | 793 | if password: 794 | # 当设置了密码时,使用 pyzipper 进行 AES 加密 795 | zip_file = pyzipper.AESZipFile(zip_file_path, 'w', compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES) 796 | zip_file.setpassword(password.encode()) 797 | 798 | with zip_file: 799 | self.log(f"Compressing file with encryption: {input_file_path}\n较大的文件可能会花费较长时间...") 800 | 801 | # 收集所有需要压缩的文件 802 | file_list = [] 803 | 804 | if os.path.isdir(input_file_path): 805 | root_folder = os.path.basename(input_file_path) 806 | for root, dirs, files in os.walk(input_file_path): 807 | for file in files: 808 | file_full_path = os.path.join(root, file) 809 | arcname = os.path.join(root_folder, os.path.relpath(file_full_path, start=input_file_path)) 810 | file_list.append((file_full_path, arcname)) 811 | else: 812 | file_full_path = input_file_path 813 | arcname = os.path.basename(input_file_path) 814 | file_list.append((file_full_path, arcname)) 815 | 816 | # 随机化文件列表顺序 817 | random.shuffle(file_list) 818 | 819 | for file_full_path, arcname in file_list: 820 | # 随机选择压缩方法 821 | compress_type = random.choice([pyzipper.ZIP_DEFLATED, pyzipper.ZIP_STORED]) 822 | 823 | # 将文件写入 ZIP 存档 824 | zip_file.write(file_full_path, arcname=arcname, compress_type=compress_type) 825 | 826 | # 更新已处理的大小并更新进度条 827 | processed_size += os.path.getsize(file_full_path) 828 | if self.progress_callback: 829 | self.progress_callback(processed_size, self.total_file_size) 830 | 831 | # 设置 ZIP 文件的注释 832 | zip_file.comment = zip_comment.encode('utf-8') 833 | 834 | else: 835 | # 当未设置密码时,使用 zipfile 模块,这样可以设置 compresslevel 等 836 | zip_file = zipfile.ZipFile(zip_file_path, 'w') 837 | 838 | with zip_file: 839 | self.log(f"Compressing file without encryption: {input_file_path}\n较大的文件可能会花费较长时间...") 840 | 841 | # 收集所有需要压缩的文件 842 | file_list = [] 843 | 844 | if os.path.isdir(input_file_path): 845 | root_folder = os.path.basename(input_file_path) 846 | for root, dirs, files in os.walk(input_file_path): 847 | for file in files: 848 | file_full_path = os.path.join(root, file) 849 | arcname = os.path.join(root_folder, os.path.relpath(file_full_path, start=input_file_path)) 850 | file_list.append((file_full_path, arcname)) 851 | else: 852 | file_full_path = input_file_path 853 | arcname = os.path.basename(input_file_path) 854 | file_list.append((file_full_path, arcname)) 855 | 856 | # 随机化文件列表顺序 857 | random.shuffle(file_list) 858 | 859 | for file_full_path, arcname in file_list: 860 | # 随机选择压缩方法 861 | compress_type = random.choice([zipfile.ZIP_DEFLATED, zipfile.ZIP_DEFLATED]) # , zipfile.ZIP_STORED 862 | 863 | # 如果使用 ZIP_DEFLATED,随机选择压缩等级 864 | if compress_type == zipfile.ZIP_DEFLATED: 865 | compresslevel = random.randint(1, 9) # 压缩等级 1-9 866 | else: 867 | compresslevel = None # 对于 ZIP_STORED,压缩等级无效, 目前暂不启用 868 | 869 | # 随机生成文件日期和时间 870 | while True: 871 | try: 872 | date_time = ( 873 | random.randint(1980, 2099), # 年 874 | random.randint(1, 12), # 月 875 | random.randint(1, 28), # 日 876 | random.randint(0, 23), # 时 877 | random.randint(0, 59), # 分 878 | random.randint(0, 59) # 秒 879 | ) 880 | datetime.datetime(*date_time) 881 | break 882 | except ValueError: 883 | continue 884 | 885 | # 创建 ZipInfo 对象 886 | zi = zipfile.ZipInfo(filename=arcname, date_time=date_time) 887 | zi.compress_type = compress_type 888 | 889 | # 将文件写入 ZIP 存档 890 | if compresslevel is not None: 891 | zip_file.write(file_full_path, arcname=arcname, compress_type=compress_type, compresslevel=compresslevel) 892 | else: 893 | zip_file.write(file_full_path, arcname=arcname, compress_type=compress_type) 894 | 895 | # 更新已处理的大小并更新进度条 896 | processed_size += os.path.getsize(file_full_path) 897 | if self.progress_callback: 898 | self.progress_callback(processed_size, self.total_file_size) 899 | 900 | # 设置 ZIP 文件的注释 901 | zip_file.comment = zip_comment.encode('utf-8') 902 | 903 | def hide_file(self, input_file_path, 904 | cover_video_CLI=None, 905 | password=None, 906 | processed_files=0, 907 | output_file_path=None, 908 | output_option=None, 909 | output_cover_video_name_mode=None, 910 | type_option_var=None, 911 | video_folder_path=None): 912 | 913 | self.type_option_var = type_option_var 914 | self.output_option = output_option 915 | self.output_cover_video_name_mode = output_cover_video_name_mode 916 | self.password = password 917 | self.video_folder_path = video_folder_path 918 | 919 | # 1. 隐写外壳文件选择 920 | cover_video_path = self.choose_cover_video_file(cover_video_CLI=cover_video_CLI, 921 | processed_files=processed_files, 922 | output_cover_video_name_mode=output_cover_video_name_mode, 923 | video_folder_path=self.video_folder_path) 924 | print(f"实际隐写外壳文件:{cover_video_path}") 925 | 926 | # 2. 隐写的临时zip文件名 927 | zip_file_path = os.path.join(os.path.dirname(input_file_path), os.path.basename(input_file_path) + f"_hidden_{processed_files}.zip") 928 | 929 | # 3. 计算要压缩的文件总大小 930 | self.total_file_size = get_file_or_folder_size(input_file_path) 931 | print(f"要压缩的文件总大小: {self.total_file_size} bytes") 932 | 933 | processed_size = 0 # 初始化已处理的大小为0 934 | self.compress_files(zip_file_path, input_file_path, processed_size=processed_size, password=password) # 创建隐写的临时zip文件 935 | 936 | try: 937 | # 4.1. 隐写MP4文件的逻辑 938 | if self.type_option_var == 'mp4': 939 | # 指定输出文件名 940 | output_file = self.get_output_file_path(input_file_path, 941 | output_file_path, 942 | processed_files, 943 | self.output_option, 944 | self.output_cover_video_name_mode, 945 | cover_video_path) 946 | 947 | self.log(f"Output file: {output_file}") 948 | 949 | try: 950 | total_size_hidden = os.path.getsize(cover_video_path) + os.path.getsize(zip_file_path) 951 | processed_size = 0 952 | with open(cover_video_path, "rb") as file1: 953 | with open(zip_file_path, "rb") as file2: 954 | with open(output_file, "wb") as output: 955 | self.log(f"Hiding file: {input_file_path}") 956 | 957 | # 外壳 MP4 文件 958 | self.log(f"Hiding cover video: {file1}") 959 | for chunk in self.read_in_chunks(file1): 960 | output.write(chunk) 961 | processed_size += len(chunk) 962 | if self.progress_callback: 963 | self.progress_callback(processed_size, total_size_hidden) 964 | 965 | # 生成 moov box 头部 966 | moov_size = os.path.getsize(zip_file_path) + 8 967 | if moov_size <= 4294967295: # 当zip文件大小小于4GB (2**32-1), 直接嵌入 moov box 中 968 | moov_header = b'moov' + moov_size.to_bytes(4, byteorder='big') 969 | output.write(moov_header) 970 | else: # largesize 971 | moov_header = b'moov' + (1).to_bytes(4, byteorder='big') 972 | output.write(moov_header) 973 | output.write(moov_size.to_bytes(8, byteorder='big')) 974 | 975 | # 压缩包 zip 文件 976 | self.log(f"Hiding zip file: {file2}") 977 | for chunk in self.read_in_chunks(file2): 978 | output.write(chunk) 979 | processed_size += len(chunk) 980 | if self.progress_callback: 981 | self.progress_callback(processed_size, total_size_hidden) 982 | 983 | # 随机写入 2 种压缩文件特征码, 用来混淆网盘的检测系统 984 | head_signatures = { 985 | "RAR4": b'\x52\x61\x72\x21\x1A\x07\x00', 986 | "RAR5": b'\x52\x61\x72\x21\x1A\x07\x01\x00', 987 | "7Z": b'\x37\x7A\xBC\xAF\x27\x1C', 988 | "ZIP": b'\x50\x4B\x03\x04', 989 | "GZIP": b'\x1F\x8B', 990 | "BZIP2": b'\x42\x5A\x68', 991 | "XZ": b'\xFD\x37\x7A\x58\x5A\x00', 992 | } 993 | 994 | # 添加随机压缩文件特征码 995 | random_bytes = os.urandom(1024 * random.randint(5, 10)) # 20KB - 25KB 的随机字节 996 | output.write(random.choice(list(head_signatures.values()))) # 随机压缩文件特征码 997 | output.write(random_bytes) 998 | 999 | output.write(random.choice(list(head_signatures.values()))) # 第二个压缩文件特征码 1000 | random_bytes = os.urandom(1024 * random.randint(5, 10)) # 20KB - 22KB 的随机字节 1001 | output.write(random_bytes) 1002 | 1003 | # 添加 MP4 文件的结尾标记 (空的 "mdat" box) 1004 | add_empty_mdat_box(output) 1005 | 1006 | except Exception as e: 1007 | self.log(f"在写入MP4文件时发生未预料的错误: {str(e)}") 1008 | raise 1009 | 1010 | # 4.2. 隐写mkv文件的逻辑 1011 | elif self.type_option_var == 'mkv': 1012 | # 指定输出文件名 1013 | output_file = self.get_output_file_path(input_file_path, 1014 | output_file_path, 1015 | processed_files, 1016 | self.output_option, 1017 | self.output_cover_video_name_mode, 1018 | cover_video_path) 1019 | 1020 | # 生成末尾随机字节 1021 | random_data_path = f"temp_{generate_random_filename(length=16)}" 1022 | try: 1023 | with open(random_data_path, "wb") as f: 1024 | random_bytes = os.urandom(1024*8) # 8kb 1025 | f.write(random_bytes) 1026 | 1027 | self.log(f"Output file: {output_file}") 1028 | cmd = [ 1029 | self.mkvmerge_exe, '-o', 1030 | output_file, cover_video_path, 1031 | '--attach-file', zip_file_path, 1032 | '--attach-file', random_data_path, 1033 | ] 1034 | self.log(f"Hiding file: {input_file_path}") 1035 | result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8') 1036 | 1037 | # 删除临时随机字节 1038 | os.remove(random_data_path) 1039 | 1040 | if result.returncode != 0: 1041 | raise subprocess.CalledProcessError(result.returncode, cmd, output=result.stdout, stderr=result.stderr) 1042 | 1043 | except subprocess.CalledProcessError as cpe: 1044 | self.log(f"隐写时发生错误: {str(cpe)}") 1045 | self.log(f'CalledProcessError output:{cpe.output}') if cpe.output else None 1046 | self.log(f'CalledProcessError stderr:{cpe.stderr}') if cpe.stderr else None 1047 | raise 1048 | 1049 | except Exception as e: 1050 | self.log(f"在执行mkvmerge时发生未预料的错误: {str(e)}") 1051 | raise 1052 | 1053 | except Exception as e: 1054 | self.log(f"隐写时发生未预料的错误: {str(e)}") 1055 | raise 1056 | finally: 1057 | # 5. 删除临时zip文件 1058 | os.remove(zip_file_path) 1059 | 1060 | self.log(f"Output file created: {os.path.exists(output_file)}") 1061 | 1062 | # 隐写时指定输出文件名+路径的方法 1063 | def get_output_file_path(self, input_file_path=None, 1064 | output_file_path=None, 1065 | processed_files=0, 1066 | output_option=None, 1067 | output_cover_video_name_mode=None, 1068 | cover_video_path=None, 1069 | ): 1070 | 1071 | # 输出文件名指定 1072 | if output_file_path: 1073 | return output_file_path # 如果指定了输出文件名就用输出文件名(CLI模式) 1074 | 1075 | print(f'type option: {self.type_option_var}') 1076 | if self.type_option_var == 'mp4': 1077 | print(f'input_file_path: {input_file_path}') 1078 | print(f'cover_video_path: {cover_video_path}') 1079 | 1080 | # 输出文件名选择 1081 | print(f'output_option: {output_option}') 1082 | if output_option == '原文件名': 1083 | if os.path.isdir(input_file_path): 1084 | output_file_path = input_file_path + f"_hidden_{processed_files+1}.mp4" # 当为文件夹时不存在扩展名 1085 | else: 1086 | output_file_path = os.path.splitext(input_file_path)[0] + f"_hidden_{processed_files+1}.mp4" 1087 | elif output_option == '外壳文件名': 1088 | output_file_path = os.path.join(os.path.split(input_file_path)[0], 1089 | os.path.split(cover_video_path)[1].replace('.mp4', f'_{processed_files+1}.mp4') 1090 | ) 1091 | elif output_option == '随机文件名': 1092 | output_file_path = os.path.join(os.path.split(input_file_path)[0], 1093 | generate_random_filename(length=16) + f'_{processed_files+1}.mp4') 1094 | print(f"output_file_path: {output_file_path}\n") 1095 | 1096 | elif self.type_option_var == 'mkv': 1097 | 1098 | # 输出文件名选择 1099 | print(f'output_option: {output_option}') 1100 | if output_option == '原文件名': 1101 | if os.path.isdir(input_file_path): 1102 | output_file_path = input_file_path + f"_hidden_{processed_files+1}.mkv" # 当为文件夹时不存在扩展名 1103 | else: 1104 | output_file_path = os.path.splitext(input_file_path)[0] + f"_hidden_{processed_files+1}.mkv" 1105 | elif output_option == '外壳文件名': 1106 | output_file_path = os.path.join(os.path.split(input_file_path)[0], 1107 | os.path.split(cover_video_path)[1].replace('.mp4', f'_{processed_files+1}.mkv') 1108 | ) 1109 | elif output_option == '随机文件名': 1110 | output_file_path = os.path.join(os.path.split(input_file_path)[0], 1111 | generate_random_filename(length=16) + f'_{processed_files+1}.mkv') 1112 | print(f"output_file_path: {output_file_path}\n") 1113 | 1114 | return output_file_path 1115 | 1116 | # 解除隐写部分 1117 | 1118 | ## 读取密码本 1119 | def load_passwords(self): 1120 | passwords = [] 1121 | if os.path.exists(self.password_file): 1122 | with open(self.password_file, 'r', encoding='utf-8-sig') as f: 1123 | for line in f: 1124 | parts = line.strip().split('\t') 1125 | if parts: 1126 | # 移除可能的 BOM 和其他不可打印字符 1127 | password = parts[0].strip().encode('ascii', 'ignore').decode('ascii') 1128 | if password: 1129 | passwords.append(password) 1130 | return passwords 1131 | 1132 | def reveal_file(self, input_file_path, password=None, type_option_var=None): 1133 | self.type_option_var = type_option_var 1134 | 1135 | # 添加调试信息 1136 | self.log(f"Loaded passwords: {self.passwords}") 1137 | self.log(f"User provided password: {password}") 1138 | 1139 | password_list = [password] if password else [] 1140 | password_list.extend(self.passwords) 1141 | 1142 | # 如果没有提供任何密码,添加空密码('')到列表中 1143 | if not password_list: 1144 | password_list.append('') 1145 | 1146 | if self.type_option_var == 'mp4': 1147 | for test_password in password_list: 1148 | try: 1149 | # 添加更多详细的调试信息 1150 | self.log(f"Attempting to reveal file with password: '{test_password}' (len: {len(test_password)})") 1151 | 1152 | total_size_hidden = os.path.getsize(input_file_path) 1153 | processed_size = 0 1154 | 1155 | with open(input_file_path, "rb") as file1: 1156 | with pyzipper.AESZipFile(file1) as zip_file: 1157 | # 仅当密码不为空时才设置密码 1158 | if test_password: 1159 | zip_file.setpassword(test_password.encode()) 1160 | for name in zip_file.namelist(): 1161 | output_file_path = os.path.join(os.path.dirname(input_file_path), name) 1162 | self.log(f"Attempting to create file: {output_file_path}") 1163 | os.makedirs(os.path.dirname(output_file_path), exist_ok=True) 1164 | with zip_file.open(name) as source, open(output_file_path, 'wb') as output: 1165 | for chunk in self.read_in_chunks(source): 1166 | output.write(chunk) 1167 | processed_size += len(chunk) 1168 | if self.progress_callback: 1169 | self.progress_callback(processed_size, total_size_hidden) 1170 | 1171 | os.remove(input_file_path) 1172 | self.log(f"File extracted successfully with password: {test_password}") 1173 | return # 成功解压后退出函数 1174 | 1175 | except (pyzipper.BadZipFile, ValueError, RuntimeError) as e: 1176 | self.log(f"无法解压文件 {input_file_path}, 使用密码 {test_password} 失败: {str(e)}") 1177 | 1178 | self.log("所有密码尝试失败,无法解压文件。") 1179 | 1180 | elif self.type_option_var == 'mkv': 1181 | # 获取mkv附件id函数 1182 | def get_attachment_name(input_file_path): 1183 | cmd = [self.mkvinfo_exe, input_file_path] 1184 | try: 1185 | result = subprocess.run(cmd, capture_output=True, text=True, check=True, encoding='utf-8') 1186 | lines = result.stdout.splitlines() 1187 | for idx, line in enumerate(lines): 1188 | if "MIME" in line: 1189 | parts = lines[idx-1].split(':') 1190 | attachments_name = parts[1].strip().split()[-1] 1191 | break 1192 | except Exception as e: 1193 | self.log(f"获取附件时出错: {e}") 1194 | return None 1195 | return attachments_name 1196 | 1197 | # 提取mkv附件 1198 | def extract_attachment(input_file_path, output_path): 1199 | cmd = [self.mkvextract_exe, 'attachments', input_file_path, f'1:{output_path}'] 1200 | try: 1201 | subprocess.run(cmd, check=True) 1202 | except subprocess.CalledProcessError as e: 1203 | raise Exception(f"提取附件时出错: {e}") 1204 | 1205 | attachments_name = get_attachment_name(input_file_path) 1206 | if attachments_name: 1207 | output_path = os.path.join(os.path.dirname(input_file_path), attachments_name) 1208 | self.log(f"Mkvextracting attachment file: {output_path}") 1209 | try: 1210 | extract_attachment(input_file_path, output_path) 1211 | 1212 | if attachments_name.endswith('.zip'): 1213 | zip_path = output_path 1214 | self.log(f"Extracting ZIP file: {zip_path}") 1215 | 1216 | for test_password in password_list: 1217 | try: 1218 | with pyzipper.AESZipFile(zip_path, 'r', compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES) as zip_file: 1219 | # 仅当密码不为空时才设置密码 1220 | if test_password: 1221 | zip_file.setpassword(test_password.encode()) 1222 | zip_file.extractall(os.path.dirname(input_file_path)) 1223 | 1224 | os.remove(zip_path) 1225 | os.remove(input_file_path) 1226 | self.log(f"File extracted successfully with password: {test_password}") 1227 | return # 成功解压后退出函数 1228 | 1229 | except RuntimeError as e: 1230 | self.log(f"使用密码 {test_password} 解压失败: {e}") 1231 | 1232 | self.log("所有密码尝试失败,无法解压文件。") 1233 | 1234 | else: 1235 | self.log(f"提取附件 {attachments_name} 成功") 1236 | os.remove(input_file_path) 1237 | 1238 | except Exception as e: 1239 | self.log(f"提取附件 {attachments_name} 时出错: {e}") 1240 | else: 1241 | self.log("该 MKV 文件中没有可提取的附件。") 1242 | 1243 | def run_cli(self, args): 1244 | # self.type_option_var = argparse.Namespace() 1245 | # self.type_option_var.get = lambda: args.type # 模拟.get() 方法 1246 | 1247 | print(f"输入文件/文件夹路径: {args.input}") 1248 | print(f"输出文件/文件夹路径: {args.output}") 1249 | print(f"密码: {args.password}") 1250 | print(f"输出文件类型: {args.type}") 1251 | print(f"设定外壳MP4视频路径: {args.cover}") 1252 | print(f"执行解除隐写: {args.reveal}") 1253 | 1254 | self.type_option_var = args.type 1255 | 1256 | if not args.reveal: 1257 | if args.output: 1258 | output_file = args.output 1259 | else: 1260 | input_file_name = os.path.splitext(os.path.basename(args.input))[0] 1261 | output_file = f"{input_file_name}_hidden.{args.type}" 1262 | 1263 | self.hide_file(input_file_path=args.input, 1264 | cover_video_CLI=args.cover, 1265 | password=args.password, 1266 | output_file_path=output_file, 1267 | type_option_var=self.type_option_var) # 调用hide_file方法 1268 | else: 1269 | self.reveal_file(input_file_path=args.input, 1270 | password=args.password, 1271 | type_option_var=self.type_option_var) # 调用reveal_file方法 1272 | 1273 | if __name__ == "__main__": 1274 | # 修正CLI模式的编码问题 1275 | if sys.stdout is not None: 1276 | sys.stdout.reconfigure(encoding='utf-8') 1277 | else: 1278 | print("sys.stdout is None") 1279 | 1280 | # 关于程序执行路径的问题 1281 | if getattr(sys, 'frozen', False): # 打包成exe的情况 1282 | application_path = os.path.dirname(sys.executable) 1283 | else: # 在开发环境中运行 1284 | application_path = os.path.dirname(__file__) 1285 | 1286 | parser = argparse.ArgumentParser(description='隐写者 Ver.1.2.5 CLI 作者: 层林尽染') 1287 | parser.add_argument('-i', '--input', default=None, help='指定输入文件或文件夹的路径') 1288 | parser.add_argument('-o', '--output', default=None, help='1.指定输出文件名(包含后缀名) [或] 2.输出文件夹路径(默认为原文件名+"hidden")') 1289 | parser.add_argument('-p', '--password', default='', help='设置密码 (默认无密码)') 1290 | parser.add_argument('-t', '--type', default='mp4', choices=['mp4', 'mkv'], help='设置输出文件类型 (默认为mp4)') 1291 | parser.add_argument('-c', '--cover', default=None, help='指定外壳MP4视频(默认在程序同路径下搜索)') 1292 | parser.add_argument('-r', '--reveal', action='store_true', help='执行解除隐写') 1293 | parser.add_argument('-pf', '--password-file', default=None, help='指定密码文件路径') 1294 | 1295 | args, unknown = parser.parse_known_args() 1296 | 1297 | if unknown: # 假如没有指定参数标签, 那么默认第一个传入为 -i 参数 1298 | args.input = unknown[0] 1299 | 1300 | if args.input: 1301 | print('CLI') 1302 | # 首先调整传入的参数 1303 | # 1. 处理输出路径 1304 | if args.output is None: 1305 | # 1.1 如果没有指定输出文件路径, 则默认和输入文件同路径, 使用原文件名+"_hidden.mp4/mkv" 1306 | input_dir = os.path.dirname(os.path.abspath(args.input)) 1307 | args.output = os.path.join(input_dir, f"{os.path.splitext(os.path.basename(args.input))[0]}_hidden.{args.type}") 1308 | else: 1309 | # 1.2. 如果指定了输出路径但不包含文件名, 仍使用原文件名+"_hidden.mp4/mkv" 1310 | if os.path.splitext(args.output)[1] == '': 1311 | input_filename = os.path.splitext(os.path.basename(args.input))[0] 1312 | args.output = f"{os.path.join(args.output, input_filename)}_hidden.{args.type}" 1313 | # 1.3. 其余情况则使用指定的输出文件名 1314 | else: 1315 | args.output = args.output 1316 | 1317 | # 2. 处理外壳MP4文件 1318 | if args.cover is None: 1319 | mp4list = [] 1320 | # 2.1 如果没有指定外壳视频路径, 则自动在程序同路径下的 cover_video 文件夹中寻找第一个文件 1321 | cover_video_path = os.path.join(application_path, 'cover_video') 1322 | if os.path.exists(cover_video_path): 1323 | mp4list = [os.path.join(cover_video_path, item) for item in os.listdir(cover_video_path) if item.endswith('.mp4')] 1324 | 1325 | # 2.2 否则使用程序所在目录中的第一个mp4文件 1326 | mp4list += [os.path.join(application_path, item) for item in os.listdir(application_path) if item.endswith('.mp4')] # 程序所在目录 1327 | 1328 | # 2.3 假如以上都没找到,那么就在输入文件/目录所在目录下寻找 1329 | if not mp4list: 1330 | input_dir = os.path.dirname(os.path.abspath(args.input)) # 获取输入文件/文件夹的父目录 1331 | mp4list += [os.path.join(input_dir, item) for item in os.listdir(input_dir) if item.endswith('.mp4')] # 输入文件/目录所在目录 1332 | 1333 | if mp4list: 1334 | args.cover = mp4list[0] 1335 | else: 1336 | print('请指定外壳MP4文件') 1337 | exit(1) # 退出程序 1338 | 1339 | # 3. 处理密码文件 1340 | if args.password_file is None: 1341 | args.password_file = os.path.join(application_path,"modules", "PW.txt") 1342 | 1343 | steganographier = Steganographier(password_file=args.password_file) 1344 | steganographier.run_cli(args) 1345 | else: 1346 | print('GUI') 1347 | SteganographierGUI() 1348 | -------------------------------------------------------------------------------- /cover_video/Rick Astley - Never Gonna Give You Up (Official Music Video) [dQw4w9WgXcQ].mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cenglin123/SteganographierGUI/c0d15409f0c64ea3ce631eceb768b6c14c7eac76/cover_video/Rick Astley - Never Gonna Give You Up (Official Music Video) [dQw4w9WgXcQ].mp4 -------------------------------------------------------------------------------- /modules/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cenglin123/SteganographierGUI/c0d15409f0c64ea3ce631eceb768b6c14c7eac76/modules/favicon.ico -------------------------------------------------------------------------------- /modules/favicon_captcha_generator.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cenglin123/SteganographierGUI/c0d15409f0c64ea3ce631eceb768b6c14c7eac76/modules/favicon_captcha_generator.ico -------------------------------------------------------------------------------- /modules/favicon_hash_modifier.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cenglin123/SteganographierGUI/c0d15409f0c64ea3ce631eceb768b6c14c7eac76/modules/favicon_hash_modifier.ico -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyzipper==0.3.6 2 | tkinterdnd2==0.3.0 3 | -------------------------------------------------------------------------------- /tools/captcha_generator.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cenglin123/SteganographierGUI/c0d15409f0c64ea3ce631eceb768b6c14c7eac76/tools/captcha_generator.exe -------------------------------------------------------------------------------- /tools/hash_modifier.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cenglin123/SteganographierGUI/c0d15409f0c64ea3ce631eceb768b6c14c7eac76/tools/hash_modifier.exe -------------------------------------------------------------------------------- /tools/mkvextract.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cenglin123/SteganographierGUI/c0d15409f0c64ea3ce631eceb768b6c14c7eac76/tools/mkvextract.exe -------------------------------------------------------------------------------- /tools/mkvinfo.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cenglin123/SteganographierGUI/c0d15409f0c64ea3ce631eceb768b6c14c7eac76/tools/mkvinfo.exe -------------------------------------------------------------------------------- /tools/mkvmerge.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cenglin123/SteganographierGUI/c0d15409f0c64ea3ce631eceb768b6c14c7eac76/tools/mkvmerge.exe --------------------------------------------------------------------------------