2PST
Flask cookies bypass MySQL privilege escalation checks
cloud_webmail_migration

Flask cookies bypass MySQL privilege escalation checks

A migration is not finished when the export button stops spinning. It is finished when the data still makes sense after the move.


A migration is not finished when the export button stops spinning. It is finished when the data still makes sense after the move. That includes the mail, the folders, the contacts, and the ugly little settings that keep a mailbox usable.

This lesson is about a different kind of failure. It starts with a Flask app, a few cookies, and a MySQL account that looks harmless at first glance. Then the checks fall apart. Once that happens, a migration or webmail system can turn into a clean path to command execution and root-level file writes.

Start with the obvious thing: compare the two copies

When two backup ZIP files arrive, the first job is not panic. It is comparison. Each archive gets extracted into its own folder, then the folders get compared side by side.

That simple directory diff shows what changed. In this case, the shared files in the Misk and templates folders match. The code paths are the same in those places. The useful clues are elsewhere.

The MySQL credentials differ. One side has a generic value. The other has a specific one. The left copy also carries an attachment that the right copy does not. It includes export features too, with names that tell a story: export note, export not local, export not remote. That left version looks newer, and it also looks like someone tried to hide the database details.

That is how a lot of real work starts. Not with a dramatic alert. With a folder that has one more file than it should.

Read the code for sinks, not style

The next step is code review. Not for naming. Not for taste. For sinks.

A sink is a place where input turns into action. A SQL query is one sink. A shell command is another. If input reaches either one in the wrong form, trouble follows.

The safe part here is the database access. The SQL queries use prepared statements through Python’s database layer. That means values go in as values. They do not get pasted into the query text. So the MySQL read and write paths are not the weak spot.

The weak spot is the export code.

One export path takes note body content and hands it to subprocess.run through a simple format string. That is a bad mix. The note content is user-controlled, and it gets inserted into a system command. That is how remote code execution, or RCE, shows up in plain clothes.

The second export path is even cleaner for an attacker. It takes a URL, builds a command from that URL, and runs it. No database step is needed. No note has to be planted first. A malicious file can sit on a network host, and the application will fetch it and execute the wrong thing.

A small example makes this less abstract. If a web form takes the string hello and drops it into a shell command, the shell sees the string, not the intent. If that same field carries shell syntax, the shell sees that too. The command no longer means what the developer thought it meant.

That is the whole problem. The code trusted a string that should never have been trusted.

Why the remote export path is worse

The export not local path needs content already present in the app. That means the attacker has to get a crafted note into the system first.

The export not remote path skips that chore. It takes a URL and points command execution at a file from outside the host. That makes the exploit simpler. The payload can live on a listener at port 1111 or 4321, and the app can be tricked into pulling it in.

The payload itself does not need to be fancy. A reverse shell line, wrapped with the right separators and a comment marker, is enough to hand a shell back to the attacker. In the demo flow, the dashboard export option becomes the trigger. The URL field is the opening. The shell listener catches the result.

That is a bad day for any system. It is worse for a migration tool, because the boundary between data movement and code execution has vanished.

What the shell shows next

Once a shell lands, the next move is quiet enumeration. Find the binaries. Check the processes. See what the host is willing to reveal.

Standard tools are present. which and type show the usual command set. ps and ps aux show only processes tied to the SVC account. That matters because it suggests the system is hiding client processes from the current view. Network checks with ss -tln or netstat show the important ports: 5000 for Python, 53 for DNS, 22 and 21 for SSH and FTP, and 3306 for MySQL.

Now the puzzle gets practical. The diff already exposed a MySQL user. So the next test is a login.

The login works.

MySQL with too much power

A database account is supposed to touch data, not the whole file system. That line gets crossed fast when SELECT ... INTO OUTFILE works where it should not.

Here, a simple write test creates /tmp/test. The file appears. More than that, it is owned by root. That is the part that should make anyone sit up straight. A database account that can write as root is already sitting on a bad bridge.

This kind of privilege is dangerous because it opens several doors at once. It is not one neat problem. It is a stack of them.

File overwrite attacks come first to mind. Writing to ~/.ssh/authorized_keys is the classic move, but that failed here because the file already existed and the write was not appendable. A cron job overwrite was tried too. Same result. The path existed, and the attack did not get the shape it wanted.

That is where many people stop. They should not. There is another route.

Use UDF when file writes are not enough

A known path here is a User Defined Function, or UDF. In MySQL, a UDF can load a shared object, the .so file, and expose code that the database can call.

That matters because a UDF can wrap system(). Once that function is available, the database is no longer only writing files. It is running commands. At that point, the jump from database account to operating system control becomes very short.

The Raptor UDF 2 technique fits that shape. The shared object is compiled, loaded into MySQL, and used to reach system(). A command can then be run from inside the database context.

In practice, this is where a migration lesson and a security lesson meet. A file move looks harmless until a format or parser turns it into execution. A mailbox export looks harmless until it passes unchecked input to the shell. A database privilege looks harmless until it is allowed to write and then call code.

The pattern is the same every time. Trust the file, and the file bites back.

One small example that ties it together

Picture a note export screen. A user types a note that contains shell syntax. The app reads that note and builds a command with subprocess.run. The shell sees the syntax and runs it.

Now picture the same app using a URL field instead. The attacker hosts a malicious file at that address. The app fetches it and runs the result. No local note is needed. No extra step blocks the attack.

That is the difference between a messy bug and a direct path to RCE. One is awkward. The other is a door left open.

A migration lesson lives inside this too. If a tool says it exported the data, that is not the same as proving the data stayed intact and the path stayed safe. A clean finish line is not an error message. It is the lack of hidden damage.

I still measure success the hard way. Every message. Every folder. Every contact. If the tool moves them but loses trust, the job is not done.

The Migration Brief keeps that same plain standard in view: one practical fix, one file format explained, and one safer way to move your mail.