← Back to Writeups

web/85_reasons_why | LACTF 2023

Ruien Luo, Sun Feb 12 2023 • Tags: web, LACTF 2023

This challenge was part of LACTF 2023, where asmhole placed 33rd out of nearly 1,400 teams.

Challenge description

Author: Rory
If you wanna catch up on ALL the campus news, check out my new blog. It even has a reverse image search feature!

85-reasons-why.lac.tf

Challenge Files Mirror: src.tar.gz

Solution

homepage

Homepage of the site

At first glance, the site looks pretty normal. it's got a couple of posts (4) and three tabs, 'Home', 'Search', 'About'.

Immediately, however, something stood out as more than very suspicious to me. What, you ask? The image search feature mentioned in the description.

In the source code, the image search feature actually just takes the base85 string of the input file - yes, any file, not just an image (there is no type validation) - and looks for the same one in the database of post images.

@app.route('/image-search', methods=['GET', 'POST'])
def image_search():
    if 'image-query' not in request.files or request.method == 'GET':
        return render_template('image-search.html', results=[])
 
    incoming_file = request.files['image-query']
    size = os.fstat(incoming_file.fileno()).st_size
    if size > MAX_IMAGE_SIZE:
        flash("image is too large (50kb max)");
        return redirect(url_for('home'))
 
    spic = serialize_image(incoming_file.read())
 
    try:
        res = db.session.connection().execute(\
            text("select parent as PID from images where b85_image = '{}' AND ((select active from posts where id=PID) = TRUE)".format(spic)))
    except Exception:
        return ("SQL error encountered", 500)
 
    results = []
    for row in res:
        post = db.session.query(Post).get(row[0])
        if (post not in results):
            results.append(post)
 
    return render_template('image-search.html', results=results)

The models.py file also had the model of a Post, which had all the usual attributes, but also an "active" attribute. I suspected that there was a hidden post which I had to find.

class Post(db.Model):
    __tablename__ = 'posts'
 
    id = db.Column(db.String(36), primary_key=True)
    title = db.Column(db.String(200), nullable=False)
    content = db.Column(db.String(), nullable=False)
    author = db.Column(db.String(200), nullable=False)
    date = db.Column(db.String(20), nullable=False)
    # This is suspicious
    active = db.Column(db.Boolean(), nullable=False)
    images = db.relationship("Image", backref="post", uselist=True)
    comments = db.relationship("Comment", backref="post", uselist=True)
 
    def __init__(self, title, content, author):
        self.id = str(uuid.uuid4())
        self.title = title
        self.content = content
        self.author = author
        self.date = datetime.now().strftime("%m-%d-%Y, %H:%M:%S")
        self.active = True

Initially, I attempted to just upload a text file to the image search containing an SQL injection of ' OR 1=1;--. However, that wasn't very successful, and just returned no results.

noresults

No results were returned

I then noticed that all the input images were being fed through the serialize_image() function. Perhaps this was why my queries were failing?

I looked at the file where the function was declared, utils.py:

def serialize_image(pp):
    b85 = base64.a85encode(pp)
    b85_string = b85.decode('UTF-8', 'ignore')
 
    # identify single quotes, and then escape them
    b85_string = re.sub('\\\\\\\\\\\\\'', '~', b85_string)
    b85_string = re.sub('\'', '\'\'', b85_string)
    b85_string = re.sub('~', '\'', b85_string)
 
    b85_string = re.sub('\\:', '~', b85_string)
    return b85_string

The function appeared to take an image and convert it into a base85 string. However, there were three lines in there that appeared to be doing the majority of the SQL injection prevention.

b85_string = re.sub('\\\\\\\\\\\\\'', '~', b85_string)
b85_string = re.sub('\'', '\'\'', b85_string)
b85_string = re.sub('~', '\'', b85_string)

Line two appeared to be replacing single quotes with a pair of quotes, which wasn't what I wanted to achieve. Lines one and three, though, appeared to do exactly what I wanted - keep my single quote in place to cut off the sql statement and allow me to inject my own stuff. Therefore, I could use the following query to bypass this (dividing the number of slashes in front of that quote by two) \\\\\\\' OR 1=1;--. This would first be translated into ~ OR 1=1;-- and then back into the form I wanted, ' OR 1=1;--.

steps

I attempted to encode my SQLi statement into base85 and submit that as an image, but shortly realized that the serialize_image() function encodes to base85, not decodes. This meant that I had to start from the assumption that my query was base85, and decode it to get a file. However, base85 doesn't have spaces. This didn't turn out to be a problem though, since, while you can't have a query like this: 'OR1=1;-- in SQL, you can use comment characters (/**/) to replace a space. Coincidentally, base85 does have support for both forward slashes and asterisks. My final query ended up being \\\\\\\'/**/OR/**/1=1;--. Using a handy online tool (dcode.fr (opens in a new tab)), I encoded decoded my query (from base85) into a text file.

encodeonline

Then, it was just as simple as dragging-and-dropping my file into the website and clicking search to get access to the hidden post.

inactive

The flag shows up in the body of the hidden post

Turns out I only needed one reason: Bad SQL is bad.

Questions/comments?

Send me an email at [email protected].